今回達成すること
今回は、タグ一覧ページを作成していきます。
同時に、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: 1
Contentfulの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: true
disabledプロパティは、リンクを無効化するものです。
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の使い方を学びながら、タグ一覧ページを見栄え良くします。
どうぞ、お楽しみに。