Qlitre's Blog

2022.07.22 Next.js /microCMS

Next.js, microCMS, Chakra UIで作るブログ ⑤タグでの絞り込み

Next.js, microCMS, Chakra-UIで作るブログシリーズの4つ目の記事です。
今回はタグでの絞り込み機能を実装させていきます。

タグリンクコンポーネントの作成

親からタグオブジェクトを受け取って、リンクラベルを表示するイメージです。

/* src/components/TagLink.tsx */
import {
    Link,
    Tag,
    TagLabel
} from "@chakra-ui/react";
import NextLink from "next/link";
import { FC } from "react";
import type { PostTag } from "types/blog";

export const TagLink: FC<{ tag: PostTag }> = ({ tag }) => {
    return (
        <NextLink key={tag.id} href={`/tags/${tag.id}/page/1`} passHref>
            <Link>
                <Tag variant='subtle' colorScheme='cyan'>
                    <TagLabel fontSize="md">{tag.name}</TagLabel>
                </Tag>
            </Link>
        </NextLink>
    );
};


ポスト一覧に表示

つぎにPostList.tsxを編集して、こちらのコンポーネントを呼び出します。

/* src/components/PostList.tsx */
import type { Post } from 'types/blog';
import {
    Box,
    Heading,
    Stack,
    Link,
    Text,
    Button,
    // 追加
    Wrap,
    WrapItem,
} from "@chakra-ui/react";
import { DateTime } from 'components/DateTime';
// 追加
import { TagLink } from 'components/TagLink';

type Props = {
    posts: Post[]
}

export const PostList = ({ posts }: Props) => {
    return (
        <>
            {posts.map(post => (
                <Box key={post.id}>
                    {/* 追加 */}
                    <Wrap>
                        {post.tag.map(tag => (
                            <WrapItem key={tag.id}>
                                <TagLink tag={tag} />
                            </WrapItem>
                        ))}
                    </Wrap>
                    ...省略
                </Box>
            ))}
        </>
    )
}



タグ一覧表示用のページを作成

無事に表示されたことを確認できたら、タグで絞り込まれた一覧を表示するページを作っていきましょう。
TagLink.tsx内ではリンクURLを/tags/${tag.id}/page/1としていました。
なのでpages内にtags→[tagId]→page→[id].tsxという階層でファイルを作成をします。
params.tagIdでタグのIDが、params.idでページナンバーが受け取れるような構成ですね。

少し長いです。

/* src/pages/tags/[tagId]/page/[id].tsx */
import { Pagination } from 'components/Pagination';
import { client } from 'libs/client';
import { GetStaticPaths, GetStaticProps, } from "next";
import type { Post } from "types/blog";
import { Header } from 'components/Header'
import { PostList } from 'components/PostList';
import type { PostTag } from 'types/blog';
import {
    Box,
    Container,
    Heading
} from "@chakra-ui/react";
import { BLOG_PER_PAGE } from 'settings/siteSettings';

type Props = {
    posts: Post[]
    totalCount: number
    currentPage: number
};

export default function BlogTagId({ posts, totalCount, currentPage }: Props) {
    return (
        <Box>
            <Header />
            <Container as="main" maxW="container.lg" marginTop="4" marginBottom="16">
                <Heading as="h1" marginBottom="8" fontSize="2xl">
                    Home
                </Heading>
                <PostList posts={posts} />
                <Pagination totalCount={totalCount} currentPage={currentPage} />
            </Container>
        </Box>
    );
}

const getAllTagPagePaths = async () => {
    const resTag = await client.getList<PostTag>({
        endpoint: 'tag',
        // タグを全て取得する必要があるが、記事数に比べると限定的
        // これ以上は増えないだろう、という値をいれておく
        queries: { limit: 100 }
    })

    const paths: string[][] = await Promise.all(
        // タグごとに繰りかえして、紐づいた記事一覧のGetリクエストを行い、totalCountを取得していく
        resTag.contents.map((item: PostTag) => {
            const result = client
                .getList<Post>({
                    endpoint: 'post',
                    queries: {
                        filters: `tag[contains]${item.id}`,
                    },
                })
                // タグごとにページ1…ページ2…とパスを生成していく
                .then(({ totalCount }) => {
                    const range = (start: number, end: number) =>
                        [...Array(end - start + 1)].map((_, i) => start + i)

                    return range(1, Math.ceil(totalCount / BLOG_PER_PAGE)).map(
                        (repo) => `/tags/${item.id}/page/${repo}`
                    )
                })
            return result
        })
    )
    // タグごとに配列になっているので、最後にフラットにして返す
    return paths.flat()
}

export const getStaticPaths: GetStaticPaths = async () => {
    const paths = await getAllTagPagePaths()
    return { paths, fallback: false }
}

export const getStaticProps: GetStaticProps<Props, { tagId: string, id: string }> = async ({ params }) => {
    if (!params) throw new Error("Error Tag ID Not Found");
    const tagId = params.tagId
    const pageId = Number(params.id);
    const data = await client.getList<Post>({
        endpoint: "post",
        queries: {
            offset: (pageId - 1) * BLOG_PER_PAGE,
            limit: BLOG_PER_PAGE, filters: `tag[contains]${tagId}`
        }
    });

    return {
        props: {
            posts: data.contents,
            totalCount: data.totalCount,
            currentPage: pageId,
        },
    };
};


少し複雑なのは、getAllTagPagePaths関数でしょうか。
今回のブログは静的生成する関係で有りうるパスをNext側へ全て渡してあげる必要があります。
ページネーションを作った時と基本的に同じ考えですが、それをタグごとに繰り返さないといけません。
以下のような処理の流れです。

  1. タグを全て取得
  2. タグごとに繰り返して紐づく記事数を取得
  3. 記事数を元に特定のタグごとのページパスを生成
  4. 最後に配列をフラットにして返す


タグを全て取得のところですが、今回はlimitを100にして設定しました。

const resTag = await client.getList<PostTag>({
        endpoint: 'tag',
        // タグを全て取得する必要があるが、記事数に比べると限定的
        // これ以上は増えないだろう、という値をいれておく
        queries: { limit: 100 }
    })


ここはtotalCountを取得して、totalCountをlimitに指定する方が確実性は高まると思います。
とはいえ、そのために一回リクエストを投げなくてもいいような場面である気もするので、こうしています。

この時点でタグをクリックすると絞り込まれたページが表示されます。

生活をクリックした状態。



プログラミングをクリックした状態



タグごとに絞り込まれた記事が表示されました。

ページネーションの編集

現状、1ページ目では絞り込まれた状態です。
しかし、2ページ目にアクセスすると、通常のページ送りの記事一覧に遷移してしまいます。
/tags/programing/page/1みたいなリンクから/page/2に遷移してしまうということですね。

こちらを解消するために、Pagination.tsxを編集しましょう。

/* src/components/Pagination.tsx */
import {
    Box,
    HStack,
    Link,
    Text
} from "@chakra-ui/react";
import { BLOG_PER_PAGE } from 'settings/siteSettings'

type Props = {
    totalCount: number;
    currentPage?: number;
    // 追加 オプションでタグIDを受け取れるように
    tagId?: string;
};

// 変更 tagIdを受け取る
export const Pagination = ({ totalCount, currentPage = 1, tagId }: Props) => {
    const range = (start: number, end: number) =>
        [...Array(end - start + 1)].map((_, i) => start + i)
    const pageCount = Math.ceil(totalCount / BLOG_PER_PAGE)

    // 追加 tagIdが存在するときはタグ一覧用のページリンクを返す
    const getPath = (p: number) => {
        if (tagId) return `/tags/${tagId}/page/${p}`
        return `/page/${p}`
    }

    const getPaginationItem = (p: number) => {
        if (p === currentPage) return <Text color="gray.700" fontSize="3xl">{p}</Text>
        // 変更 getPath関数でリンクを取得
        return <Link href={getPath(p)} color="gray.400" fontSize="3xl">{p}</Link>
    }
    return (
        <HStack spacing='10' justifyContent="center">
            {range(1, pageCount).map((number, index) => (
                <Box key={index}>
                    {getPaginationItem(number)}
                </Box>
            ))}
        </HStack >
    );
};


タグIDをオプショナルに受け取り、存在する場合はタグ用の一覧を返すように編集しました。

次にタグ一覧ページから呼び出します。

/* src/pages/tags/[tagId]/page/[id].tsx */
import { Pagination } from 'components/Pagination';

... 省略

type Props = {
    posts: Post[]
    totalCount: number
    currentPage: number
    // 追加
    tag: PostTag
};

// 変更 tagを受け取る
export default function BlogTagId({ posts, totalCount, currentPage, tag }: Props) {
    return (
        <Box>
            <Header />
            <Container as="main" maxW="container.lg" marginTop="4" marginBottom="16">
                <Heading as="h1" marginBottom="8" fontSize="2xl">
                    Home
                </Heading>
                <PostList posts={posts} />
                {/* 変更 tagのidをpaginationに渡す */}
                <Pagination totalCount={totalCount} currentPage={currentPage} tagId={tag.id} />
            </Container>
        </Box>
    );
}

...省略

export const getStaticProps: GetStaticProps<Props, { tagId: string, id: string }> = async ({ params }) => {
    if (!params) throw new Error("Error Tag ID Not Found");
    const tagId = params.tagId
    const pageId = Number(params.id);

    const data = await client.getList<Post>({
        ...省略
    });

    // 追加 タグオブジェクトを取得
    const tag = await client.getListDetail<PostTag>({
        endpoint: 'tag', contentId: tagId
    })

    return {
        props: {
            posts: data.contents,
            totalCount: data.totalCount,
            currentPage: pageId,
            // 追加
            tag: tag
        },
    };
};


これで正しくページ送りがされるようになりました。

パンくずリストを表示する

最後にタグで絞り込まれた時にパンくずリストを使って、ユーザーに表示をするようにしていきましょう。
コンポーネント内にBreadcrumbs.tsxを作成します。

/* src/components/Breadcrumbs.tsx */
import {
    Breadcrumb,
    BreadcrumbItem,
    BreadcrumbLink,
} from "@chakra-ui/react";
import { ChevronRightIcon } from "@chakra-ui/icons";
import type { PostTag } from 'types/blog';

type Props = {
    tag?: PostTag;
};

export const Breadcrumbs = ({ tag }: Props) => {
    return (
        <Breadcrumb spacing='8px' separator={<ChevronRightIcon color='gray.500' fontSize="xl" fontWeight="bold" />} mb="8">
            <BreadcrumbItem>
                <BreadcrumbLink href='/' fontSize="2xl" fontWeight="bold" >Home</BreadcrumbLink>
            </BreadcrumbItem>
            {tag && (
                <BreadcrumbItem>
                    <BreadcrumbLink href={`/tags/${tag.id}/page/1`} fontSize="2xl" fontWeight="bold">{tag.name}</BreadcrumbLink>
                </BreadcrumbItem>
            )}
        </Breadcrumb>
    );
};


任意でタグが渡された場合はタグ名を表示をするようなイメージですね。
あとはこれを各一覧コンポーネントから呼び出します。

/* src/pages/index.tsx */
// 追加
import { Breadcrumbs } from 'components/Breadcrumbs';

const Home: NextPage<Props> = ({ posts, totalCount }) => {
  return (
    <>
      <Header />
      <Container as="main" maxW="container.lg" marginTop="4" marginBottom="16">
        {/* 変更 */}
        <Breadcrumbs />
        <PostList posts={posts} />
        <Pagination totalCount={totalCount}></Pagination>
      </Container>
    </>
  )
}


/* src/pages/page/[id].tsx */

import { Breadcrumbs } from 'components/Breadcrumbs';

export default function BlogPageId({ posts, totalCount, currentPage }: Props) {
    return (
        <Box>
            <Header />
            <Container as="main" maxW="container.lg" marginTop="4" marginBottom="16">
                <Breadcrumbs />
                <PostList posts={posts} />
                <Pagination totalCount={totalCount} currentPage={currentPage} />
            </Container>
        </Box>
    );
}


/* src/pages/tags/[tagId]/page/[id].tsx */

import { Breadcrumbs } from 'components/Breadcrumbs';

export default function BlogTagId({ posts, totalCount, currentPage, tag }: Props) {
    return (
        <Box>
            <Header />
            <Container as="main" maxW="container.lg" marginTop="4" marginBottom="16">
                <Breadcrumbs tag={tag} />
                <PostList posts={posts} />                
                <Pagination totalCount={totalCount} currentPage={currentPage} tagId={tag.id} />
            </Container>
        </Box>
    );
}


タグ付き一覧の時のみ、タグ名が良い感じに表示されました。


おわりに

大体やりたいことができた気がするので、一旦このシリーズを終わりにしようと思います。
その他Next.js関連の記事を書く際は個別に切り出していきます。

ソースコード

ここまでのソースコードはgithubにアップしました。
https://github.com/qlitre/nextjs-microcms-chakra-blog-template