From 6f0e77781514802dd6cca29c50acaef2173f4728 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:58:31 +0000 Subject: [PATCH 1/7] Initial plan From dd1300e5843fba2101938cc99c1a2423976cbee9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:12:47 +0000 Subject: [PATCH 2/7] storybook: Add React Hook Form setup with RHFTextField and RHFNumberField (COM-2777) Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- pnpm-lock.yaml | 36 ++-- storybook/package.json | 1 + .../src/admin/form/ReactHookForm.stories.tsx | 124 +++++++++++++ storybook/src/helpers/rhf/RHFNumberField.tsx | 167 ++++++++++++++++++ storybook/src/helpers/rhf/RHFTextField.tsx | 81 +++++++++ 5 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 storybook/src/admin/form/ReactHookForm.stories.tsx create mode 100644 storybook/src/helpers/rhf/RHFNumberField.tsx create mode 100644 storybook/src/helpers/rhf/RHFTextField.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5c3ea30054..2b376915544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1842,7 +1842,7 @@ importers: version: 6.1.2 ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) uuid: specifier: ^11.1.0 version: 11.1.0 @@ -1972,7 +1972,7 @@ importers: version: 7.8.2 ts-jest: specifier: ^29.0.5 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3) @@ -2231,7 +2231,7 @@ importers: version: 7.8.2 ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3) @@ -2295,7 +2295,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-formatjs: specifier: ^5.4.2 - version: 5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3))(typescript@5.9.3) + version: 5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3))(typescript@5.9.3) eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) @@ -2386,7 +2386,7 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2672,6 +2672,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) @@ -8031,6 +8034,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 +15734,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: @@ -22039,7 +22051,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@formatjs/ts-transformer@3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3))': + '@formatjs/ts-transformer@3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3))': dependencies: '@formatjs/icu-messageformat-parser': 2.11.4 '@types/node': 22.18.10 @@ -22048,7 +22060,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 optionalDependencies: - ts-jest: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + ts-jest: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) '@getbrevo/brevo@3.0.1': dependencies: @@ -29560,10 +29572,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-formatjs@5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3))(typescript@5.9.3): + eslint-plugin-formatjs@5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3))(typescript@5.9.3): dependencies: '@formatjs/icu-messageformat-parser': 2.11.4 - '@formatjs/ts-transformer': 3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3)) + '@formatjs/ts-transformer': 3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3)) '@types/eslint': 9.6.1 '@types/picomatch': 3.0.2 '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -34975,6 +34987,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 @@ -36680,7 +36696,7 @@ snapshots: dependencies: tslib: 2.8.1 - ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 diff --git a/storybook/package.json b/storybook/package.json index 6dad9cdb91e..2aa5b7d2c26 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.71.2", "react-intl": "^7.1.11", "ts-dedent": "^2.2.0", "use-debounce": "^10.0.6", diff --git a/storybook/src/admin/form/ReactHookForm.stories.tsx b/storybook/src/admin/form/ReactHookForm.stories.tsx new file mode 100644 index 00000000000..8c795d11460 --- /dev/null +++ b/storybook/src/admin/form/ReactHookForm.stories.tsx @@ -0,0 +1,124 @@ +import { Alert } from "@comet/admin"; +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import { useForm } from "react-hook-form"; + +import { RHFNumberField } from "../../helpers/rhf/RHFNumberField"; +import { RHFTextField } from "../../helpers/rhf/RHFTextField"; + +interface FormValues { + title: string | null; + price: number | null; + stock: number | null; +} + +function RHFFormExample() { + const { control, watch } = useForm({ + defaultValues: { + title: null, + price: null, + stock: null, + }, + }); + + const values = watch(); + + return ( + <> + + + + +
{JSON.stringify(values, null, 2)}
+
+ + ); +} + +const config: Meta = { + title: "@comet/admin/form/react-hook-form", +}; +export default config; + +type Story = StoryObj; + +export const RHFTextField_: Story = { + name: "RHFTextField", + render: () => { + interface TextFieldFormValues { + value: string | null; + } + + function TextFieldStory() { + const { control, watch } = useForm({ + defaultValues: { value: null }, + }); + const values = watch(); + return ( + <> + + +
{JSON.stringify(values, null, 2)}
+
+ + ); + } + + return ; + }, +}; + +export const RHFTextField_Clearable: Story = { + name: "RHFTextField Clearable", + render: () => { + interface TextFieldFormValues { + value: string | null; + } + + function TextFieldClearableStory() { + const { control, watch } = useForm({ + defaultValues: { value: null }, + }); + const values = watch(); + return ( + <> + + +
{JSON.stringify(values, null, 2)}
+
+ + ); + } + + return ; + }, +}; + +export const RHFNumberField_: Story = { + name: "RHFNumberField", + render: () => { + interface NumberFieldFormValues { + value: number | null; + } + + function NumberFieldStory() { + const { control, watch } = useForm({ + defaultValues: { value: null }, + }); + const values = watch(); + return ( + <> + + +
{JSON.stringify(values, null, 2)}
+
+ + ); + } + + return ; + }, +}; + +export const RHFForm: Story = { + render: () => , +}; diff --git a/storybook/src/helpers/rhf/RHFNumberField.tsx b/storybook/src/helpers/rhf/RHFNumberField.tsx new file mode 100644 index 00000000000..910fcd0ca99 --- /dev/null +++ b/storybook/src/helpers/rhf/RHFNumberField.tsx @@ -0,0 +1,167 @@ +import { ClearInputAdornment, FieldContainer, type FieldContainerProps } from "@comet/admin"; +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"; + +// 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, + ...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/storybook/src/helpers/rhf/RHFTextField.tsx b/storybook/src/helpers/rhf/RHFTextField.tsx new file mode 100644 index 00000000000..6de96d73f35 --- /dev/null +++ b/storybook/src/helpers/rhf/RHFTextField.tsx @@ -0,0 +1,81 @@ +import { ClearInputAdornment, FieldContainer, type FieldContainerProps } from "@comet/admin"; +import { InputBase, type InputBaseProps } from "@mui/material"; +import { Controller, type FieldPathByValue, type FieldValues, type UseControllerProps } from "react-hook-form"; +import { useIntl } from "react-intl"; + +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, + clearable, + ...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={ + clearable && ( + field.onChange(null)} + /> + ) + } + /> + + ); + }} + /> + ); +} From e1e93fdcd385e0ecf5fc28f80731d7d4c647a5e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:34:32 +0000 Subject: [PATCH 3/7] admin: Move RHFTextField and RHFNumberField to @comet/admin with admin package storybook Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- packages/admin/admin/package.json | 2 + .../admin/src/form/fields}/RHFNumberField.tsx | 4 +- .../admin/src/form/fields}/RHFTextField.tsx | 4 +- .../__stories__/RHFNumberField.stories.tsx | 121 +++++++++++++++++ .../__stories__/RHFTextField.stories.tsx | 90 +++++++++++++ packages/admin/admin/src/index.ts | 2 + pnpm-lock.yaml | 3 + storybook/package.json | 1 - .../src/admin/form/ReactHookForm.stories.tsx | 124 ------------------ 9 files changed, 224 insertions(+), 127 deletions(-) rename {storybook/src/helpers/rhf => packages/admin/admin/src/form/fields}/RHFNumberField.tsx (97%) rename {storybook/src/helpers/rhf => packages/admin/admin/src/form/fields}/RHFTextField.tsx (95%) create mode 100644 packages/admin/admin/src/form/fields/__stories__/RHFNumberField.stories.tsx create mode 100644 packages/admin/admin/src/form/fields/__stories__/RHFTextField.stories.tsx delete mode 100644 storybook/src/admin/form/ReactHookForm.stories.tsx 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/storybook/src/helpers/rhf/RHFNumberField.tsx b/packages/admin/admin/src/form/fields/RHFNumberField.tsx similarity index 97% rename from storybook/src/helpers/rhf/RHFNumberField.tsx rename to packages/admin/admin/src/form/fields/RHFNumberField.tsx index 910fcd0ca99..0f35c48a222 100644 --- a/storybook/src/helpers/rhf/RHFNumberField.tsx +++ b/packages/admin/admin/src/form/fields/RHFNumberField.tsx @@ -1,4 +1,3 @@ -import { ClearInputAdornment, FieldContainer, type FieldContainerProps } from "@comet/admin"; import { InputBase, type InputBaseProps } from "@mui/material"; import { type ChangeEvent, type FocusEvent, useCallback, useEffect, useState } from "react"; import { @@ -11,6 +10,9 @@ import { } 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; diff --git a/storybook/src/helpers/rhf/RHFTextField.tsx b/packages/admin/admin/src/form/fields/RHFTextField.tsx similarity index 95% rename from storybook/src/helpers/rhf/RHFTextField.tsx rename to packages/admin/admin/src/form/fields/RHFTextField.tsx index 6de96d73f35..da30c0c8d81 100644 --- a/storybook/src/helpers/rhf/RHFTextField.tsx +++ b/packages/admin/admin/src/form/fields/RHFTextField.tsx @@ -1,8 +1,10 @@ -import { ClearInputAdornment, FieldContainer, type FieldContainerProps } from "@comet/admin"; 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, diff --git a/packages/admin/admin/src/form/fields/__stories__/RHFNumberField.stories.tsx b/packages/admin/admin/src/form/fields/__stories__/RHFNumberField.stories.tsx new file mode 100644 index 00000000000..156d3340a73 --- /dev/null +++ b/packages/admin/admin/src/form/fields/__stories__/RHFNumberField.stories.tsx @@ -0,0 +1,121 @@ +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)}> + + + + ); + } + + return ; + }, +}; diff --git a/packages/admin/admin/src/form/fields/__stories__/RHFTextField.stories.tsx b/packages/admin/admin/src/form/fields/__stories__/RHFTextField.stories.tsx new file mode 100644 index 00000000000..f90ed4d0742 --- /dev/null +++ b/packages/admin/admin/src/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)}> + + + + ); + } + + return ; + }, +}; diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 9eaef4acf07..8d7ad1ae71b 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -149,6 +149,8 @@ export { CheckboxField, type CheckboxFieldProps } from "./form/fields/CheckboxFi export { CheckboxListField, type CheckboxListFieldProps } from "./form/fields/CheckboxListField"; export { NumberField, type NumberFieldProps } from "./form/fields/NumberField"; export { RadioGroupField, type RadioGroupFieldProps } from "./form/fields/RadioGroupField"; +export { RHFNumberField } from "./form/fields/RHFNumberField"; +export { RHFTextField } from "./form/fields/RHFTextField"; export { SearchField, type SearchFieldProps } from "./form/fields/SearchField"; export { SelectField, type SelectFieldOption, type SelectFieldProps } from "./form/fields/SelectField"; export { SwitchField, type SwitchFieldProps } from "./form/fields/SwitchField"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b376915544..23b710797d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,6 +935,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) diff --git a/storybook/package.json b/storybook/package.json index 2aa5b7d2c26..6dad9cdb91e 100644 --- a/storybook/package.json +++ b/storybook/package.json @@ -44,7 +44,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", - "react-hook-form": "^7.71.2", "react-intl": "^7.1.11", "ts-dedent": "^2.2.0", "use-debounce": "^10.0.6", diff --git a/storybook/src/admin/form/ReactHookForm.stories.tsx b/storybook/src/admin/form/ReactHookForm.stories.tsx deleted file mode 100644 index 8c795d11460..00000000000 --- a/storybook/src/admin/form/ReactHookForm.stories.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Alert } from "@comet/admin"; -import type { Meta, StoryObj } from "@storybook/react-webpack5"; -import { useForm } from "react-hook-form"; - -import { RHFNumberField } from "../../helpers/rhf/RHFNumberField"; -import { RHFTextField } from "../../helpers/rhf/RHFTextField"; - -interface FormValues { - title: string | null; - price: number | null; - stock: number | null; -} - -function RHFFormExample() { - const { control, watch } = useForm({ - defaultValues: { - title: null, - price: null, - stock: null, - }, - }); - - const values = watch(); - - return ( - <> - - - - -
{JSON.stringify(values, null, 2)}
-
- - ); -} - -const config: Meta = { - title: "@comet/admin/form/react-hook-form", -}; -export default config; - -type Story = StoryObj; - -export const RHFTextField_: Story = { - name: "RHFTextField", - render: () => { - interface TextFieldFormValues { - value: string | null; - } - - function TextFieldStory() { - const { control, watch } = useForm({ - defaultValues: { value: null }, - }); - const values = watch(); - return ( - <> - - -
{JSON.stringify(values, null, 2)}
-
- - ); - } - - return ; - }, -}; - -export const RHFTextField_Clearable: Story = { - name: "RHFTextField Clearable", - render: () => { - interface TextFieldFormValues { - value: string | null; - } - - function TextFieldClearableStory() { - const { control, watch } = useForm({ - defaultValues: { value: null }, - }); - const values = watch(); - return ( - <> - - -
{JSON.stringify(values, null, 2)}
-
- - ); - } - - return ; - }, -}; - -export const RHFNumberField_: Story = { - name: "RHFNumberField", - render: () => { - interface NumberFieldFormValues { - value: number | null; - } - - function NumberFieldStory() { - const { control, watch } = useForm({ - defaultValues: { value: null }, - }); - const values = watch(); - return ( - <> - - -
{JSON.stringify(values, null, 2)}
-
- - ); - } - - return ; - }, -}; - -export const RHFForm: Story = { - render: () => , -}; From 0670b693f71073e8261d8f0fe28480e19fc3abe8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:48:28 +0000 Subject: [PATCH 4/7] admin: Move RHF fields to form/react-hook-form/fields subdirectory Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- .../fields/RHFNumberField.tsx | 4 ++-- .../fields/RHFTextField.tsx | 4 ++-- .../__stories__/RHFNumberField.stories.tsx | 0 .../__stories__/RHFTextField.stories.tsx | 0 packages/admin/admin/src/index.ts | 4 ++-- pnpm-lock.yaml | 23 ++++++++----------- 6 files changed, 16 insertions(+), 19 deletions(-) rename packages/admin/admin/src/form/{ => react-hook-form}/fields/RHFNumberField.tsx (97%) rename packages/admin/admin/src/form/{ => react-hook-form}/fields/RHFTextField.tsx (95%) rename packages/admin/admin/src/form/{ => react-hook-form}/fields/__stories__/RHFNumberField.stories.tsx (100%) rename packages/admin/admin/src/form/{ => react-hook-form}/fields/__stories__/RHFTextField.stories.tsx (100%) diff --git a/packages/admin/admin/src/form/fields/RHFNumberField.tsx b/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx similarity index 97% rename from packages/admin/admin/src/form/fields/RHFNumberField.tsx rename to packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx index 0f35c48a222..282eb1f250b 100644 --- a/packages/admin/admin/src/form/fields/RHFNumberField.tsx +++ b/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx @@ -10,8 +10,8 @@ import { } from "react-hook-form"; import { useIntl } from "react-intl"; -import { ClearInputAdornment } from "../../common/ClearInputAdornment"; -import { FieldContainer, type FieldContainerProps } from "../FieldContainer"; +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; diff --git a/packages/admin/admin/src/form/fields/RHFTextField.tsx b/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx similarity index 95% rename from packages/admin/admin/src/form/fields/RHFTextField.tsx rename to packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx index da30c0c8d81..12de2e2b2a6 100644 --- a/packages/admin/admin/src/form/fields/RHFTextField.tsx +++ b/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx @@ -2,8 +2,8 @@ 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"; +import { ClearInputAdornment } from "../../../common/ClearInputAdornment"; +import { FieldContainer, type FieldContainerProps } from "../../FieldContainer"; type RHFTextFieldProps< TFieldValues extends FieldValues, diff --git a/packages/admin/admin/src/form/fields/__stories__/RHFNumberField.stories.tsx b/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFNumberField.stories.tsx similarity index 100% rename from packages/admin/admin/src/form/fields/__stories__/RHFNumberField.stories.tsx rename to packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFNumberField.stories.tsx diff --git a/packages/admin/admin/src/form/fields/__stories__/RHFTextField.stories.tsx b/packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFTextField.stories.tsx similarity index 100% rename from packages/admin/admin/src/form/fields/__stories__/RHFTextField.stories.tsx rename to packages/admin/admin/src/form/react-hook-form/fields/__stories__/RHFTextField.stories.tsx diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 8d7ad1ae71b..ca489f0b4af 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -149,8 +149,6 @@ export { CheckboxField, type CheckboxFieldProps } from "./form/fields/CheckboxFi export { CheckboxListField, type CheckboxListFieldProps } from "./form/fields/CheckboxListField"; export { NumberField, type NumberFieldProps } from "./form/fields/NumberField"; export { RadioGroupField, type RadioGroupFieldProps } from "./form/fields/RadioGroupField"; -export { RHFNumberField } from "./form/fields/RHFNumberField"; -export { RHFTextField } from "./form/fields/RHFTextField"; export { SearchField, type SearchFieldProps } from "./form/fields/SearchField"; export { SelectField, type SelectFieldOption, type SelectFieldProps } from "./form/fields/SelectField"; export { SwitchField, type SwitchFieldProps } from "./form/fields/SwitchField"; @@ -181,6 +179,8 @@ 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 { 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 23b710797d1..123d776902e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1845,7 +1845,7 @@ importers: version: 6.1.2 ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) uuid: specifier: ^11.1.0 version: 11.1.0 @@ -1975,7 +1975,7 @@ importers: version: 7.8.2 ts-jest: specifier: ^29.0.5 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3) @@ -2234,7 +2234,7 @@ importers: version: 7.8.2 ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3) @@ -2298,7 +2298,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-formatjs: specifier: ^5.4.2 - version: 5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3))(typescript@5.9.3) + version: 5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3))(typescript@5.9.3) eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) @@ -2389,7 +2389,7 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -2675,9 +2675,6 @@ 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) @@ -22054,7 +22051,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@formatjs/ts-transformer@3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3))': + '@formatjs/ts-transformer@3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3))': dependencies: '@formatjs/icu-messageformat-parser': 2.11.4 '@types/node': 22.18.10 @@ -22063,7 +22060,7 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 optionalDependencies: - ts-jest: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3) + ts-jest: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3) '@getbrevo/brevo@3.0.1': dependencies: @@ -29575,10 +29572,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-formatjs@5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3))(typescript@5.9.3): + eslint-plugin-formatjs@5.4.2(eslint@9.39.2(jiti@2.6.1))(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3))(typescript@5.9.3): dependencies: '@formatjs/icu-messageformat-parser': 2.11.4 - '@formatjs/ts-transformer': 3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3)) + '@formatjs/ts-transformer': 3.14.2(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3)) '@types/eslint': 9.6.1 '@types/picomatch': 3.0.2 '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -36699,7 +36696,7 @@ snapshots: dependencies: tslib: 2.8.1 - ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.11.0)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.11.0)(babel-plugin-macros@3.1.0))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 From e5be57ea47310dc7c37d8db278715c340983e1fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:38:33 +0000 Subject: [PATCH 5/7] admin: align RHFTextField and RHFNumberField with Final Form fields Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- .../react-hook-form/fields/RHFNumberField.tsx | 10 +++++--- .../react-hook-form/fields/RHFTextField.tsx | 25 ++++++++++++------- .../__stories__/RHFNumberField.stories.tsx | 11 ++------ .../__stories__/RHFTextField.stories.tsx | 4 +-- 4 files changed, 27 insertions(+), 23 deletions(-) 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 index 282eb1f250b..8b636514154 100644 --- a/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx +++ b/packages/admin/admin/src/form/react-hook-form/fields/RHFNumberField.tsx @@ -118,7 +118,10 @@ type RHFNumberFieldProps< TName extends FieldPathByValue, TTransformedValues, > = UseControllerProps & - Pick & { clearable?: boolean; decimals?: number } & InputBaseProps; + Pick & { + clearable?: boolean; + decimals?: number; + } & InputBaseProps; export function RHFNumberField, TTransformedValues>({ name, @@ -134,6 +137,7 @@ export function RHFNumberField) { const intl = useIntl(); @@ -141,7 +145,7 @@ export function RHFNumberField + ); 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 index 12de2e2b2a6..e4b712087c8 100644 --- a/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx +++ b/packages/admin/admin/src/form/react-hook-form/fields/RHFTextField.tsx @@ -10,7 +10,7 @@ type RHFTextFieldProps< TName extends FieldPathByValue, TTransformedValues, > = UseControllerProps & - Pick & { clearable?: boolean } & InputBaseProps; + Pick & { clearable?: boolean } & InputBaseProps; export function RHFTextField, TTransformedValues>({ name, @@ -24,14 +24,16 @@ export function RHFTextField) { const intl = useIntl(); return ( + field.onChange(null)} - /> + (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 index 156d3340a73..e6810435bfd 100644 --- 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 @@ -102,15 +102,8 @@ export const WithValidation: Story = { defaultValues: { value: null }, }); return ( -
undefined)}> - + undefined)} noValidate> + ); 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 index f90ed4d0742..f16eec5743b 100644 --- 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 @@ -78,8 +78,8 @@ export const WithValidation: Story = { defaultValues: { value: null }, }); return ( -
undefined)}> - + undefined)} noValidate> + ); From 43ccddbfa09519a7c2626ba918d7b6a42701084f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:23:08 +0000 Subject: [PATCH 6/7] Convert ProductForm to react-hook-form and add RHF storybook story Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- demo/admin/package.json | 1 + demo/admin/src/products/ProductForm.tsx | 572 +++++++++--------- pnpm-lock.yaml | 6 + storybook/package.json | 1 + .../form/components/ReactHookForm.stories.tsx | 281 +++++++++ 5 files changed, 566 insertions(+), 295 deletions(-) create mode 100644 storybook/src/docs/form/components/ReactHookForm.stories.tsx 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..4fc1cc5238d 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, RHFTextField, Savable } 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, useEffect, useRef } 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 { control, handleSubmit, reset, formState, setError } = useForm({ + defaultValues: { + title: null, + slug: null, + inStock: false, + additionalTypes: [], + tags: [], + image: rootBlocks.image.defaultValues(), + dimensions: width !== undefined ? { width } : undefined, + }, + }); + + useEffect(() => { const filteredData = data ? filterByFragment(productFormFragment, data.product) : undefined; - if (!filteredData) { - return { - image: rootBlocks.image.defaultValues(), - inStock: false, - additionalTypes: [], - tags: [], - dimensions: width !== undefined ? { width } : undefined, - }; + if (filteredData) { + reset( + { + ...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, + }, + { keepDirtyValues: true }, + ); } - 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]); + }, [data, reset]); - const saveConflict = useFormSaveConflict({ + const isDirtyRef = useRef(false); + isDirtyRef.current = formState.isDirty; + const hasChanges = useCallback(() => isDirtyRef.current, []); + + const saveConflict = useSaveConflict({ checkConflict: async () => { const updatedAt = await queryUpdatedAt(client, "product", id); return resolveHasSaveConflict(data?.product.updatedAt, updatedAt); }, - formApiRef, + hasChanges, loadLatestVersion: async () => { await refetch(); }, + onDiscardButtonPressed: async () => { + await refetch(); + reset(); + }, }); - 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], + ); + + const doSave = useCallback( + (): Promise => + new Promise((resolve) => { + handleSubmit( + async (values) => { + try { + await onSubmit(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + [handleSubmit, onSubmit], + ); if (error) throw error; @@ -197,194 +197,176 @@ 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/pnpm-lock.yaml b/pnpm-lock.yaml index 123d776902e..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) @@ -2675,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) 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..1a52cefa90f --- /dev/null +++ b/storybook/src/docs/form/components/ReactHookForm.stories.tsx @@ -0,0 +1,281 @@ +import { gql, useApolloClient } from "@apollo/client"; +import { MockedProvider, type MockedResponse } from "@apollo/client/testing"; +import { RHFTextField, Savable, SaveBoundary, SaveBoundarySaveButton, SnackbarProvider } from "@comet/admin"; +import { queryUpdatedAt, resolveHasSaveConflict, useSaveConflict } from "@comet/cms-admin"; +import { useCallback, useEffect, useRef } 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 { control, handleSubmit, reset, formState } = useForm({ + defaultValues: { + title: null, + slug: null, + }, + }); + + useEffect(() => { + if (!id) return; + + client + .query({ + query: productQuery, + variables: { id }, + }) + .then(({ data }) => { + if (data?.product) { + reset( + { + title: data.product.title, + slug: data.product.slug, + }, + { keepDirtyValues: true }, + ); + } + }); + }, [id, client, reset]); + + const isDirtyRef = useRef(false); + isDirtyRef.current = formState.isDirty; + const hasChanges = useCallback(() => isDirtyRef.current, []); + + const saveConflict = useSaveConflict({ + checkConflict: async () => { + if (!id) return false; + const updatedAt = await queryUpdatedAt(client, "product", id); + return resolveHasSaveConflict(mockedProduct.updatedAt, updatedAt); + }, + hasChanges, + loadLatestVersion: async () => { + if (!id) return; + await client.query({ query: productQuery, variables: { id }, fetchPolicy: "network-only" }); + }, + onDiscardButtonPressed: async () => { + if (!id) return; + await client.query({ query: productQuery, variables: { id }, fetchPolicy: "network-only" }); + reset(); + }, + }); + + 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], + ); + + const doSave = useCallback( + (): Promise => + new Promise((resolve) => { + handleSubmit( + async (values) => { + try { + await onSubmit(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + [handleSubmit, onSubmit], + ); + + return ( + <> + + {saveConflict.dialogs} + } /> + } /> + + {/* TODO: Not yet implemented in RHF - will be added later */} + {/* + + + + + + + + + + ... + + + + */} + + ); +} + +export const EditProductForm = { + render: () => { + return ( + + + + + + + + + ); + }, +}; + +export const AddProductForm = { + render: () => { + return ( + + + + + window.alert(`Created product with id: ${id}`)} /> + + + + ); + }, +}; From 73075c99979bfc19872069268f0a6ae73421ab23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:40:52 +0000 Subject: [PATCH 7/7] Add RHFForm component and refactor ProductForm to use it Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- demo/admin/src/products/ProductForm.tsx | 74 +++++---------- .../src/form/react-hook-form/RHFForm.tsx | 68 ++++++++++++++ packages/admin/admin/src/index.ts | 1 + .../form/components/ReactHookForm.stories.tsx | 91 ++++++++----------- 4 files changed, 131 insertions(+), 103 deletions(-) create mode 100644 packages/admin/admin/src/form/react-hook-form/RHFForm.tsx diff --git a/demo/admin/src/products/ProductForm.tsx b/demo/admin/src/products/ProductForm.tsx index 4fc1cc5238d..ed9944c920f 100644 --- a/demo/admin/src/products/ProductForm.tsx +++ b/demo/admin/src/products/ProductForm.tsx @@ -1,5 +1,5 @@ import { useApolloClient, useQuery } from "@apollo/client"; -import { filterByFragment, Loading, RHFTextField, Savable } from "@comet/admin"; +import { filterByFragment, Loading, RHFForm, RHFTextField } from "@comet/admin"; import { type BlockState, DamImageBlock, @@ -9,7 +9,7 @@ import { useSaveConflict, } from "@comet/cms-admin"; import { type GQLProductMutationErrorCode, type GQLProductType } from "@src/graphql.generated"; -import { type ReactNode, useCallback, useEffect, useRef } from "react"; +import { type ReactNode, useCallback } from "react"; import { useForm } from "react-hook-form"; import { FormattedMessage } from "react-intl"; @@ -66,7 +66,19 @@ export function ProductForm({ id, width, onCreate }: FormProps) { id ? { variables: { id } } : { skip: true }, ); - const { control, handleSubmit, reset, formState, setError } = useForm({ + 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, @@ -76,44 +88,25 @@ export function ProductForm({ id, width, onCreate }: FormProps) { image: rootBlocks.image.defaultValues(), dimensions: width !== undefined ? { width } : undefined, }, + values: formValues, + resetOptions: { + keepDirtyValues: true, + }, }); - - useEffect(() => { - const filteredData = data ? filterByFragment(productFormFragment, data.product) : undefined; - if (filteredData) { - reset( - { - ...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, - }, - { keepDirtyValues: true }, - ); - } - }, [data, reset]); - - const isDirtyRef = useRef(false); - isDirtyRef.current = formState.isDirty; - const hasChanges = useCallback(() => isDirtyRef.current, []); + const { control, setError } = form; const saveConflict = useSaveConflict({ checkConflict: async () => { const updatedAt = await queryUpdatedAt(client, "product", id); return resolveHasSaveConflict(data?.product.updatedAt, updatedAt); }, - hasChanges, + hasChanges: () => form.formState.isDirty, loadLatestVersion: async () => { await refetch(); }, onDiscardButtonPressed: async () => { + form.reset(); await refetch(); - reset(); }, }); @@ -172,24 +165,6 @@ export function ProductForm({ id, width, onCreate }: FormProps) { [client, id, mode, onCreate, saveConflict, setError], ); - const doSave = useCallback( - (): Promise => - new Promise((resolve) => { - handleSubmit( - async (values) => { - try { - await onSubmit(values); - resolve(true); - } catch { - resolve(false); - } - }, - () => resolve(false), - )(); - }), - [handleSubmit, onSubmit], - ); - if (error) throw error; if (loading) { @@ -197,8 +172,7 @@ export function ProductForm({ id, width, onCreate }: FormProps) { } return ( - <> - + {saveConflict.dialogs} } /> } /> @@ -367,6 +341,6 @@ export function ProductForm({ id, width, onCreate }: FormProps) { fullWidth /> */} - + ); } 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/index.ts b/packages/admin/admin/src/index.ts index ca489f0b4af..9b6d7ec094a 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -181,6 +181,7 @@ 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/storybook/src/docs/form/components/ReactHookForm.stories.tsx b/storybook/src/docs/form/components/ReactHookForm.stories.tsx index 1a52cefa90f..5ef67a7a58e 100644 --- a/storybook/src/docs/form/components/ReactHookForm.stories.tsx +++ b/storybook/src/docs/form/components/ReactHookForm.stories.tsx @@ -1,8 +1,8 @@ import { gql, useApolloClient } from "@apollo/client"; import { MockedProvider, type MockedResponse } from "@apollo/client/testing"; -import { RHFTextField, Savable, SaveBoundary, SaveBoundarySaveButton, SnackbarProvider } from "@comet/admin"; +import { RHFForm, RHFTextField, SaveBoundary, SaveBoundarySaveButton, SnackbarProvider } from "@comet/admin"; import { queryUpdatedAt, resolveHasSaveConflict, useSaveConflict } from "@comet/cms-admin"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useForm } from "react-hook-form"; import { FormattedMessage } from "react-intl"; @@ -129,37 +129,26 @@ function ProductForm({ id, onCreate }: ProductFormProps) { const client = useApolloClient(); const mode = id ? "edit" : "add"; - const { control, handleSubmit, reset, formState } = useForm({ + 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, + }, }); - - useEffect(() => { - if (!id) return; - - client - .query({ - query: productQuery, - variables: { id }, - }) - .then(({ data }) => { - if (data?.product) { - reset( - { - title: data.product.title, - slug: data.product.slug, - }, - { keepDirtyValues: true }, - ); - } - }); - }, [id, client, reset]); - - const isDirtyRef = useRef(false); - isDirtyRef.current = formState.isDirty; - const hasChanges = useCallback(() => isDirtyRef.current, []); + const { control } = form; const saveConflict = useSaveConflict({ checkConflict: async () => { @@ -167,15 +156,15 @@ function ProductForm({ id, onCreate }: ProductFormProps) { const updatedAt = await queryUpdatedAt(client, "product", id); return resolveHasSaveConflict(mockedProduct.updatedAt, updatedAt); }, - hasChanges, + 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" }); - reset(); }, }); @@ -205,27 +194,8 @@ function ProductForm({ id, onCreate }: ProductFormProps) { [client, id, mode, onCreate, saveConflict], ); - const doSave = useCallback( - (): Promise => - new Promise((resolve) => { - handleSubmit( - async (values) => { - try { - await onSubmit(values); - resolve(true); - } catch { - resolve(false); - } - }, - () => resolve(false), - )(); - }), - [handleSubmit, onSubmit], - ); - return ( - <> - + {saveConflict.dialogs} } /> } /> @@ -246,10 +216,25 @@ function ProductForm({ id, onCreate }: ProductFormProps) { */} - + ); } +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 ( @@ -257,7 +242,7 @@ export const EditProductForm = { - + @@ -272,7 +257,7 @@ export const AddProductForm = { - window.alert(`Created product with id: ${id}`)} /> + window.alert(`Created product with id: ${id}`)} />