Skip to content

Commit

Permalink
feat: Portal frontend 58 (#65)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SKairinos authored Oct 11, 2024
1 parent 6683ccc commit 5d85b31
Show file tree
Hide file tree
Showing 13 changed files with 209 additions and 83 deletions.
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

0 comments on commit 5d85b31

Please sign in to comment.