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.
Files changed (147) hide show
  1. package/.eslintrc.json +47 -0
  2. package/.github/workflows/contributing.yaml +8 -0
  3. package/.github/workflows/main.yml +36 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc.json +4 -0
  6. package/.vscode/launch.json +22 -0
  7. package/.vscode/settings.json +30 -0
  8. package/CHANGELOG.md +1864 -0
  9. package/CONTRIBUTING.md +3 -0
  10. package/LICENSE.md +3 -0
  11. package/README.md +94 -0
  12. package/codecov.yml +11 -0
  13. package/package.json +139 -0
  14. package/src/api/createApi.ts +84 -0
  15. package/src/api/endpoints/authFactor.ts +31 -0
  16. package/src/api/endpoints/index.ts +9 -0
  17. package/src/api/endpoints/klass.ts +87 -0
  18. package/src/api/endpoints/school.ts +34 -0
  19. package/src/api/endpoints/session.ts +40 -0
  20. package/src/api/endpoints/user.ts +70 -0
  21. package/src/api/index.ts +4 -0
  22. package/src/api/models.ts +144 -0
  23. package/src/api/tagTypes.ts +12 -0
  24. package/src/api/urls.ts +13 -0
  25. package/src/components/App.css +38 -0
  26. package/src/components/App.tsx +150 -0
  27. package/src/components/ClickableTooltip.tsx +43 -0
  28. package/src/components/CopyIconButton.test.tsx +16 -0
  29. package/src/components/CopyIconButton.tsx +27 -0
  30. package/src/components/Countdown.tsx +42 -0
  31. package/src/components/ElevatedAppBar.tsx +41 -0
  32. package/src/components/Image.tsx +41 -0
  33. package/src/components/InputFileButton.tsx +27 -0
  34. package/src/components/ItemizedList.tsx +61 -0
  35. package/src/components/OrderedGrid.tsx +92 -0
  36. package/src/components/ScrollIntoViewLink.tsx +23 -0
  37. package/src/components/SyncError.tsx +14 -0
  38. package/src/components/TablePagination.tsx +132 -0
  39. package/src/components/YouTubeVideo.tsx +26 -0
  40. package/src/components/form/ApiAutocompleteField.tsx +180 -0
  41. package/src/components/form/AutocompleteField.tsx +124 -0
  42. package/src/components/form/CheckboxField.tsx +81 -0
  43. package/src/components/form/CountryField.tsx +68 -0
  44. package/src/components/form/DatePickerField.tsx +119 -0
  45. package/src/components/form/EmailField.tsx +38 -0
  46. package/src/components/form/FirstNameField.tsx +40 -0
  47. package/src/components/form/Form.tsx +82 -0
  48. package/src/components/form/OtpField.tsx +28 -0
  49. package/src/components/form/PasswordField.tsx +71 -0
  50. package/src/components/form/RepeatField.tsx +115 -0
  51. package/src/components/form/SubmitButton.tsx +47 -0
  52. package/src/components/form/TextField.tsx +103 -0
  53. package/src/components/form/UkCountyField.tsx +67 -0
  54. package/src/components/form/index.tsx +28 -0
  55. package/src/components/index.ts +26 -0
  56. package/src/components/page/Banner.tsx +84 -0
  57. package/src/components/page/Notification.tsx +71 -0
  58. package/src/components/page/Page.tsx +73 -0
  59. package/src/components/page/Section.tsx +21 -0
  60. package/src/components/page/TabBar.tsx +131 -0
  61. package/src/components/page/index.ts +10 -0
  62. package/src/components/router/Link.tsx +22 -0
  63. package/src/components/router/LinkButton.tsx +21 -0
  64. package/src/components/router/LinkIconButton.tsx +21 -0
  65. package/src/components/router/LinkListItem.tsx +21 -0
  66. package/src/components/router/LinkTab.tsx +21 -0
  67. package/src/components/router/Navigate.tsx +33 -0
  68. package/src/components/router/index.tsx +12 -0
  69. package/src/components/table/CellStack.tsx +19 -0
  70. package/src/components/table/Table.tsx +55 -0
  71. package/src/components/table/index.tsx +10 -0
  72. package/src/features/InactiveDialog.tsx +40 -0
  73. package/src/features/ScreenTimeDialog.tsx +33 -0
  74. package/src/features/index.ts +4 -0
  75. package/src/fonts/Inter-VariableFont_slnt,wght.ttf +0 -0
  76. package/src/fonts/SpaceGrotesk-VariableFont_wght.ttf +0 -0
  77. package/src/hooks/api.tsx +37 -0
  78. package/src/hooks/auth.tsx +87 -0
  79. package/src/hooks/general.ts +110 -0
  80. package/src/hooks/index.ts +4 -0
  81. package/src/hooks/router.tsx +168 -0
  82. package/src/index.ts +2 -0
  83. package/src/middlewares/index.ts +1 -0
  84. package/src/middlewares/session.ts +16 -0
  85. package/src/public/images/brain.svg +1 -0
  86. package/src/schemas/user.ts +4 -0
  87. package/src/scripts/freshDesk.js +473 -0
  88. package/src/scripts/index.ts +1 -0
  89. package/src/server.js +181 -0
  90. package/src/settings/custom.ts +22 -0
  91. package/src/settings/index.ts +5 -0
  92. package/src/settings/vite.ts +26 -0
  93. package/src/setupTests.ts +1 -0
  94. package/src/slices/createSlice.ts +8 -0
  95. package/src/slices/index.ts +2 -0
  96. package/src/slices/session.ts +32 -0
  97. package/src/theme/ThemedBox.tsx +265 -0
  98. package/src/theme/colors.ts +57 -0
  99. package/src/theme/components/MuiAccordion.tsx +13 -0
  100. package/src/theme/components/MuiAutocomplete.tsx +11 -0
  101. package/src/theme/components/MuiButton.ts +70 -0
  102. package/src/theme/components/MuiCardActions.tsx +12 -0
  103. package/src/theme/components/MuiCheckbox.ts +12 -0
  104. package/src/theme/components/MuiContainer.ts +19 -0
  105. package/src/theme/components/MuiDialog.tsx +16 -0
  106. package/src/theme/components/MuiFormControlLabel.ts +18 -0
  107. package/src/theme/components/MuiFormHelperText.ts +12 -0
  108. package/src/theme/components/MuiGrid2.ts +16 -0
  109. package/src/theme/components/MuiInputBase.ts +14 -0
  110. package/src/theme/components/MuiLink.ts +41 -0
  111. package/src/theme/components/MuiList.ts +12 -0
  112. package/src/theme/components/MuiListItemText.ts +18 -0
  113. package/src/theme/components/MuiMenu.ts +14 -0
  114. package/src/theme/components/MuiMenuItem.ts +15 -0
  115. package/src/theme/components/MuiSelect.ts +16 -0
  116. package/src/theme/components/MuiTab.ts +29 -0
  117. package/src/theme/components/MuiTable.ts +29 -0
  118. package/src/theme/components/MuiTableBody.ts +15 -0
  119. package/src/theme/components/MuiTableHead.ts +26 -0
  120. package/src/theme/components/MuiTabs.ts +26 -0
  121. package/src/theme/components/MuiTextField.ts +86 -0
  122. package/src/theme/components/MuiToolbar.ts +11 -0
  123. package/src/theme/components/MuiTypography.ts +12 -0
  124. package/src/theme/components/_components.ts +93 -0
  125. package/src/theme/components/index.ts +57 -0
  126. package/src/theme/index.ts +25 -0
  127. package/src/theme/palette.ts +98 -0
  128. package/src/theme/spacing.ts +8 -0
  129. package/src/theme/typography.ts +101 -0
  130. package/src/utils/api.test.ts +19 -0
  131. package/src/utils/api.tsx +327 -0
  132. package/src/utils/auth.ts +17 -0
  133. package/src/utils/form.ts +153 -0
  134. package/src/utils/general.test.ts +42 -0
  135. package/src/utils/general.ts +498 -0
  136. package/src/utils/router.test.ts +156 -0
  137. package/src/utils/router.ts +67 -0
  138. package/src/utils/schema.ts +80 -0
  139. package/src/utils/store.ts +31 -0
  140. package/src/utils/test.tsx +82 -0
  141. package/src/utils/theme.tsx +82 -0
  142. package/src/utils/window.ts +9 -0
  143. package/src/vite-env.d.ts +1 -0
  144. package/tsconfig.json +31 -0
  145. package/tsconfig.node.json +11 -0
  146. package/types/fixes.d.ts +18 -0
  147. 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