From fe6950d3eb30e1b94da063864d0dad88e19352dd Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Mon, 17 Feb 2025 16:46:04 +0000 Subject: [PATCH] More agnostic form component --- docs/pages/toolpad/core/api/edit.json | 3 +- docs/translations/api-docs/edit/edit.json | 5 +- packages/toolpad-core/src/Crud/Create.tsx | 119 ++++++++++++-- packages/toolpad-core/src/Crud/CrudForm.tsx | 160 +++++-------------- packages/toolpad-core/src/Crud/Edit.tsx | 167 ++++++++++++++++---- packages/toolpad-core/src/Crud/List.tsx | 2 +- packages/toolpad-core/src/Crud/Show.tsx | 2 +- 7 files changed, 291 insertions(+), 167 deletions(-) diff --git a/docs/pages/toolpad/core/api/edit.json b/docs/pages/toolpad/core/api/edit.json index cefc7ab34c2..61a3d25b37f 100644 --- a/docs/pages/toolpad/core/api/edit.json +++ b/docs/pages/toolpad/core/api/edit.json @@ -1,8 +1,7 @@ { "props": { "dataSource": { "type": { "name": "object" } }, - "onSubmitSuccess": { "type": { "name": "func" } }, - "resetOnSubmit": { "type": { "name": "bool" } } + "onSubmitSuccess": { "type": { "name": "func" } } }, "name": "Edit", "imports": ["import { Edit } from '@toolpad/core/Crud';"], diff --git a/docs/translations/api-docs/edit/edit.json b/docs/translations/api-docs/edit/edit.json index 17f54a2776a..68bbcc0ea73 100644 --- a/docs/translations/api-docs/edit/edit.json +++ b/docs/translations/api-docs/edit/edit.json @@ -2,10 +2,7 @@ "componentDescription": "", "propDescriptions": { "dataSource": { "description": "Server-side data source." }, - "onSubmitSuccess": { "description": "Callback fired when the form is successfully submitted." }, - "resetOnSubmit": { - "description": "Whether the form fields should reset after the form is submitted." - } + "onSubmitSuccess": { "description": "Callback fired when the form is successfully submitted." } }, "classDescriptions": {} } diff --git a/packages/toolpad-core/src/Crud/Create.tsx b/packages/toolpad-core/src/Crud/Create.tsx index 22b4f921086..7cd2376181a 100644 --- a/packages/toolpad-core/src/Crud/Create.tsx +++ b/packages/toolpad-core/src/Crud/Create.tsx @@ -3,8 +3,9 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import { CrudForm } from './CrudForm'; -import { DataModel, DataSource, OmitId } from './shared'; +import { useNotifications } from '../useNotifications'; import { CrudContext } from '../shared/context'; +import { DataModel, DataSource, OmitId } from './shared'; export interface CreateProps { /** @@ -19,7 +20,7 @@ export interface CreateProps { /** * Callback fired when the form is successfully submitted. */ - onSubmitSuccess?: () => void; + onSubmitSuccess?: (formValues: Partial>) => void | Promise; /** * Whether the form fields should reset after the form is submitted. */ @@ -36,7 +37,7 @@ export interface CreateProps { * - [Create API](https://mui.com/toolpad/core/api/create) */ function Create(props: CreateProps) { - const { initialValues, onSubmitSuccess, resetOnSubmit } = props; + const { initialValues = {} as Partial>, onSubmitSuccess, resetOnSubmit } = props; const crudContext = React.useContext(CrudContext); const dataSource = (props.dataSource ?? crudContext.dataSource) as Exclude< @@ -44,28 +45,118 @@ function Create(props: CreateProps) { undefined >; + const notifications = useNotifications(); + invariant(dataSource, 'No data source found.'); - const { createOne } = dataSource; + const { fields, createOne, validate } = dataSource; - const handleCreate = React.useCallback( - async (formValues: Partial>) => { - await createOne(formValues); + const [formState, setFormState] = React.useState<{ + values: Partial>; + errors: Partial>; + }>({ + values: { + ...Object.fromEntries( + fields + .filter(({ field }) => field !== 'id') + .map(({ field, type }) => [ + field, + type === 'boolean' ? (initialValues[field] ?? false) : initialValues[field], + ]), + ), + ...initialValues, + }, + errors: {}, + }); + const formValues = formState.values; + const formErrors = formState.errors; + + const setFormValues = React.useCallback((newFormValues: Partial>) => { + setFormState((previousState) => ({ + ...previousState, + values: newFormValues, + })); + }, []); + + const setFormErrors = React.useCallback((newFormErrors: Partial>) => { + setFormState((previousState) => ({ + ...previousState, + errors: newFormErrors, + })); + }, []); + + const handleFormFieldChange = React.useCallback( + (name: keyof D, value: string | number | boolean | File | null) => { + const validateField = async (values: Partial>) => { + if (validate) { + const errors = await validate(values); + setFormErrors({ ...formErrors, [name]: errors[name] }); + } + }; + + const newFormValues = { ...formValues, [name]: value }; + + setFormValues(newFormValues); + validateField(newFormValues); }, - [createOne], + [formErrors, formValues, setFormErrors, setFormValues, validate], ); + const handleFormReset = React.useCallback(() => { + setFormValues(initialValues); + }, [initialValues, setFormValues]); + + const handleFormSubmit = React.useCallback(async () => { + if (validate) { + const errors = await validate(formValues); + if (Object.keys(errors).length > 0) { + setFormErrors(errors); + throw new Error('Form validation failed'); + } + } + setFormErrors({}); + + try { + await createOne(formValues); + notifications.show('Item created successfully.', { + severity: 'success', + autoHideDuration: 3000, + }); + + if (onSubmitSuccess) { + await onSubmitSuccess(formValues); + } + + if (resetOnSubmit) { + handleFormReset(); + } + } catch (createError) { + notifications.show(`Failed to create item.\n${(createError as Error).message}`, { + severity: 'error', + autoHideDuration: 3000, + }); + throw createError; + } + }, [ + createOne, + formValues, + handleFormReset, + notifications, + onSubmitSuccess, + resetOnSubmit, + setFormErrors, + validate, + ]); + return ( ); diff --git a/packages/toolpad-core/src/Crud/CrudForm.tsx b/packages/toolpad-core/src/Crud/CrudForm.tsx index 9aa0ccbd400..5c3265992a3 100644 --- a/packages/toolpad-core/src/Crud/CrudForm.tsx +++ b/packages/toolpad-core/src/Crud/CrudForm.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import invariant from 'invariant'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Checkbox from '@mui/material/Checkbox'; @@ -19,23 +20,29 @@ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import type { GridSingleSelectColDef } from '@mui/x-data-grid'; import dayjs, { Dayjs } from 'dayjs'; -import { useNotifications } from '../useNotifications'; +import { CrudContext } from '../shared/context'; import { DataField, DataModel, DataSource, OmitId } from './shared'; +interface CrudFormState { + values: Partial>; + errors: Partial>; +} + interface CrudFormLocaleText { submitButtonLabel: string; - submitSuccessMessage: string; - submitErrorMessage: string; } export interface CrudFormProps { dataSource: | (DataSource & Required, 'createOne'>>) | (DataSource & Required, 'updateOne'>>); - initialValues?: Partial>; + formState: CrudFormState; + onFieldChange: ( + name: keyof D, + value: string | number | boolean | File | null, + ) => void | Promise; onSubmit: (formValues: Partial>) => void | Promise; - onSubmitSuccess?: () => void; - resetOnSubmit?: boolean; + onReset?: (formValues: Partial>) => void | Promise; localeText: CrudFormLocaleText; } @@ -43,149 +50,67 @@ export interface CrudFormProps { * @ignore - internal component. */ function CrudForm(props: CrudFormProps) { - const { - dataSource, - initialValues = {} as Partial>, - onSubmit, - onSubmitSuccess, - resetOnSubmit = true, - localeText, - } = props; - const { fields, validate } = dataSource; - - const notifications = useNotifications(); + const { formState, onFieldChange, onSubmit, onReset, localeText } = props; - const [formState, setFormState] = React.useState<{ - values: Partial>; - errors: Partial>; - }>({ - values: { - ...Object.fromEntries( - fields - .filter(({ field }) => field !== 'id') - .map(({ field, type }) => [ - field, - type === 'boolean' ? (initialValues[field] ?? false) : initialValues[field], - ]), - ), - ...initialValues, - }, - errors: {}, - }); const formValues = formState.values; const formErrors = formState.errors; - const setFormValues = React.useCallback((newFormValues: Partial>) => { - setFormState((previousState) => ({ - ...previousState, - values: newFormValues, - })); - }, []); - - const setFormErrors = React.useCallback((newFormErrors: Partial>) => { - setFormState((previousState) => ({ - ...previousState, - errors: newFormErrors, - })); - }, []); - - const handleFormFieldChange = React.useCallback( - (name: keyof D, value: string | number | boolean | File | null) => { - const validateField = async (values: Partial>) => { - if (validate) { - const errors = await validate(values); - setFormErrors({ ...formErrors, [name]: errors[name] }); - } - }; - - const newFormValues = { ...formValues, [name]: value }; - - setFormValues(newFormValues); - validateField(newFormValues); - }, - [formErrors, formValues, setFormErrors, setFormValues, validate], - ); + const crudContext = React.useContext(CrudContext); + const dataSource = (props.dataSource ?? crudContext.dataSource) as Exclude< + typeof props.dataSource, + undefined + >; - const handleFormReset = React.useCallback(() => { - setFormValues(initialValues); - }, [initialValues, setFormValues]); + invariant(dataSource, 'No data source found.'); - const [hasSubmittedSuccessfully, setHasSubmittedSuccessfully] = React.useState(false); + const { fields } = dataSource; const [, submitAction, isSubmitting] = React.useActionState(async () => { - if (validate) { - const errors = await validate(formValues); - if (Object.keys(errors).length > 0) { - setFormErrors(errors); - return new Error('Form validation failed'); - } - } - setFormErrors({}); - try { - await onSubmit(formValues); - notifications.show(localeText.submitSuccessMessage, { - severity: 'success', - autoHideDuration: 3000, - }); - - if (onSubmitSuccess) { - onSubmitSuccess(); - } - - if (resetOnSubmit) { - handleFormReset(); - } - - setHasSubmittedSuccessfully(true); - } catch (createError) { - notifications.show(`${localeText.submitErrorMessage}\n${(createError as Error).message}`, { - severity: 'error', - autoHideDuration: 3000, - }); - return createError as Error; + await onSubmit(formState.values); + } catch (error) { + return error as Error; } - return null; }, null); const handleTextFieldChange = React.useCallback( (event: React.ChangeEvent) => { - handleFormFieldChange(event.target.name, event.target.value); + onFieldChange(event.target.name, event.target.value); }, - [handleFormFieldChange], + [onFieldChange], ); const handleNumberFieldChange = React.useCallback( (event: React.ChangeEvent) => { - handleFormFieldChange(event.target.name, Number(event.target.value)); + onFieldChange(event.target.name, Number(event.target.value)); }, - [handleFormFieldChange], + [onFieldChange], ); const handleCheckboxFieldChange = React.useCallback( (event: React.ChangeEvent, checked: boolean) => { - handleFormFieldChange(event.target.name, checked); + onFieldChange(event.target.name, checked); }, - [handleFormFieldChange], + [onFieldChange], ); const handleDateFieldChange = React.useCallback( (name: string) => (value: Dayjs | null) => { if (value?.isValid()) { - handleFormFieldChange(name, value.toISOString() ?? null); + onFieldChange(name, value.toISOString() ?? null); } else if (formValues[name]) { - handleFormFieldChange(name, null); + onFieldChange(name, null); } }, - [formValues, handleFormFieldChange], + [formValues, onFieldChange], ); const handleSelectFieldChange = React.useCallback( (event: SelectChangeEvent) => { - handleFormFieldChange(event.target.name, event.target.value); + onFieldChange(event.target.name, event.target.value); }, - [handleFormFieldChange], + [onFieldChange], ); const renderField = React.useCallback( @@ -334,13 +259,19 @@ function CrudForm(props: CrudFormProps) { ], ); + const handleReset = React.useCallback(async () => { + if (onReset) { + await onReset(formState.values); + } + }, [formState.values, onReset]); + return ( @@ -349,12 +280,7 @@ function CrudForm(props: CrudFormProps) { - diff --git a/packages/toolpad-core/src/Crud/Edit.tsx b/packages/toolpad-core/src/Crud/Edit.tsx index 41200741015..8acebef52bd 100644 --- a/packages/toolpad-core/src/Crud/Edit.tsx +++ b/packages/toolpad-core/src/Crud/Edit.tsx @@ -5,9 +5,132 @@ import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import invariant from 'invariant'; +import { useNotifications } from '../useNotifications'; +import { CrudContext } from '../shared/context'; import { CrudForm } from './CrudForm'; import { DataModel, DataModelId, DataSource, OmitId } from './shared'; -import { CrudContext } from '../shared/context'; + +interface EditFormProps { + dataSource: DataSource & Required, 'getOne' | 'updateOne'>>; + initialValues: Partial>; + onSubmit: (formValues: Partial>) => void | Promise; + onSubmitSuccess?: (formValues: Partial>) => void | Promise; +} + +function EditForm(props: EditFormProps) { + const { dataSource, initialValues, onSubmit, onSubmitSuccess } = props; + const { fields, validate } = dataSource; + + const notifications = useNotifications(); + + const [formState, setFormState] = React.useState<{ + values: Partial>; + errors: Partial>; + }>({ + values: { + ...Object.fromEntries( + fields + .filter(({ field }) => field !== 'id') + .map(({ field, type }) => [ + field, + type === 'boolean' ? (initialValues[field] ?? false) : initialValues[field], + ]), + ), + ...initialValues, + }, + errors: {}, + }); + const formValues = formState.values; + const formErrors = formState.errors; + + const setFormValues = React.useCallback((newFormValues: Partial>) => { + setFormState((previousState) => ({ + ...previousState, + values: newFormValues, + })); + }, []); + + const setFormErrors = React.useCallback((newFormErrors: Partial>) => { + setFormState((previousState) => ({ + ...previousState, + errors: newFormErrors, + })); + }, []); + + const handleFormFieldChange = React.useCallback( + (name: keyof D, value: string | number | boolean | File | null) => { + const validateField = async (values: Partial>) => { + if (validate) { + const errors = await validate(values); + setFormErrors({ ...formErrors, [name]: errors[name] }); + } + }; + + const newFormValues = { ...formValues, [name]: value }; + + setFormValues(newFormValues); + validateField(newFormValues); + }, + [formErrors, formValues, setFormErrors, setFormValues, validate], + ); + + const handleFormReset = React.useCallback(() => { + setFormValues(initialValues); + }, [initialValues, setFormValues]); + + const handleFormSubmit = React.useCallback(async () => { + if (validate) { + const errors = await validate(formValues); + if (Object.keys(errors).length > 0) { + setFormErrors(errors); + throw new Error('Form validation failed'); + } + } + setFormErrors({}); + + try { + await onSubmit(formValues); + notifications.show('Item edited successfully.', { + severity: 'success', + autoHideDuration: 3000, + }); + + if (onSubmitSuccess) { + await onSubmitSuccess(formValues); + } + } catch (editError) { + notifications.show(`Failed to edit item.\n${(editError as Error).message}`, { + severity: 'error', + autoHideDuration: 3000, + }); + throw editError; + } + }, [formValues, notifications, onSubmit, onSubmitSuccess, setFormErrors, validate]); + + return ( + + ); +} + +EditForm.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + dataSource: PropTypes.object.isRequired, + initialValues: PropTypes.object.isRequired, + onSubmit: PropTypes.func.isRequired, + onSubmitSuccess: PropTypes.func, +} as any; export interface EditProps { id: DataModelId; @@ -18,12 +141,9 @@ export interface EditProps { /** * Callback fired when the form is successfully submitted. */ - onSubmitSuccess?: () => void; - /** - * Whether the form fields should reset after the form is submitted. - */ - resetOnSubmit?: boolean; + onSubmitSuccess?: (formValues: Partial>) => void | Promise; } + /** * * Demos: @@ -35,7 +155,7 @@ export interface EditProps { * - [Edit API](https://mui.com/toolpad/core/api/edit) */ function Edit(props: EditProps) { - const { id, onSubmitSuccess, resetOnSubmit } = props; + const { id, onSubmitSuccess } = props; const crudContext = React.useContext(CrudContext); const dataSource = (props.dataSource ?? crudContext.dataSource) as Exclude< @@ -45,16 +165,9 @@ function Edit(props: EditProps) { invariant(dataSource, 'No data source found.'); - const { fields, ...methods } = dataSource; + const { fields, validate, ...methods } = dataSource; const { getOne, updateOne } = methods; - const handleEdit = React.useCallback( - async (formValues: Partial>) => { - await updateOne(id, formValues); - }, - [id, updateOne], - ); - const [data, setData] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [error, setError] = React.useState(null); @@ -75,6 +188,14 @@ function Edit(props: EditProps) { loadData(); }, [loadData]); + const handleSubmit = React.useCallback( + async (formValues: Partial>) => { + const updatedData = await updateOne(id, formValues); + setData(updatedData); + }, + [id, updateOne], + ); + const renderEdit = React.useMemo(() => { if (isLoading) { return ( @@ -102,20 +223,14 @@ function Edit(props: EditProps) { } return data ? ( - ) : null; - }, [data, dataSource, error, handleEdit, isLoading, onSubmitSuccess, resetOnSubmit]); + }, [data, dataSource, error, handleSubmit, isLoading, onSubmitSuccess]); return {renderEdit}; } @@ -137,10 +252,6 @@ Edit.propTypes /* remove-proptypes */ = { * Callback fired when the form is successfully submitted. */ onSubmitSuccess: PropTypes.func, - /** - * Whether the form fields should reset after the form is submitted. - */ - resetOnSubmit: PropTypes.bool, } as any; export { Edit }; diff --git a/packages/toolpad-core/src/Crud/List.tsx b/packages/toolpad-core/src/Crud/List.tsx index d24b93b8e4d..7538f8f1bc2 100644 --- a/packages/toolpad-core/src/Crud/List.tsx +++ b/packages/toolpad-core/src/Crud/List.tsx @@ -128,7 +128,7 @@ function List(props: ListProps) { invariant(dataSource, 'No data source found.'); - const { fields, ...methods } = dataSource; + const { fields, validate, ...methods } = dataSource; const { getMany, deleteOne } = methods; const routerContext = React.useContext(RouterContext); diff --git a/packages/toolpad-core/src/Crud/Show.tsx b/packages/toolpad-core/src/Crud/Show.tsx index adc0def957a..807de5c35c6 100644 --- a/packages/toolpad-core/src/Crud/Show.tsx +++ b/packages/toolpad-core/src/Crud/Show.tsx @@ -55,7 +55,7 @@ function Show(props: ShowProps) { invariant(dataSource, 'No data source found.'); - const { fields, ...methods } = dataSource; + const { fields, validate, ...methods } = dataSource; const { getOne, deleteOne } = methods; const dialogs = useDialogs();