今回達成すること
今回は、タグ一覧ページを作成していきます。
同時に、Contentfulのincludesプロパティを使って、記事に関連付いたモデルを取得するよう変更を加えます。
この変更により、Contentfulへ一回のリクエストを行うだけで「blogPost」「category」「tag」の3つのモデルを取得できるようになります。
タグ一覧ページの完成イメージ

タグ取得の要件
作業に取り掛かる前に、タグ取得の要件を整理しておきます。
今回タグ一覧ページに表示するタグは、記事が関連付いたタグのみを表示します。
どの記事にも関連付いていない、単一で存在するタグは取得しません。
記事に関連づいたタグだけを取得するには?
Contentfulでは、関連先(tag)から関連元(blogPost)の参照を行うことができません。

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

関連先データは、includesプロパティに保存されている
この関連先のデータを取得するには、includesプロパティ を使います。
下の画像は、getPosts()に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の中身

- 
赤で囲まれている部分 res.includes.EntryにblogPostモデルに関連付いたデータが入っています。ここには、関連先のカテゴリーモデルとタグモデルのデータがごっちゃに入っています。 
- 
黄色で囲まれている部分 res.itemsにはcontent_typeで指定した、ブログ記事モデルのデータが入っています。
試しにどの記事にも関連づかないtagを作成してみてください :slightly_smiling_face:
res.includes.Entryには表示されません。
記事に関連づいたタグだけを取得するには?の答え
- 記事に関連付いたタグだけを取得するには、includesプロパティを使う
- includesプロパティを使うことによって、記事に関連付いたタグだけを取得することができる
タグ取得の実装に取り掛かる
それでは実装に取り掛かりましょう。
まず、
(最終的な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: 1Contentfulのincludeプロパティは、関連先の階層を表します。 0〜10を指定できます。 例えば、tagに関連づくモデル(いわゆる孫モデル)までを取得したい場合は「2」を指定します。 デフォルトは「1」なので結果は変わりませんが、明示的に指定しています。 参考 
- 
cats.sort((a, b) => a.fields.sort - b.fields.sort)モデルの並び替えは自分で行わなければなりません。 カテゴリーモデルはsortの小さい順に並び替えるよう、Javascriptのsortメソッドを使用しています。 
最終的な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
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
<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です。

タグ一覧ページを編集する
続いて
動作が分かるよう、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>
このように表示されましたか?
リンクもうまく機能しているはずです。

【step2】タグに関連づく記事数を表示する
次は、記事数を表示しましょう。
記事数はindex.jsに作成した、associatePostsを使います。
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>
タグの記事数が表示されましたね。
リンクに飛んで記事数を確認して、ちゃんと一致していれば成功です!

【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: truedisabledプロパティは、リンクを無効化するものです。 Vuetifyのデフォルトで用意されています。 参考 
おやおや、iconだけ色がついて不格好ですね。編集を加えましょう。

breadcrumbsのiconカラーを変更する
iconカラーを変更できるように、
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が返されます。 
これで見栄え良くなりましたね。下の画像がタグ一覧ページの完成イメージです。

【step4】本番環境でサーバーサイドレタリングを行うセッティング
さあ、本番環境のセッティングを行いましょう。
generateプロパティへタグの動的ルーティングを追加します。
この設定により、タグの個別ページもサーバーサイドレタリングを行うことができます。
generateプロパティは、最終的にこのようになります。
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" が出力されていたら、ちゃんと設定できている証拠です。

さて、ここまでの変更を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になっている(下書き記事が含まれないため)

おわりに
今回は、タグ一覧ページを作成していきました。
これで、ブログ機能に欠かせない、「記事」「カテゴリー」「タグ」の3本柱が完成しました。
やっと骨組みが完成しましたね!
次回は?
さて、次回はタグ一覧ページをスタイリングしていきます。
Vuetify2のdata-tableの使い方を学びながら、タグ一覧ページを見栄え良くします。
どうぞ、お楽しみに。