From 451a237fd746f8871eb0b48f07633e6c3454810d Mon Sep 17 00:00:00 2001 From: Sven van de Scheur Date: Mon, 9 Dec 2024 12:02:58 +0100 Subject: [PATCH] :label: - chore: improve (typing) of (Form) --- .../data/attributetable/attributetable.tsx | 5 +- .../data/datagrid/datagrid.stories.tsx | 1 + src/components/data/datagrid/datagrid.tsx | 7 +- .../data/datagrid/datagridfilter.tsx | 9 +- .../data/datagrid/datagridtoolbar.tsx | 2 +- .../form/checkbox/checkboxgroup.tsx | 11 +- .../form/choicefield/choicefield.tsx | 15 ++- src/components/form/datepicker/datepicker.tsx | 2 +- src/components/form/form/form.tsx | 26 ++-- src/components/form/radio/radiogroup.tsx | 9 +- src/components/form/select/select.tsx | 9 -- src/hooks/dialog/useformdialog.tsx | 8 +- src/lib/form/typeguards.ts | 3 + src/lib/form/utils.ts | 127 ++++++++++++------ src/lib/form/validation.ts | 9 +- src/templates/login/login.tsx | 30 +++-- 16 files changed, 174 insertions(+), 99 deletions(-) diff --git a/src/components/data/attributetable/attributetable.tsx b/src/components/data/attributetable/attributetable.tsx index f8035717..6920e58b 100644 --- a/src/components/data/attributetable/attributetable.tsx +++ b/src/components/data/attributetable/attributetable.tsx @@ -3,6 +3,7 @@ import React, { useId, useState } from "react"; import { Field, + SerializedFormData, TypedField, string2Title, typedFieldByFields, @@ -19,7 +20,7 @@ export type AttributeTableProps = { labeledObject?: Record; editable?: boolean; fields?: Field[] | TypedField[]; - formProps?: FormProps; + formProps?: FormProps>; labelCancel?: string; labelEdit?: string; valign?: "middle" | "start"; @@ -60,7 +61,7 @@ export const AttributeTable = ({ const renderTable = () => { return editable ? ( -
> fieldsetClassName="mykn-attributetable__body" showActions={isFormOpenState} secondaryActions={[ diff --git a/src/components/data/datagrid/datagrid.stories.tsx b/src/components/data/datagrid/datagrid.stories.tsx index 6f6bfa37..110cb8dd 100644 --- a/src/components/data/datagrid/datagrid.stories.tsx +++ b/src/components/data/datagrid/datagrid.stories.tsx @@ -132,6 +132,7 @@ export const Filterable: Story = { argTypes: { ...DataGridComponent.argTypes, onFilter: { action: "onFilter" }, + filterTransform: { action: "filterTransform" }, }, }; diff --git a/src/components/data/datagrid/datagrid.tsx b/src/components/data/datagrid/datagrid.tsx index 45d25da4..cf731bae 100644 --- a/src/components/data/datagrid/datagrid.tsx +++ b/src/components/data/datagrid/datagrid.tsx @@ -14,6 +14,7 @@ import { Field, SerializedFormData, TypedField, + TypedSerializedFormData, filterDataArray, typedFieldByFields, } from "../../../lib"; @@ -67,7 +68,9 @@ export type DataGridProps = { * A function transforming the filter values. * This can be used to adjust filter input to an API spec. */ - filterTransform?: (value: T) => F; + filterTransform?: ( + value: Partial>, + ) => F; /** * Can be any valid CSS `height` property or `"fill-available-space"` to @@ -272,7 +275,7 @@ type PaginatorPropsAliases = { * filtering, row selection, and pagination. * * @typeParam T - The shape of a single data row. - * @typeParam F - If the shape of the filtered returned by `filterTransform` + * @typeParam F - If the shape of the data returned by `filterTransform` */ export const DataGrid = ( props: DataGridProps, diff --git a/src/components/data/datagrid/datagridfilter.tsx b/src/components/data/datagrid/datagridfilter.tsx index b3ae1b6e..f1e0db10 100644 --- a/src/components/data/datagrid/datagridfilter.tsx +++ b/src/components/data/datagrid/datagridfilter.tsx @@ -59,7 +59,10 @@ export const DataGridFilter = < e.preventDefault(); } - const data = serializeForm(input.form as HTMLFormElement) as T; + const data = serializeForm( + input.form as HTMLFormElement, + true, + ); const _data = filterTransform ? filterTransform(data) : data; // Reset page on filter (length of dataset may change). @@ -122,7 +125,9 @@ export const DataGridFilter = < } onClick={ field.type === "boolean" - ? (e: React.MouseEvent) => handleFilter(e) + ? (e: React.MouseEvent) => { + handleFilter(e); + } : undefined } /> diff --git a/src/components/data/datagrid/datagridtoolbar.tsx b/src/components/data/datagrid/datagridtoolbar.tsx index 9956d36e..64b488f7 100644 --- a/src/components/data/datagrid/datagridtoolbar.tsx +++ b/src/components/data/datagrid/datagridtoolbar.tsx @@ -149,7 +149,7 @@ export const DataGridToolbar = < labelSubmit={ucFirst(_labelSaveFieldSelection)} onSubmit={(e) => { const form = e.target as HTMLFormElement; - const data = serializeForm(form); + const data = serializeForm(form, false); const selectedFields = (data.fields || []) as string[]; const newTypedFieldsState = fields.map((f) => ({ ...f, diff --git a/src/components/form/checkbox/checkboxgroup.tsx b/src/components/form/checkbox/checkboxgroup.tsx index 0b359469..d1fbba58 100644 --- a/src/components/form/checkbox/checkboxgroup.tsx +++ b/src/components/form/checkbox/checkboxgroup.tsx @@ -10,30 +10,37 @@ export type CheckboxGroupProps = ChoiceFieldProps< >; export const CheckboxGroup: React.FC = ({ + form, id = "", + label, name, options, variant, + required, onChange, + onClick, }) => { const reactId = useId(); const _id = id || reactId; return ( -
+
{options.map((option, index) => { const checkboxId = `${_id}-choice-${index}`; return ( {option.label} diff --git a/src/components/form/choicefield/choicefield.tsx b/src/components/form/choicefield/choicefield.tsx index 04a5ad9e..86384c6e 100644 --- a/src/components/form/choicefield/choicefield.tsx +++ b/src/components/form/choicefield/choicefield.tsx @@ -11,21 +11,30 @@ export type ChoiceFieldProps< /** Can be used to generate `SelectOption` components from an array of objects. */ options: Option[]; + /** The associated form's id. */ + form?: string; + + /** The (accessible) label .*/ + label?: string; + /** Form element name. */ name?: string; + /** Whether a value is required. */ + required?: boolean; + /** Form element type. */ type?: string; - /** Gets called when the input is blurred. */ - onBlur?: React.FormEventHandler; - /** Value of the form element */ value?: Option["value"] | null; /** The variant (style) of the form element. */ variant?: "normal" | "transparent"; + /** Gets called when the input is blurred. */ + onBlur?: React.FormEventHandler; + /** * * A custom "change" event created with `detail` set to the selected option. diff --git a/src/components/form/datepicker/datepicker.tsx b/src/components/form/datepicker/datepicker.tsx index 2c70abab..bad7fb65 100644 --- a/src/components/form/datepicker/datepicker.tsx +++ b/src/components/form/datepicker/datepicker.tsx @@ -33,7 +33,7 @@ export type DatePickerProps = Omit< placeholder?: string; /** Can be a `Date` `[Date, Date]` or a (date) `string` or a (time) `number`. */ - value?: DatePickerValue | string | number; + value?: DatePickerValue | string | number | null; labelNextYear?: string; labelPreviousYear?: string; diff --git a/src/components/form/form/form.tsx b/src/components/form/form/form.tsx index 22489f3d..43d0cfc4 100644 --- a/src/components/form/form/form.tsx +++ b/src/components/form/form/form.tsx @@ -2,24 +2,28 @@ import clsx from "clsx"; import React, { FormEvent, useContext, useEffect, useState } from "react"; import { ConfigContext } from "../../../contexts"; -import { FormField } from "../../../lib/form/typeguards"; -import { getValueFromFormData, serializeForm } from "../../../lib/form/utils"; +import { ucFirst } from "../../../lib"; +import { useIntl } from "../../../lib"; +import { FormField } from "../../../lib"; +import { + SerializedFormData, + getValueFromFormData, + serializeForm, +} from "../../../lib"; import { DEFAULT_VALIDATION_ERROR_REQUIRED, Validator, getErrorFromErrors, validateForm, -} from "../../../lib/form/validation"; -import { forceArray } from "../../../lib/format/array"; -import { ucFirst } from "../../../lib/format/string"; -import { useIntl } from "../../../lib/i18n/useIntl"; +} from "../../../lib"; +import { forceArray } from "../../../lib"; import { ButtonProps } from "../../button"; import { Toolbar, ToolbarItem, ToolbarProps } from "../../toolbar"; import { ErrorMessage } from "../errormessage"; import { FormControl } from "../formcontrol"; import "./form.scss"; -export type FormProps = Omit< +export type FormProps = Omit< React.ComponentProps<"form">, "onChange" | "onSubmit" > & { @@ -105,7 +109,7 @@ export type FormProps = Omit< * * @typeParam T - The shape of the serialized form data. */ -export const Form = ({ +export const Form = ({ buttonProps, children, debug = false, @@ -134,7 +138,7 @@ export const Form = ({ const _debug = debug || contextDebug; const _nonFieldErrors = forceArray(nonFieldErrors); - const [valuesState, setValuesState] = useState(initialValues); + const [valuesState, setValuesState] = useState(initialValues); const [errorsState, setErrorsState] = useState(errors || {}); useEffect(() => { @@ -178,7 +182,7 @@ export const Form = ({ const form = (event.target as HTMLInputElement).form; if (form && !onChange) { - const data = serializeForm(form, useTypedResults) as T; + const data = serializeForm(form, useTypedResults) as T; setValuesState(data); } }; @@ -200,7 +204,7 @@ export const Form = ({ } const form = event.target as HTMLFormElement; - const data = serializeForm(form, useTypedResults) as T; + const data = serializeForm(form, useTypedResults) as T; if (onSubmit) { onSubmit(event, data); diff --git a/src/components/form/radio/radiogroup.tsx b/src/components/form/radio/radiogroup.tsx index 2bf19073..8fc701e5 100644 --- a/src/components/form/radio/radiogroup.tsx +++ b/src/components/form/radio/radiogroup.tsx @@ -10,10 +10,13 @@ export type RadioGroupProps = ChoiceFieldProps< >; export const RadioGroup: React.FC = ({ id = "", + label, name, options, + required, variant, onChange, + onClick, }) => { const reactId = useId(); const _id = id || reactId; @@ -34,7 +37,7 @@ export const RadioGroup: React.FC = ({ }; return ( -
+
{options.map((option, index) => { const radioId = `${_id}-choice-${index}`; const optionValue = option.value || option.label; @@ -43,11 +46,13 @@ export const RadioGroup: React.FC = ({ {option.label} diff --git a/src/components/form/select/select.tsx b/src/components/form/select/select.tsx index fd36c061..463a34df 100644 --- a/src/components/form/select/select.tsx +++ b/src/components/form/select/select.tsx @@ -29,12 +29,6 @@ export type SelectProps = ChoiceFieldProps & { /** Component to use as icon. */ icon?: React.ReactNode; - /** Select label. */ - label?: string; - - /** Whether a value is required, a required select can't be cleared. */ - required?: boolean; - /** Whether to apply padding. */ pad?: boolean | "h" | "v"; @@ -49,9 +43,6 @@ export type SelectProps = ChoiceFieldProps & { /** The clear value (accessible) label. */ labelClear?: string; - - /** The associated form's id. */ - form?: React.ComponentProps<"select">["form"]; }; /** diff --git a/src/hooks/dialog/useformdialog.tsx b/src/hooks/dialog/useformdialog.tsx index 18c81d66..41234992 100644 --- a/src/hooks/dialog/useformdialog.tsx +++ b/src/hooks/dialog/useformdialog.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect } from "react"; import { Form, FormProps, ModalProps, P } from "../../components"; import { ModalServiceContext } from "../../contexts"; -import { FormField } from "../../lib"; +import { FormField, SerializedFormData } from "../../lib"; import { useDialog } from "./usedialog"; /** @@ -31,7 +31,9 @@ export const useFormDialog = () => { * @param formProps * @param autofocus */ - const fn = ( + const fn = < + FT extends SerializedFormData = Record, + >( title: string, message: React.ReactNode, fields: FormField[], @@ -66,7 +68,7 @@ export const useFormDialog = () => { return fn; }; -const PromptForm = ({ +const PromptForm = ({ fields, labelConfirm, labelCancel, diff --git a/src/lib/form/typeguards.ts b/src/lib/form/typeguards.ts index 0e9bdda8..d1634a4e 100644 --- a/src/lib/form/typeguards.ts +++ b/src/lib/form/typeguards.ts @@ -11,6 +11,9 @@ import { DateInputProps } from "../../components/form/dateinput"; import { DateRangeInputProps } from "../../components/form/daterangeinput"; export type FormField = + | CheckboxProps + | RadioProps + | ChoiceFieldProps | DateInputProps | DateRangeInputProps | DatePickerProps diff --git a/src/lib/form/utils.ts b/src/lib/form/utils.ts index 6ad4d9b1..2cf08f9a 100644 --- a/src/lib/form/utils.ts +++ b/src/lib/form/utils.ts @@ -2,65 +2,99 @@ import { Primitive, isPrimitive } from "../data"; import { date2DateString } from "../format"; import { FormField } from "./typeguards"; -export type SerializedFormData = Record< - string, +export type SerializedFormData = + | UnTypedSerializedFormData + | TypedSerializedFormData; + +export type UnTypedSerializedFormData = Record< + T, FormDataEntryValue | FormDataEntryValue[] >; +export type TypedSerializedFormData = Record< + T, + TypedFormDataEntryValue | TypedFormDataEntryValue[] +>; + +export type TypedFormDataEntryValue = + | FormDataEntryValue + | FormDataEntryValue[] + | boolean + | number + | Date + | [Date, Date]; + /** * Basic `FormData` based serialization function. * Duplicate keys (input names) are merged into an `Array`. * @param form * @param useTypedResults Whether the convert results to type inferred from input type (e.g. checkbox value will be boolean). */ -export const serializeForm = ( +export function serializeForm( + form: HTMLFormElement, + useTypedResults: true, +): TypedSerializedFormData; +export function serializeForm( form: HTMLFormElement, - useTypedResults = false, -): SerializedFormData => { + useTypedResults: false, +): UnTypedSerializedFormData; +export function serializeForm( + form: HTMLFormElement, + useTypedResults: boolean, +): typeof useTypedResults extends true + ? TypedSerializedFormData + : UnTypedSerializedFormData; +export function serializeForm( + form: HTMLFormElement, + useTypedResults?: boolean, +): TypedSerializedFormData | UnTypedSerializedFormData { const formData = new FormData(form); - const entries = Array.from(formData.entries()); - - const data = entries.reduce((acc, [name, value]) => { - const inputsMatchingName = [...form.elements].filter( - (n) => n.getAttribute("name") === name, - ) as Array< - | HTMLButtonElement - | HTMLInputElement - | HTMLSelectElement - | HTMLTextAreaElement - >; - const nameMultipleTimesInForm = inputsMatchingName.length > 1; - // Edge case: radio can possibly have more than one DOM element representing a single value. - const isRadio = inputsMatchingName.every((i) => i.type === "radio"); - - if (nameMultipleTimesInForm && !isRadio) { - // Key is already set on (serialized) `acc`, replace it with an `Array`. - const existingValue = acc[name]; - - acc[name] = existingValue - ? Array.isArray(existingValue) - ? [...existingValue, value] - : [existingValue, value] - : [value]; - } else { - // Key is not yet set in (serialized) `acc`, set it. - acc[name] = value; - } - return acc; - }, {}); - - return useTypedResults ? typeSerializedFormData(form, data) : data; -}; + const entries = Array.from(formData.entries()) as Array<[T, string | File]>; + + const data = entries.reduce>( + (acc, [name, value]) => { + const inputsMatchingName = [...form.elements].filter( + (n) => n.getAttribute("name") === name, + ) as Array< + | HTMLButtonElement + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + >; + const nameMultipleTimesInForm = inputsMatchingName.length > 1; + // Edge case: radio can possibly have more than one DOM element representing a single value. + const isRadio = inputsMatchingName.every((i) => i.type === "radio"); + + if (nameMultipleTimesInForm && !isRadio) { + // Key is already set on (serialized) `acc`, replace it with an `Array`. + const existingValue = acc[name]; + + acc[name] = existingValue + ? Array.isArray(existingValue) + ? [...existingValue, value] + : [existingValue, value] + : [value]; + } else { + // Key is not yet set in (serialized) `acc`, set it. + acc[name] = value; + } + return acc; + }, + {} as UnTypedSerializedFormData, + ); + + return useTypedResults ? typeSerializedFormData(form, data) : data; +} /** * Converts values in `data` to the type implied by the associated form control in `form`. * @param form * @param data */ -const typeSerializedFormData = ( +const typeSerializedFormData = ( form: HTMLFormElement, - data: SerializedFormData, -) => { + data: UnTypedSerializedFormData, +): TypedSerializedFormData => { // Work around for edge case where checkbox is not present in `data` when unchecked. // This builds an object with all form fields keys (including unchecked checkbox) // set to `undefined`, we can merge this with `data` later on to get a complete @@ -79,7 +113,10 @@ const typeSerializedFormData = ( ); // Merge `data` and baseData into `completeData`. - const completeData: SerializedFormData = Object.assign(baseData, data); + const completeData: UnTypedSerializedFormData = Object.assign( + baseData, + data, + ); return Object.fromEntries( Object.entries(completeData).map(([key, value]) => { @@ -95,7 +132,7 @@ const typeSerializedFormData = ( const _value = constructor ? constructor(value) : value; return [key, _value]; }), - ); + ) as TypedSerializedFormData; }; /** @@ -155,7 +192,9 @@ export const getInputType = ( * @param values * @param field */ -export const getValueFromFormData = ( +export const getValueFromFormData = < + T extends SerializedFormData = SerializedFormData, +>( fields: FormField[], values: T, field: FormField, diff --git a/src/lib/form/validation.ts b/src/lib/form/validation.ts index 8c2aa160..f05ed7f0 100644 --- a/src/lib/form/validation.ts +++ b/src/lib/form/validation.ts @@ -1,5 +1,5 @@ import { FormField } from "./typeguards"; -import { parseFieldName } from "./utils"; +import { SerializedFormData, parseFieldName } from "./utils"; export const DEFAULT_VALIDATION_ERROR_REQUIRED = "Dit veld is verplicht"; @@ -22,7 +22,7 @@ export const validateRequired: Validator = ( message = DEFAULT_VALIDATION_ERROR_REQUIRED, ) => { // Not required, don't validate. - if (!field.required || value) { + if (!field?.required || value) { return; } @@ -36,8 +36,8 @@ export const validateRequired: Validator = ( * @param fields * @param validators */ -export const validateForm = ( - values: T, +export const validateForm = ( + values: T | Record, fields: FormField[], validators: Validator[] = [validateRequired], ) => { @@ -49,6 +49,7 @@ export const validateForm = ( string, number | undefined, ]; + // @ts-expect-error - fixme const value = values[name as keyof T]; // Validate array, multiple values. diff --git a/src/templates/login/login.tsx b/src/templates/login/login.tsx index b329cb26..6386f3e9 100644 --- a/src/templates/login/login.tsx +++ b/src/templates/login/login.tsx @@ -2,26 +2,28 @@ import React, { useContext } from "react"; import { Body, Card, Form, FormProps, Hr, Logo } from "../../components"; import { ConfigContext } from "../../contexts"; +import { SerializedFormData } from "../../lib"; import { ucFirst } from "../../lib/format/string"; import { useIntl } from "../../lib/i18n/useIntl"; import { BaseTemplate, BaseTemplateProps } from "../base"; -export type LoginTemplateProps = - BaseTemplateProps & { - fields?: FormProps["fields"]; +export type LoginTemplateProps< + T extends SerializedFormData = SerializedFormData, +> = BaseTemplateProps & { + fields?: FormProps["fields"]; - /** Form props. */ - formProps: FormProps; + /** Form props. */ + formProps: FormProps; - /** The login form label. */ - labelLogin?: FormProps["labelSubmit"]; + /** The login form label. */ + labelLogin?: FormProps["labelSubmit"]; - /** Logo (JSX) slot. */ - slotLogo?: React.ReactNode; + /** Logo (JSX) slot. */ + slotLogo?: React.ReactNode; - labelOidcLogin?: string; - urlOidcLogin?: string; - }; + labelOidcLogin?: string; + urlOidcLogin?: string; +}; /** * Login Template @@ -31,7 +33,9 @@ export type LoginTemplateProps = * * @typeParam T - The shape of the serialized form data. */ -export const LoginTemplate = ({ +export const LoginTemplate = < + T extends SerializedFormData = SerializedFormData, +>({ formProps, labelLogin, labelOidcLogin,