commit 3c640c595b31a9ad4beaec818c9e48971df03701
parent b37e04abc81ddb29855083121b4ad8ad31c43fd7
Author: Yuukin256 <Yuukin256@gmail.com>
Date: Sun, 6 Nov 2022 02:29:03 +0900
クラス別時間割表の登録画面を実装
Diffstat:
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"