Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions demo/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
550 changes: 253 additions & 297 deletions demo/admin/src/products/ProductForm.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/admin/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions packages/admin/admin/src/form/react-hook-form/RHFForm.tsx
Original file line number Diff line number Diff line change
@@ -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<any> }) {
const { isDirty } = useFormState();
const formContext = useFormContext();

const doSave = useCallback(
() =>
new Promise<boolean>((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
<Savable hasChanges={isDirty} checkForChanges={() => formContext.formState.isDirty} doSave={doSave} doReset={doReset} />
);
}

export type RHFFormProps<TFieldValues extends FieldValues = FieldValues, TContext = any, TTransformedValues = TFieldValues> = UseFormReturn<
TFieldValues,
TContext,
TTransformedValues
> & {
children: ReactNode;
onSubmit: SubmitHandler<TFieldValues>;
};

export function RHFForm<TFieldValues extends FieldValues = FieldValues, TContext = any, TTransformedValues = TFieldValues>({
children,
onSubmit,
...form
}: RHFFormProps<TFieldValues, TContext, TTransformedValues>) {
const saveBoundaryApi = useSaveBoundaryApi();
if (!saveBoundaryApi) throw new Error("RHFForm must be used inside a SaveBoundary");

return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
saveBoundaryApi.save();
}}
>
<SavableRHF onSubmit={onSubmit} />
{children}
</form>
</FormProvider>
);
}
Original file line number Diff line number Diff line change
@@ -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<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>>({
clearable,
endAdornment,
decimals = 0,
field,
...restProps
}: {
clearable?: boolean;
decimals?: number;
field: ControllerRenderProps<TFieldValues, TName>;
} & 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<InputBase
{...restProps}
value={formattedNumberValue}
onChange={handleChange}
onBlur={handleBlur}
name={field.name}
inputRef={field.ref}
disabled={field.disabled}
endAdornment={
(endAdornment || clearable) && (
<>
{clearable && (
<ClearInputAdornment
position="end"
hasClearableContent={typeof field.value === "number"}
onClick={() => field.onChange(null)}
/>
)}
{endAdornment}
</>
)
}
/>
);
}

type RHFNumberFieldProps<
TFieldValues extends FieldValues,
TName extends FieldPathByValue<TFieldValues, number | null>,
TTransformedValues,
> = UseControllerProps<TFieldValues, TName, TTransformedValues> &
Pick<FieldContainerProps, "label" | "variant" | "fullWidth" | "helperText" | "required"> & {
clearable?: boolean;
decimals?: number;
} & InputBaseProps;

export function RHFNumberField<TFieldValues extends FieldValues, TName extends FieldPathByValue<TFieldValues, number | null>, TTransformedValues>({
name,
rules,
shouldUnregister,
defaultValue,
control,
disabled,
exact,
clearable,
decimals,
label,
variant,
fullWidth,
helperText,
required,
...restProps
}: RHFNumberFieldProps<TFieldValues, TName, TTransformedValues>) {
const intl = useIntl();

return (
<Controller
name={name}
rules={required ? { required: true, ...rules } : rules}
shouldUnregister={shouldUnregister}
defaultValue={defaultValue}
control={control}
disabled={disabled}
exact={exact}
render={({ field, fieldState }) => {
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 (
<FieldContainer label={label} variant={variant} fullWidth={fullWidth} helperText={helperText} required={required} error={error}>
<RHFNumberFieldInner {...restProps} field={field} clearable={clearable} decimals={decimals} />
</FieldContainer>
);
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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<TFieldValues, string | null>,
TTransformedValues,
> = UseControllerProps<TFieldValues, TName, TTransformedValues> &
Pick<FieldContainerProps, "label" | "variant" | "fullWidth" | "helperText" | "required"> & { clearable?: boolean } & InputBaseProps;

export function RHFTextField<TFieldValues extends FieldValues, TName extends FieldPathByValue<TFieldValues, string | null>, TTransformedValues>({
name,
rules,
shouldUnregister,
defaultValue,
control,
disabled,
exact,
label,
variant,
fullWidth,
helperText,
required,
clearable,
endAdornment,
...restProps
}: RHFTextFieldProps<TFieldValues, TName, TTransformedValues>) {
const intl = useIntl();
return (
<Controller
name={name}
rules={required ? { required: true, ...rules } : rules}
shouldUnregister={shouldUnregister}
defaultValue={defaultValue}
control={control}
disabled={disabled}
exact={exact}
render={({ field, fieldState }) => {
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 (
<FieldContainer label={label} variant={variant} fullWidth={fullWidth} helperText={helperText} required={required} error={error}>
<InputBase
{...restProps}
name={field.name}
value={field.value ?? ""}
onChange={(event) => {
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 && (
<ClearInputAdornment
position="end"
hasClearableContent={field.value !== null && field.value !== ""}
onClick={() => field.onChange(null)}
/>
)}
{endAdornment}
</>
)
}
/>
</FieldContainer>
);
}}
/>
);
}
Loading