timetable-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 3c640c595b31a9ad4beaec818c9e48971df03701
parent b37e04abc81ddb29855083121b4ad8ad31c43fd7
Author: Yuukin256 <Yuukin256@gmail.com>
Date:   Sun,  6 Nov 2022 02:29:03 +0900

クラス別時間割表の登録画面を実装

Diffstat:
Mpackage.json | 3++-
Msrc/api/$api.ts | 6++++++
Msrc/hooks/useStandardTimetable.ts | 79++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/pages/_app.tsx | 23++++++++++++++---------
Msrc/pages/console/standard.tsx | 110+++++++++++++++++++++++++++++++++----------------------------------------------
Myarn.lock | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
6 files changed, 222 insertions(+), 117 deletions(-)

diff --git a/package.json b/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@aspida/fetch": "^1.11.0", - "@aspida/swr": "^1.11.0", + "@aspida/react-query": "^1.11.0", "@date-io/date-fns": "^2.16.0", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", @@ -35,6 +35,7 @@ "notistack": "^2.0.8", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-query": "^3.39.2", "react-swipeable-views": "^0.14.0", "react-swipeable-views-utils": "^0.14.0", "react-use": "^17.4.0", diff --git a/src/api/$api.ts b/src/api/$api.ts @@ -53,6 +53,12 @@ const api = <T>({ baseURL, fetch }: AspidaClient<T>) => { const prefix2 = `${prefix1}/${val2}`; return { + get: (option?: { config?: T | undefined } | undefined) => + fetch<Methods3['get']['resBody']>(prefix, prefix2, GET, option).json(), + $get: (option?: { config?: T | undefined } | undefined) => + fetch<Methods3['get']['resBody']>(prefix, prefix2, GET, option) + .json() + .then((r) => r.body), put: (option: { body: Methods3['put']['reqBody']; config?: T | undefined }) => fetch<Methods3['put']['resBody']>(prefix, prefix2, PUT, option).json(), $put: (option: { body: Methods3['put']['reqBody']; config?: T | undefined }) => diff --git a/src/hooks/useStandardTimetable.ts b/src/hooks/useStandardTimetable.ts @@ -1,24 +1,25 @@ -import useAspidaSWR from '@aspida/swr'; +import { useAspidaQuery } from '@aspida/react-query'; import { useSnackbar } from 'notistack'; -import { useBoolean } from 'react-use'; +import { useState } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; +import { useMap } from 'react-use'; import { apiClient } from 'lib/apiClient'; +import type { SchoolClass } from '@prisma/client'; import type { AbstractLessonNameEn, StandardEn } from 'types'; -type Props = { - classId: number; -}; - type Result = { loading: boolean; - standard: StandardEn | undefined; - error: unknown; - set: (key: AbstractLessonNameEn, value: string[]) => void; + classes: SchoolClass[]; + classId: number; + setClassId: (val: number) => void; + standard: StandardEn; + setStandard: (key: AbstractLessonNameEn, value: string[]) => void; submit: () => void; }; -const fallbackData: StandardEn = { +const emptyStandard: StandardEn = { Mon1: [], Mon2: [], Mon3: [], @@ -55,41 +56,45 @@ const fallbackData: StandardEn = { Fri7: [], }; -const useStandardTimetable = (props: Props): Result => { +const useStandardTimetable = (): Result => { + const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); - const { data, error, mutate } = useAspidaSWR(apiClient.classes._classId(props.classId).standard, { - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - onError() { - enqueueSnackbar('エラーが発生しました', { variant: 'error' }); - }, + const [classId, setClassId] = useState<number>(-1); + const [standard, { set: setStandard, setAll }] = useMap(emptyStandard); + + const classesQuery = useAspidaQuery(apiClient.classes, { + onError: () => enqueueSnackbar('エラーが発生しました', { variant: 'error' }), }); - const [submitting, setSubmitting] = useBoolean(false); - const standard = data ?? fallbackData; + const standardQuery = useAspidaQuery(apiClient.classes._classId(classId).standard, { + enabled: !!classId, + onSuccess: (data) => data && setAll(data), + onError: () => enqueueSnackbar('エラーが発生しました', { variant: 'error' }), + }); - const set = (key: AbstractLessonNameEn, value: string[]) => { - mutate({ ...standard, [key]: value }, false); - }; + const standardMutation = useMutation( + (body: StandardEn) => apiClient.classes._classId(classId).standard.$put({ body }), + { + onSuccess: () => { + enqueueSnackbar('正常に保存されました', { variant: 'success' }); + queryClient.invalidateQueries(apiClient.classes._classId(classId).standard.$path()); + }, + onError: () => { + enqueueSnackbar('エラーが発生しました', { variant: 'error' }); + }, + } + ); - const submit = () => { - setSubmitting(true); - apiClient.classes - ._classId(props.classId) - .standard.$post({ body: standard }) - .then((newData) => mutate(newData)) - .then(() => enqueueSnackbar('正常に保存されました', { variant: 'success' })) - .catch(() => enqueueSnackbar('エラーが発生しました', { variant: 'error' })) - .finally(() => setSubmitting(false)); - }; + const submit = () => standardMutation.mutate(standard); return { - loading: (!data && !error) || submitting, - standard: data, - error, - set, + loading: classesQuery.isFetching || standardQuery.isFetching || standardMutation.isLoading, + classes: classesQuery.data ?? [], + classId, + setClassId, + standard, + setStandard, submit, }; }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx @@ -3,6 +3,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import Head from 'next/head'; import { SnackbarProvider } from 'notistack'; +import { QueryClient, QueryClientProvider } from 'react-query'; import createEmotionCache from 'lib/createEmotionCache'; @@ -17,6 +18,8 @@ interface MyAppProps extends AppProps { emotionCache?: EmotionCache; } +const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } }); + function App(props: MyAppProps) { const { Component, emotionCache = clientSideEmotionCache, pageProps } = props; @@ -24,15 +27,17 @@ function App(props: MyAppProps) { return ( <SnackbarProvider maxSnack={3}> - <CacheProvider value={emotionCache}> - <Head> - <meta name='viewport' content='initial-scale=1, width=device-width' /> - </Head> - <ThemeProvider theme={theme}> - <CssBaseline /> - <Component {...pageProps} /> - </ThemeProvider> - </CacheProvider> + <QueryClientProvider client={queryClient}> + <CacheProvider value={emotionCache}> + <Head> + <meta name='viewport' content='initial-scale=1, width=device-width' /> + </Head> + <ThemeProvider theme={theme}> + <CssBaseline /> + <Component {...pageProps} /> + </ThemeProvider> + </CacheProvider> + </QueryClientProvider> </SnackbarProvider> ); } diff --git a/src/pages/console/standard.tsx b/src/pages/console/standard.tsx @@ -1,5 +1,4 @@ -import useAspidaSWR from '@aspida/swr'; -import SendIcon from '@mui/icons-material/Send'; +import SaveIcon from '@mui/icons-material/Save'; import Backdrop from '@mui/material/Backdrop'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -17,21 +16,23 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import TextField from '@mui/material/TextField'; -import { useSnackbar } from 'notistack'; -import { useState } from 'react'; -import { SWRConfig } from 'swr'; +import Typography from '@mui/material/Typography'; import useStandardTimetable from 'hooks/useStandardTimetable'; -import { apiClient } from 'lib/apiClient'; import type { NextPage } from 'next'; import type { FC } from 'react'; import type { AbstractLessonNameEn, StandardEn } from 'types'; -const ClassUnitCell: FC<{ subjects: string[]; handleChange: (newValue: string) => void; tabIndex: number }> = ({ - subjects, - handleChange, - tabIndex, -}) => { + +// TODO: ステート管理を一元化し、ロジックとビューを分離する +// TODO: 分離したビュー層をコンポーネントに分割する + +const ClassUnitCell: FC<{ + subjects: string[]; + handleChange: (newValue: string) => void; + tabIndex: number; + disabled?: boolean; +}> = ({ subjects, handleChange, tabIndex, disabled }) => { return ( <TableCell> <TextField @@ -39,23 +40,26 @@ const ClassUnitCell: FC<{ subjects: string[]; handleChange: (newValue: string) = value={subjects.join('/')} onChange={(e) => handleChange(e.target.value)} inputProps={{ tabIndex: tabIndex }} + disabled={disabled} /> </TableCell> ); }; -const StandardTimetableTable: FC<{ data: StandardEn; set: (key: AbstractLessonNameEn, value: string[]) => void }> = ({ - data, - set, -}) => { +const StandardTimetableForm: FC<{ + standard: StandardEn; + setStandard: (key: AbstractLessonNameEn, value: string[]) => void; + disabled?: boolean; +}> = ({ standard, setStandard, disabled }) => { const getCellParams = (abstractLessonName: AbstractLessonNameEn) => { return { - subjects: data[abstractLessonName], + subjects: standard[abstractLessonName], handleChange: (newValue: string) => - set( + setStandard( abstractLessonName, newValue.split('/').map((v) => v.trim()) ), + disabled, }; }; @@ -135,58 +139,36 @@ const StandardTimetableTable: FC<{ data: StandardEn; set: (key: AbstractLessonNa ); }; -const StandardTimetableForm: FC<{ classId: number }> = ({ classId }) => { - const { standard, loading, set, submit } = useStandardTimetable({ classId }); - - // TODO: Backdrop を app 共通にして context or SWR で制御 - return ( - <> - <Backdrop open={loading}> - <CircularProgress color='inherit' /> - </Backdrop> - {standard && <StandardTimetableTable data={standard} set={set} />} - {standard && ( - <Button variant='contained' disabled={loading} sx={{ m: 4 }} endIcon={<SendIcon />} onClick={() => submit()}> - 送信 - </Button> - )} - </> - ); -}; - const Page: NextPage = () => { - const { enqueueSnackbar } = useSnackbar(); - const { data } = useAspidaSWR(apiClient.classes); - - const classes = data ?? []; - const [classId, setClassId] = useState<number>(); + const { loading, classes, classId, setClassId, standard, setStandard, submit } = useStandardTimetable(); // TODO: classes 取得時に Backdrop // TODO: SWRConfig を _app.tsx へ return ( - <SWRConfig - value={{ - onError() { - enqueueSnackbar('エラーが発生しました', { variant: 'error' }); - }, - }} - > - <Container maxWidth='lg'> - <Box width='7rem' m={4}> - <FormControl fullWidth> - <InputLabel>クラス</InputLabel> - <Select value={classId ?? ''} label='クラス' onChange={(e) => setClassId(e.target.value as number)}> - {classes.map((c) => ( - <MenuItem value={c.id} key={c.id}> - {c.grade}-{c.class} - </MenuItem> - ))} - </Select> - </FormControl> - </Box> - {classId && <StandardTimetableForm classId={classId} />} - </Container> - </SWRConfig> + <Container maxWidth='lg' component={Box} py={2}> + <Backdrop open={loading}> + <CircularProgress color='inherit' /> + </Backdrop> + <Typography variant='h2' fontSize={40}> + クラス別時間割表 登録・変更 + </Typography> + <Box width='7rem' m={4}> + <FormControl fullWidth disabled={loading}> + <InputLabel>クラス</InputLabel> + <Select value={classId} label='クラス' onChange={(e) => setClassId(e.target.value as number)}> + {classes.map((c) => ( + <MenuItem value={c.id} key={c.id}> + {c.grade}-{c.class} + </MenuItem> + ))} + </Select> + </FormControl> + </Box> + <StandardTimetableForm standard={standard} setStandard={setStandard} disabled={loading} /> + <Button variant='contained' disabled={loading} sx={{ m: 4 }} startIcon={<SaveIcon />} onClick={() => submit()}> + 保存 + </Button> + </Container> ); }; diff --git a/yarn.lock b/yarn.lock @@ -14,12 +14,12 @@ __metadata: languageName: node linkType: hard -"@aspida/swr@npm:^1.11.0": +"@aspida/react-query@npm:^1.11.0": version: 1.11.0 - resolution: "@aspida/swr@npm:1.11.0" + resolution: "@aspida/react-query@npm:1.11.0" dependencies: aspida: ^1.7.1 - checksum: 08f28c87e6774d2bca4feca9f0b52693b897e8e474272586d0ef30676468904dd4898672188a254ff610328810e2d7a4f6c0e1a2f44a60a10de34fa87b38cba8 + checksum: 4b178d8f97d2ebaf4a0bdb6adee5cdf80e2dcc552e2d81cb8c9d2de8aee6e1cce634a6d96c488ab1a65ba3f778814a257ddcf1c6d100ed195afdda50df690457 languageName: node linkType: hard @@ -105,7 +105,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.19.0": +"@babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.19.0, @babel/runtime@npm:^7.6.2, @babel/runtime@npm:^7.7.2": version: 7.20.1 resolution: "@babel/runtime@npm:7.20.1" dependencies: @@ -1397,6 +1397,13 @@ __metadata: languageName: node linkType: hard +"big-integer@npm:^1.6.16": + version: 1.6.51 + resolution: "big-integer@npm:1.6.51" + checksum: 3d444173d1b2e20747e2c175568bedeebd8315b0637ea95d75fd27830d3b8e8ba36c6af40374f36bdaea7b5de376dcada1b07587cb2a79a928fccdb6e6e3c518 + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.2.0 resolution: "binary-extensions@npm:2.2.0" @@ -1432,6 +1439,22 @@ __metadata: languageName: node linkType: hard +"broadcast-channel@npm:^3.4.1": + version: 3.7.0 + resolution: "broadcast-channel@npm:3.7.0" + dependencies: + "@babel/runtime": ^7.7.2 + detect-node: ^2.1.0 + js-sha3: 0.8.0 + microseconds: 0.2.0 + nano-time: 1.0.0 + oblivious-set: 1.0.0 + rimraf: 3.0.2 + unload: 2.2.0 + checksum: 803794c48dcce7f03aca69797430bd8b1c4cfd70b7de22079cd89567eeffaa126a1db98c7c2d86af8131d9bb41ed367c0fef96dfb446151c927b831572c621fc + languageName: node + linkType: hard + "cacache@npm:^16.1.0": version: 16.1.3 resolution: "cacache@npm:16.1.3" @@ -1836,6 +1859,13 @@ __metadata: languageName: node linkType: hard +"detect-node@npm:^2.0.4, detect-node@npm:^2.1.0": + version: 2.1.0 + resolution: "detect-node@npm:2.1.0" + checksum: 832184ec458353e41533ac9c622f16c19f7c02d8b10c303dfd3a756f56be93e903616c0bb2d4226183c9351c15fc0b3dba41a17a2308262afabcfa3776e6ae6e + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -3265,6 +3295,13 @@ __metadata: languageName: node linkType: hard +"js-sha3@npm:0.8.0": + version: 0.8.0 + resolution: "js-sha3@npm:0.8.0" + checksum: 75df77c1fc266973f06cce8309ce010e9e9f07ec35ab12022ed29b7f0d9c8757f5a73e1b35aa24840dced0dea7059085aa143d817aea9e188e2a80d569d9adce + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -3512,6 +3549,16 @@ __metadata: languageName: node linkType: hard +"match-sorter@npm:^6.0.2": + version: 6.3.1 + resolution: "match-sorter@npm:6.3.1" + dependencies: + "@babel/runtime": ^7.12.5 + remove-accents: 0.4.2 + checksum: a4b02b676ac4ce64a89a091539ee4a70a802684713bcf06f2b70787927f510fe8a2adc849f9288857a90906083ad303467e530e8723b4a9756df9994fc164550 + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -3543,6 +3590,13 @@ __metadata: languageName: node linkType: hard +"microseconds@npm:0.2.0": + version: 0.2.0 + resolution: "microseconds@npm:0.2.0" + checksum: 22bfa8553f92c7d95afff6de0aeb2aecf750680d41b8c72b02098ccc5bbbb0a384380ff539292dbd3788f5dfc298682f9d38a2b4c101f5ee2c9471d53934c5fa + languageName: node + linkType: hard + "mime-db@npm:1.52.0": version: 1.52.0 resolution: "mime-db@npm:1.52.0" @@ -3717,6 +3771,15 @@ __metadata: languageName: node linkType: hard +"nano-time@npm:1.0.0": + version: 1.0.0 + resolution: "nano-time@npm:1.0.0" + dependencies: + big-integer: ^1.6.16 + checksum: eef8548546cc1020625f8e44751a7263e9eddf0412a6a1a6c80a8d2be2ea7973622804a977cdfe796807b85b20ff6c8ba340e8dd20effcc7078193ed5edbb5d4 + languageName: node + linkType: hard + "nanoid@npm:^3.3.4": version: 3.3.4 resolution: "nanoid@npm:3.3.4" @@ -4000,6 +4063,13 @@ __metadata: languageName: node linkType: hard +"oblivious-set@npm:1.0.0": + version: 1.0.0 + resolution: "oblivious-set@npm:1.0.0" + checksum: f31740ea9c3a8242ad2324e4ebb9a35359fbc2e6e7131731a0fc1c8b7b1238eb07e4c8c631a38535243a7b8e3042b7e89f7dc2a95d2989afd6f80bd5793b0aab + languageName: node + linkType: hard + "once@npm:^1.3.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -4317,6 +4387,24 @@ __metadata: languageName: node linkType: hard +"react-query@npm:^3.39.2": + version: 3.39.2 + resolution: "react-query@npm:3.39.2" + dependencies: + "@babel/runtime": ^7.5.5 + broadcast-channel: ^3.4.1 + match-sorter: ^6.0.2 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 83b199e66af28ab67ec4d22e51da42f02447186623db926b75d27a1c09a3383da6ddc9e5b83d82578fac7abdba7e6f265aecbffdb91db61981950d3d59409346 + languageName: node + linkType: hard + "react-swipeable-views-core@npm:^0.14.0": version: 0.14.0 resolution: "react-swipeable-views-core@npm:0.14.0" @@ -4481,6 +4569,13 @@ __metadata: languageName: node linkType: hard +"remove-accents@npm:0.4.2": + version: 0.4.2 + resolution: "remove-accents@npm:0.4.2" + checksum: 84a6988555dea24115e2d1954db99509588d43fe55a1590f0b5894802776f7b488b3151c37ceb9e4f4b646f26b80b7325dcea2fae58bc3865df146e1fa606711 + languageName: node + linkType: hard + "resize-observer-polyfill@npm:^1.5.1": version: 1.5.1 resolution: "resize-observer-polyfill@npm:1.5.1" @@ -4613,7 +4708,7 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.2": +"rimraf@npm:3.0.2, rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" dependencies: @@ -5169,7 +5264,7 @@ __metadata: resolution: "timetable-app@workspace:." dependencies: "@aspida/fetch": ^1.11.0 - "@aspida/swr": ^1.11.0 + "@aspida/react-query": ^1.11.0 "@date-io/date-fns": ^2.16.0 "@emotion/react": ^11.10.5 "@emotion/styled": ^11.10.5 @@ -5205,6 +5300,7 @@ __metadata: prisma: ^4.5.0 react: ^18.2.0 react-dom: ^18.2.0 + react-query: ^3.39.2 react-swipeable-views: ^0.14.0 react-swipeable-views-utils: ^0.14.0 react-use: ^17.4.0 @@ -5364,6 +5460,16 @@ __metadata: languageName: node linkType: hard +"unload@npm:2.2.0": + version: 2.2.0 + resolution: "unload@npm:2.2.0" + dependencies: + "@babel/runtime": ^7.6.2 + detect-node: ^2.0.4 + checksum: 88ba950c5ff83ab4f9bbd8f63bbf19ba09687ed3c434efd43b7338cc595bc574df8f9b155ee6eee7a435de3d3a4a226726988428977a68ba4907045f1fac5d41 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1"