ブログ構築 7. タグ機能の構築 #03
2019年10月30日に公開

Contentfulのincludesを使って関連モデルを取得しタグ一覧ページを作成する

今回達成すること

今回は、タグ一覧ページを作成していきます。

同時に、Contentfulのincludesプロパティを使って、記事に関連付いたモデルを取得するよう変更を加えます。

この変更により、Contentfulへ一回のリクエストを行うだけで「blogPost」「category」「tag」の3つのモデルを取得できるようになります。

タグ一覧ページの完成イメージ

2019-10-30 11-25-53

タグ取得の要件

作業に取り掛かる前に、タグ取得の要件を整理しておきます。

今回タグ一覧ページに表示するタグは、記事が関連付いたタグのみを表示します。

どの記事にも関連付いていない、単一で存在するタグは取得しません。

記事に関連づいたタグだけを取得するには?

Contentfulでは、関連先(tag)から関連元(blogPost)の参照を行うことができません。

images orignal.003

逆に、関連元(blogPost)からの参照は可能です。

images orignal.004

関連先データは、includesプロパティに保存されている

この関連先のデータを取得するには、includesプロパティ を使います。

下の画像は、store/index.jsgetPosts()にconsole.logを追加した画像です。

store/index.js
export const actions = {
  
  async getPosts({ commit }) {
    await client.getEntries({
      content_type: process.env.CTF_BLOG_POST_TYPE_ID,
      order: '-fields.publishDate'
    }).then((res) => {
      consolole.log(res)						// 追記した
      commit('setPosts', res.items)
    }).catch(console.error)
  },
  ...
}
Contentfulから返ってきたresponseの中身

2019-10-25 14-15-42

  • で囲まれている部分

    res.includes.EntryにblogPostモデルに関連付いたデータが入っています。

    ここには、関連先のカテゴリーモデルとタグモデルのデータがごっちゃに入っています。

  • 黄色で囲まれている部分

    res.itemsにはcontent_typeで指定した、ブログ記事モデルのデータが入っています。

試しにどの記事にも関連づかないtagを作成してみてください 🙂

res.includes.Entryには表示されません。

記事に関連づいたタグだけを取得するには?の答え

  • 記事に関連付いたタグだけを取得するには、includesプロパティを使う
  • includesプロパティを使うことによって、記事に関連付いたタグだけを取得することができる

タグ取得の実装に取り掛かる

それでは実装に取り掛かりましょう。

まず、store/index.jsにタグ取得のコードを書いていきます。

(最終的なindex.jsは下にあります)

ついでにincludesからカテゴリーも取得しましょう。

store/index.js

export const state = () => ({
	...
  tags: []				// 追記
})

export const getters = {
	...
  // 追記
  associatePosts: state => (currentTag) => {
    const posts = []
    for (let i = 0; i < state.posts.length; i++) {
      const post = state.posts[i]
      if (post.fields.tags) {
        const tag = post.fields.tags.find(tag => tag.sys.id === currentTag.sys.id)

        if (tag) posts.push(post)
      }
    }
    return posts
  }
}

export const mutations = {
	
  setPosts(state, payload) {
    state.posts = payload
  },
 
  // 削除する
  // setCategories(state, payload) {
  //   state.categories = payload
  // },

  // 追記
  setLinks(state, entries) {
    state.tags = []
    state.categories = []
    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i]
      if (entry.sys.contentType.sys.id === 'tag') state.tags.push(entry)
      else if (entry.sys.contentType.sys.id === 'category') state.categories.push(entry)
    }
    state.categories.sort((a, b) => a.fields.sort - b.fields.sort)
  }

}

export const actions = {
  
  async getPosts({ commit }) {
    await client.getEntries({
      content_type: process.env.CTF_BLOG_POST_TYPE_ID,
      order: '-fields.publishDate',
      include: 1																// 追記
    // 削除
    // }).then(res => 
    //   commit('setPosts', res.items)
    // ).catch(console.error)

    // 変更・追記
    }).then((res) => {
      commit('setLinks', res.includes.Entry)
      commit('setPosts', res.items)
    }).catch(console.error)
  }
	
  // 削除する
  
  // async getCategories({ commit }) {
  //   await client.getEntries({
  //     content_type: 'category',
  //     order: 'fields.sort'
  //   }).then(res =>
  //     commit('setCategories', res.items)
  //   ).catch(console.error)
  // }
}
  • associatePosts: state => (currentTag)

    この算出プロパティは、tagPostsから引数に渡したcurrentTagに関連付くpostの配列を返します。

  • getPosts()からsetLinks()へコミットするようにコードを追加

    res.includes.Entryには、カテゴリーモデルとタグモデルが混ざっているため、setLinks()で振り分けを行っています。

    振り分けの基準は、entry.sys.contentType.sys.idに保管されているモデル名です。

  • getPosts({ ... , include: 1

    Contentfulのincludeプロパティは、関連先の階層を表します。

    0〜10を指定できます。

    例えば、tagに関連づくモデル(いわゆる孫モデル)までを取得したい場合は「2」を指定します。

    デフォルトは「1」なので結果は変わりませんが、明示的に指定しています。

    参考

    Contentful - API - Links

  • cats.sort((a, b) => a.fields.sort - b.fields.sort)

    モデルの並び替えは自分で行わなければなりません。

    カテゴリーモデルはsortの小さい順に並び替えるよう、Javascriptのsortメソッドを使用しています。

最終的なstore/index.js

下が最終的なstore/index.jsです。

操作に詰まったら、コピペしてください。

import defaultEyeCatch from '~/assets/images/defaultEyeCatch.png'
import client from '~/plugins/contentful'

export const state = () => ({
  posts: [],
  categories: [],
  tags: []
})

export const getters = {
  setEyeCatch: () => (post) => {
    if (!!post.fields.image && !!post.fields.image.fields) return { url: `https:${post.fields.image.fields.file.url}`, title: post.fields.image.fields.title }
    else return { url: defaultEyeCatch, title: 'defaultImage' }
  },

  draftChip: () => (post) => {
    if (!post.fields.publishDate) return 'draftChip'
  },

  linkTo: () => (name, obj) => {
    return { name: `${name}-slug`, params: { slug: obj.fields.slug } }
  },

  relatedPosts: state => (category) => {
    const posts = []
    for (let i = 0; i < state.posts.length; i++) {
      const catId = state.posts[i].fields.category.sys.id
      if (category.sys.id === catId) posts.push(state.posts[i])
    }
    return posts
  },

  associatePosts: state => (currentTag) => {
    const posts = []
    for (let i = 0; i < state.posts.length; i++) {
      const post = state.posts[i]
      if (post.fields.tags) {
        const tag = post.fields.tags.find(tag => tag.sys.id === currentTag.sys.id)

        if (tag) posts.push(post)
      }
    }
    return posts
  }
}

export const mutations = {
  setPosts(state, payload) {
    state.posts = payload
  },

  setLinks(state, entries) {
    state.tags = []
    state.categories = []
    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i]
      if (entry.sys.contentType.sys.id === 'tag') state.tags.push(entry)
      else if (entry.sys.contentType.sys.id === 'category') state.categories.push(entry)
    }
    state.categories.sort((a, b) => a.fields.sort - b.fields.sort)
  }

}

export const actions = {
  async getPosts({ commit }) {
    await client.getEntries({
      content_type: process.env.CTF_BLOG_POST_TYPE_ID,
      order: '-fields.publishDate',
      include: 1
    }).then((res) => {
      commit('setLinks', res.includes.Entry)
      commit('setPosts', res.items)
    }).catch(console.error)
  }

}

続いてmiddleware/getContentful.js内のカテゴリー取得を削除します。

middleware/getContentful.js
export default async ({ store }) => {
  if (!store.state.posts.length) await store.dispatch('getPosts')

  // 削除する
  // if (!store.state.categories.length) await store.dispatch('getCategories')
}

これで、一回のリクエストだけで「blogPost」「category」「tag」の3つのモデルが取得できるようになりました。

tags/_slug.vueのタグ取得方法を変更する

Vuexにタグを保管できるようになったので、pages/tags/_slug.vueのタグの取得方法を変更しましょう。

pages/tags/_slug.vue
<script>
  
// 削除する
// import client from '~/plugins/contentful'

export default {
	...
  // asyncは削除する
	asyncData({ payload, params, error, store, env }) {
    // 追記
    const tag = payload || store.state.tags.find(tag => tag.fields.slug === params.slug)

		// 削除する
    // let tag = payload
    // if (!tag) {
    //   for (let i = 0; i < store.state.posts.length; i++) {
    //     const tags = store.state.posts[i].fields.tags
    //     if (tags) tag = tags.find(tag => tag.fields.slug === params.slug)
    //     if (tag) break
    //   }
    // }
    if (tag) {
      // 追記
      const relatedPosts = store.getters.associatePosts(tag)
      
      // 削除する
      // const relatedPosts = await client.getEntries({
      //   content_type: env.CTF_BLOG_POST_TYPE_ID,
      //   'fields.tags.sys.id': tag.sys.id,
      //   order: '-fields.publishDate'
      // }).then(res => res.items).catch(console.error)

      return { tag, relatedPosts }
    } else {
      error({ statusCode: 400 })
    }
  }
}
</script>
  • const relatedPosts = store.getters.associatePosts(tag)

    先ほどindex.jsに作成したassociatePostsを呼び出しています。

タグの個別ページが表示できたらここまでの編集はOKです。

2019-10-27 16-39-00

タグ一覧ページを編集する

続いてpages/tags/index.vueを編集していきます。

動作が分かるよう、step順に説明します。

【step1】tags/index.vueにタグ一覧を表示する

まずはタグの名前とリンクを表示します。

pages/tags/index.vue(タグの名前とリンクを表示する)
<template>
  <div>
    <breadcrumbs />
    <div
      v-for="(tag, i) in tags"
      :key="i"
    >
      <nuxt-link
        :to="linkTo('tags', tag)"
      >
        {{ tag.fields.name }}
      </nuxt-link>
    </div>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  computed: {
    ...mapState(['tags']),
    ...mapGetters(['linkTo'])
  }
}
</script>

このように表示されましたか?

リンクもうまく機能しているはずです。

2019-10-27 12-44-24

【step2】タグに関連づく記事数を表示する

次は、記事数を表示しましょう。

記事数はindex.jsに作成した、associatePostsを使います。

tags/index.vueにpostCount()の算出プロパティを追加します。

pages/tags/index.vue
<template>
  <div>
    <breadcrumbs />

    <div
      v-for="(tag, i) in tags"
      :key="i"
    >
      <nuxt-link
        :to="linkTo('tags', tag)"
      >
        {{ tag.fields.name }}
        {{ postCount(tag) }}	<!-- 追記 -->
      </nuxt-link>
    </div>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  computed: {
    ...mapState(['tags']),
    ...mapGetters(['linkTo']),
    // 追記
    postCount() {
      return (currentTag) => {
        return this.$store.getters.associatePosts(currentTag).length
      }
    }
  }
}
</script>

タグの記事数が表示されましたね。

リンクに飛んで記事数を確認して、ちゃんと一致していれば成功です!

2019-10-27 16-45-08

【step3】パンくずリストを追加する

現状のタグ一覧ページは、一目で「今どこのページにいるか」がわかりません。

そこでパンくずリストを追加します。

pages/tags/index.vue
<template>
  <div>
    <breadcrumbs :add-items="addBreads" />  <!-- add-itemsを追加する -->

    ...
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  computed: {
		...
    // 追記
    addBreads() {
      return [{ icon: 'mdi-tag-outline', text: 'タグ一覧', to: '/tags', disabled: true, iconColor: 'grey' }]
    }
  }
}
</script>
  • return [{ ... disabled: true

    disabledプロパティは、リンクを無効化するものです。

    Vuetifyのデフォルトで用意されています。

    参考

    Vuerify - breadcrumbs

おやおや、iconだけ色がついて不格好ですね。編集を加えましょう。

2019-10-30 11-16-29

iconカラーを変更できるように、components/ui/breadcrumbs.vueを編集します。

components/ui/breadcrumbs.vue
<template>
  ...
    <v-icon
      v-if="props.item.icon"
      :color="iconColor(props.item)"   <!-- colorを変更 -->
    >
      {{ props.item.icon }}
    </v-icon>
 ...
</template>

<script>
export default {
	...
  computed: {
    ...
    // 追記
    iconColor() {
      return (item) => {
        return item.iconColor || 'primary'
      }
    }
  }
}
</script>
  • iconColor()

    この算出プロパティは、渡されたitemにiconColorというプロパティが存在すればそのcolorを返します。

    存在しなければ、デフォルトで指定したprimayが返されます。

これで見栄え良くなりましたね。下の画像がタグ一覧ページの完成イメージです。

2019-10-30 11-25-53

【step4】本番環境でサーバーサイドレタリングを行うセッティング

さあ、本番環境のセッティングを行いましょう。

generateプロパティへタグの動的ルーティングを追加します。

この設定により、タグの個別ページもサーバーサイドレタリングを行うことができます。

nuxt.config.jsgenerateプロパティは、最終的にこのようになります。

nuxt.config.js
...

export default {

	...
  generate: {
    routes() {
      return Promise.all([
        client.getEntries({
          content_type: process.env.CTF_BLOG_POST_TYPE_ID
        }),
        client.getEntries({
          content_type: 'category'
        }),
        client.getEntries({
          content_type: 'tag'
        })
      ]).then(([posts, categories, tags]) => {
        return [
          ...posts.items.map((post) => {
            return { route: `posts/${post.fields.slug}`, payload: post }
          }),
          ...categories.items.map((category) => {
            return { route: `categories/${category.fields.slug}`, payload: category }
          }),
          ...tags.items.map((tag) => {
            return { route: `tags/${tag.fields.slug}`, payload: tag }
          })
        ]
      })
    }
  }

}

うまく設定できたか確認してみましょう。

一旦、Nuxt.jsのサーバーをストップしてコマンドを実行します。

$ yarn generate

ターミナルを確認しましょう。

“tags/タグslug” が出力されていたら、ちゃんと設定できている証拠です。

2019-10-30 11-50-06

さて、ここまでの変更をpushする

お疲れ様でした。

ここまでの変更をpushしましょう。

$ git add -A 
$ git commit -m "finish_tag_pages"

ブランチを作成している方は、一度masterブランチに戻って、tagブランチをマージしてpushします。

$ git checkout master
$ git merge tag
$ git push

本番環境のURLを確認してみよう

Netlifyのデプロイ完了したら、本番環境URLで確認してみましょう。

筆者のURLは" https://demo-blog.cloud-acct.com/ "です。

「タグ一覧ページ」「タグ個別ページ」「パンくずリスト」はうまく機能していますか?

下書きの記事については、記事カウントにも含まれていないはずです。

本番環境では「Ruby」の投稿数が2になっている(下書き記事が含まれないため)

2019-10-30 12-03-57

おわりに

今回は、タグ一覧ページを作成していきました。

これで、ブログ機能に欠かせない、「記事」「カテゴリー」「タグ」の3本柱が完成しました。

やっと骨組みが完成しましたね!

次回は?

さて、次回はタグ一覧ページをスタイリングしていきます。

Vuetify2のdata-tableの使い方を学びながら、タグ一覧ページを見栄え良くします。

どうぞ、お楽しみに。

現在、カテゴリー「Rails apiとNuxt.jsでSPA開発」のデモアプリを構築中です。記事になるまでもう少々のお時間が必要です。ブログの更新が止まって申し訳ありません。デモアプリの進捗状況は こちらの記事 で随時お伝えしてまいります。
スポンサー広告
次の記事はこちらです
ブログ構築
1. 今回作るアプリケーション
#01
Nuxt.jsとContentfulで作るマイブログ
今日のTweet
スポンサー広告