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
2 changes: 1 addition & 1 deletion src/api/endpoints/authFactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}),
}
}
2 changes: 1 addition & 1 deletion src/api/endpoints/klass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}),
}
}
2 changes: 1 addition & 1 deletion src/api/endpoints/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}),
}
}
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
14 changes: 6 additions & 8 deletions src/components/TablePagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,7 @@ export type TablePaginationProps<
AdditionalProps = {},
> = Omit<
MuiTablePaginationProps<RootComponent, AdditionalProps>,
| "component"
| "count"
| "rowsPerPage"
| "onRowsPerPageChange"
| "page"
| "onPageChange"
| "rowsPerPageOptions"
"component" | "count" | "rowsPerPage" | "page" | "rowsPerPageOptions"
> & {
children: (
data: ResultType["data"],
Expand Down Expand Up @@ -60,6 +54,8 @@ const TablePagination = <
rowsPerPage: initialLimit = 50,
rowsPerPageOptions = [50, 100, 150],
stackProps,
onRowsPerPageChange,
onPageChange,
...tablePaginationProps
}: TablePaginationProps<
QueryArg,
Expand Down Expand Up @@ -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)}
Expand Down
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
4 changes: 4 additions & 0 deletions src/schemas/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import * as yup from "yup"

// TODO: restrict character set; no special characters
export const firstNameSchema = yup.string().max(150)
25 changes: 12 additions & 13 deletions src/utils/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -238,6 +238,10 @@ export function tagData<Type extends string, M extends Model<any>>(
return tags
}

function getModelId(result: Result<M, any>) {
return getNestedProperty(result, id)
}

return (result, error, arg) => {
if (!error) {
if (arg) {
Expand All @@ -257,24 +261,19 @@ export function tagData<Type extends string, M extends Model<any>>(
}

if (result) {
// The result is a model that contains the id field.
if (id in result) {
return tags([(result as Result<M, any>)[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<M, any>) !== undefined) {
return tags([getModelId(result as Result<M, any>)])
}

// The result is a list that contains an array of models that contain
// the id field.
return tags(
(result as ListResult<M, any>).data.map(
result => result[id] as ModelId,
),
true,
)
return tags((result as ListResult<M, any>).data.map(getModelId), true)
}
}

Expand Down
Loading