From 5d85b3152ae588247e7d7971cac71fcbfa796aea Mon Sep 17 00:00:00 2001 From: Stefan Kairinos Date: Fri, 11 Oct 2024 15:45:03 +0100 Subject: [PATCH] feat: Portal frontend 58 (#65) * cell props per header * type of header is string * fix arg types * fix: integrate onSubmit helper * fix: use mutation * fix: required options * fix: split lines * fix: export first name schema * fix: input file button * fix: split * fix: array schema * include list tag * get nested property * on page change or rows per page change * add todo --- src/api/endpoints/authFactor.ts | 2 +- src/api/endpoints/klass.ts | 2 +- src/api/endpoints/user.ts | 2 +- src/components/InputFileButton.tsx | 27 +++++++++ src/components/TablePagination.tsx | 14 ++--- src/components/form/FirstNameField.tsx | 4 +- src/components/form/Form.tsx | 75 +++++++++++++++++++---- src/components/form/TextField.tsx | 32 ++++++++-- src/components/index.ts | 2 + src/components/table/Table.tsx | 20 ++++--- src/schemas/user.ts | 4 ++ src/utils/api.tsx | 25 ++++---- src/utils/form.ts | 83 ++++++++++++++++---------- 13 files changed, 209 insertions(+), 83 deletions(-) create mode 100644 src/components/InputFileButton.tsx create mode 100644 src/schemas/user.ts diff --git a/src/api/endpoints/authFactor.ts b/src/api/endpoints/authFactor.ts index 34d63e41..8264c5e9 100644 --- a/src/api/endpoints/authFactor.ts +++ b/src/api/endpoints/authFactor.ts @@ -25,7 +25,7 @@ export default function getReadAuthFactorEndpoints< url: buildUrl(urls.authFactor.list, { search }), method: "GET", }), - providesTags: tagData(AUTH_FACTOR_TAG), + providesTags: tagData(AUTH_FACTOR_TAG, { includeListTag: true }), }), } } diff --git a/src/api/endpoints/klass.ts b/src/api/endpoints/klass.ts index 2a28b7e3..49d8e0ea 100644 --- a/src/api/endpoints/klass.ts +++ b/src/api/endpoints/klass.ts @@ -81,7 +81,7 @@ export default function getReadClassEndpoints< url: buildUrl(urls.class.list, { search }), method: "GET", }), - providesTags: tagData(CLASS_TAG), + providesTags: tagData(CLASS_TAG, { includeListTag: true }), }), } } diff --git a/src/api/endpoints/user.ts b/src/api/endpoints/user.ts index 5b6485d7..0a6d26be 100644 --- a/src/api/endpoints/user.ts +++ b/src/api/endpoints/user.ts @@ -64,7 +64,7 @@ export default function getReadUserEndpoints< url: buildUrl(urls.user.list, { search }), method: "GET", }), - providesTags: tagData(USER_TAG), + providesTags: tagData(USER_TAG, { includeListTag: true }), }), } } diff --git a/src/components/InputFileButton.tsx b/src/components/InputFileButton.tsx new file mode 100644 index 00000000..e8796859 --- /dev/null +++ b/src/components/InputFileButton.tsx @@ -0,0 +1,27 @@ +import { + type FC, + type DetailedHTMLProps, + type InputHTMLAttributes, +} from "react" +import { Button, type ButtonProps } from "@mui/material" + +export interface InputFileButtonProps + extends Omit, "component"> { + inputProps?: Omit< + DetailedHTMLProps, HTMLInputElement>, + "type" | "hidden" + > +} + +const InputFileButton: FC = ({ + children, + inputProps, + ...otherButtonProps +}) => ( + +) + +export default InputFileButton diff --git a/src/components/TablePagination.tsx b/src/components/TablePagination.tsx index 0f17fa17..823e77d2 100644 --- a/src/components/TablePagination.tsx +++ b/src/components/TablePagination.tsx @@ -24,13 +24,7 @@ export type TablePaginationProps< AdditionalProps = {}, > = Omit< MuiTablePaginationProps, - | "component" - | "count" - | "rowsPerPage" - | "onRowsPerPageChange" - | "page" - | "onPageChange" - | "rowsPerPageOptions" + "component" | "count" | "rowsPerPage" | "page" | "rowsPerPageOptions" > & { children: ( data: ResultType["data"], @@ -60,6 +54,8 @@ const TablePagination = < rowsPerPage: initialLimit = 50, rowsPerPageOptions = [50, 100, 150], stackProps, + onRowsPerPageChange, + onPageChange, ...tablePaginationProps }: TablePaginationProps< QueryArg, @@ -106,10 +102,12 @@ const TablePagination = < rowsPerPage={limit} onRowsPerPageChange={event => { setPagination({ limit: parseInt(event.target.value), page: 0 }) + if (onRowsPerPageChange) onRowsPerPageChange(event) }} page={page} - onPageChange={(_, page) => { + onPageChange={(event, page) => { setPagination(({ limit }) => ({ limit, page })) + if (onPageChange) onPageChange(event, page) }} // ascending order rowsPerPageOptions={rowsPerPageOptions.sort((a, b) => a - b)} diff --git a/src/components/form/FirstNameField.tsx b/src/components/form/FirstNameField.tsx index 3300934a..97291d8a 100644 --- a/src/components/form/FirstNameField.tsx +++ b/src/components/form/FirstNameField.tsx @@ -1,9 +1,9 @@ import { PersonOutlined as PersonOutlinedIcon } from "@mui/icons-material" import { InputAdornment } from "@mui/material" import type { FC } from "react" -import { string as YupString } from "yup" import TextField, { type TextFieldProps } from "./TextField" +import { firstNameSchema } from "../../schemas/user" export type FirstNameFieldProps = Omit< TextFieldProps, @@ -20,7 +20,7 @@ const FirstNameField: FC = ({ }) => { return ( extends FormikConfig {} +import { + submitForm, + type SubmitFormOptions, + type FormValues, +} from "../../utils/form" -const Form = ({ +const _ = ({ children, ...otherFormikProps -}: FormProps): JSX.Element => { +}: FormikConfig) => ( + + {formik => ( + + {typeof children === "function" ? children(formik) : children} + + )} + +) + +type SubmitFormProps< + Values extends FormValues, + QueryArg extends FormValues, + ResultType, +> = Omit, "onSubmit"> & { + useMutation: TypedUseMutation +} & (Values extends QueryArg + ? { submitOptions?: SubmitFormOptions } + : { submitOptions: SubmitFormOptions }) + +const SubmitForm = < + Values extends FormValues, + QueryArg extends FormValues, + ResultType, +>({ + useMutation, + submitOptions, + ...formikProps +}: SubmitFormProps): JSX.Element => { + const [trigger] = useMutation() + return ( - - {formik => ( - - {typeof children === "function" ? children(formik) : children} - + <_ + {...formikProps} + onSubmit={submitForm( + trigger, + submitOptions as SubmitFormOptions, )} - + /> ) } +export type FormProps< + Values extends FormValues, + QueryArg extends FormValues, + ResultType, +> = FormikConfig | SubmitFormProps + +const Form: { + (props: FormikConfig): JSX.Element + ( + props: SubmitFormProps, + ): JSX.Element +} = < + Values extends FormValues = FormValues, + QueryArg extends FormValues = FormValues, + ResultType = any, +>( + props: FormProps, +): JSX.Element => { + return "onSubmit" in props ? <_ {...props} /> : SubmitForm(props) +} + export default Form export { type FormikErrors as FormErrors } diff --git a/src/components/form/TextField.tsx b/src/components/form/TextField.tsx index 305780ea..6840e57d 100644 --- a/src/components/form/TextField.tsx +++ b/src/components/form/TextField.tsx @@ -4,7 +4,13 @@ import { } from "@mui/material" import { Field, type FieldConfig, type FieldProps } from "formik" import { type FC, useState, useEffect } from "react" -import { type StringSchema, type ValidateOptions } from "yup" +import { + type ArraySchema, + type StringSchema, + type ValidateOptions, + array as YupArray, + type Schema, +} from "yup" import { schemaToFieldValidator } from "../../utils/form" import { getNestedProperty } from "../../utils/general" @@ -23,6 +29,7 @@ export type TextFieldProps = Omit< schema: StringSchema validateOptions?: ValidateOptions dirty?: boolean + split?: string | RegExp } // https://formik.org/docs/examples/with-material-ui @@ -33,20 +40,27 @@ const TextField: FC = ({ type = "text", required = false, dirty = false, + split, validateOptions, ...otherTextFieldProps }) => { - const [initialValue, setInitialValue] = useState("") + const [initialValue, setInitialValue] = useState("") const dotPath = name.split(".") - if (required) schema = schema.required() - if (dirty) schema = schema.notOneOf([initialValue], "cannot be initial value") + let _schema: Schema = schema + if (split) _schema = YupArray().of(_schema) + if (required) { + _schema = _schema.required() + if (split) _schema = (_schema as ArraySchema).min(1) + } + if (dirty) + _schema = _schema.notOneOf([initialValue], "cannot be initial value") const fieldConfig: FieldConfig = { name, type, - validate: schemaToFieldValidator(schema, validateOptions), + validate: schemaToFieldValidator(_schema, validateOptions), } const _Field: FC = ({ form }) => { @@ -59,6 +73,14 @@ const TextField: FC = ({ setInitialValue(initialValue) }, [initialValue]) + useEffect(() => { + form.setFieldValue( + name, + split && typeof value === "string" ? value.split(split) : value, + true, + ) + }, [value]) // eslint-disable-line react-hooks/exhaustive-deps + return ( children: ReactNode containerProps?: TableContainerProps headProps?: TableHeadProps - headCellProps?: TableCellProps headRowProps?: TableRowProps bodyProps?: TableBodyProps } @@ -29,7 +28,6 @@ const Table: FC = ({ children, containerProps, headProps, - headCellProps, headRowProps, bodyProps, ...tableProps @@ -38,11 +36,15 @@ const Table: FC = ({ - {headers.map((header, index) => ( - - {header} - - ))} + {headers.map((header, index) => { + const key = `table-head-cell-${index}` + + return typeof header === "string" || isValidElement(header) ? ( + {header} + ) : ( + + ) + })} {children} diff --git a/src/schemas/user.ts b/src/schemas/user.ts new file mode 100644 index 00000000..845a3a93 --- /dev/null +++ b/src/schemas/user.ts @@ -0,0 +1,4 @@ +import * as yup from "yup" + +// TODO: restrict character set; no special characters +export const firstNameSchema = yup.string().max(150) diff --git a/src/utils/api.tsx b/src/utils/api.tsx index dbacaecf..ca5ff07f 100644 --- a/src/utils/api.tsx +++ b/src/utils/api.tsx @@ -8,7 +8,7 @@ import type { import { type ReactNode } from "react" import SyncError from "../components/SyncError" -import type { Optional, Required } from "./general" +import { type Optional, type Required, getNestedProperty } from "./general" // ----------------------------------------------------------------------------- // Model Types @@ -238,6 +238,10 @@ export function tagData>( return tags } + function getModelId(result: Result) { + return getNestedProperty(result, id) + } + return (result, error, arg) => { if (!error) { if (arg) { @@ -257,24 +261,19 @@ export function tagData>( } if (result) { - // The result is a model that contains the id field. - if (id in result) { - return tags([(result as Result)[id] as ModelId]) - } - // The result is an array of models that contain the id field. if (Array.isArray(result)) { - return tags(result.map(result => result[id] as ModelId)) + return tags(result.map(getModelId)) + } + + // The result is a model that contains the id field. + if (getModelId(result as Result) !== undefined) { + return tags([getModelId(result as Result)]) } // The result is a list that contains an array of models that contain // the id field. - return tags( - (result as ListResult).data.map( - result => result[id] as ModelId, - ), - true, - ) + return tags((result as ListResult).data.map(getModelId), true) } } diff --git a/src/utils/form.ts b/src/utils/form.ts index 954d1d7a..8943773b 100644 --- a/src/utils/form.ts +++ b/src/utils/form.ts @@ -4,6 +4,8 @@ import { ValidationError, type Schema, type ValidateOptions } from "yup" import { excludeKeyPaths } from "./general" +export type FormValues = Record + export function isFormError(error: unknown): boolean { return ( typeof error === "object" && @@ -33,51 +35,65 @@ export function setFormErrors( } export type SubmitFormOptions< - QueryArg extends object, + Values extends FormValues, + QueryArg extends FormValues, ResultType, - FormValues extends QueryArg, > = Partial<{ - clean: (values: FormValues) => QueryArg exclude: string[] then: ( result: ResultType, - values: FormValues, - helpers: FormikHelpers, + values: Values, + helpers: FormikHelpers, ) => void catch: ( error: unknown, - values: FormValues, - helpers: FormikHelpers, + values: Values, + helpers: FormikHelpers, ) => void - finally: (values: FormValues, helpers: FormikHelpers) => void -}> - -export type SubmitFormHandler< - QueryArg extends object, - FormValues extends QueryArg, -> = ( - values: FormValues, - helpers: FormikHelpers, + finally: (values: Values, helpers: FormikHelpers) => void +}> & + (Values extends QueryArg + ? { clean?: (values: Values) => QueryArg } + : { clean: (values: Values) => QueryArg }) + +export type SubmitFormHandler = ( + values: Values, + helpers: FormikHelpers, ) => void | Promise export function submitForm< - QueryArg extends object, + Values extends QueryArg, + QueryArg extends FormValues, + ResultType, +>( + trigger: TypedMutationTrigger, + options?: SubmitFormOptions, +): SubmitFormHandler + +export function submitForm< + Values extends FormValues, + QueryArg extends FormValues, ResultType, - FormValues extends QueryArg, >( trigger: TypedMutationTrigger, - options?: SubmitFormOptions, -): SubmitFormHandler { - const { - clean, - exclude, - then, - catch: _catch, - finally: _finally, - } = options || {} + options: SubmitFormOptions, +): SubmitFormHandler + +export function submitForm< + Values extends FormValues, + QueryArg extends FormValues, + ResultType, +>( + trigger: TypedMutationTrigger, + options?: SubmitFormOptions, +): SubmitFormHandler { + const { exclude, then, catch: _catch, finally: _finally } = options || {} return (values, helpers) => { - let arg: QueryArg = clean ? clean(values) : values + let arg = + options && options.clean + ? options.clean(values as QueryArg & FormValues) + : (values as unknown as QueryArg) if (exclude) arg = excludeKeyPaths(arg, exclude) @@ -116,7 +132,7 @@ export function schemaToFieldValidator( // Checking if individual fields are dirty is not currently supported. // https://github.com/jaredpalmer/formik/issues/1421 export function getDirty< - Values extends Record, + Values extends FormValues, Names extends Array, >( values: Values, @@ -128,9 +144,10 @@ export function getDirty< ) as Record } -export function isDirty< - Values extends Record, - Name extends keyof Values, ->(values: Values, initialValues: Values, name: Name): boolean { +export function isDirty( + values: Values, + initialValues: Values, + name: Name, +): boolean { return values[name] !== initialValues[name] }