Next.js + TypescriptでミニCMSを作る（4. レビュー一覧画面の作成）

# そろそろ実装に入ります。
:::note info
[前回](https://qiita.com/AndanteSysDes/items/9353e541b0cf9fd37c36)の続きです。
[初回はこちらです。](https://qiita.com/AndanteSysDes/items/85178d222b9c1dfc7105)
:::

まずはレビュー一覧画面に手を加えていきます。ワイヤーフレームはこんな感じ。（再掲）

![レビュー一覧.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/9f15e927-d466-2f91-838a-e9605e7291e7.png)

## レビュー一覧画面のコンポーネント構成
メニューとパンくずリストはページが増えた後で作ろうと思うので、それ以外（「レビュー一覧」のヘディングテキスト以下）を作っていきます。

コンポーネントの構成は、大まかにこんな感じで考えてみました。

![Component-Structure-ReviewList.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/ee0b4e81-8de9-e515-65a4-1e43dd1e7ea2.png)


### dashboard/review
サーバーコンポーネント。レビュー一覧ページ全体を包括します。
DBデータに依存しないパーツを表示し、**ページがリクエストされたときにDBからレビューデータ取得して、Propsとして子コンポーネントに渡します。**

### components/ReviewCardContainer
クライアントコンポーネント。親からレビューリストを受け取って、個々のレビューデータを子となるReviewCardコンポーネントに渡します。ReviewCardに削除ボタンをつけていて、**削除を実行したとき画面のカードリストが即時反映してほしいので**クライアントコンポーネントにしています。
削除ボタンの動作については別の記事でまとめます。

### components/ReviewCard
サーバーコンポーネント。ただし、今回は親がクライアントコンポーネントなので、このページはデフォルトでクライアントコンポーネントとして機能します。（コンポーネントに`'use client'`のキーワードがない場合、呼び出される親と同じ種類のコンポーネントになります。）
親からレビューデータを受け取り、カードとして表示します。
内部に細かいコンポーネントを内包していますが、今は割愛します。


# データ取得～レビューカードの表示までの実装
前置きが大変長くなりましたが、ここからNext.jsの実装を進めます。

## 準備
まず、`globals.css`のリセットと、Prismaクライアント、テストデータの準備をします。
画面で使うアイコン素材も、`public`フォルダに入れておきます。

### `globals.css`
中身をすべて削除しましょう。
私は今回、ワイヤーフレームを作る際にCSSを一緒に書き出しているので、色やフォントなど全ページ共通で使えるものはこちらの`globals.css`に記載しています。
CSSの話は今回の趣旨からは外れますので、ソースコードは以下に隠しておきますね。

<details><summary>globals.css</summary>

```css: globals.css
:root {

  /* Colors: */
  --custom-main-color: #00247A;
  --custom-base-color: #F1F1F4;
  --custom-sub-color: #E7E7F2;
  --custom-sub-dark-color: #7C8AAC;
  --custom-accent-color: #8F8FFF;
  --custom-accent-light-color: #ceceff;
  --custom-gray-color: #4C4C4C;
  --custom-gray-light-color: #C0C0C0;
  --custom-white-color: #FaFaFa;
  --custom-warning-color: #d40125; 

  /* Font/text values */
  --default-font-family-inter: Inter;
  --default-font-style-normal: normal;
  --default-font-weight-600: 600px;
  --default-font-weight-bold: bold;
  --default-font-weight-normal: normal;
  --default-font-size-11: 11px;
  --default-font-size-14: 14px;
  --default-font-size-16: 16px;
  --default-font-size-18: 18px;
  --default-font-size-20: 20px;
  --default-font-size-24: 24px;
  --default-font-size-28: 28px;
  --default-character-spacing-0: 0px;
  --default-line-spacing-14: 14px;
  --default-line-spacing-18: 18px;
  --default-line-spacing-20: 20px;
  --default-line-spacing-24: 24px;
  --default-line-spacing-34: 34px;
  --default-line-spacing-36: 36px;
  --default-decoration-underline: underline;
}
  
html * {
  box-sizing: border-box;
}

body {
    margin: 0;
    background-color: var(--custom-base-color);

}

  /* Character Styles */
h1,
.heading1 {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-bold);
  font-size: var(--default-font-size-28);
  line-height: var(--default-line-spacing-34);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
  margin-top: 20px;
  margin-bottom: 16px;
}

h2,
.heading2 {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-bold);
  font-size: var(--default-font-size-20);
  line-height: var(--default-line-spacing-24);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
  margin-top: 10px;
  margin-bottom: 8px;
}

p {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-14);
  line-height: var(--default-line-spacing-18);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
  margin-top: 8px;
  margin-bottom: 8px;
}

h3,
.heading3 {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-18);
  line-height: var(--default-line-spacing-20);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
}
.menu-font {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-600);
  font-size: var(--default-font-size-24);
  line-height: var(--default-line-spacing-36);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-gray-color);
}
.memo-link {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-11);
  line-height: var(--default-line-spacing-14);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-sub-dark-color);
  text-decoration: var(--default-decoration-underline);
}
.memo {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-11);
  line-height: var(--default-line-spacing-14);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-sub-dark-color);
}
.placeholder {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-16);
  line-height: var(--default-line-spacing-18);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-gray-light-color);
}
.tag {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-11);
  line-height: var(--default-line-spacing-14);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-white-color);
}
.errorMessage {
  color: var(--custom-warning-color);
  font-weight: var(--default-font-weight-bold);
}
```

</details>


### Prismaクライアント
Prismaを使いたいときはPrismaクライアントを使いますが、各所でインスタンスを作るのは効率が悪いので、共通して使える単一インスタンスをつくります。

```ts: lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
```

これで各ページに`prisma`をimportすることで利用することができます。

さらに、Prismaクライアントを利用して、レビューデータの型を設定しておくことで、Props受け渡しの際に型を定義するのが楽になります。
レビュー一覧画面では、`createdAt`,`updatedAt`以外のReviewテーブルの各カラムと、各レコードにリレーションを持っているProductテーブルの`id`,`name`があればOKなので、それを設定します。
共通利用ができるよう、定義する型は`app/types/index.d.ts`にまとめます。

```typescript: app/types/index.d.ts
import { Review } from '@prisma/client'

export type ReviewWithProductId = Omit < Review,
"createdAt" | "updatedAt" > & {
    products: {
        id: number,
        name: string | null,
    }[] | []
}
```

### テストデータ
Series, Productテーブルに適当なレコードをいくつか追加します。
それと、Reviewテーブルに、以下の３パターン、ダミーデータを作成しておきます。
- 下書き用（`isDraft` = true）
- トップ掲載記事（`isDraft` = false && `displayTop` = true）
- その他の公開記事（`isDraft` = false && `displayTop` = false）

それぞれのレビューレコードに、製品情報を紐づけておきましょう。

`npx prisma studio`でPrisma Studioを起動すると、簡単にデータをセットできます。


## dashboard/review
まずはページを構成する親コンポーネントを作ります。

まず、ディレクトリを切ります。管理画面は、`dashboard/`の配下にあってほしいので、`app`フォルダの下に`dashboard/`、さらにその下に`review/`のディレクトリを作ります。
`dashboard/review/`のディレクトリ配下に、`page.tsx`と`reviews.module.css`を作ります。CSSをモジュールに切り分けることで、コンポーネントを使いまわした際のCSS衝突、デザイン崩れを防ぐことができます。

```tsx: dashboard/review/page.tsx
import Link from "next/link";
import styles from "./reviews.module.css";
import { prisma } from "@/lib/prisma";
import { ReviewWithProductId } from "@/app/types";
import ReviewCardContainer from "@/app/components/ReviewCardContainer/ReviewCardContainer";

export default async function ReviewList() {
    const reviews : ReviewWithProductId[] = await prisma
    .review
    .findMany({
        omit: {
            createdAt :true,
            updatedAt :true,
        },
        include: {
            products : {
                select: {
                    id: true,
                    name: true
                }
            }
        }
    });
    
    const drafts = reviews.filter(review => review.isDraft)
    const topArticles = reviews.filter(review => review.displayTop && (!review.isDraft))
    const otherArticles = reviews.filter(review => (!review.displayTop) && (!review.isDraft))

    return (
        <div className={styles.container}>
            <div className={styles.headContainer}>
                <h1>レビュー一覧</h1>
                <Link href="/dashboard/review/new" className={styles.textButton}>
                    新規投稿
                </Link>
            </div>
            
            <ReviewCardContainer title="下書き" reviews={drafts}/>
            <ReviewCardContainer title="トップページ掲載中" reviews={topArticles}/>
            <ReviewCardContainer title="その他" reviews={otherArticles}/>

        </div>
    )
}
```


<details><summary>CSS</summary>

```css: dashboard/review/reviews.module.css
.container{
  width: 100%;
  max-width: 1072px;
  margin: 10px auto;
}

.headContainer {
  display: flex;
  align-content: space-between;
  justify-content: space-between;
}

.textButton{
  text-decoration: none;
  box-sizing: content-box;
  background-color: var(--custom-white-color);
  margin: auto 0px;
  display: block;
  padding: 8px 16px;
  border-radius: 3px;
  border: solid  var(--custom-main-color) 1px;
  color: var(--custom-main-color);
  font-weight: bold;
}

.textButton:hover {
    opacity: 0.7;
}
```

</details>

取得したデータをフラグによって３種類（drafts / topArticles / otherArticles）に分類分けします。
この分けたデータをそれぞれ子コンポーネント`ReviewCardContainer`にPropsとして渡してあげる構造です。

データはPrismaを使ってReviewテーブルの情報を取得しています。
`omit`,`include`,`select`キーワードを使って、必要なデータのみ取得することで、応答のサイズを小さくし、クエリ速度を向上させることができます。
クエリ部分は`lib/prisma.ts`で設定した型に合わせています。今後DBスキーマを拡張した際に、設定した型以外の余計なデータが入ってくるリスクも減らすことができるので、積極的に使っていきましょう。

https://www.prisma.io/docs/orm/prisma-client/queries/select-fields

https://www.prisma.io/docs/orm/prisma-client/queries/excluding-fields

#### omitAPI
`omit`を使ってフィールドをローカルで除外する機能は、プレビュー機能となりますので、`schema.prisma`で`omitAPI`の利用を明示する必要があります。

```diff_typescript:shema.prisma
generator client {
  provider = "prisma-client-js"
+  previewFeatures = ["omitApi"]
}
```


## components/ReviewCardContainer
ページ内に乗せる個々の部品は、`app/components/`フォルダに作っていきます。

```tsx:components/ReviewCardContainer/ReviewCardContainer.tsx
'use client'

import { ReviewWithProductId } from "@/app/types";
import styles from "./CardContainer.module.css";
import ReviewCard from "@/app/components/ReviewCard/ReviewCard";

type Props = {
  title: string;
  reviews: ReviewWithProductId[];
}

export default function ReviewCardContainer(props: Props) {

  const reviews = props.reviews;
  
  return ( 

    <div>
      <hr />
      <div className={styles.titleContainer}>
        <h2>{props.title}</h2>
        <p>{reviews.length}</p>
      </div>
      {reviews.length == 0 
      ?  (<p className={styles.noPost}>投稿はありません。</p>) 
      :  reviews.map((review, index) =>(
          <ReviewCard key={index} review={review} />
        ))}
    </div>
  )
}
```

<details><summary>CSS</summary>

```css: components/ReviewCardContainer/CardContainer.module.css
.titleContainer{
  display: flex;
}

.titleContainer * {
  margin: auto 10px;
}

.titleContainer p {
  background-color: var(--custom-accent-light-color);
  padding: 2px 6px;
  border-radius: 50px;
  text-align: center;
  font-weight: bold;
  color:  var(--custom-main-color);
  opacity: 0.8;
}

.noPost {
  margin: 10px 20px;
  padding: 10px;
}
```

</details>

親から受け取ったレビューデータの配列を、`map()`で一要素ずつ`ReviewCard`コンポーネントにPropsにして渡します。
受け取った配列に何もなかった場合は「投稿はありません」というメッセージを表示したいので、[三項演算子](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Conditional_operator)で条件分岐させます。
Next.js(というかReact)の`return()`内部ではif文が書けないので、条件分岐させたい場合はこの方法を取ります。

## components/ReviewCard

```tsx:components/ReviewCard/ReviewCard.tsx
import Image from "next/image";
import styles from "./reviewCard.module.css";
import Link from "next/link";
import { ReviewWithProductId } from "@/app/types";

interface Props {
  review: ReviewWithProductId,
}

export default function ReviewCard({review} : Props) {

    return (
        <div
            className={`${styles.reviewCard} ${review.isDraft
            ? styles.draft
            : ""}`}>
            <div className={styles.innerBox}>
                <div className={styles.buttonContainer}>
                    <Image src="/img/icon/awesome-bars.svg" width={20} height={20} alt="並べ替え"/>
                </div>

                {/* THUMBNAIL */}
                <div className={styles.imageContainer}>
                    <Image
                        src={review.imgUrl || "/img/placehold_500_300.png"}
                        alt="サムネイル画像"
                        fill
                        sizes="150px"
                        quality={50}
                        className={styles.thumbnail}/>
                </div>

                <div className={styles.textContainer}>

                    {/* TITLE */}
                    <div>
                        {review.url
                            ? (
                                <Link href={review.url} className="heading2" target="_blank">{review.title}</Link>
                            )
                            : (
                                <h2>{review.title}</h2>
                            )
                        }
                    </div>


                    {/* DATE */}
                    <div>
                        {review.articleDate
                            ? (
                                <p className="memo">
                                    {review.articleDate?.getFullYear()}年 
                                    {review.articleDate
                                            ? review.articleDate?.getMonth() + 1
                                            : ""}月
                                    {review.articleDate?.getDate()}日
                                </p>
                            )
                            : ""}

                    </div>

                    {/* DETAIL */}
                    <div className={styles.detail}>
                        <p>{review.detail}</p>
                    </div>

                    {/* TAG */}
                    <div className={styles.tagContainer}>

                        <p className="tag">
                            {review
                                .products
                                .map((product, index) => (
                                    <mark key={index} className={styles.productTag}>{product.name}</mark>
                                ))}
                        </p>

                    </div>
                </div>
                <div className={styles.buttonContainer}>
                    <Link href={`/dashboard/review/${review.id}`} className={styles.buttonLink}>
                        <Image src="/img/icon/awesome-edit.svg" width={20} height={20} alt="編集"/>
                    </Link>

                    <button className={styles.deleteButton}>
                        <Image src="/img/icon/material-delete.svg" width={20} height={20} alt="削除"/>
                    </button>

                </div>
            </div>

        </div>
    )
}
```


<details><summary>CSS</summary>

```css: components/ReviewCard/reviewCard.module.css
.reviewCard.draft {
  padding: 10px;
  background: var(--custom-sub-color) 0% 0% no-repeat padding-box;
  box-shadow: 0px 0px 10px #00000029;
  border-radius: 3px;
}

.reviewCard {
  padding: 10px;
  background: var(--custom-white-color) 0% 0% no-repeat padding-box;
  box-shadow: 0px 0px 10px #00000029;
  border-radius: 3px;
  margin: 10px 0;
}

.reviewCard h2 {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
  overflow: hidden;
}

.innerBox {
  display: grid;
  width: 100%;
  grid-template-columns: 40px 1fr 5fr 80px;
}

.textContainer{
 justify-self: stretch;
 padding: 0 10px;
 width: 100%;
}

.detail p {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}

.tagContainer{
  width: 100%;
}

.productTag {
  background-color: var(--custom-accent-color);
  color: #fff;
  border-radius: 50px;
  padding: 4px 14px;
  margin: 4px;
  display: inline-block;
}

.buttonContainer{
  margin: auto;
}

.buttonLink {
  margin: 0;
  padding: 5px;
  margin: 5px;
}

.buttonLink:hover {
  opacity: 0.7;
}

.imageContainer {
  position: relative;
  margin: 4px;
  min-height: 110px;
}


.thumbnail{
  object-fit: contain;
}

.deleteButton {
  border: 0;
  background-color: transparent;
  cursor: pointer;
}

.deleteButton:hover {
  opacity: 0.7;
}
```

</details>

# こんな感じにできてきました。

![レビュー一覧_基礎構造完了.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/c10613af-c472-d814-d6f2-b9eb745f2722.png)

まだ作っていない機能や、カンプからデザインを変更した部分もありますが、概ね形ができてきましたね！


# 次回
大枠のページができてきたので、複雑な機能の作り込みを進めていきます。`components/ReviewCard`もごたついてるので、ちょっと整理します。

- 時間の調整（UTCと日本時間との調整）
- レビュー削除ボタン
- サムネイル表示部分（Youtube分岐、Cloudinaryの活用）