Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Portal frontend 58 #65

Merged
merged 15 commits into from
Oct 11, 2024
27 changes: 27 additions & 0 deletions src/components/InputFileButton.tsx
Original file line number Diff line number Diff line change
@@ -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<ButtonProps<"label">, "component"> {
inputProps?: Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
"type" | "hidden"
>
}

const InputFileButton: FC<InputFileButtonProps> = ({
children,
inputProps,
...otherButtonProps
}) => (
<Button component="label" {...otherButtonProps}>
{children}
<input type="file" hidden {...inputProps} />
</Button>
)

export default InputFileButton
4 changes: 2 additions & 2 deletions src/components/form/FirstNameField.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,7 +20,7 @@ const FirstNameField: FC<FirstNameFieldProps> = ({
}) => {
return (
<TextField
schema={YupString().max(150)}
schema={firstNameSchema}
name={name}
label={label}
placeholder={placeholder}
Expand Down
75 changes: 65 additions & 10 deletions src/components/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,80 @@ import {
Form as FormikForm,
type FormikConfig,
type FormikErrors,
type FormikValues,
} from "formik"
import type { TypedUseMutation } from "@reduxjs/toolkit/query/react"

export interface FormProps<Values> extends FormikConfig<Values> {}
import {
submitForm,
type SubmitFormOptions,
type FormValues,
} from "../../utils/form"

const Form = <Values extends FormikValues = FormikValues>({
const _ = <Values extends FormValues>({
children,
...otherFormikProps
}: FormProps<Values>): JSX.Element => {
}: FormikConfig<Values>) => (
<Formik {...otherFormikProps}>
{formik => (
<FormikForm>
{typeof children === "function" ? children(formik) : children}
</FormikForm>
)}
</Formik>
)

type SubmitFormProps<
Values extends FormValues,
QueryArg extends FormValues,
ResultType,
> = Omit<FormikConfig<Values>, "onSubmit"> & {
useMutation: TypedUseMutation<ResultType, QueryArg, any>
} & (Values extends QueryArg
? { submitOptions?: SubmitFormOptions<Values, QueryArg, ResultType> }
: { submitOptions: SubmitFormOptions<Values, QueryArg, ResultType> })

const SubmitForm = <
Values extends FormValues,
QueryArg extends FormValues,
ResultType,
>({
useMutation,
submitOptions,
...formikProps
}: SubmitFormProps<Values, QueryArg, ResultType>): JSX.Element => {
const [trigger] = useMutation()

return (
<Formik {...otherFormikProps}>
{formik => (
<FormikForm>
{typeof children === "function" ? children(formik) : children}
</FormikForm>
<_
{...formikProps}
onSubmit={submitForm<Values, QueryArg, ResultType>(
trigger,
submitOptions as SubmitFormOptions<Values, QueryArg, ResultType>,
)}
</Formik>
/>
)
}

export type FormProps<
Values extends FormValues,
QueryArg extends FormValues,
ResultType,
> = FormikConfig<Values> | SubmitFormProps<Values, QueryArg, ResultType>

const Form: {
<Values extends FormValues>(props: FormikConfig<Values>): JSX.Element
<Values extends FormValues, QueryArg extends FormValues, ResultType>(
props: SubmitFormProps<Values, QueryArg, ResultType>,
): JSX.Element
} = <
Values extends FormValues = FormValues,
QueryArg extends FormValues = FormValues,
ResultType = any,
>(
props: FormProps<Values, QueryArg, ResultType>,
): JSX.Element => {
return "onSubmit" in props ? <_ {...props} /> : SubmitForm(props)
}

export default Form
export { type FormikErrors as FormErrors }
32 changes: 27 additions & 5 deletions src/components/form/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -33,20 +40,27 @@ const TextField: FC<TextFieldProps> = ({
type = "text",
required = false,
dirty = false,
split,
validateOptions,
...otherTextFieldProps
}) => {
const [initialValue, setInitialValue] = useState("")
const [initialValue, setInitialValue] = useState<string | string[]>("")

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<string[], any>).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<FieldProps> = ({ form }) => {
Expand All @@ -59,6 +73,14 @@ const TextField: FC<TextFieldProps> = ({
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 (
<MuiTextField
id={id ?? name}
Expand Down
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export { default as ElevatedAppBar } from "./ElevatedAppBar"
export * from "./Image"
export { default as Image } from "./Image"
export * from "./ItemizedList"
export { default as InputFileButton } from "./InputFileButton"
export * from "./InputFileButton"
export { default as ItemizedList } from "./ItemizedList"
export * from "./OrderedGrid"
export { default as OrderedGrid } from "./OrderedGrid"
Expand Down
20 changes: 11 additions & 9 deletions src/components/table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FC, type ReactNode } from "react"
import { type FC, type ReactNode, isValidElement } from "react"
import {
Table as MuiTable,
type TableProps as MuiTableProps,
Expand All @@ -15,11 +15,10 @@ import {
} from "@mui/material"

export interface TableProps extends MuiTableProps {
headers: ReactNode[]
headers: Array<ReactNode | TableCellProps>
children: ReactNode
containerProps?: TableContainerProps
headProps?: TableHeadProps
headCellProps?: TableCellProps
headRowProps?: TableRowProps
bodyProps?: TableBodyProps
}
Expand All @@ -29,7 +28,6 @@ const Table: FC<TableProps> = ({
children,
containerProps,
headProps,
headCellProps,
headRowProps,
bodyProps,
...tableProps
Expand All @@ -38,11 +36,15 @@ const Table: FC<TableProps> = ({
<MuiTable {...tableProps}>
<TableHead {...headProps}>
<TableRow {...headRowProps}>
{headers.map((header, index) => (
<TableCell {...headCellProps} key={`table-head-cell-${index}`}>
{header}
</TableCell>
))}
{headers.map((header, index) => {
const key = `table-head-cell-${index}`

return typeof header === "string" || isValidElement(header) ? (
<TableCell key={key}>{header}</TableCell>
) : (
<TableCell key={key} {...(header as TableCellProps)} />
)
})}
</TableRow>
</TableHead>
<TableBody {...bodyProps}>{children}</TableBody>
Expand Down
3 changes: 3 additions & 0 deletions src/schemas/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as yup from "yup"

export const firstNameSchema = yup.string().max(150)
83 changes: 50 additions & 33 deletions src/utils/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ValidationError, type Schema, type ValidateOptions } from "yup"

import { excludeKeyPaths } from "./general"

export type FormValues = Record<string, any>

export function isFormError(error: unknown): boolean {
return (
typeof error === "object" &&
Expand Down Expand Up @@ -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<FormValues>,
values: Values,
helpers: FormikHelpers<Values>,
) => void
catch: (
error: unknown,
values: FormValues,
helpers: FormikHelpers<FormValues>,
values: Values,
helpers: FormikHelpers<Values>,
) => void
finally: (values: FormValues, helpers: FormikHelpers<FormValues>) => void
}>

export type SubmitFormHandler<
QueryArg extends object,
FormValues extends QueryArg,
> = (
values: FormValues,
helpers: FormikHelpers<FormValues>,
finally: (values: Values, helpers: FormikHelpers<Values>) => void
}> &
(Values extends QueryArg
? { clean?: (values: Values) => QueryArg }
: { clean: (values: Values) => QueryArg })

export type SubmitFormHandler<Values extends FormValues> = (
values: Values,
helpers: FormikHelpers<Values>,
) => void | Promise<any>

export function submitForm<
QueryArg extends object,
Values extends QueryArg,
QueryArg extends FormValues,
ResultType,
>(
trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
options?: SubmitFormOptions<Values, QueryArg, ResultType>,
): SubmitFormHandler<Values>

export function submitForm<
Values extends FormValues,
QueryArg extends FormValues,
ResultType,
FormValues extends QueryArg,
>(
trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
options?: SubmitFormOptions<QueryArg, ResultType, FormValues>,
): SubmitFormHandler<QueryArg, FormValues> {
const {
clean,
exclude,
then,
catch: _catch,
finally: _finally,
} = options || {}
options: SubmitFormOptions<Values, QueryArg, ResultType>,
): SubmitFormHandler<Values>

export function submitForm<
Values extends FormValues,
QueryArg extends FormValues,
ResultType,
>(
trigger: TypedMutationTrigger<ResultType, QueryArg, any>,
options?: SubmitFormOptions<Values, QueryArg, ResultType>,
): SubmitFormHandler<Values> {
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)

Expand Down Expand Up @@ -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<string, any>,
Values extends FormValues,
Names extends Array<keyof Values>,
>(
values: Values,
Expand All @@ -128,9 +144,10 @@ export function getDirty<
) as Record<Names[number], boolean>
}

export function isDirty<
Values extends Record<string, any>,
Name extends keyof Values,
>(values: Values, initialValues: Values, name: Name): boolean {
export function isDirty<Values extends FormValues, Name extends keyof Values>(
values: Values,
initialValues: Values,
name: Name,
): boolean {
return values[name] !== initialValues[name]
}