RedwoodJS v7.7 Tutorial Chapter 2

Aug 13, 2024

はじめに

Chapter 2ではCRUD処理を自動生成するscaffoldやデータの取得表示を行うセルについて主に学んでいきます。

前回の記事

データベーススキーマ作成

RedwoodJSではデータベースに関する部分はPrismaを採用しています。 すでにschema.prismaファイルが用意されていますので、こちらを編集してテーブルを作成していきます。

// api/db/schema.prisma

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime @default(now())
}

Postテーブルを追加しました。

マイグレーション

スキーマができたらマイグレーションコマンドを実行します。

yarn rw prisma migrate dev

Running Prisma CLI...
$ yarn prisma migrate dev --schema /xxx/xxx/xxx/my-first-redwood-app/api/db/schema.prisma

Environment variables loaded from .env
Prisma schema loaded from api/db/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

 Enter a name for the new migration: create post
Applying migration `xxxxxxxxx_create_post`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ xxxxxxxxx_create_post/
    └─ migration.sql

Your database is now in sync with your schema.

 Generated Prisma Client (v5.14.0) to ./node_modules/@prisma/client in 30ms

Running seed command `yarn rw exec seed` ...
[STARTED] Generating Prisma client
[COMPLETED] Generating Prisma client
[STARTED] Running script

  No seed data, skipping. See scripts/seed.ts to start seeding your database!

[COMPLETED] Running script

🌱  The seed command has been executed.

Prisma Studio

無事にテーブルが作られたか確認するためにPrisma Studioを立ち上げます。

yarn rw prisma studio

Prisma Studio

ここからレコードの追加なども行えるので大変便利です。

Scaffolding

scaffoldコマンドを使って、PostのCRUDを作ります。

yarn rw g scaffold post
 Generating scaffold files...
 Successfully wrote file `./web/src/components/Post/EditPostCell/EditPostCell.tsx`
 Successfully wrote file `./web/src/components/Post/Post/Post.tsx`
 Successfully wrote file `./web/src/components/Post/PostCell/PostCell.tsx`
 Successfully wrote file `./web/src/components/Post/PostForm/PostForm.tsx`
 Successfully wrote file `./web/src/components/Post/Posts/Posts.tsx`
 Successfully wrote file `./web/src/components/Post/PostsCell/PostsCell.tsx`
 Successfully wrote file `./web/src/components/Post/NewPost/NewPost.tsx`
 Successfully wrote file `./api/src/graphql/posts.sdl.ts`
 Successfully wrote file `./api/src/services/posts/posts.ts`
 Successfully wrote file `./api/src/services/posts/posts.scenarios.ts`
 Successfully wrote file `./api/src/services/posts/posts.test.ts`
 Successfully wrote file `./web/src/scaffold.css`
 Successfully wrote file `./web/src/lib/formatters.tsx`
 Successfully wrote file `./web/src/lib/formatters.test.tsx`
 Successfully wrote file `./web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx`
 Successfully wrote file `./web/src/pages/Post/EditPostPage/EditPostPage.tsx`
 Successfully wrote file `./web/src/pages/Post/PostPage/PostPage.tsx`
 Successfully wrote file `./web/src/pages/Post/PostsPage/PostsPage.tsx`
 Successfully wrote file `./web/src/pages/Post/NewPostPage/NewPostPage.tsx`
 Install helper packages
 Adding layout import...
 Adding set import...
 Adding scaffold routes...
 Adding scaffold asset imports...
 Generating types ...

色々なファイルが作成されました。 ここでは各ファイルについて解説しません。詳しくは公式Tutorialのページを参照してください。

ざっくりいうと、CRUDに必要な以下の内容のファイル作成または変更がおこなわれました。

  • ページの作成
  • レイアウトの作成
  • ルーティングの追加
  • セルの作成(この後説明)
  • コンポーネントの作成(フォームやテーブルなど)
  • SDL(GraphQL関連)の作成
  • サービスの作成(PrismaからDBへのアクセス処理)

実際に/postsへアクセスしてみると、Postの一覧が表示され、そこから作成、詳細、編集、削除ができました。

セル(Cells)

セルはクライアント側のデータフェッチに関する処理を書いたRedwoodJS独自の手法で、GraphQLクエリを書いてデータを取得します。

先ほどscaffoldしたファイルの1つであるPostsCell.tsxを見てみます。

import type { FindPosts, FindPostsVariables } from 'types/graphql'

import { Link, routes } from '@redwoodjs/router'
import type {
  CellSuccessProps,
  CellFailureProps,
  TypedDocumentNode,
} from '@redwoodjs/web'

import Posts from 'src/components/Post/Posts'

export const QUERY: TypedDocumentNode<FindPosts, FindPostsVariables> = gql`
  query FindPosts {
    posts {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => {
  return (
    <div className="rw-text-center">
      {'No posts yet. '}
      <Link to={routes.newPost()} className="rw-link">
        {'Create one?'}
      </Link>
    </div>
  )
}

export const Failure = ({ error }: CellFailureProps<FindPosts>) => (
  <div className="rw-cell-error">{error?.message}</div>
)

export const Success = ({
  posts,
}: CellSuccessProps<FindPosts, FindPostsVariables>) => {
  return <Posts posts={posts} />
}

それぞれexportしているものを見ていきます。

  • Query: GraphQLクエリ
  • Loading: レスポンスを受け取るまで表示されるコンポーネント
  • Empty: データが空(nullまたは空配列)だった場合に表示されるコンポーネント
  • Failure: エラーだった場合に表示されるコンポーネント
  • Success: データ取得に成功した場合に表示されるコンポーネント

他にもクエリ実行前後の処理が書けるbeforeQueryafterQueryというライフサイクルヘルパーというものがあるそうです。

次にセルコンポーネントのみ生成してみます。

yarn rw g cell Articles
 Generating cell files...
 Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.mock.ts`
 Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.test.tsx`
 Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.stories.tsx`
 Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.tsx`
 Skipping type generation: no SDL defined for "articles". To generate types, run 'yarn rw g sdl articles'.

scaffoldコマンドの時はweb/src/components/Postディレクトリ以下にさらにPostCellディレクトリやPostsCellディレクトリなどができましたが、今回はweb/src/components/ArticlesCellディレクトリが直接できました。

そこにあるArticlesCell.tsxの中身を見てみます。

import type { ArticlesQuery, ArticlesQueryVariables } from 'types/graphql'

import type {
  CellSuccessProps,
  CellFailureProps,
  TypedDocumentNode,
} from '@redwoodjs/web'

export const QUERY: TypedDocumentNode<
  ArticlesQuery,
  ArticlesQueryVariables
> = gql`
  query ArticlesQuery {
    articles {
      id
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }: CellFailureProps) => (
  <div style={{ color: 'red' }}>Error: {error?.message}</div>
)

export const Success = ({ articles }: CellSuccessProps<ArticlesQuery>) => {
  return (
    <ul>
      {articles.map((item) => {
        return <li key={item.id}>{JSON.stringify(item)}</li>
      })}
    </ul>
  )
}

PostsCell.tsxと似たようなコードになっていますね。

ただしVSCodeエディタなど使ってる方は気づくかもしれませんが、いくつか型エラーが出ています。

ここでGraphQLクエリの箇所を以下のように変更してみます。

export const QUERY: TypedDocumentNode<
  ArticlesQuery,
  ArticlesQueryVariables
> = gql`
  query ArticlesQuery {
    articles {
      id
    }
  }
`

クエリをarticlesからpostsにしました。

すると開発サーバーを起動している方は上記変更を検知してimportしているtypes/graphqlの型が生成されます。

これはセルのみを今回は生成したので、articlesクエリというSDL(GraphQLのスキーマを定義するファイル)やService(ビジネスロジックやデータ操作を行うファイル)が存在しないため起こったエラーです。

すでにscaffoldで存在しているpostsクエリに書き直すことでエラーがなくなりました。

無事クエリエラーを解決したので、Successコンポーネントを以下のように変更してデータを表示させてみます。

export const Success = ({ posts }: CellSuccessProps<ArticlesQuery>) => {
  return (
    <ul>
      {posts.map((item) => {
        return <li key={item.id}>{JSON.stringify(item)}</li>
      })}
    </ul>
  )
}

HomePage.tsxにArticlesCellを表示させます。

import { Metadata } from '@redwoodjs/web'

import ArticlesCell from 'src/components/ArticlesCell'

const HomePage = () => {
  return (
    <>
      <Metadata title="Home" description="Home page" />
      <ArticlesCell />
    </>
  )
}

export default HomePage

ブラウザで確認してみるとうまくデータが表示されているのがわかります。

以下のようにGraphQLクエリの結果に含まれる変数名にエイリアスを使うこともできます。

export const QUERY: TypedDocumentNode<
  ArticlesQuery,
  ArticlesQueryVariables
> = gql`
  query ArticlesQuery {
    articles: posts {
      id
    }
  }
`

export const Success = ({ articles }: CellSuccessProps<ArticlesQuery>) => {
  return (
    <ul>
      {articles.map((item) => {
        return <li key={item.id}>{JSON.stringify(item)}</li>
      })}
    </ul>
  )
}

最後に取得項目を増やして見た目を整えたら完成です。

import type { ArticlesQuery, ArticlesQueryVariables } from 'types/graphql'

import type {
  CellSuccessProps,
  CellFailureProps,
  TypedDocumentNode,
} from '@redwoodjs/web'

export const QUERY: TypedDocumentNode<
  ArticlesQuery,
  ArticlesQueryVariables
> = gql`
  query ArticlesQuery {
    articles: posts {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }: CellFailureProps) => (
  <div style={{ color: 'red' }}>Error: {error?.message}</div>
)

export const Success = ({ articles }: CellSuccessProps<ArticlesQuery>) => {
  return (
    <>
      {articles.map((article) => (
        <article key={article.id}>
          <header>
            <h2>{article.title}</h2>
          </header>
          <p>{article.body}</p>
          <div>Posted at: {article.createdAt}</div>
        </article>
      ))}
    </>
  )
}

ルーティングパラメータ

ホームにPost一覧の表示できたので、次は詳細画面を作成してみます。

詳細ページ作成

まずは以下コマンドで詳細ページを作成します。

yarn rw g page Article
 Generating page files...
 Successfully wrote file `./web/src/pages/ArticlePage/ArticlePage.stories.tsx`
 Successfully wrote file `./web/src/pages/ArticlePage/ArticlePage.test.tsx`
 Successfully wrote file `./web/src/pages/ArticlePage/ArticlePage.tsx`
 Updating routes file...
 Generating types...
 One more thing...
  Page created! A note about <Metadata>:
  At the top of your newly created page is a <Metadata> component,
  which contains the title and description for your page, essential
  to good SEO. Check out this page for best practices:
  https://developers.google.com/search/docs/advanced/appearance/good-titles-snippets

ArticleCell.tsxに詳細ページへのリンクを追加します。

export const Success = ({ articles }: CellSuccessProps<ArticlesQuery>) => {
  return (
    <>
      {articles.map((article) => (
        <article key={article.id}>
          <header>
            <Link to={routes.article()}>{article.title}</Link>
          </header>
          <p>{article.body}</p>
          <div>Posted at: {article.createdAt}</div>
        </article>
      ))}
    </>
  )
}

そして、今回はURLを/article/1のようにIDを含めたいので、自動追加されたArticlePageのルートを編集します。

// Routes.tsx

<Route path="/article/{id}" page={ArticlePage} name="article" />

pathに{id}を加えことでリンクの方もパラメータを渡すことができます。

// web/src/components/ArticlesCell/ArticlesCell.tsx
<h2>
  <Link to={routes.article({ id: article.id })}>{article.title}</Link>
</h2>

Note

ここで実際route.articleで待ち受けているidとarticle.idは型が違って(stringとnumber)TSエラーになります。公式Tutorialでは一旦エラーを無視して良いと書かれています。 次のセクションでこの部分について説明があるようです。

仕上げにArticlePage.tsxのリンクも不要なので削除します。

// web/src/pages/ArticlePage.tsx
import { Metadata } from '@redwoodjs/web'

const ArticlePage = () => {
  return (
    <>
      <Metadata title="Article" description="Article page" />

      <h1>ArticlePage</h1>
      <p>
        Find me in <code>./web/src/pages/ArticlePage/ArticlePage.tsx</code>
      </p>
      <p>
        My default route is named <code>article</code>, link to me with `
      </p>
    </>
  )
}

export default ArticlePage

これで詳細ページ遷移時にブラウザのURLを確認すると/article/1のような値になっているはずです。

詳細データ取得表示

ページが用意できたので、セルを作成して詳細データの取得表示を行います。

yarn rw g cell Article
 Generating cell files...
 Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.mock.ts`
 Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.test.tsx`
 Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.stories.tsx`
 Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.tsx`
 Skipping type generation: no SDL defined for "article". To generate types, run 'yarn rw g sdl article'.

生成されたArticleCellをArticlePageで表示します。

// web/src/pages/ArticlePage/ArticlePage.tsx
import { Metadata } from '@redwoodjs/web'
import ArticleCell from 'src/components/ArticleCell'

const ArticlePage = () => {
  return (
    <>
      <Metadata title="Article" description="Article page" />

      <ArticleCell />
    </>
  )
}

export default ArticlePage

ArticleCellの方もArticlesCellの時と同様に

export const QUERY: TypedDocumentNode<
  FindArticleQuery,
  FindArticleQueryVariables
> = gql`
  query FindArticleQuery($id: Int!) {
    article: article(id: $id) {
      id
    }
  }
`

export const QUERY: TypedDocumentNode<
  FindArticleQuery,
  FindArticleQueryVariables
> = gql`
  query FindArticleQuery($id: Int!) {
    article: post(id: $id) {
      id
      title
      body
      createdAt
    }
  }
`

に変更します。 ついでにtitle/body/createdAtを追加で取得するようにしました。

そして、ArticleCellにidを渡さないといけないので、ArticlePageの方も変更します。

import { Metadata } from '@redwoodjs/web'

import ArticleCell from 'src/components/ArticleCell'

interface Props {
  id: number
}

const ArticlePage = ({ id }: Props) => {
  return (
    <>
      <Metadata title="Article" description="Article page" />

      <ArticleCell id={id} />
    </>
  )
}

export default ArticlePage

これで完成かと思い、ブラウザで動作確認してみると詳細画面で以下のようなエラーが出ます。

Error: Response not successful: Received status code 400

これは先ほどのTSエラーと関係していて、GraphQLではidをIntegerとして待ち受けていましたが、実際はStringで渡ってきてしまっていたために起こったエラーです。

これを解決するためにはRoute Param Typesというものを使います。 Routes.tsxの詳細ページ部分を以下のように書き換えます。

<Route path="/article/{id:Int}" page={ArticlePage} name="article" />

{id:Int}に変わりました。これによりidがIntegerに変換されるだけでなく、Integerではない値が来た場合はNotFoundPageが表示されるようになります。

これで前回のTSエラーも解消され、無事に詳細画面が見れるようになりました。

コンポーネントの作成

最後に再利用可能なコンポーネントを作成してArticlesCellとArticleCellの両方で使えるようにします。

yarn rw g component Article
 Generating component files...
 Successfully wrote file `./web/src/components/Article/Article.test.tsx`
 Successfully wrote file `./web/src/components/Article/Article.stories.tsx`
 Successfully wrote file `./web/src/components/Article/Article.tsx`

生成されたweb/src/components/Article/Article.tsxの中身は以下です。

const Article = () => {
  return (
    <div>
      <h2>{'Article'}</h2>
      <p>{'Find me in ./web/src/components/Article/Article.tsx'}</p>
    </div>
  )
}

export default Article

ArticlesCellのarticleタグ部分をArticleコンポーネントに持ってきます。

import type { Post } from 'types/graphql'

import { Link, routes } from '@redwoodjs/router'

interface Props {
  article: Post
}

const Article = ({ article }: Props) => {
  return (
    <article>
      <header>
        <h2>
          <Link to={routes.article({ id: article.id })}>{article.title}</Link>
        </h2>
      </header>
      <div>{article.body}</div>
      <div>Posted at: {article.createdAt}</div>
    </article>
  )
}

export default Article

ArticlesCell.tsx側もArticleコンポーネントに差し替えます。

import type { ArticlesQuery, ArticlesQueryVariables } from 'types/graphql'

import type {
  CellSuccessProps,
  CellFailureProps,
  TypedDocumentNode,
} from '@redwoodjs/web'

import Article from 'src/components/Article/Article'

export const QUERY: TypedDocumentNode<
  ArticlesQuery,
  ArticlesQueryVariables
> = gql`
  query ArticlesQuery {
    articles: posts {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }: CellFailureProps) => (
  <div style={{ color: 'red' }}>Error: {error?.message}</div>
)

export const Success = ({ articles }: CellSuccessProps<ArticlesQuery>) => {
  return (
    <>
      {articles.map((article) => (
        <Article key={article.id} article={article} />
      ))}
    </>
  )
}

ArticleCell.tsxも同じくArticleコンポーネントを使うようにします。

import type { FindArticleQuery, FindArticleQueryVariables } from 'types/graphql'

import type {
  CellSuccessProps,
  CellFailureProps,
  TypedDocumentNode,
} from '@redwoodjs/web'

import Article from 'src/components/Article'

export const QUERY: TypedDocumentNode<
  FindArticleQuery,
  FindArticleQueryVariables
> = gql`
  query FindArticleQuery($id: Int!) {
    article: post(id: $id) {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({
  error,
}: CellFailureProps<FindArticleQueryVariables>) => (
  <div style={{ color: 'red' }}>Error: {error?.message}</div>
)

export const Success = ({
  article,
}: CellSuccessProps<FindArticleQuery, FindArticleQueryVariables>) => {
  return <Article article={article} />
}

これで一覧画面と詳細画面で共通した見た目を持つことができました。 Chapter 2はここまでになります。