Next.js + material-table + redux + firestoreでcrud操作のメモ

## 概要

material-tableが便利だったのでメモ。

[この時点のソース](https://github.com/hibohiboo/create-now/tree/636494759bcec0b2548b637f9af86afa1164153a/)


## firestore

* 今回はゆるめの制約。
  * 本人以外は削除できない

```fireabase/config/firestore.rules
rules_version = '2';
service cloud.firestore {
  function isSignedIn() {
    return request.auth != null;
  }
  function isOwner(rsc) {
    // データ作成した本人であることを確認
    return isSignedIn() && request.auth.uid == rsc.data.uid;
  }

  // マッチングルール
  match /databases/{database}/documents {
    match /others/memolist/{documents=**} {
      allow read, create, update
      allow delete: if isOwner(resource)
    }
  }
}

```

### firestore部

```ts:src/firestore/memoList.ts
// import usePagination from 'firestore-pagination-hook'
import firebase from 'firebase/app'
import { MemoListItem } from '~/store/modules/memoListModule'
import { db } from '~/lib/firebase/initFirebase'

const memoList = (firestore: firebase.firestore.Firestore) =>
  firestore.collection('others').doc('memolist')

export const readMemoListCollection = async (
  cn: string,
  limit = 10,
) => {
  let query = memoList(db).collection(cn).orderBy('point', 'desc')
  query = query.limit(limit)
  const querySnapshot = await query.get()
  const ret: any[] = []
  querySnapshot.forEach((doc) => {
    const data = doc.data()
    ret.push({
      ...data,
      id: doc.id,
    })
  })
  return ret
}

export const readMemoList = async () => {
  const ref = await memoList(db).get()

  return ref.data()
}
export const createMemo = async (
  cn: string,
  memo: MemoListItem,
  uid: string,
) => {
  const memos = memoList(db).collection(cn)
  const { id } = await memos.doc()
  await Promise.all([
    memos.doc(id).set({
      ...memo,
      uid,
    }),
  ])
  return id
}

export const updateMemo = async (cn: string, memo: MemoListItem) =>
  await memoList(db).collection(cn).doc(memo.id).set(memo)
export const deleteMemo = async (cn: string, memo: MemoListItem) =>
  await memoList(db).collection(cn).doc(memo.id).delete()
```

### redux module

* useStateでViewModelのようにして返している
  * material-tableのオプションもここで設定

```ts:src/store/modules/memoListModule.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { useSelector } from 'react-redux'
import * as store from '~/firestore/memoList'
import { AppThunk } from '~/store/rootState'

export interface MemoListItem {
  name: string
  tags: string[]
  memo: string
  point: number
  uid: string
  id: string
  nickname?: string
  url?: string
}
type CollectionName = 'systems'
interface MemoListState {
  current: CollectionName
  counts: { [k in CollectionName]: number }
  list: { [k in CollectionName]: MemoListItem[] }
}
export const init: MemoListState = {
  current: 'systems',
  counts: {
    systems: 0,
  },
  list: {
    systems: [],
  },
}

// actions と reducers の定義
const memoListModule = createSlice({
  name: 'memoList',
  initialState: init,
  reducers: {
    setList: (
      state,
      action: PayloadAction<{ current: CollectionName; list: MemoListItem[] }>,
    ) => {
      state.current = action.payload.current
      state.list[action.payload.current] = action.payload.list
    },
    setCounts: (
      state,
      action: PayloadAction<{ [k in CollectionName]: number }>,
    ) => {
      state.counts = action.payload
    },
    addItem: (
      state,
      action: PayloadAction<{ current: CollectionName; item: MemoListItem }>,
    ) => {
      state.list[action.payload.current].push(action.payload.item)
    },
    setItem: (
      state,
      {
        payload: { current, item },
      }: PayloadAction<{ current: CollectionName; item: MemoListItem }>,
    ) => {
      state.list[current][
        state.list[current].findIndex((i) => i.id === item.id)
      ] = item
    },
    deleteItem: (
      state,
      {
        payload: { current, item },
      }: PayloadAction<{ current: CollectionName; item: MemoListItem }>,
    ) => {
      state.list[current] = state.list[current].filter((i) => i.id !== item.id)
    },
  },
})

const options = {
  sorting: false,
  paging: false,
  rowStyle: {
    whiteSpace: 'nowrap',
  },
  headerStyle: {
    whiteSpace: 'nowrap',
  },
} as const

const separator = ' '

export const useViewModel = () => {
  return useSelector(
    (state: { memoList: ReturnType<typeof memoListModule.reducer> }) => ({
      data: state.memoList.list[state.memoList.current].map((item) => ({
        ...item,
        // 配列でTableRowに渡そうとするとエラー
        tags: item.tags.join(separator),
      })),
      columns: [
        { title: '略称', field: 'nickname' },
        { title: '名前', field: 'name' },
        { title: '備考', field: 'memo' },
        { title: 'タグ', field: 'tags' },
        {
          title: '★',
          field: 'point',
          type: 'numeric',
          editable: 'never',
        } as const, // typeを指定するときはconst
      ],
      options,
    }),
  )
}

export default memoListModule

const {
  setList,
  setCounts,
  addItem,
  setItem,
  deleteItem,
} = memoListModule.actions

export const readMemoList = (limit = 10): AppThunk => async (
  dispatch,
) => {
  const current = 'systems'
  const list = await store.readMemoListCollection(current)
  dispatch(setList({ current, list }))
}

export const readCounts = (): AppThunk => async (dispatch) => {
  const data = await store.readMemoList()
  dispatch(setCounts(data as any))
}

type TableRow = Omit<Partial<MemoListItem>, 'tags'> & { tags?: string }
const createMemoListItem = (data: TableRow): MemoListItem => ({
  id: data.id || '',
  uid: data.uid || '',
  name: data.name || 'ななし',
  tags: (data.tags && data.tags.split(separator)) || [],
  memo: data.memo || '',
  point: data.point || 0,
  nickname: data.nickname || '',
  url: data.url || '',
})

export const addMemoItem = (data: TableRow, uid: string): AppThunk => async (
  dispatch,
) => {
  console.log('data', data)
  const current = 'systems'
  const item = createMemoListItem(data)
  const id = await store.createMemo(current, item, uid)
  dispatch(addItem({ current, item: { ...item, uid, id } }))
}

export const updateMemoItem = (data: TableRow): AppThunk => async (
  dispatch,
) => {
  const current = 'systems'
  const item = createMemoListItem(data)
  await store.updateMemo(current, item)
  dispatch(setItem({ current, item }))
}

export const deleteMemoItem = (data: TableRow): AppThunk => async (
  dispatch,
) => {
  const current = 'systems'
  const item = createMemoListItem(data)
  await store.deleteMemo(current, item)
  dispatch(deleteItem({ current, item }))
}
```

## next page

* `resetServerContext()`を呼ばないと以下のエラーが出るので注意。
  * `Prop data-react-beautiful-dnd-draggable did not match. Server: "2" Client: "0"`
* `setTimeout(() => ..., 300`は、少しスピナーを出さないと更新・削除が分かりにくいため。


```tsx:src/page/memoList/index.tsx
/* eslint-disable react/display-name */
import * as _ from 'lodash'
import React, { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { NextPage } from 'next'
import Head from 'next/head'
import MaterialTable from 'material-table'
import { resetServerContext } from 'react-beautiful-dnd' // material-table の内部のdraggableで使用している模様

import { useAuth, createAuthClientSide } from '~/store/modules/authModule'
import {
  useViewModel,
  readMemoList,
  readCounts,
  addMemoItem,
  updateMemoItem,
  deleteMemoItem,
} from '~/store/modules/memoListModule'
import Container from '~/components/organisms/lostrpg/LostrpgContainer'
import Link from '~/components/atoms/mui/Link'
import TableIcons from '~/components/molcures/TableIcons'


const Page: NextPage = () => {
  const dispatch = useDispatch()
  const authUser = useAuth()
  const vm = useViewModel()

  // next.jsのSSR時にリセットしないとエラー
  resetServerContext()

  useEffect(() => {
    dispatch(createAuthClientSide())
    dispatch(readMemoList())
    dispatch(readCounts())
  }, [])

  return (
    <Container>
      <Head>
        <title>TRPG関連メモ</title>
      </Head>
      <MaterialTable
        title={'システム'}
        icons={TableIcons}
        options={vm.options}
        columns={vm.columns}
        data={vm.data}
        onQueryChange={(query) => {
          dispatch(readMemoList(query.pageSize))
        }}
        editable={{
          onRowAdd: async (newData) => {
            dispatch(addMemoItem(newData, authUser.uid))
          },
          onRowUpdate: async (newData, oldData) => {
            dispatch(updateMemoItem(newData))
            return new Promise((resolve) => {
              setTimeout(() => {
                resolve()
              }, 300)
            })
          },
          onRowDelete: (oldData) => {
            dispatch(deleteMemoItem(oldData))
            return new Promise((resolve) => {
              setTimeout(() => {
                resolve()
              }, 300)
            })
          },
        }}
      />
      <Link href="/">戻る</Link>
    </Container>
  )
}

export default Page
```

### Iconを使うために

```tsx:src/components/molcures/TableIcons.tsx
/* eslint-disable react/display-name */
import React, { forwardRef } from 'react'
import AddBox from '@material-ui/icons/AddBox'
import ArrowDownward from '@material-ui/icons/ArrowDownward'
import Check from '@material-ui/icons/Check'
import ChevronLeft from '@material-ui/icons/ChevronLeft'
import ChevronRight from '@material-ui/icons/ChevronRight'
import Clear from '@material-ui/icons/Clear'
import DeleteOutline from '@material-ui/icons/DeleteOutline'
import Edit from '@material-ui/icons/Edit'
import FilterList from '@material-ui/icons/FilterList'
import FirstPage from '@material-ui/icons/FirstPage'
import LastPage from '@material-ui/icons/LastPage'
import Remove from '@material-ui/icons/Remove'
import SaveAlt from '@material-ui/icons/SaveAlt'
import Search from '@material-ui/icons/Search'
import ViewColumn from '@material-ui/icons/ViewColumn'

const tableIcons: {[key: string]: React.ForwardRefExoticComponent<React.RefAttributes<SVGSVGElement>>} = {
  Add: forwardRef((props, ref) => <AddBox {...props} ref={ref} />),
  Check: forwardRef((props, ref) => <Check {...props} ref={ref} />),
  Clear: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
  Delete: forwardRef((props, ref) => <DeleteOutline {...props} ref={ref} />),
  DetailPanel: forwardRef((props, ref) => (
    <ChevronRight {...props} ref={ref} />
  )),
  Edit: forwardRef((props, ref) => <Edit {...props} ref={ref} />),
  Export: forwardRef((props, ref) => <SaveAlt {...props} ref={ref} />),
  Filter: forwardRef((props, ref) => <FilterList {...props} ref={ref} />),
  FirstPage: forwardRef((props, ref) => <FirstPage {...props} ref={ref} />),
  LastPage: forwardRef((props, ref) => <LastPage {...props} ref={ref} />),
  NextPage: forwardRef((props, ref) => <ChevronRight {...props} ref={ref} />),
  PreviousPage: forwardRef((props, ref) => (
    <ChevronLeft {...props} ref={ref} />
  )),
  ResetSearch: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
  Search: forwardRef((props, ref) => <Search {...props} ref={ref} />),
  SortArrow: forwardRef((props, ref) => <ArrowDownward {...props} ref={ref} />),
  ThirdStateCheck: forwardRef((props, ref) => <Remove {...props} ref={ref} />),
  ViewColumn: forwardRef((props, ref) => <ViewColumn {...props} ref={ref} />),
}
export default tableIcons
```


## 参考

["react-beautiful-dnd" で実装する React の Drag&Drop with TypeScript, 関数コンポーネント](https://qiita.com/karube5217/items/bd9c3e8aff6117fdae42)
[material-ui table](https://material-table.com/#/docs/features/filtering)