diff --git a/demo/admin/package.json b/demo/admin/package.json index 264e37c7ad1..1d90ba54e50 100644 --- a/demo/admin/package.json +++ b/demo/admin/package.json @@ -55,6 +55,7 @@ "react-dnd-multi-backend": "^9.0.0", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", + "react-hook-form": "^7.0.0", "react-intl": "^7.1.11", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", diff --git a/demo/admin/src/products/ProductForm.tsx b/demo/admin/src/products/ProductForm.tsx index b7fa281a240..ed9944c920f 100644 --- a/demo/admin/src/products/ProductForm.tsx +++ b/demo/admin/src/products/ProductForm.tsx @@ -1,50 +1,18 @@ -import { gql, useApolloClient, useQuery } from "@apollo/client"; -import { - AsyncSelectField, - CheckboxField, - Field, - filterByFragment, - FinalForm, - FinalFormRangeInput, - type FinalFormSubmitEvent, - Loading, - OnChangeField, - SelectField, - TextAreaField, - TextField, - useFormApiRef, -} from "@comet/admin"; -import { DateField, DateTimeField } from "@comet/admin-date-time"; +import { useApolloClient, useQuery } from "@apollo/client"; +import { filterByFragment, Loading, RHFForm, RHFTextField } from "@comet/admin"; import { type BlockState, - createFinalFormBlock, DamImageBlock, - FileUploadField, type GQLFinalFormFileUploadFragment, queryUpdatedAt, resolveHasSaveConflict, - useFormSaveConflict, + useSaveConflict, } from "@comet/cms-admin"; -import { InputAdornment, MenuItem } from "@mui/material"; import { type GQLProductMutationErrorCode, type GQLProductType } from "@src/graphql.generated"; -import { - type GQLManufacturerCountriesQuery, - type GQLManufacturerCountriesQueryVariables, - type GQLManufacturersQuery, - type GQLManufacturersQueryVariables, -} from "@src/products/ProductForm.generated"; -import { FORM_ERROR, type FormApi } from "final-form"; -import isEqual from "lodash.isequal"; -import { type ReactNode, useMemo } from "react"; +import { type ReactNode, useCallback } from "react"; +import { useForm } from "react-hook-form"; import { FormattedMessage } from "react-intl"; -import { FutureProductNotice } from "./helpers/FutureProductNotice"; -import { - type GQLProductCategoriesSelectQuery, - type GQLProductCategoriesSelectQueryVariables, - type GQLProductTagsSelectQuery, - type GQLProductTagsSelectQueryVariables, -} from "./ProductForm.generated"; import { createProductMutation, productFormFragment, productQuery, updateProductMutation } from "./ProductForm.gql"; import { type GQLCreateProductMutation, @@ -72,17 +40,14 @@ type ProductFormManualFragment = Omit; }; -type FormValues = Omit & { +type FormValues = Omit & { + title: string | null; + slug: string | null; image: BlockState; manufacturerCountry?: { id: string; label: string }; lastCheckedAt?: Date | null; }; -// TODO should we use a deep partial here? -type InitialFormValues = Omit, "dimensions"> & { - dimensions?: { width?: number; height?: number; depth?: number } | null; -}; - const submissionErrorMessages: { [K in GQLProductMutationErrorCode]: ReactNode } = { titleTooShort: ( (); const { data, error, loading, refetch } = useQuery( productQuery, id ? { variables: { id } } : { skip: true }, ); - const initialValues = useMemo(() => { - const filteredData = data ? filterByFragment(productFormFragment, data.product) : undefined; - if (!filteredData) { - return { - image: rootBlocks.image.defaultValues(), - inStock: false, - additionalTypes: [], - tags: [], - dimensions: width !== undefined ? { width } : undefined, - }; - } - return { - ...filteredData, - image: rootBlocks.image.input2State(filteredData.image), - manufacturerCountry: filteredData.manufacturer - ? { - id: filteredData.manufacturer?.addressAsEmbeddable.country, - label: filteredData.manufacturer?.addressAsEmbeddable.country, - } - : undefined, - lastCheckedAt: filteredData.lastCheckedAt ? new Date(filteredData.lastCheckedAt) : null, - }; - }, [data, width]); + const filteredData = data ? filterByFragment(productFormFragment, data.product) : undefined; + const formValues: FormValues | undefined = filteredData + ? { + ...filteredData, + image: rootBlocks.image.input2State(filteredData.image), + manufacturerCountry: filteredData.manufacturer + ? { id: filteredData.manufacturer.addressAsEmbeddable.country, label: filteredData.manufacturer.addressAsEmbeddable.country } + : undefined, + lastCheckedAt: filteredData.lastCheckedAt ? new Date(filteredData.lastCheckedAt) : null, + } + : undefined; + + const form = useForm({ + defaultValues: { + title: null, + slug: null, + inStock: false, + additionalTypes: [], + tags: [], + image: rootBlocks.image.defaultValues(), + dimensions: width !== undefined ? { width } : undefined, + }, + values: formValues, + resetOptions: { + keepDirtyValues: true, + }, + }); + const { control, setError } = form; - const saveConflict = useFormSaveConflict({ + const saveConflict = useSaveConflict({ checkConflict: async () => { const updatedAt = await queryUpdatedAt(client, "product", id); return resolveHasSaveConflict(data?.product.updatedAt, updatedAt); }, - formApiRef, + hasChanges: () => form.formState.isDirty, loadLatestVersion: async () => { await refetch(); }, + onDiscardButtonPressed: async () => { + form.reset(); + await refetch(); + }, }); - const handleSubmit = async ({ manufacturerCountry, ...formValues }: FormValues, form: FormApi, event: FinalFormSubmitEvent) => { - if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); + const onSubmit = useCallback( + async ({ manufacturerCountry, ...formValues }: FormValues) => { + if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); - const output = { - ...formValues, - description: formValues.description ?? null, - image: rootBlocks.image.state2Output(formValues.image), - type: formValues.type as GQLProductType, - category: formValues.category ? formValues.category.id : null, - tags: formValues.tags.map((i) => i.id), - articleNumbers: [], - discounts: [], - statistics: { views: 0 }, - priceList: formValues.priceList ? formValues.priceList.id : null, - datasheets: formValues.datasheets?.map(({ id }) => id), - manufacturer: formValues.manufacturer?.id, - lastCheckedAt: formValues.lastCheckedAt ? formValues.lastCheckedAt.toISOString() : null, - }; + const output = { + ...formValues, + title: formValues.title ?? "", // required field, validated by form + slug: formValues.slug ?? "", // required field, validated by form + description: formValues.description ?? null, + image: rootBlocks.image.state2Output(formValues.image), + type: formValues.type as GQLProductType, + category: formValues.category ? formValues.category.id : null, + tags: formValues.tags.map((i) => i.id), + articleNumbers: [], + discounts: [], + statistics: { views: 0 }, + priceList: formValues.priceList ? formValues.priceList.id : null, + datasheets: formValues.datasheets?.map(({ id }) => id), + manufacturer: formValues.manufacturer?.id, + lastCheckedAt: formValues.lastCheckedAt ? formValues.lastCheckedAt.toISOString() : null, + }; - if (mode === "edit") { - if (!id) throw new Error(); - await client.mutate({ - mutation: updateProductMutation, - variables: { id, input: output }, - }); - } else { - const { data: mutationResponse } = await client.mutate({ - mutation: createProductMutation, - variables: { input: output }, - }); - if (mutationResponse?.createProduct.errors.length) { - return mutationResponse.createProduct.errors.reduce( - (submissionErrors, error) => { + if (mode === "edit") { + if (!id) throw new Error(); + await client.mutate({ + mutation: updateProductMutation, + variables: { id, input: output }, + }); + } else { + const { data: mutationResponse } = await client.mutate({ + mutation: createProductMutation, + variables: { input: output }, + }); + if (mutationResponse?.createProduct.errors.length) { + mutationResponse.createProduct.errors.forEach((error) => { const errorMessage = submissionErrorMessages[error.code]; if (error.field) { - submissionErrors[error.field] = errorMessage; + setError(error.field as keyof FormValues, { message: String(errorMessage) }); } else { - submissionErrors[FORM_ERROR] = errorMessage; + setError("root", { message: String(errorMessage) }); } - return submissionErrors; - }, - {} as Record, - ); - } - const id = mutationResponse?.createProduct.product?.id; - if (id) { - setTimeout(() => { - onCreate?.(id); - }); + }); + throw new Error("Submit errors"); + } + const newId = mutationResponse?.createProduct.product?.id; + if (newId) { + setTimeout(() => { + onCreate?.(newId); + }, 0); + } } - } - }; + }, + [client, id, mode, onCreate, saveConflict, setError], + ); if (error) throw error; @@ -197,194 +172,175 @@ export function ProductForm({ id, width, onCreate }: FormProps) { } return ( - - apiRef={formApiRef} - onSubmit={handleSubmit} - mode={mode} - initialValues={initialValues} - initialValuesEqual={isEqual} //required to compare block data correctly - subscription={{ values: true }} // values required because disable and loadOptions of manufacturer-select depends on values - > - {({ values, form }) => ( - <> - {saveConflict.dialogs} - } /> - } /> + + {saveConflict.dialogs} + } /> + } /> - } - fullWidth - component={FinalFormRangeInput} - min={5} - max={100} - startAdornment={} - disableSlider - /> - } /> - } - /> - - { - const { data } = await client.query({ - query: gql` - query ManufacturerCountries { - manufacturerCountries { - nodes { - id - label - } - } + {/* TODO: Not yet implemented in RHF - will be added later */} + {/* + } + fullWidth + component={FinalFormRangeInput} + min={5} + max={100} + startAdornment={} + disableSlider + /> + } /> + } + /> + + { + const { data } = await client.query({ + query: gql` + query ManufacturerCountries { + manufacturerCountries { + nodes { + id + label } - `, - }); - - return data.manufacturerCountries.nodes; - }} - getOptionLabel={(option) => option.label} - label={} - fullWidth - /> - { - const { data } = await client.query({ - query: gql` - query Manufacturers($filter: ManufacturerFilter) { - manufacturers(filter: $filter) { - nodes { - id - name - } - } + } + } + `, + }); + return data.manufacturerCountries.nodes; + }} + getOptionLabel={(option) => option.label} + label={} + fullWidth + /> + { + const { data } = await client.query({ + query: gql` + query Manufacturers($filter: ManufacturerFilter) { + manufacturers(filter: $filter) { + nodes { + id + name } - `, - variables: { - filter: { - addressAsEmbeddable_country: { - equal: values.manufacturerCountry?.id, - }, - }, - }, - }); - - return data.manufacturers.nodes; - }} - getOptionLabel={(option) => option.name} - label={} - fullWidth - disabled={!values?.manufacturerCountry} - /> - - {(value, previousValue) => { - if (value.id !== previousValue.id) { - form.change("manufacturer", undefined); + } } - }} - - } required fullWidth> - - - - - - - - - - - } - fullWidth - multiple - > - - - - - - - - - - - } - loadOptions={async () => { - const { data } = await client.query({ - query: gql` - query ProductCategoriesSelect { - productCategories { - nodes { - id - title - } - } + `, + variables: { + filter: { + addressAsEmbeddable_country: { + equal: values.manufacturerCountry?.id, + }, + }, + }, + }); + return data.manufacturers.nodes; + }} + getOptionLabel={(option) => option.name} + label={} + fullWidth + disabled={!values?.manufacturerCountry} + /> + } required fullWidth> + + + + + + + + + + + } + fullWidth + multiple + > + + + + + + + + + + + } + loadOptions={async () => { + const { data } = await client.query({ + query: gql` + query ProductCategoriesSelect { + productCategories { + nodes { + id + title } - `, - }); - - return data.productCategories.nodes; - }} - getOptionLabel={(option) => option.title} - /> - } - multiple - loadOptions={async () => { - const { data } = await client.query({ - query: gql` - query ProductTagsSelect { - productTags { - nodes { - id - title - } - } + } + } + `, + }); + return data.productCategories.nodes; + }} + getOptionLabel={(option) => option.title} + /> + } + multiple + loadOptions={async () => { + const { data } = await client.query({ + query: gql` + query ProductTagsSelect { + productTags { + nodes { + id + title } - `, - }); - - return data.productTags.nodes; - }} - getOptionLabel={(option) => option.title} - /> - } fullWidth /> - - {createFinalFormBlock(rootBlocks.image)} - - } - name="priceList" - maxFileSize={1024 * 1024 * 4} // 4 MB - fullWidth - /> - } - name="datasheets" - multiple - maxFileSize={1024 * 1024 * 4} // 4 MB - fullWidth - layout="grid" - /> - } - name="lastCheckedAt" - fullWidth - /> - - )} - + } + } + `, + }); + return data.productTags.nodes; + }} + getOptionLabel={(option) => option.title} + /> + } fullWidth /> + + {createFinalFormBlock(rootBlocks.image)} + + } + name="priceList" + maxFileSize={1024 * 1024 * 4} // 4 MB + fullWidth + /> + } + name="datasheets" + multiple + maxFileSize={1024 * 1024 * 4} // 4 MB + fullWidth + layout="grid" + /> + } + name="lastCheckedAt" + fullWidth + /> + */} + ); } diff --git a/packages/admin/admin/package.json b/packages/admin/admin/package.json index bba76adf609..705c755e534 100644 --- a/packages/admin/admin/package.json +++ b/packages/admin/admin/package.json @@ -94,6 +94,7 @@ "react-dnd": "^16.0.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", + "react-hook-form": "^7.71.2", "react-intl": "^7.1.11", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", @@ -121,6 +122,7 @@ "react-dnd": "^16.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-final-form": "^6.3.1", + "react-hook-form": "^7.0.0", "react-intl": "^5.0.0 || ^6.0.0 || ^7.0.0", "react-router": "^5.1.2", "react-router-dom": "^5.1.2" diff --git a/packages/admin/admin/src/form/react-hook-form/RHFForm.tsx b/packages/admin/admin/src/form/react-hook-form/RHFForm.tsx new file mode 100644 index 00000000000..8e25082417a --- /dev/null +++ b/packages/admin/admin/src/form/react-hook-form/RHFForm.tsx @@ -0,0 +1,68 @@ +import { type ReactNode, useCallback } from "react"; +import { type FieldValues, FormProvider, type SubmitHandler, useFormContext, type UseFormReturn, useFormState } from "react-hook-form"; + +import { Savable, useSaveBoundaryApi } from "../../saveBoundary/SaveBoundary"; + +function SavableRHF({ onSubmit }: { onSubmit: SubmitHandler }) { + const { isDirty } = useFormState(); + const formContext = useFormContext(); + + const doSave = useCallback( + () => + new Promise((resolve) => { + formContext.handleSubmit( + async (values) => { + try { + await onSubmit(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + [formContext, onSubmit], + ); + + const doReset = useCallback(() => { + formContext.reset(); + }, [formContext]); + + return ( + // hasChanges drives React state rerenders in SaveBoundary; checkForChanges is a synchronous callback for the router prompt + formContext.formState.isDirty} doSave={doSave} doReset={doReset} /> + ); +} + +export type RHFFormProps = UseFormReturn< + TFieldValues, + TContext, + TTransformedValues +> & { + children: ReactNode; + onSubmit: SubmitHandler; +}; + +export function RHFForm({ + children, + onSubmit, + ...form +}: RHFFormProps) { + const saveBoundaryApi = useSaveBoundaryApi(); + if (!saveBoundaryApi) throw new Error("RHFForm must be used inside a SaveBoundary"); + + return ( + +
{ + e.preventDefault(); + saveBoundaryApi.save(); + }} + > + + {children} + +
+ ); +} diff --git a/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx b/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx new file mode 100644 index 00000000000..8b636514154 --- /dev/null +++ b/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx @@ -0,0 +1,173 @@ +import { InputBase, type InputBaseProps } from "@mui/material"; +import { type ChangeEvent, type FocusEvent, useCallback, useEffect, useState } from "react"; +import { + Controller, + type ControllerRenderProps, + type FieldPath, + type FieldPathByValue, + type FieldValues, + type UseControllerProps, +} from "react-hook-form"; +import { useIntl } from "react-intl"; + +import { ClearInputAdornment } from "../../../common/ClearInputAdornment"; +import { FieldContainer, type FieldContainerProps } from "../../FieldContainer"; + +// A number with integer and decimal parts used to extract locale-specific formatting symbols +const LOCALE_FORMAT_SAMPLE_NUMBER = 1111.111; + +function roundToDecimals(numericValue: number, decimals: number): number { + const factor = Math.pow(10, decimals); + return Math.round(numericValue * factor) / factor; +} + +function RHFNumberFieldInner = FieldPath>({ + clearable, + endAdornment, + decimals = 0, + field, + ...restProps +}: { + clearable?: boolean; + decimals?: number; + field: ControllerRenderProps; +} & InputBaseProps) { + const intl = useIntl(); + + const [formattedNumberValue, setFormattedNumberValue] = useState(""); + + const getFormattedValue = useCallback( + (value: number | null) => { + return value !== null ? intl.formatNumber(value, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) : ""; + }, + [decimals, intl], + ); + + const handleChange = (event: ChangeEvent) => { + const { value } = event.target; + setFormattedNumberValue(value); + }; + + const updateFormattedNumberValue = useCallback( + (inputValue: number | null) => { + if (!inputValue && inputValue !== 0) { + setFormattedNumberValue(""); + } else { + setFormattedNumberValue(getFormattedValue(inputValue)); + } + }, + [getFormattedValue], + ); + + const handleBlur = (event: FocusEvent) => { + field.onBlur(); + const { value } = event.target; + const numberParts = intl.formatNumberToParts(LOCALE_FORMAT_SAMPLE_NUMBER); + const decimalSymbol = numberParts.find(({ type }) => type === "decimal")?.value; + const thousandSeparatorSymbol = numberParts.find(({ type }) => type === "group")?.value; + + const numericValue = parseFloat( + value + .split(thousandSeparatorSymbol ?? "") + .join("") + .split(decimalSymbol ?? ".") + .join("."), + ); + + const inputValue: number | null = isNaN(numericValue) ? null : roundToDecimals(numericValue, decimals); + field.onChange(inputValue); + + if (field.value === inputValue) { + updateFormattedNumberValue(inputValue); + } + }; + + useEffect(() => { + updateFormattedNumberValue(field.value); + }, [updateFormattedNumberValue, field.value]); + + return ( + + {clearable && ( + field.onChange(null)} + /> + )} + {endAdornment} + + ) + } + /> + ); +} + +type RHFNumberFieldProps< + TFieldValues extends FieldValues, + TName extends FieldPathByValue, + TTransformedValues, +> = UseControllerProps & + Pick & { + clearable?: boolean; + decimals?: number; + } & InputBaseProps; + +export function RHFNumberField, TTransformedValues>({ + name, + rules, + shouldUnregister, + defaultValue, + control, + disabled, + exact, + clearable, + decimals, + label, + variant, + fullWidth, + helperText, + required, + ...restProps +}: RHFNumberFieldProps) { + const intl = useIntl(); + + return ( + { + let error = undefined; + if (fieldState.error) { + if (fieldState.error.message) { + error = fieldState.error.message; + } else if (fieldState.error.type === "required") { + error = intl.formatMessage({ id: "form.validation.required", defaultMessage: "Required" }); + } else { + error = fieldState.error.type; + } + } + return ( + + + + ); + }} + /> + ); +} diff --git a/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx b/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx new file mode 100644 index 00000000000..e4b712087c8 --- /dev/null +++ b/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx @@ -0,0 +1,90 @@ +import { InputBase, type InputBaseProps } from "@mui/material"; +import { Controller, type FieldPathByValue, type FieldValues, type UseControllerProps } from "react-hook-form"; +import { useIntl } from "react-intl"; + +import { ClearInputAdornment } from "../../../common/ClearInputAdornment"; +import { FieldContainer, type FieldContainerProps } from "../../FieldContainer"; + +type RHFTextFieldProps< + TFieldValues extends FieldValues, + TName extends FieldPathByValue, + TTransformedValues, +> = UseControllerProps & + Pick & { clearable?: boolean } & InputBaseProps; + +export function RHFTextField, TTransformedValues>({ + name, + rules, + shouldUnregister, + defaultValue, + control, + disabled, + exact, + label, + variant, + fullWidth, + helperText, + required, + clearable, + endAdornment, + ...restProps +}: RHFTextFieldProps) { + const intl = useIntl(); + return ( + { + let error = undefined; + if (fieldState.error) { + if (fieldState.error.message) { + error = fieldState.error.message; + } else if (fieldState.error.type === "required") { + error = intl.formatMessage({ id: "form.validation.required", defaultMessage: "Required" }); + } else { + error = fieldState.error.type; + } + } + return ( + + { + const value = event.target.value; + if (value === "") { + field.onChange(null); + } else { + field.onChange(value); + } + }} + onBlur={field.onBlur} + inputRef={field.ref} + disabled={field.disabled} + endAdornment={ + (endAdornment || clearable) && ( + <> + {clearable && ( + field.onChange(null)} + /> + )} + {endAdornment} + + ) + } + /> + + ); + }} + /> + ); +} diff --git a/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFNumberField.stories.tsx b/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFNumberField.stories.tsx new file mode 100644 index 00000000000..e6810435bfd --- /dev/null +++ b/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFNumberField.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import { useForm } from "react-hook-form"; + +import { RHFNumberField } from "../RHFNumberField"; + +type Story = StoryObj; +const config: Meta = { + component: RHFNumberField, + title: "components/form/RHFNumberField", +}; +export default config; + +/** + * The basic RHFNumberField component allows users to enter numeric values in a React Hook Form. + * + * Use this when you need: + * - A number input in a React Hook Form + * - Integration with react-hook-form's validation and state management + * - Consistent styling with other Comet form fields + */ +export const Default: Story = { + render: () => { + interface FormValues { + value: number | null; + } + + function DefaultStory() { + const { control } = useForm({ + defaultValues: { value: null }, + }); + return ; + } + + return ; + }, +}; + +/** + * RHFNumberField with clearable functionality allows users to reset the numeric value. + * + * Use this when: + * - The number field is optional + * - Users should be able to clear their input + * - You want to provide an easy way to reset the field + */ +export const Clearable: Story = { + render: () => { + interface FormValues { + value: number | null; + } + + function ClearableStory() { + const { control } = useForm({ + defaultValues: { value: null }, + }); + return ; + } + + return ; + }, +}; + +/** + * RHFNumberField with configurable decimal places for monetary values or other precise measurements. + * + * Use this when: + * - You need to display and input decimal numbers + * - You want to control the number of decimal places + */ +export const WithDecimals: Story = { + render: () => { + interface FormValues { + value: number | null; + } + + function WithDecimalsStory() { + const { control } = useForm({ + defaultValues: { value: null }, + }); + return ; + } + + return ; + }, +}; + +/** + * RHFNumberField with required validation shows an error message when the field is left empty after submission. + * + * Use this when: + * - The number field is mandatory + * - You need to enforce input before form submission + */ +export const WithValidation: Story = { + render: () => { + interface FormValues { + value: number | null; + } + + function ValidationStory() { + const { control, handleSubmit } = useForm({ + defaultValues: { value: null }, + }); + return ( +
undefined)} noValidate> + + + + ); + } + + return ; + }, +}; diff --git a/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFTextField.stories.tsx b/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFTextField.stories.tsx new file mode 100644 index 00000000000..f16eec5743b --- /dev/null +++ b/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFTextField.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import { useForm } from "react-hook-form"; + +import { RHFTextField } from "../RHFTextField"; + +type Story = StoryObj; +const config: Meta = { + component: RHFTextField, + title: "components/form/RHFTextField", +}; +export default config; + +/** + * The basic RHFTextField component allows users to enter text values in a React Hook Form. + * + * Use this when you need: + * - A simple text input in a React Hook Form + * - Integration with react-hook-form's validation and state management + * - Consistent styling with other Comet form fields + */ +export const Default: Story = { + render: () => { + interface FormValues { + value: string | null; + } + + function DefaultStory() { + const { control } = useForm({ + defaultValues: { value: null }, + }); + return ; + } + + return ; + }, +}; + +/** + * RHFTextField with clearable functionality allows users to reset the text value. + * + * Use this when: + * - The text field is optional + * - Users should be able to clear their input + * - You want to provide an easy way to reset the field + */ +export const Clearable: Story = { + render: () => { + interface FormValues { + value: string | null; + } + + function ClearableStory() { + const { control } = useForm({ + defaultValues: { value: null }, + }); + return ; + } + + return ; + }, +}; + +/** + * RHFTextField with required validation shows an error message when the field is left empty after submission. + * + * Use this when: + * - The text field is mandatory + * - You need to enforce input before form submission + */ +export const WithValidation: Story = { + render: () => { + interface FormValues { + value: string | null; + } + + function ValidationStory() { + const { control, handleSubmit } = useForm({ + defaultValues: { value: null }, + }); + return ( +
undefined)} noValidate> + + + + ); + } + + return ; + }, +}; diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 9eaef4acf07..9b6d7ec094a 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -179,6 +179,9 @@ export { FinalFormToggleButtonGroup, type FinalFormToggleButtonGroupProps } from export { FormSection, type FormSectionClassKey, type FormSectionProps } from "./form/FormSection"; export { OnChangeField } from "./form/helpers/OnChangeField"; export { FinalFormRadio, type FinalFormRadioProps } from "./form/Radio"; +export { RHFNumberField } from "./form/react-hook-form/fields/RHFNumberField"; +export { RHFTextField } from "./form/react-hook-form/fields/RHFTextField"; +export { RHFForm, type RHFFormProps } from "./form/react-hook-form/RHFForm"; export { FinalFormSwitch, type FinalFormSwitchProps } from "./form/Switch"; export { FormMutation } from "./FormMutation"; export { FullPageAlert, type FullPageAlertClassKey, type FullPageAlertProps } from "./fullPageAlert/FullPageAlert"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5c3ea30054..80c6cd4c16f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: react-final-form: specifier: ^6.5.9 version: 6.5.9(final-form@4.20.10)(react@18.3.1) + react-hook-form: + specifier: ^7.0.0 + version: 7.71.2(react@18.3.1) react-intl: specifier: ^7.1.11 version: 7.1.11(react@18.3.1)(typescript@5.9.3) @@ -935,6 +938,9 @@ importers: react-final-form: specifier: ^6.5.9 version: 6.5.9(final-form@4.20.10)(react@18.3.1) + react-hook-form: + specifier: ^7.71.2 + version: 7.71.2(react@18.3.1) react-intl: specifier: ^7.1.11 version: 7.1.11(react@18.3.1)(typescript@5.9.3) @@ -2672,6 +2678,9 @@ importers: react-final-form: specifier: ^6.5.9 version: 6.5.9(final-form@4.20.10)(react@18.3.1) + react-hook-form: + specifier: ^7.0.0 + version: 7.71.2(react@18.3.1) react-intl: specifier: ^7.1.11 version: 7.1.11(react@18.3.1)(typescript@5.9.3) @@ -8031,6 +8040,9 @@ packages: '@sinonjs/text-encoding@0.7.2': resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} + deprecated: |- + Deprecated: no longer maintained and no longer used by Sinon packages. See + https://github.com/sinonjs/nise/issues/243 for replacement details. '@slorber/react-helmet-async@1.3.0': resolution: {integrity: sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==} @@ -15728,6 +15740,12 @@ packages: '@types/react': optional: true + react-hook-form@7.71.2: + resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-hotkeys-hook@3.4.7: resolution: {integrity: sha512-+bbPmhPAl6ns9VkXkNNyxlmCAIyDAcWbB76O4I0ntr3uWCRuIQf/aRLartUahe9chVMPj+OEzzfk3CQSjclUEQ==} peerDependencies: @@ -34975,6 +34993,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 + react-hook-form@7.71.2(react@18.3.1): + dependencies: + react: 18.3.1 + react-hotkeys-hook@3.4.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: hotkeys-js: 3.9.4 diff --git a/storybook/package.json b/storybook/package.json index 6dad9cdb91e..56de8543330 100644 --- a/storybook/package.json +++ b/storybook/package.json @@ -44,6 +44,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", + "react-hook-form": "^7.0.0", "react-intl": "^7.1.11", "ts-dedent": "^2.2.0", "use-debounce": "^10.0.6", diff --git a/storybook/src/docs/form/components/ReactHookForm.stories.tsx b/storybook/src/docs/form/components/ReactHookForm.stories.tsx new file mode 100644 index 00000000000..5ef67a7a58e --- /dev/null +++ b/storybook/src/docs/form/components/ReactHookForm.stories.tsx @@ -0,0 +1,266 @@ +import { gql, useApolloClient } from "@apollo/client"; +import { MockedProvider, type MockedResponse } from "@apollo/client/testing"; +import { RHFForm, RHFTextField, SaveBoundary, SaveBoundarySaveButton, SnackbarProvider } from "@comet/admin"; +import { queryUpdatedAt, resolveHasSaveConflict, useSaveConflict } from "@comet/cms-admin"; +import { useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { FormattedMessage } from "react-intl"; + +export default { + title: "Docs/Form/Components/ReactHookForm", +}; + +interface FormValues { + title: string | null; + slug: string | null; +} + +const productQuery = gql` + query StoryProduct($id: ID!) { + product(id: $id) { + id + updatedAt + title + slug + } + } +`; + +const updateProductMutation = gql` + mutation StoryUpdateProduct($id: ID!, $input: ProductUpdateInput!) { + updateProduct(id: $id, input: $input) { + id + updatedAt + title + slug + } + } +`; + +const createProductMutation = gql` + mutation StoryCreateProduct($input: ProductInput!) { + createProduct(input: $input) { + product { + id + updatedAt + title + slug + } + errors { + code + field + } + } + } +`; + +const mockedProduct = { + id: "1", + updatedAt: new Date("2024-01-01T00:00:00.000Z").toISOString(), + title: "Example Product", + slug: "example-product", +}; + +const mocks: MockedResponse[] = [ + { + request: { + query: productQuery, + variables: { id: "1" }, + }, + result: { + data: { + product: mockedProduct, + }, + }, + }, + { + request: { + query: updateProductMutation, + variables: { + id: "1", + input: { + title: "Updated Product", + slug: "updated-product", + }, + }, + }, + result: { + data: { + updateProduct: { + ...mockedProduct, + title: "Updated Product", + slug: "updated-product", + }, + }, + }, + }, + { + request: { + query: createProductMutation, + variables: { + input: { + title: "New Product", + slug: "new-product", + }, + }, + }, + result: { + data: { + createProduct: { + product: { + id: "2", + updatedAt: new Date("2024-01-01T00:00:00.000Z").toISOString(), + title: "New Product", + slug: "new-product", + }, + errors: [], + }, + }, + }, + }, +]; + +interface ProductFormProps { + id?: string; + onCreate?: (id: string) => void; +} + +function ProductForm({ id, onCreate }: ProductFormProps) { + const client = useApolloClient(); + const mode = id ? "edit" : "add"; + + const { data } = client.readQuery({ query: productQuery, variables: id ? { id } : undefined }) ?? {}; + + const formValues: FormValues | undefined = data?.product + ? { + title: data.product.title, + slug: data.product.slug, + } + : undefined; + + const form = useForm({ + defaultValues: { + title: null, + slug: null, + }, + values: formValues, + resetOptions: { + keepDirtyValues: true, + }, + }); + const { control } = form; + + const saveConflict = useSaveConflict({ + checkConflict: async () => { + if (!id) return false; + const updatedAt = await queryUpdatedAt(client, "product", id); + return resolveHasSaveConflict(mockedProduct.updatedAt, updatedAt); + }, + hasChanges: () => form.formState.isDirty, + loadLatestVersion: async () => { + if (!id) return; + await client.query({ query: productQuery, variables: { id }, fetchPolicy: "network-only" }); + }, + onDiscardButtonPressed: async () => { + form.reset(); + if (!id) return; + await client.query({ query: productQuery, variables: { id }, fetchPolicy: "network-only" }); + }, + }); + + const onSubmit = useCallback( + async (formValues: FormValues) => { + if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); + + if (mode === "edit") { + if (!id) throw new Error(); + await client.mutate({ + mutation: updateProductMutation, + variables: { id, input: formValues }, + }); + } else { + const { data: mutationResponse } = await client.mutate({ + mutation: createProductMutation, + variables: { input: formValues }, + }); + const newId = mutationResponse?.createProduct.product?.id; + if (newId) { + setTimeout(() => { + onCreate?.(newId); + }, 0); + } + } + }, + [client, id, mode, onCreate, saveConflict], + ); + + return ( + + {saveConflict.dialogs} + } /> + } /> + + {/* TODO: Not yet implemented in RHF - will be added later */} + {/* + + + + + + + + + + ... + + + + */} + + ); +} + +function StoryWithApollo({ id, onCreate }: ProductFormProps) { + const client = useApolloClient(); + + if (id) { + // Preload data into Apollo cache + client.writeQuery({ + query: productQuery, + variables: { id }, + data: { product: mockedProduct }, + }); + } + + return ; +} + +export const EditProductForm = { + render: () => { + return ( + + + + + + + + + ); + }, +}; + +export const AddProductForm = { + render: () => { + return ( + + + + + window.alert(`Created product with id: ${id}`)} /> + + + + ); + }, +};