codeforlife 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +47 -0
- package/.github/workflows/contributing.yaml +8 -0
- package/.github/workflows/main.yml +36 -0
- package/.prettierignore +5 -0
- package/.prettierrc.json +4 -0
- package/.vscode/launch.json +22 -0
- package/.vscode/settings.json +30 -0
- package/CHANGELOG.md +1864 -0
- package/CONTRIBUTING.md +3 -0
- package/LICENSE.md +3 -0
- package/README.md +94 -0
- package/codecov.yml +11 -0
- package/package.json +139 -0
- package/src/api/createApi.ts +84 -0
- package/src/api/endpoints/authFactor.ts +31 -0
- package/src/api/endpoints/index.ts +9 -0
- package/src/api/endpoints/klass.ts +87 -0
- package/src/api/endpoints/school.ts +34 -0
- package/src/api/endpoints/session.ts +40 -0
- package/src/api/endpoints/user.ts +70 -0
- package/src/api/index.ts +4 -0
- package/src/api/models.ts +144 -0
- package/src/api/tagTypes.ts +12 -0
- package/src/api/urls.ts +13 -0
- package/src/components/App.css +38 -0
- package/src/components/App.tsx +150 -0
- package/src/components/ClickableTooltip.tsx +43 -0
- package/src/components/CopyIconButton.test.tsx +16 -0
- package/src/components/CopyIconButton.tsx +27 -0
- package/src/components/Countdown.tsx +42 -0
- package/src/components/ElevatedAppBar.tsx +41 -0
- package/src/components/Image.tsx +41 -0
- package/src/components/InputFileButton.tsx +27 -0
- package/src/components/ItemizedList.tsx +61 -0
- package/src/components/OrderedGrid.tsx +92 -0
- package/src/components/ScrollIntoViewLink.tsx +23 -0
- package/src/components/SyncError.tsx +14 -0
- package/src/components/TablePagination.tsx +132 -0
- package/src/components/YouTubeVideo.tsx +26 -0
- package/src/components/form/ApiAutocompleteField.tsx +180 -0
- package/src/components/form/AutocompleteField.tsx +124 -0
- package/src/components/form/CheckboxField.tsx +81 -0
- package/src/components/form/CountryField.tsx +68 -0
- package/src/components/form/DatePickerField.tsx +119 -0
- package/src/components/form/EmailField.tsx +38 -0
- package/src/components/form/FirstNameField.tsx +40 -0
- package/src/components/form/Form.tsx +82 -0
- package/src/components/form/OtpField.tsx +28 -0
- package/src/components/form/PasswordField.tsx +71 -0
- package/src/components/form/RepeatField.tsx +115 -0
- package/src/components/form/SubmitButton.tsx +47 -0
- package/src/components/form/TextField.tsx +103 -0
- package/src/components/form/UkCountyField.tsx +67 -0
- package/src/components/form/index.tsx +28 -0
- package/src/components/index.ts +26 -0
- package/src/components/page/Banner.tsx +84 -0
- package/src/components/page/Notification.tsx +71 -0
- package/src/components/page/Page.tsx +73 -0
- package/src/components/page/Section.tsx +21 -0
- package/src/components/page/TabBar.tsx +131 -0
- package/src/components/page/index.ts +10 -0
- package/src/components/router/Link.tsx +22 -0
- package/src/components/router/LinkButton.tsx +21 -0
- package/src/components/router/LinkIconButton.tsx +21 -0
- package/src/components/router/LinkListItem.tsx +21 -0
- package/src/components/router/LinkTab.tsx +21 -0
- package/src/components/router/Navigate.tsx +33 -0
- package/src/components/router/index.tsx +12 -0
- package/src/components/table/CellStack.tsx +19 -0
- package/src/components/table/Table.tsx +55 -0
- package/src/components/table/index.tsx +10 -0
- package/src/features/InactiveDialog.tsx +40 -0
- package/src/features/ScreenTimeDialog.tsx +33 -0
- package/src/features/index.ts +4 -0
- package/src/fonts/Inter-VariableFont_slnt,wght.ttf +0 -0
- package/src/fonts/SpaceGrotesk-VariableFont_wght.ttf +0 -0
- package/src/hooks/api.tsx +37 -0
- package/src/hooks/auth.tsx +87 -0
- package/src/hooks/general.ts +110 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/router.tsx +168 -0
- package/src/index.ts +2 -0
- package/src/middlewares/index.ts +1 -0
- package/src/middlewares/session.ts +16 -0
- package/src/public/images/brain.svg +1 -0
- package/src/schemas/user.ts +4 -0
- package/src/scripts/freshDesk.js +473 -0
- package/src/scripts/index.ts +1 -0
- package/src/server.js +181 -0
- package/src/settings/custom.ts +22 -0
- package/src/settings/index.ts +5 -0
- package/src/settings/vite.ts +26 -0
- package/src/setupTests.ts +1 -0
- package/src/slices/createSlice.ts +8 -0
- package/src/slices/index.ts +2 -0
- package/src/slices/session.ts +32 -0
- package/src/theme/ThemedBox.tsx +265 -0
- package/src/theme/colors.ts +57 -0
- package/src/theme/components/MuiAccordion.tsx +13 -0
- package/src/theme/components/MuiAutocomplete.tsx +11 -0
- package/src/theme/components/MuiButton.ts +70 -0
- package/src/theme/components/MuiCardActions.tsx +12 -0
- package/src/theme/components/MuiCheckbox.ts +12 -0
- package/src/theme/components/MuiContainer.ts +19 -0
- package/src/theme/components/MuiDialog.tsx +16 -0
- package/src/theme/components/MuiFormControlLabel.ts +18 -0
- package/src/theme/components/MuiFormHelperText.ts +12 -0
- package/src/theme/components/MuiGrid2.ts +16 -0
- package/src/theme/components/MuiInputBase.ts +14 -0
- package/src/theme/components/MuiLink.ts +41 -0
- package/src/theme/components/MuiList.ts +12 -0
- package/src/theme/components/MuiListItemText.ts +18 -0
- package/src/theme/components/MuiMenu.ts +14 -0
- package/src/theme/components/MuiMenuItem.ts +15 -0
- package/src/theme/components/MuiSelect.ts +16 -0
- package/src/theme/components/MuiTab.ts +29 -0
- package/src/theme/components/MuiTable.ts +29 -0
- package/src/theme/components/MuiTableBody.ts +15 -0
- package/src/theme/components/MuiTableHead.ts +26 -0
- package/src/theme/components/MuiTabs.ts +26 -0
- package/src/theme/components/MuiTextField.ts +86 -0
- package/src/theme/components/MuiToolbar.ts +11 -0
- package/src/theme/components/MuiTypography.ts +12 -0
- package/src/theme/components/_components.ts +93 -0
- package/src/theme/components/index.ts +57 -0
- package/src/theme/index.ts +25 -0
- package/src/theme/palette.ts +98 -0
- package/src/theme/spacing.ts +8 -0
- package/src/theme/typography.ts +101 -0
- package/src/utils/api.test.ts +19 -0
- package/src/utils/api.tsx +327 -0
- package/src/utils/auth.ts +17 -0
- package/src/utils/form.ts +153 -0
- package/src/utils/general.test.ts +42 -0
- package/src/utils/general.ts +498 -0
- package/src/utils/router.test.ts +156 -0
- package/src/utils/router.ts +67 -0
- package/src/utils/schema.ts +80 -0
- package/src/utils/store.ts +31 -0
- package/src/utils/test.tsx +82 -0
- package/src/utils/theme.tsx +82 -0
- package/src/utils/window.ts +9 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +31 -0
- package/tsconfig.node.json +11 -0
- package/types/fixes.d.ts +18 -0
- package/vite.config.ts +21 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type React from "react"
|
|
2
|
+
import { Unstable_Grid2 as Grid, type Grid2Props } from "@mui/material"
|
|
3
|
+
|
|
4
|
+
interface ItemProps
|
|
5
|
+
extends Omit<
|
|
6
|
+
Grid2Props,
|
|
7
|
+
| "key"
|
|
8
|
+
| "order"
|
|
9
|
+
| "xs"
|
|
10
|
+
| "sm"
|
|
11
|
+
| "md"
|
|
12
|
+
| "lg"
|
|
13
|
+
| "xl"
|
|
14
|
+
| "xsOffset"
|
|
15
|
+
| "smOffset"
|
|
16
|
+
| "mdOffset"
|
|
17
|
+
| "lgOffset"
|
|
18
|
+
| "xlOffset"
|
|
19
|
+
> {}
|
|
20
|
+
|
|
21
|
+
interface GlobalItemProps extends ItemProps {
|
|
22
|
+
xs: number
|
|
23
|
+
sm: number
|
|
24
|
+
md: number
|
|
25
|
+
lg: number
|
|
26
|
+
xl: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OrderedGridProps {
|
|
30
|
+
rows: Array<
|
|
31
|
+
Array<{
|
|
32
|
+
element: React.ReactElement
|
|
33
|
+
itemProps?: ItemProps
|
|
34
|
+
}>
|
|
35
|
+
>
|
|
36
|
+
containerProps?: Omit<Grid2Props, "container">
|
|
37
|
+
globalItemProps: GlobalItemProps
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const OrderedGrid: React.FC<OrderedGridProps> = ({
|
|
41
|
+
rows,
|
|
42
|
+
containerProps = {},
|
|
43
|
+
globalItemProps,
|
|
44
|
+
}) => {
|
|
45
|
+
const columns = Number(containerProps.columns ?? 12)
|
|
46
|
+
|
|
47
|
+
const getItemsPerRow = (size: number): number => Math.floor(columns / size)
|
|
48
|
+
|
|
49
|
+
const getOrder = (
|
|
50
|
+
rowIndex: number,
|
|
51
|
+
itemIndex: number,
|
|
52
|
+
size: number,
|
|
53
|
+
): number =>
|
|
54
|
+
Math.floor(itemIndex / getItemsPerRow(size)) * rows.length + rowIndex
|
|
55
|
+
|
|
56
|
+
const getOffset = (itemIndex: number, size: number): number => {
|
|
57
|
+
const itemsOnLastRow = rows[0].length % getItemsPerRow(size)
|
|
58
|
+
return itemsOnLastRow !== 0 && itemIndex === rows[0].length - 1
|
|
59
|
+
? (columns - itemsOnLastRow * size) / 2
|
|
60
|
+
: 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Grid container {...containerProps}>
|
|
65
|
+
{rows.map((row, rowIndex) =>
|
|
66
|
+
row.map(({ element, itemProps = {} }, itemIndex) => (
|
|
67
|
+
<Grid
|
|
68
|
+
key={`${rowIndex}-${itemIndex}`}
|
|
69
|
+
order={{
|
|
70
|
+
xs: getOrder(rowIndex, itemIndex, globalItemProps.xs),
|
|
71
|
+
sm: getOrder(rowIndex, itemIndex, globalItemProps.sm),
|
|
72
|
+
md: getOrder(rowIndex, itemIndex, globalItemProps.md),
|
|
73
|
+
lg: getOrder(rowIndex, itemIndex, globalItemProps.lg),
|
|
74
|
+
xl: getOrder(rowIndex, itemIndex, globalItemProps.xl),
|
|
75
|
+
}}
|
|
76
|
+
xsOffset={getOffset(itemIndex, globalItemProps.xs)}
|
|
77
|
+
smOffset={getOffset(itemIndex, globalItemProps.sm)}
|
|
78
|
+
mdOffset={getOffset(itemIndex, globalItemProps.md)}
|
|
79
|
+
lgOffset={getOffset(itemIndex, globalItemProps.lg)}
|
|
80
|
+
xlOffset={getOffset(itemIndex, globalItemProps.xl)}
|
|
81
|
+
{...globalItemProps}
|
|
82
|
+
{...itemProps}
|
|
83
|
+
>
|
|
84
|
+
{element}
|
|
85
|
+
</Grid>
|
|
86
|
+
)),
|
|
87
|
+
)}
|
|
88
|
+
</Grid>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default OrderedGrid
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type FC } from "react"
|
|
2
|
+
import { Link, type LinkProps } from "@mui/material"
|
|
3
|
+
|
|
4
|
+
export interface ScrollIntoViewLinkProps extends Omit<LinkProps, "onClick"> {
|
|
5
|
+
elementId: string
|
|
6
|
+
options?: ScrollIntoViewOptions
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ScrollIntoViewLink: FC<ScrollIntoViewLinkProps> = ({
|
|
10
|
+
elementId,
|
|
11
|
+
options,
|
|
12
|
+
...linkProps
|
|
13
|
+
}) => (
|
|
14
|
+
<Link
|
|
15
|
+
{...linkProps}
|
|
16
|
+
onClick={() => {
|
|
17
|
+
const element = document.getElementById(elementId)
|
|
18
|
+
element?.scrollIntoView(options)
|
|
19
|
+
}}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
export default ScrollIntoViewLink
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SyncProblem as SyncProblemIcon } from "@mui/icons-material"
|
|
2
|
+
import { Stack, Typography } from "@mui/material"
|
|
3
|
+
import { type FC } from "react"
|
|
4
|
+
|
|
5
|
+
export interface SyncErrorProps {}
|
|
6
|
+
|
|
7
|
+
const SyncError: FC<SyncErrorProps> = () => (
|
|
8
|
+
<Stack alignItems="center" alignContent="center">
|
|
9
|
+
<SyncProblemIcon color="error" />
|
|
10
|
+
<Typography color="error.main">Failed to sync data</Typography>
|
|
11
|
+
</Stack>
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
export default SyncError
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TablePagination as MuiTablePagination,
|
|
3
|
+
type TablePaginationProps as MuiTablePaginationProps,
|
|
4
|
+
Stack,
|
|
5
|
+
type StackProps,
|
|
6
|
+
type TablePaginationBaseProps,
|
|
7
|
+
} from "@mui/material"
|
|
8
|
+
import type { TypedUseLazyQuery } from "@reduxjs/toolkit/query/react"
|
|
9
|
+
import {
|
|
10
|
+
type ElementType,
|
|
11
|
+
type JSXElementConstructor,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
useEffect,
|
|
14
|
+
} from "react"
|
|
15
|
+
|
|
16
|
+
import { type Pagination, usePagination } from "../hooks/api"
|
|
17
|
+
import { type ListArg, type ListResult, handleResultState } from "../utils/api"
|
|
18
|
+
|
|
19
|
+
export type TablePaginationProps<
|
|
20
|
+
QueryArg extends ListArg,
|
|
21
|
+
ResultType extends ListResult<any>,
|
|
22
|
+
RootComponent extends
|
|
23
|
+
ElementType = JSXElementConstructor<TablePaginationBaseProps>,
|
|
24
|
+
AdditionalProps = {},
|
|
25
|
+
> = Omit<
|
|
26
|
+
MuiTablePaginationProps<RootComponent, AdditionalProps>,
|
|
27
|
+
| "component"
|
|
28
|
+
| "count"
|
|
29
|
+
| "rowsPerPage"
|
|
30
|
+
| "onRowsPerPageChange"
|
|
31
|
+
| "rowsPerPageOptions"
|
|
32
|
+
| "page"
|
|
33
|
+
| "onPageChange"
|
|
34
|
+
> &
|
|
35
|
+
Partial<
|
|
36
|
+
Pick<
|
|
37
|
+
MuiTablePaginationProps<RootComponent, AdditionalProps>,
|
|
38
|
+
"onRowsPerPageChange" | "onPageChange"
|
|
39
|
+
>
|
|
40
|
+
> & {
|
|
41
|
+
children: (
|
|
42
|
+
data: ResultType["data"],
|
|
43
|
+
pagination: Pagination & { count?: number; maxLimit?: number },
|
|
44
|
+
) => ReactNode
|
|
45
|
+
useLazyListQuery: TypedUseLazyQuery<ResultType, QueryArg, any>
|
|
46
|
+
preferCacheValue?: boolean
|
|
47
|
+
filters?: Omit<QueryArg, "limit" | "offset">
|
|
48
|
+
rowsPerPageOptions?: number[]
|
|
49
|
+
stackProps?: StackProps
|
|
50
|
+
page?: number
|
|
51
|
+
rowsPerPage?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TablePagination = <
|
|
55
|
+
QueryArg extends ListArg,
|
|
56
|
+
ResultType extends ListResult<any>,
|
|
57
|
+
RootComponent extends
|
|
58
|
+
ElementType = JSXElementConstructor<TablePaginationBaseProps>,
|
|
59
|
+
AdditionalProps = {},
|
|
60
|
+
>({
|
|
61
|
+
children,
|
|
62
|
+
useLazyListQuery,
|
|
63
|
+
preferCacheValue,
|
|
64
|
+
filters,
|
|
65
|
+
page: initialPage = 0,
|
|
66
|
+
rowsPerPage: initialLimit = 50,
|
|
67
|
+
rowsPerPageOptions = [50, 100, 150],
|
|
68
|
+
stackProps,
|
|
69
|
+
onRowsPerPageChange,
|
|
70
|
+
onPageChange,
|
|
71
|
+
...tablePaginationProps
|
|
72
|
+
}: TablePaginationProps<
|
|
73
|
+
QueryArg,
|
|
74
|
+
ResultType,
|
|
75
|
+
RootComponent,
|
|
76
|
+
AdditionalProps
|
|
77
|
+
>): JSX.Element => {
|
|
78
|
+
const [trigger, result] = useLazyListQuery()
|
|
79
|
+
const [{ limit, page, offset }, setPagination] = usePagination({
|
|
80
|
+
page: initialPage,
|
|
81
|
+
limit: initialLimit,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
useEffect(
|
|
85
|
+
() => {
|
|
86
|
+
trigger({ limit, offset, ...filters } as QueryArg, preferCacheValue)
|
|
87
|
+
},
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
[trigger, limit, offset, ...Object.values(filters || {}), preferCacheValue],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const { count, max_limit } = result.data || {}
|
|
93
|
+
|
|
94
|
+
if (max_limit) {
|
|
95
|
+
rowsPerPageOptions = rowsPerPageOptions.filter(
|
|
96
|
+
option => option <= max_limit,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Stack {...stackProps}>
|
|
102
|
+
{handleResultState(result, ({ data }) =>
|
|
103
|
+
children(data, {
|
|
104
|
+
limit,
|
|
105
|
+
page,
|
|
106
|
+
offset,
|
|
107
|
+
count,
|
|
108
|
+
maxLimit: max_limit,
|
|
109
|
+
}),
|
|
110
|
+
)}
|
|
111
|
+
<MuiTablePagination
|
|
112
|
+
component="div"
|
|
113
|
+
count={count ?? 0}
|
|
114
|
+
rowsPerPage={limit}
|
|
115
|
+
onRowsPerPageChange={event => {
|
|
116
|
+
setPagination({ limit: parseInt(event.target.value), page: 0 })
|
|
117
|
+
if (onRowsPerPageChange) onRowsPerPageChange(event)
|
|
118
|
+
}}
|
|
119
|
+
page={page}
|
|
120
|
+
onPageChange={(event, page) => {
|
|
121
|
+
setPagination(({ limit }) => ({ limit, page }))
|
|
122
|
+
if (onPageChange) onPageChange(event, page)
|
|
123
|
+
}}
|
|
124
|
+
// ascending order
|
|
125
|
+
rowsPerPageOptions={rowsPerPageOptions.sort((a, b) => a - b)}
|
|
126
|
+
{...tablePaginationProps}
|
|
127
|
+
/>
|
|
128
|
+
</Stack>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default TablePagination
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type React from "react"
|
|
2
|
+
import { Box, type BoxProps } from "@mui/material"
|
|
3
|
+
|
|
4
|
+
export interface YouTubeVideoProps extends Omit<BoxProps, "component"> {
|
|
5
|
+
src: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const YouTubeVideo: React.FC<YouTubeVideoProps> = ({
|
|
9
|
+
src,
|
|
10
|
+
style = {},
|
|
11
|
+
...otherProps
|
|
12
|
+
}) => {
|
|
13
|
+
return (
|
|
14
|
+
<Box
|
|
15
|
+
component="iframe"
|
|
16
|
+
width="100%"
|
|
17
|
+
src={src}
|
|
18
|
+
title="YouTube video player"
|
|
19
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
|
20
|
+
style={{ border: "0px", aspectRatio: "16 / 9", ...style }}
|
|
21
|
+
{...otherProps}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default YouTubeVideo
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Button, CircularProgress, type ChipTypeMap } from "@mui/material"
|
|
2
|
+
import type { TypedUseLazyQuery } from "@reduxjs/toolkit/query/react"
|
|
3
|
+
import {
|
|
4
|
+
Children,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useState,
|
|
8
|
+
type ElementType,
|
|
9
|
+
} from "react"
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
AutocompleteField,
|
|
13
|
+
type AutocompleteFieldProps,
|
|
14
|
+
} from "../../components/form"
|
|
15
|
+
import { usePagination } from "../../hooks/api"
|
|
16
|
+
import type { ListArg, ListResult, ModelId } from "../../utils/api"
|
|
17
|
+
import SyncError from "../SyncError"
|
|
18
|
+
|
|
19
|
+
export interface ApiAutocompleteFieldProps<
|
|
20
|
+
SearchKey extends keyof Omit<QueryArg, "limit" | "offset">,
|
|
21
|
+
// api type args
|
|
22
|
+
QueryArg extends ListArg,
|
|
23
|
+
ResultType extends ListResult<any>,
|
|
24
|
+
// autocomplete type args
|
|
25
|
+
Multiple extends boolean | undefined = false,
|
|
26
|
+
DisableClearable extends boolean | undefined = false,
|
|
27
|
+
FreeSolo extends boolean | undefined = false,
|
|
28
|
+
ChipComponent extends ElementType = ChipTypeMap["defaultComponent"],
|
|
29
|
+
> extends Omit<
|
|
30
|
+
AutocompleteFieldProps<
|
|
31
|
+
ModelId,
|
|
32
|
+
Multiple,
|
|
33
|
+
DisableClearable,
|
|
34
|
+
FreeSolo,
|
|
35
|
+
ChipComponent
|
|
36
|
+
>,
|
|
37
|
+
| "options"
|
|
38
|
+
| "ListboxComponent"
|
|
39
|
+
| "filterOptions"
|
|
40
|
+
| "getOptionLabel"
|
|
41
|
+
| "getOptionKey"
|
|
42
|
+
| "onInputChange"
|
|
43
|
+
> {
|
|
44
|
+
useLazyListQuery: TypedUseLazyQuery<ResultType, QueryArg, any>
|
|
45
|
+
filterOptions?: Omit<QueryArg, "limit" | "offset" | SearchKey>
|
|
46
|
+
getOptionLabel: (result: ResultType["data"][number]) => string
|
|
47
|
+
getOptionKey?: (result: ResultType["data"][number]) => ModelId
|
|
48
|
+
searchKey: SearchKey
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ApiAutocompleteField = <
|
|
52
|
+
SearchKey extends keyof Omit<QueryArg, "limit" | "offset">,
|
|
53
|
+
// api type args
|
|
54
|
+
QueryArg extends ListArg,
|
|
55
|
+
ResultType extends ListResult<any>,
|
|
56
|
+
// autocomplete type args
|
|
57
|
+
Multiple extends boolean | undefined = false,
|
|
58
|
+
DisableClearable extends boolean | undefined = false,
|
|
59
|
+
FreeSolo extends boolean | undefined = false,
|
|
60
|
+
ChipComponent extends ElementType = ChipTypeMap["defaultComponent"],
|
|
61
|
+
>({
|
|
62
|
+
useLazyListQuery,
|
|
63
|
+
filterOptions,
|
|
64
|
+
getOptionLabel,
|
|
65
|
+
getOptionKey = result => result.id,
|
|
66
|
+
searchKey,
|
|
67
|
+
...otherAutocompleteFieldProps
|
|
68
|
+
}: ApiAutocompleteFieldProps<
|
|
69
|
+
SearchKey,
|
|
70
|
+
// api type args
|
|
71
|
+
QueryArg,
|
|
72
|
+
ResultType,
|
|
73
|
+
// autocomplete type args
|
|
74
|
+
Multiple,
|
|
75
|
+
DisableClearable,
|
|
76
|
+
FreeSolo,
|
|
77
|
+
ChipComponent
|
|
78
|
+
>): JSX.Element => {
|
|
79
|
+
const [search, setSearch] = useState("")
|
|
80
|
+
const [trigger, { isLoading, isError }] = useLazyListQuery()
|
|
81
|
+
const [{ limit, offset }, setPagination] = usePagination()
|
|
82
|
+
const [{ options, hasMore }, setState] = useState<{
|
|
83
|
+
options: Record<ModelId, ResultType["data"][number]>
|
|
84
|
+
hasMore: boolean
|
|
85
|
+
}>({ options: {}, hasMore: true })
|
|
86
|
+
|
|
87
|
+
// Call api
|
|
88
|
+
useEffect(
|
|
89
|
+
() => {
|
|
90
|
+
const arg = { limit, offset, ...filterOptions } as QueryArg
|
|
91
|
+
// @ts-expect-error
|
|
92
|
+
if (search) arg[searchKey] = search
|
|
93
|
+
|
|
94
|
+
trigger(arg, true)
|
|
95
|
+
.unwrap()
|
|
96
|
+
.then(({ data, offset, limit, count }) => {
|
|
97
|
+
setState(({ options: previousOptions }) => {
|
|
98
|
+
const options = { ...previousOptions }
|
|
99
|
+
data.forEach(result => {
|
|
100
|
+
options[getOptionKey(result)] = result
|
|
101
|
+
})
|
|
102
|
+
return { options, hasMore: offset + limit < count }
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
.catch(error => {
|
|
106
|
+
if (error) console.error(error)
|
|
107
|
+
// TODO: gracefully handle error
|
|
108
|
+
})
|
|
109
|
+
},
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
[
|
|
112
|
+
trigger,
|
|
113
|
+
limit,
|
|
114
|
+
offset,
|
|
115
|
+
searchKey,
|
|
116
|
+
search,
|
|
117
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
118
|
+
...Object.values(filterOptions || {}),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Get options keys
|
|
123
|
+
let optionKeys: ModelId[] = Object.keys(options)
|
|
124
|
+
if (!optionKeys.length) return <></>
|
|
125
|
+
if (typeof getOptionKey(Object.values(options)[0]) === "number") {
|
|
126
|
+
optionKeys = optionKeys.map(Number)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function loadNextPage() {
|
|
130
|
+
setPagination(({ page, limit }) => ({ page: page + 1, limit }))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<AutocompleteField
|
|
135
|
+
options={optionKeys}
|
|
136
|
+
getOptionLabel={id => getOptionLabel(options[id])}
|
|
137
|
+
onInputChange={(_, value, reason) => {
|
|
138
|
+
setSearch(reason === "input" ? value : "")
|
|
139
|
+
}}
|
|
140
|
+
ListboxComponent={forwardRef(({ children, ...props }, ref) => {
|
|
141
|
+
const listItems = Children.toArray(children)
|
|
142
|
+
if (isLoading) listItems.push(<CircularProgress key="is-loading" />)
|
|
143
|
+
else {
|
|
144
|
+
if (isError) listItems.push(<SyncError key="is-error" />)
|
|
145
|
+
if (hasMore) {
|
|
146
|
+
listItems.push(
|
|
147
|
+
<Button key="load-more" onClick={loadNextPage}>
|
|
148
|
+
Load more
|
|
149
|
+
</Button>,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<ul
|
|
156
|
+
{...props}
|
|
157
|
+
// @ts-expect-error
|
|
158
|
+
ref={ref}
|
|
159
|
+
onScroll={event => {
|
|
160
|
+
// If not already loading and scrolled to bottom
|
|
161
|
+
if (
|
|
162
|
+
!isLoading &&
|
|
163
|
+
event.currentTarget.clientHeight +
|
|
164
|
+
event.currentTarget.scrollTop >=
|
|
165
|
+
event.currentTarget.scrollHeight
|
|
166
|
+
) {
|
|
167
|
+
loadNextPage()
|
|
168
|
+
}
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{listItems}
|
|
172
|
+
</ul>
|
|
173
|
+
)
|
|
174
|
+
})}
|
|
175
|
+
{...otherAutocompleteFieldProps}
|
|
176
|
+
/>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default ApiAutocompleteField
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Autocomplete,
|
|
3
|
+
TextField,
|
|
4
|
+
type AutocompleteProps,
|
|
5
|
+
type ChipTypeMap,
|
|
6
|
+
type TextFieldProps,
|
|
7
|
+
} from "@mui/material"
|
|
8
|
+
import { Field, type FieldConfig, type FieldProps } from "formik"
|
|
9
|
+
import { type ElementType } from "react"
|
|
10
|
+
import {
|
|
11
|
+
number as YupNumber,
|
|
12
|
+
string as YupString,
|
|
13
|
+
type ValidateOptions,
|
|
14
|
+
} from "yup"
|
|
15
|
+
|
|
16
|
+
import { schemaToFieldValidator } from "../../utils/form"
|
|
17
|
+
import { getNestedProperty } from "../../utils/general"
|
|
18
|
+
|
|
19
|
+
export interface AutocompleteFieldProps<
|
|
20
|
+
Value extends string | number,
|
|
21
|
+
Multiple extends boolean | undefined = false,
|
|
22
|
+
DisableClearable extends boolean | undefined = false,
|
|
23
|
+
FreeSolo extends boolean | undefined = false,
|
|
24
|
+
ChipComponent extends ElementType = ChipTypeMap["defaultComponent"],
|
|
25
|
+
> extends Omit<
|
|
26
|
+
AutocompleteProps<
|
|
27
|
+
Value,
|
|
28
|
+
Multiple,
|
|
29
|
+
DisableClearable,
|
|
30
|
+
FreeSolo,
|
|
31
|
+
ChipComponent
|
|
32
|
+
>,
|
|
33
|
+
"renderInput" | "defaultValue" | "onChange" | "onBlur" | "value"
|
|
34
|
+
> {
|
|
35
|
+
textFieldProps: Omit<
|
|
36
|
+
TextFieldProps,
|
|
37
|
+
| "name"
|
|
38
|
+
| "value"
|
|
39
|
+
| "onChange"
|
|
40
|
+
| "onBlur"
|
|
41
|
+
| "error"
|
|
42
|
+
| "helperText"
|
|
43
|
+
| "defaultValue"
|
|
44
|
+
| "type"
|
|
45
|
+
> & {
|
|
46
|
+
name: string
|
|
47
|
+
}
|
|
48
|
+
validateOptions?: ValidateOptions
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const AutocompleteField = <
|
|
52
|
+
Value extends string | number,
|
|
53
|
+
Multiple extends boolean | undefined = false,
|
|
54
|
+
DisableClearable extends boolean | undefined = false,
|
|
55
|
+
FreeSolo extends boolean | undefined = false,
|
|
56
|
+
ChipComponent extends ElementType = ChipTypeMap["defaultComponent"],
|
|
57
|
+
>({
|
|
58
|
+
textFieldProps,
|
|
59
|
+
options,
|
|
60
|
+
validateOptions,
|
|
61
|
+
...otherAutocompleteProps
|
|
62
|
+
}: AutocompleteFieldProps<
|
|
63
|
+
Value,
|
|
64
|
+
Multiple,
|
|
65
|
+
DisableClearable,
|
|
66
|
+
FreeSolo,
|
|
67
|
+
ChipComponent
|
|
68
|
+
>): JSX.Element => {
|
|
69
|
+
const { id, name, required, ...otherTextFieldProps } = textFieldProps
|
|
70
|
+
|
|
71
|
+
const dotPath = name.split(".")
|
|
72
|
+
|
|
73
|
+
const message = "not a valid option"
|
|
74
|
+
let schema =
|
|
75
|
+
typeof options[0] === "string"
|
|
76
|
+
? YupString().oneOf(options as readonly string[], message)
|
|
77
|
+
: YupNumber().oneOf(options as readonly number[], message)
|
|
78
|
+
if (required) schema = schema.required()
|
|
79
|
+
|
|
80
|
+
const fieldConfig: FieldConfig = {
|
|
81
|
+
name,
|
|
82
|
+
type: typeof options[0] === "string" ? "text" : "number",
|
|
83
|
+
validate: schemaToFieldValidator(schema, validateOptions),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Field {...fieldConfig}>
|
|
88
|
+
{({ form, meta }: FieldProps) => {
|
|
89
|
+
const value = getNestedProperty(form.values, dotPath)
|
|
90
|
+
const touched = getNestedProperty(form.touched, dotPath)
|
|
91
|
+
const error = getNestedProperty(form.errors, dotPath)
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Autocomplete
|
|
95
|
+
options={options}
|
|
96
|
+
defaultValue={
|
|
97
|
+
meta.initialValue === "" ? undefined : meta.initialValue
|
|
98
|
+
}
|
|
99
|
+
renderInput={({ id: _, ...otherParams }) => (
|
|
100
|
+
<TextField
|
|
101
|
+
id={id ?? name}
|
|
102
|
+
name={name}
|
|
103
|
+
required={required}
|
|
104
|
+
type="text" // Force to be string to avoid number incrementor/decrementor
|
|
105
|
+
value={value}
|
|
106
|
+
error={touched && Boolean(error)}
|
|
107
|
+
helperText={(touched && error) as false | string}
|
|
108
|
+
{...otherTextFieldProps}
|
|
109
|
+
{...otherParams}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
onChange={(_, value) => {
|
|
113
|
+
form.setFieldValue(name, value ?? undefined, true)
|
|
114
|
+
}}
|
|
115
|
+
onBlur={form.handleBlur}
|
|
116
|
+
{...otherAutocompleteProps}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
}}
|
|
120
|
+
</Field>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default AutocompleteField
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Checkbox,
|
|
3
|
+
FormControl,
|
|
4
|
+
FormControlLabel,
|
|
5
|
+
FormHelperText,
|
|
6
|
+
type CheckboxProps,
|
|
7
|
+
type FormControlLabelProps,
|
|
8
|
+
} from "@mui/material"
|
|
9
|
+
import { Field, type FieldConfig, type FieldProps } from "formik"
|
|
10
|
+
import { type FC } from "react"
|
|
11
|
+
import { bool as YupBool, type ValidateOptions } from "yup"
|
|
12
|
+
|
|
13
|
+
import { schemaToFieldValidator } from "../../utils/form"
|
|
14
|
+
import { getNestedProperty } from "../../utils/general"
|
|
15
|
+
|
|
16
|
+
export interface CheckboxFieldProps
|
|
17
|
+
extends Omit<
|
|
18
|
+
CheckboxProps,
|
|
19
|
+
"defaultChecked" | "value" | "onChange" | "onBlur"
|
|
20
|
+
> {
|
|
21
|
+
name: string
|
|
22
|
+
formControlLabelProps: Omit<FormControlLabelProps, "control">
|
|
23
|
+
errorMessage?: string
|
|
24
|
+
validateOptions?: ValidateOptions
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CheckboxField: FC<CheckboxFieldProps> = ({
|
|
28
|
+
id,
|
|
29
|
+
name,
|
|
30
|
+
formControlLabelProps,
|
|
31
|
+
required = false,
|
|
32
|
+
errorMessage = "this is a required field",
|
|
33
|
+
validateOptions,
|
|
34
|
+
...otherCheckboxProps
|
|
35
|
+
}) => {
|
|
36
|
+
const dotPath = name.split(".")
|
|
37
|
+
|
|
38
|
+
let schema = YupBool()
|
|
39
|
+
if (required) schema = schema.oneOf([true], errorMessage)
|
|
40
|
+
|
|
41
|
+
const fieldConfig: FieldConfig = {
|
|
42
|
+
name,
|
|
43
|
+
type: "checkbox",
|
|
44
|
+
validate: schemaToFieldValidator(schema, validateOptions),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Field {...fieldConfig}>
|
|
49
|
+
{({ form, meta }: FieldProps) => {
|
|
50
|
+
const touched = getNestedProperty(form.touched, dotPath)
|
|
51
|
+
const error = getNestedProperty(form.errors, dotPath)
|
|
52
|
+
const value = getNestedProperty(form.values, dotPath)
|
|
53
|
+
|
|
54
|
+
const hasError = touched && Boolean(error)
|
|
55
|
+
|
|
56
|
+
// https://mui.com/material-ui/react-checkbox/#formgroup
|
|
57
|
+
return (
|
|
58
|
+
<FormControl error={hasError} required={required}>
|
|
59
|
+
<FormControlLabel
|
|
60
|
+
control={
|
|
61
|
+
<Checkbox
|
|
62
|
+
defaultChecked={meta.initialValue}
|
|
63
|
+
id={id ?? name}
|
|
64
|
+
name={name}
|
|
65
|
+
value={value}
|
|
66
|
+
onChange={form.handleChange}
|
|
67
|
+
onBlur={form.handleBlur}
|
|
68
|
+
{...otherCheckboxProps}
|
|
69
|
+
/>
|
|
70
|
+
}
|
|
71
|
+
{...formControlLabelProps}
|
|
72
|
+
/>
|
|
73
|
+
{hasError && <FormHelperText>{error as string}</FormHelperText>}
|
|
74
|
+
</FormControl>
|
|
75
|
+
)
|
|
76
|
+
}}
|
|
77
|
+
</Field>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default CheckboxField
|