Movable Type 7のコンテンツタイプとData API 4.0でスマートフォンアプリを試作

Movable Type 7(MT7)で新たに登場することになった「コンテンツタイプ」。今まではブログを作成し記事に蓄えていたようなデータがコンテンツタイプで管理できるようになり、今までよりもデータは柔軟に扱えるようになり、ユーザーの追加・更新作業がしやすくなるのではないでしょうか。

さて、コンテンツタイプのデータは「Data API 4.0」を介して操作ができるということで、早速簡単なアプリを制作してData API 4.0を体験してみたいと思います。「Movable Type のコード管理についてまとめてみました-Six Apart ブログ|オウンドメディア運営者のための実践的情報とコミュニティ」にもある通り、最新のMovable TypeはGitHubのdevelopブランチにありますのでそれを使わせて頂きました。アプリは先日公開した記事「Movable Type 6 + Apliko + React Nativeでブログリーダーアプリを制作」でも利用したReact Nativeで制作することにします。

※2018年2月16日夜時点のMovable Type 7(r.2401)を利用しました。今後の開発等により内容と実際の動作が変わる可能性もありますのでご了承ください。
※2018年2月20日20:15 最新のData APIの仕様を反映しました。

コンテンツタイプの定義

沖縄の観光スポットを蓄積するコンテンツタイプ「観光スポット」を定義します。

コース名 フィールドの種類 必須
スポット名 一行テキストフィールド Yes
キャッチコピー 一行テキストフィールド Yes
写真 画像フィールド Yes
緯度 一行テキストフィールド Yes
経度 一行テキストフィールド Yes
スポットの説明 複数行テキストフィールド
公式URL URLフィールド
各種データ テーブルフィールド

プラグインで「Google マップフィールド」...地図が表示され、それを操作することにより緯度・経度が保存できるフィールドがあれば...などと考えました。(Google マップの利用規約上の問題はないか確認が必要ですね)
画面キャプチャ:コンテンツタイプ「観光スポット」の定義画面

また、今回のアプリでは使用しませんが観光スポットをピックアップして紹介するコンテンツタイプ「モデルコース」も定義しておきます。

コース名 フィールドの種類 必須
コースに含む観光スポット コンテンツタイプフィールド Yes
コース名 一行テキストフィールド Yes

コンテンツタイプを定義し終えたら、データをいくつか入力していきます。
画面キャプチャ:コンテンツタイプ「観光スポット」の編集画面

コンテンツデータの一覧を取り出してみる

コンテンツタイプに保存されたコンテンツデータの一覧は以下のエンドポイントで取得できます。

/sites/:site_id/contentTypes/:content_type_id/data

React Nativeからはfetchでエンドポイントにアクセスし、処理を行います。

function _getContentDataListEndpoint() {
  return `${BASE_URL}/sites/${config.blogId}/contentTypes/${config.contentTypeId}/data`;
}

function getContentDataList(): Promise<ContentData[]> {
  const endpoint = _getContentDataListEndpoint();
  return fetch(endpoint)
    .then(response => response.json())
    .then(json => {
      let list: Array<ContentData> = [];
      json.items.forEach(item => {
        const contentData = new ContentData(item);
        list.push(contentData);
      });
      return list;
    });
}

実際にGETしてみると以下のようなデータが返ってきました。コンテンツデータにはタイトルがないこと、コンテンツフィールドのデータが各コンテンツデータオブジェクトのdataに含まれることが特徴です。また、画像フィールドの場合は選択した画像アセットのIDが、コンテンツタイプフィールドの場合は選択したコンテンツデータのIDが格納されている点にも注目です。

テーブルフィールドは、table要素がない状態で出力されるようですね。編集画面におけるデータ編集のしやすさ、HTMLでブラウザ表示する時の扱いやすさは利点ですが、ネイティブアプリに表示するには...。今回はひとまず置いておきましょう。

コンテンツデータに格納したデータが全て含まれているので、一覧画面・詳細画面の両方で利用できそうですね。

{
  "items": [
    {
      "author": {
        "displayName": "Administrator",
        "id": "1",
        "userpicUrl": null
      },
      "basename": "d961e8a94f05fb0abca8b4724ffbb2005cb0204e",
      "blog": {
        "id": "1"
      },
      "createdDate": "2018-02-16T17:24:29+09:00",
      "data": [
        {
          "data": "与那覇前浜",
          "id": "1",
          "label": "スポット名",
          "type": "single_line_text"
        },
        {
          "data": "東洋一美しいと言われる絶景ビーチ!",
          "id": "3",
          "label": "キャッチコピー",
          "type": "single_line_text"
        },
        {
          "data": [
            "1"
          ],
          "id": "2",
          "label": "写真",
          "type": "asset_image"
        },
        {
          "data": "24.73516",
          "id": "4",
          "label": "緯度",
          "type": "number"
        },
        {
          "data": "125.26296",
          "id": "5",
          "label": "経度",
          "type": "number"
        },
        {
          "data": "トリップアドバイザーの「日本のベストビーチ トップ10」にいつもランクインするほど美しいビーチ。訪れる日が選べるなら梅雨が明けた7月頃をオススメしたい。",
          "id": "7",
          "label": "スポットの説明",
          "type": "multi_line_text"
        },
        {
          "data": null,
          "id": "8",
          "label": "公式URL",
          "type": "url"
        },
        {
          "data": "<tr>\r\n\t\t<th>所在地</th>\r\n\t\t<td>沖縄県宮古島市下地字与那覇914</td>\r\n\t</tr>\r\n\t<tr>\r\n\t\t<th>駐車可能台数</th>\r\n\t\t<td>30台</td>\r\n\t</tr>\r\n",
          "id": "11",
          "label": "各種データ",
          "type": "tables"
        }
      ],
      "date": "2018-02-16T17:23:36+09:00",
      "id": 1,
      "modifiedDate": "2018-02-17T15:49:13+09:00",
      "permalink": "http://mt7.localhost/",
      "status": "Publish",
      "unpublishedDate": null,
      "updatable": true
    },
    {
      "author": {
        "displayName": "Administrator",
        "id": "1",
        "userpicUrl": null
      },
      "basename": "0448b74774a81713818a9ffc9f019a0203db85a6",
      "blog": {
        "id": "1"
      },
      "createdDate": "2018-02-17T07:29:23+09:00",
      "data": [
        {
          "data": "ラソール ガーデン・アリビラ クリスティア教会",
          "id": "1",
          "label": "スポット名",
          "type": "single_line_text"
        },
        {
          "data": "きれいなビーチにそびえ立つ白亜の大聖堂",
          "id": "3",
          "label": "キャッチコピー",
          "type": "single_line_text"
        },
        {
          "data": [
            "3"
          ],
          "id": "2",
          "label": "写真",
          "type": "asset_image"
        },
        {
          "data": "26.41610",
          "id": "4",
          "label": "緯度",
          "type": "number"
        },
        {
          "data": "127.71440",
          "id": "5",
          "label": "経度",
          "type": "number"
        },
        {
          "data": null,
          "id": "7",
          "label": "スポットの説明",
          "type": "multi_line_text"
        },
        {
          "data": null,
          "id": "8",
          "label": "公式URL",
          "type": "url"
        },
        {
          "data": null,
          "id": "11",
          "label": "各種データ",
          "type": "tables"
        }
      ],
      "date": "2018-02-17T07:26:25+09:00",
      "id": 3,
      "modifiedDate": "2018-02-17T07:29:23+09:00",
      "permalink": "http://mt7.localhost/",
      "status": "Publish",
      "unpublishedDate": null,
      "updatable": true
    }
  ],
  "totalResults": "2"
}

コンテンツフィールドの各データを処理しやすいように整える

コンテンツフィールドのデータがdataに格納されているままでは扱いにくいので、観光スポットを管理する値オブジェクトを作成してデータを整えていきます。data内の各オブジェクトが何のデータかはidもしくはlabelで判別する必要がありそうです。今回はidを使用することとし、1ならばタイトル、2ならば画像データのように判別していきました。IDはフィールドを追加した順に付与されるようです。(フィールドを削除した場合そのIDはスキップされる。MySQLのAUTO_INCREMENTのような感じですね。)

ちなみに、緯度・経度データの保存に「数値フィールド」を使用したのですが、data(ここでいうdataは、data内の各オブジェクトが持つdata)の値は常に文字列なのでparseInt()parseFloat()で変換する必要がありました。

試作なのでクラス名をContentDataとしていますが、TouristSights等が良いのではないかと感じています。(この辺りはJavaScriptの設計の問題なのでこれも置いておきます。)

export default class ContentData {
  id: number;
  title: string;
  catchcopy: string;
  description: ?string;
  photoId: number;
  lat: number;
  lng: number;

  constructor(item: *) {
    this.id = item.id;
    item.data.forEach(field => {
      const id = parseInt(field.id);
      switch (id) {
        case 1:
          this.title = field.data;
          break;
        case 2:
          this.photoId = field.data[0];
          break;
        case 3:
          this.catchcopy = field.data;
          break;
        case 4:
          this.lat = parseFloat(field.data);
          break;
        case 5:
          this.lng = parseFloat(field.data);
          break;
        case 7:
          this.description = field.data;
          break;
        default:
          break;
      }
    });
  }
}

アプリの画面を制作する

コンテンツデータを使いやすいように整えたら、あとはアプリ側のコーディングです。とは言え「記事」が「コンテンツタイプ」に変化しただけなので、少しの調整とビューの制作だけで画面にコンテンツデータを表示することができます。

ただ今回ネックになるのは、先にも紹介したとおり画像フィールドの場合は選択した画像アセットのIDが格納されている点です。指定したアセットIDのデータを取得するエンドポイント/sites/:site_id/assets/:asset_idにアクセスすればアセットの情報は得られるのですが、例えばコンテンツデータが100あれば100回リクエストをする必要があるため、コンテンツデータ一覧画面で画像を表示したい場合はどうしようか悩ましいです。難しいことを考えても先に進まないので、今回はひとまずコンテンツデータ詳細画面でのみ画像を表示することにします。(アプリなので、起動時に一部の情報を予めダウンロードしておく等の方法がありそうですね。)

コンテンツデータ詳細画面のコードは以下のようになりました。StatecontentDataに観光スポットコンテンツデータが、photoに画像データが格納され、画面表示に利用しています。

// @flow
import config from './config';
import React, { Component } from 'react';
import { View, ScrollView, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
import MapView from 'react-native-maps';
import { Marker } from 'react-native-maps';
import ContentData from './ContentData';
import Asset from './Asset';
import { getContentData, getAsset } from './MTDataAPIService';
import type { NavigationScreenProp } from 'react-navigation';

type Props = {
  navigation: NavigationScreenProp<*>,
};

type State = {
  contentData: ?ContentData,
  photo: ?Asset,
};

class ContentDataScreen extends Component<Props, State> {
  static navigationOptions = ({ navigation }: { navigation: NavigationScreenProp<*> }) => {
    const { contentData } = navigation.state.params;
    return {
      title: contentData.title,
    };
  };

  constructor(props: Props) {
    super(props);
    this.state = {
      contentData: null,
      photo: null,
    };
  }

  componentDidMount() {
    const { navigation } = this.props;
    const { contentData } = navigation.state.params;
    this.setState({ contentData });

    const assetId = parseInt(contentData.photoId);
    getAsset(assetId)
      .then((photo) => {
        this.setState({ photo });
      });
  }

  render() {
    const { contentData, photo } = this.state;
    if (contentData && photo) {
      return (
        <View>
          <ScrollView style={ styles.container }>
            <Image
              source={{ uri: photo.url }}
              style={ styles.photo }
              resizeMode={ 'cover' }
            />
            <Text style={ styles.catchcopy }>
              { contentData.catchcopy }
            </Text>
            { contentData.description ? (
              <Text style={ styles.description }>
                { contentData.description }
              </Text>
            ) : null }
            <MapView
              style={ styles.map }
              initialRegion={{
                latitude: contentData.lat,
                longitude: contentData.lng,
                latitudeDelta: 0.1,
                longitudeDelta: 0.1,
              }}
            >
              <Marker
                coordinate={{ latitude: contentData.lat, longitude: contentData.lng }}
              />
            </MapView>
          </ScrollView>
        </View>
      );
    } else {
      return (
        <View style={ styles.indicator }>
          <ActivityIndicator />
        </View>
      );
    }
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#fff',
  },
  indicator: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  photo: {
    marginBottom: 20,
    width: '100%',
    height: 250,
  },
  catchcopy: {
    marginHorizontal: 10,
    marginBottom: 10,
    fontSize: 20,
    fontWeight: 'bold',
  },
  description: {
    marginHorizontal: 10,
    marginBottom: 20,
    lineHeight: 28,
    fontSize: 16,
  },
  map: {
    width: '100%',
    height: 250,
  },
})

export default ContentDataScreen;

シミュレーターでアプリを実行すると以下のような表示になりました。入力した緯度・経度を基に地図も表示されています。よい感じですね! 楽しい!
画面キャプチャ:iPhoneシミュレーターで出来上がったアプリを表示した画面

AndroidエミュレータでもiOSと同じように表示されました。
画面キャプチャ:Androidエミュレータで出来上がったアプリを表示した画面

さらなる発展を考える

参照されているコンテンツデータを取得したい

コンテンツタイプの定義に記したとおり、コンテンツタイプ「観光スポット」の他にコンテンツタイプ「モデルコース」も用意しています。モデルコースではコンテンツタイプフィールドを用意し、任意の観光スポットが選択できるようになっています。ここでモデルコースのデータをData APIで取得すると、データには紐付けられた観光スポットのコンテンツデータIDが含まれていることになります。しかし、観光スポットのデータをData APIで取得しても、例えば「与那覇前浜」のコンテンツデータはどのモデルコースに紐付けられているのかは分かりません。観光スポットのコンテンツデータ詳細画面で「このスポットを訪ねるモデルコース」を表示する場合どのようなロジックを組む必要があるのか、研究してみたいと思いました。