Next.js + Firestore でCRUD操作を実装したメモ

## 概要
* Next.js + FirestoreでCRUD操作を実装した
* Firestoreの日付型がSerializeできないようで、ActionのPayloadに乗せられなかった
  * `index.js:1 A non-serializable value was detected in an action, in the path: 'payload.camps.0.updatedAt'.`のエラー
* 結果、Create Update DeleteはReduxを使わずに実装
* Readについてはページネーション付きのリストを実装
  * 日付については一旦JSONにすることでserialize可能の対応を行った。。。無理やりだろうか。
* この状態ではZeit Nowに上げられない
  * 先日のapp.jsonで`App<ReduxWrapperAppProps<RootState>>`を使用した結果、すべてのページがSSRになってしまっている
  * 現在、Freeプランでは12個までしかfunctionをアップできない。
 
[作成したソース](https://github.com/hibohiboo/create-now/tree/58b1aef18a0453bca78285d2d2faf8338433a5b1/)


## ソース

```tsx:pages/lostrpg/camps/edit.tsx
import { NextPage } from 'next'
import React, { useState, useEffect } from 'react'
import Router, { useRouter, NextRouter } from 'next/router'
import { Box, Button } from '@material-ui/core'
import Link from '~/components/atoms/mui/Link'
import InputField from '~/components/form/InputField'
import Container from '~/components/organisms/lostrpg/LostrpgContainer'
import { Camp, initCamp } from '~/store/modules/lostModule'
import { useAuth } from '~/store/modules/authModule'
import {
  createCamp,
  getCamp,
  updateCamp,
  deleteCamp,
  canEdit,
} from '~/firestore/camp'

const getIdFromQuery = (router: NextRouter) => {
  if (typeof router.query.id === 'string') return router.query.id
  return null
}

const Page: NextPage = () => {
  const router = useRouter()
  const authUser = useAuth()
  const [camp, setCamp] = useState<Camp>(initCamp)
  const id = getIdFromQuery(router)
  const beforePage = '/lostrpg/camps/list'
  const editHandler = id
    ? async () => {
        await updateCamp(id, { ...camp, uid: authUser.uid }, authUser.uid)
        Router.push({ pathname: `/lostrpg/camps/view`, query: { id } })
      }
    : async () => {
        const retId = await createCamp({ ...camp, uid: authUser.uid }, authUser)
        Router.push({ pathname: `/lostrpg/camps/view`, query: { id: retId } })
      }
  const deleteHandler = async () => {
    if (confirm('削除してもよいですか？')) {
      await deleteCamp(id)
      Router.push(beforePage)
    }
  }

  useEffect(() => {
    if (!authUser || (id && canEdit(authUser, camp))) {
      Router.push(beforePage)
    }

    if (!id) {
      if (authUser) setCamp({ ...camp, playerName: authUser.displayName })
      return
    }
    ;(async () => {
      const data = await getCamp(id)
      if (data) {
        setCamp(data)
      }
    })()
  }, [])

  return (
    <>
      {!authUser ? (
        <></>
      ) : (
        <Container>
          <Box my={4} style={{ maxWidth: '500px', minWidth: '200px' }}>
            <h2>LOSTRPG キャンプ{id ? '編集' : '作成'}</h2>
            <Box my={2}>
              <InputField
                model={camp}
                type="text"
                prop="playerName"
                labelText="プレイヤー名"
                changeHandler={(e) =>
                  setCamp({ ...camp, playerName: e.target.value })
                }
              />
              <InputField
                model={camp}
                type="text"
                prop="name"
                labelText="キャンプ名"
                changeHandler={(e) =>
                  setCamp({ ...camp, name: e.target.value })
                }
              />
            </Box>
            <Box my={2}>
              <Button onClick={editHandler} variant="contained" color="primary">
                {id ? '更新' : '作成'}
              </Button>
            </Box>
            {!id ? (
              <></>
            ) : (
              <Box my={4}>
                <Button
                  onClick={deleteHandler}
                  variant="contained"
                  color="secondary"
                >
                  削除
                </Button>
              </Box>
            )}
          </Box>

          <Link href={beforePage}>戻る</Link>
        </Container>
      )}
    </>
  )
}

export default Page
```

```tsx:pages/lostrpg/camps/view.tsx
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { Box } from '@material-ui/core'
import Link from '~/components/atoms/mui/Link'
import Container from '~/components/organisms/lostrpg/LostrpgContainer'
import { getCamp } from '~/api/firestoreAPI'
import { canEdit } from '~/firestore/camp'
import { Camp } from '~/store/modules/lostModule'
import { useAuth } from '~/store/modules/authModule'

const Page: NextPage<{ camp: Camp }> = function (ctx) {
  const router = useRouter()
  const authUser = useAuth()
  const { camp } = ctx
  const { id } = router.query
  const beforePage = '/lostrpg/camps/list'
  return (
    <>
      <Container>
        <Box my={4}>
          <div style={{ maxWidth: '500px', minWidth: '200px' }}>
            <h2>{camp.name}</h2>
            {!canEdit(authUser, camp) ? (
              <></>
            ) : (
              <Box my={1}>
                <Link href={{ pathname: '/lostrpg/camps/edit', query: { id } }}>
                  編集
                </Link>
              </Box>
            )}
          </div>
        </Box>
        <Link href={beforePage}>戻る</Link>
      </Container>
    </>
  )
}

Page.getInitialProps = async ({ query }) => {
  const camp = await getCamp(query.id as string)
  return { camp }
}

export default Page
```

```tsx:pages/lostrpg/camps/list.tsx
import React, { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { NextPage } from 'next'
import { makeStyles } from '@material-ui/core/styles'
import { Box, Button } from '@material-ui/core'
import List from '@material-ui/core/List'
import ListItemText from '@material-ui/core/ListItemText'
import ListSubheader from '@material-ui/core/ListSubheader'
import Link from '~/components/atoms/mui/Link'
import ListItemLink from '~/components/atoms/mui/ListItemLink'
import Container from '~/components/organisms/lostrpg/LostrpgContainer'
import InputField from '~/components/form/InputField'
import SearchIcon from '@material-ui/icons/Search'
import { useAuth } from '~/store/modules/authModule'
import {
  useCamps,
  useCampsPagination,
  fetchCamps,
  fetchCampsMore,
} from '~/store/modules/lostModule'

const useStyles = makeStyles((theme) => ({
  root: {
    width: '100%',
    maxWidth: 360,
    backgroundColor: theme.palette.background.paper,
  },
  nested: {
    paddingLeft: theme.spacing(4),
  },
}))

const Page: NextPage = () => {
  const classes = useStyles()
  const authUser = useAuth()
  const items = useCamps()
  const [search, setSearch] = useState({ name: '' })
  const pagenation = useCampsPagination()
  const dispatch = useDispatch()
  const loadMore = () =>
    dispatch(
      fetchCampsMore(pagenation.lastLoaded, pagenation.limit, search.name),
    )
  const filteredCamps = () =>
    dispatch(fetchCamps(pagenation.limit, search.name))

  useEffect(() => {
    dispatch(fetchCamps(pagenation.limit))
  }, [])

  return (
    <Container>
      <Box my={4}>
        <h2>LOSTRPG キャンプ一覧</h2>
        {authUser ? <Link href="/lostrpg/camps/edit">新規作成</Link> : <></>}
        <Box display="flex" style={{ maxWidth: '200px', minWidth: '50px' }}>
          <InputField
            model={search}
            type="text"
            prop="name"
            labelText="キャンプ名"
            changeHandler={(e) =>
              setSearch({ ...search, name: e.target.value })
            }
          />
          <Button variant="contained" color="primary" onClick={filteredCamps}>
            <SearchIcon />
          </Button>
        </Box>

        <div>
          {pagenation.loading && <div>...</div>}
          {!pagenation.loading && (
            <List
              component="ul"
              aria-labelledby="nested-list-subheader"
              subheader={
                <ListSubheader component="div" id="nested-list-subheader">
                  キャンプ一覧
                </ListSubheader>
              }
              className={classes.root}
            ></List>
          )}
          {items.map((item) => (
            <ListItemLink
              href={{
                pathname: '/lostrpg/camps/view', // trailing slash(/lostrpg/camps/edit/)にするとエラー。
                query: { id: item.id },
              }}
              key={item.id}
            >
              <ListItemText primary={item.name} />
            </ListItemLink>
          ))}
          {pagenation.hasMore && !pagenation.loading && !search.name && (
            <Button onClick={loadMore}>次の{pagenation.limit}件</Button>
          )}
        </div>
      </Box>
      <Link href="/lostrpg">戻る</Link>
    </Container>
  )
}

export default Page
```

## now でデプロイできるように修正
* 認証ページを減らしたが、デプロイできない。
  * [認証ページを減らしたソース](https://github.com/hibohiboo/create-now/tree/aca63b888486d9ca6bb2692aadc90170c1f93059/)
* react-redux-wrapperを削除することで、デプロイ可能となった。
  *  [デプロイ可能なソース](https://github.com/hibohiboo/create-now/tree/b2f6576726ca3ab9c22e34bc4a1364ff9c48515f/)



## 参考
[firestore order by](https://firebase.google.com/docs/firestore/query-data/order-limit-data?hl=ja)
[firestore query](https://cloud.google.com/firestore/docs/query-data/query-cursors?hl=ja)
[firestore return after](https://stackoverflow.com/questions/57375231/firestore-startafter-returning-the-same-data-in-infinite-scrolling-when-ordere)
[redux thunk](https://redux-toolkit.js.org/tutorials/advanced-tutorial)
[next.js ssgサポート](https://nextjs.org/blog/next-9-3)
[next.js limit](https://vercel.com/docs/v2/platform/limits)
[今週大幅にリニューアルされた Next.js のチュートリアルをどこよりも早く全編和訳しました](https://qiita.com/thesugar/items/01896c1faa8241e6b1bc)
[Summary of the new Pricing changes (2020-04-08) with associated new limits/cost ](https://github.com/zeit/now/discussions/4029)