Skip to content

Commit 13a010c

Browse files
Required Label (wildcard) (#155)
This PR aims to: - unify the label required function - provide support for arrays and nested objects for the requiredFields
1 parent 893faf1 commit 13a010c

11 files changed

Lines changed: 139 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- support into `requiredFields` property of `Form` component, for nested objects and arrays.
13+
- `form` helper functions
14+
15+
### Fixed
16+
17+
- Required field label on `FormGroupLayoutLabel`, `ColorPicker`, `TelephoneNumberInput`, `TypeaheadTextField` (hence `StaticTypeaheadInput` and `AsyncTypeaheadInput`) in order to display \* also on nested and array fields.
18+
19+
1. `requiredFields` can still accept a `FieldPath<T>[]`
20+
2. In order to be complaint with `FieldPath` react-hook-form type (`object.${number}.property`) array properties provide a wildcard:
21+
22+
```tsx
23+
requiredFields = [
24+
`object`,
25+
`object.nestedObjects`,
26+
`objects.*.property`,
27+
`object.nestedObject.property`,
28+
`object.nestedObjects.*.property`,
29+
];
30+
```
31+
32+
is going to consider as required:
33+
34+
```tsx
35+
name="object"
36+
name="object.nestedObjects.0", "object.nestedObjects.1", etc.
37+
name="objects.0.property", name="objects.1.property", etc.
38+
name="object.nestedObject.property"
39+
name="object.nestedObjects.0.property", name="object.nestedObjects.1.property", etc.
40+
```
41+
1042
## [3.13.1] - 2025-11-13
1143

1244
### Fixed

cypress/cypress/component/FormGroupLayout/FormGroupLayout.cy.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,54 @@ it("testing existing * on nested object", () => {
7777
cy.get(`label[for=${fakePerson.city.address.street}]`).should("have.text", `${fakePerson.city.address.street} *`);
7878
});
7979

80+
it("testing existing * on arrays", () => {
81+
const fakePerson = {
82+
city: {
83+
address: {
84+
streetsAsString: [faker.random.alpha(10), faker.random.alpha(10), faker.random.alpha(10)],
85+
streetsAsObject: [
86+
{ name: faker.random.alpha(10), date: new Date() },
87+
{ name: faker.random.alpha(10), date: new Date() },
88+
{ name: faker.random.alpha(10), date: new Date() },
89+
],
90+
},
91+
},
92+
};
93+
94+
const schema = yup.object().shape({
95+
city: yup.object().shape({
96+
address: yup.object().shape({
97+
streetsAsString: yup.array().of(yup.string().required()),
98+
streetsAsObject: yup.array().of(
99+
yup.object().shape({
100+
name: yup.string().required(),
101+
date: yup.date(),
102+
}),
103+
),
104+
}),
105+
}),
106+
});
107+
108+
mount(
109+
<Form<typeof fakePerson>
110+
onSubmit={() => {
111+
// Nothing to do
112+
}}
113+
defaultValues={fakePerson}
114+
resolver={yupResolver(schema)}
115+
requiredFields={["city.address.streetsAsString", "city.address.streetsAsObject.*.name", "city.address.streetsAsObject.*.date"]}
116+
>
117+
<Input<typeof fakePerson> name="city.address.streetsAsObject.0.name" label="Street as object" />
118+
<Input<typeof fakePerson> name="city.address.streetsAsString.0" label="Street as string" />
119+
<DatePickerInput<typeof fakePerson> name="city.address.streetsAsObject.0.date" label="Date as object" />
120+
</Form>,
121+
);
122+
123+
cy.get(`label[for="city.address.streetsAsObject.0.name"`).should("have.text", "Street as object *");
124+
cy.get(`label[for="city.address.streetsAsString.0"`).should("have.text", "Street as string *");
125+
cy.get(`label[for="city.address.streetsAsObject.0.date"`).should("have.text", "Date as object *");
126+
});
127+
80128
const ValidationForm = (props: { hideValidationMessage?: boolean; hideValidationMessages?: boolean }) => {
81129
const { hideValidationMessage, hideValidationMessages } = props;
82130
const schema = yup.object().shape({

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ export * from "./lib/TelephoneNumberInput";
66
export * from "./lib/AsyncTypeaheadInput";
77
export * from "./lib/types/Typeahead";
88
export * from "./lib/types/LabelValueOption";
9+
export * from "./lib/types/Form";
910
export * from "./lib/DatePickerInput";
1011
export * from "./lib/ColorPickerInput";
1112
export * from "./lib/RatingInput";
1213
export * from "./lib/helpers/dateUtils";
14+
export * from "./lib/helpers/form";
1315
export * from "./lib/helpers/mui";
1416
export * from "./lib/helpers/typeahead";
1517
export * from "./lib/hooks/useDebounceHook";

src/lib/Form.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { ReactNode } from "react";
2-
import { DeepPartial, FieldPath, FieldValues, Resolver, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
2+
import { DeepPartial, FieldValues, Resolver, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
33
import { jsonIsoDateReviver } from "./helpers/dateUtils";
44
import { FormContext, FormContextProps } from "./context/FormContext";
55
import { AutoSubmitConfig, useAutoSubmit } from "./hooks/useAutoSubmit";
6+
import { RequiredFieldPath } from "./types/Form";
67

78
export interface FormMethods<T extends FieldValues> extends UseFormReturn<T, unknown>, FormContextProps<T> {}
89

@@ -23,9 +24,9 @@ interface FormProps<T extends FieldValues> {
2324
defaultValues?: DeepPartial<T>;
2425

2526
/**
26-
* passed fieldnames will be marked with "*"
27+
* passed field names will be marked with "*"
2728
*/
28-
requiredFields?: FieldPath<T>[];
29+
requiredFields?: RequiredFieldPath<T>[];
2930

3031
/**
3132
* disable all fields inside the form making it readonly

src/lib/FormGroupLayoutLabel.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CSSProperties, ReactNode } from "react";
22
import { useFormContext } from "./context/FormContext";
33
import { FieldPath, FieldValues } from "react-hook-form";
44
import { Label, UncontrolledTooltip } from "reactstrap";
5+
import { getRequiredLabel } from "./helpers/form";
56

67
interface FormGroupLayoutLabelProps<T extends FieldValues> {
78
label: ReactNode;
@@ -24,9 +25,7 @@ const FormGroupLayoutLabel = <T extends FieldValues>(props: FormGroupLayoutLabel
2425
return null;
2526
}
2627

27-
const fieldIsRequired = typeof label === "string" && requiredFields.includes(fieldName);
28-
const finalLabel = fieldIsRequired ? `${String(label)} *` : label;
29-
28+
const finalLabel = getRequiredLabel<T>(label, fieldName, requiredFields);
3029
const switchLayout = layout === "switch";
3130
const checkboxLayout = layout === "checkbox";
3231

src/lib/components/ColorPicker/ColorPicker.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useMemo } from "react";
1111
import { TinyColor } from "@ctrl/tinycolor";
1212
import Popover from "@mui/material/Popover";
1313
import Colorful from "@uiw/react-color-colorful"; // must be imported as default, otherwise it will provide a runtime error in nextjs
14+
import { getRequiredLabel } from "../../helpers/form";
1415

1516
const getColorByFormat = <T extends FieldValues>(color: TinyColor, format: ColorPickerInputProps<T>["format"]) => {
1617
switch (format) {
@@ -49,7 +50,7 @@ const ColorPicker = <T extends FieldValues>(props: ColorPickerInputProps<T>) =>
4950
requiredFields,
5051
formState: { errors },
5152
hideValidationMessages,
52-
} = useFormContext();
53+
} = useFormContext<T>();
5354
const focusHandler = useMarkOnFocusHandler(markAllOnFocus);
5455
const {
5556
field: { ref, ...field },
@@ -69,8 +70,7 @@ const ColorPicker = <T extends FieldValues>(props: ColorPickerInputProps<T>) =>
6970
const hideErrorMessage = useMemo(() => hideValidationMessages || hideValidationMessage, [hideValidationMessages, hideValidationMessage]);
7071
const hasError = useMemo(() => !!fieldError, [fieldError]);
7172
const errorMessage = useMemo(() => String(fieldError?.message), [fieldError]);
72-
const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
73-
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
73+
const finalLabel = useMemo(() => getRequiredLabel<T>(label, name, requiredFields), [label, name, requiredFields]);
7474

7575
return (
7676
<PopupState variant="popover" popupId={`popover-${name}`}>

src/lib/components/TelephoneNumberInput/TelephoneNumberInputInternal.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useFormContext } from "../../context/FormContext";
77
import { useEffect, useMemo, useRef, useState } from "react";
88
import Popover from "@mui/material/Popover";
99
import { TelephoneNumberInputProps } from "../../TelephoneNumberInput";
10-
10+
import { getRequiredLabel } from "../../helpers/form";
1111
import { Country, extractCountryCodeFromTelephoneNumber, extractNationalNumberFromTelephoneNumber } from "../../helpers/telephoneNumber";
1212
import { TelephoneNumberInputAdornment } from "./TelephoneNumberInputAdornment";
1313
import { isNullOrWhitespace } from "@neolution-ch/javascript-utils";
@@ -58,8 +58,7 @@ const TelephoneNumberInputInternal = <T extends FieldValues>(props: TelephoneNum
5858
const hideErrorMessage = useMemo(() => hideValidationMessages || hideValidationMessage, [hideValidationMessages, hideValidationMessage]);
5959
const hasError = useMemo(() => !!fieldError, [fieldError]);
6060
const errorMessage = useMemo(() => String(fieldError?.message), [fieldError]);
61-
const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
62-
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
61+
const finalLabel = useMemo(() => getRequiredLabel<T>(label, name, requiredFields), [label, name, requiredFields]);
6362

6463
// we need to control the country in the case the value inside the form is undefined
6564
const [country, setCountry] = useState<Country>(extractCountryCodeFromTelephoneNumber(field.value as string | undefined, defaultCountry));

src/lib/components/Typeahead/TypeaheadTextField.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CommonTypeaheadProps } from "../../types/Typeahead";
99
import { FieldError, FieldValues, get } from "react-hook-form";
1010
import { MergedAddonProps } from "../../types/CommonInputProps";
1111
import { useFormContext } from "../../context/FormContext";
12+
import { getRequiredLabel } from "../../helpers/form";
1213

1314
interface TypeaheadTextFieldProps<T extends FieldValues, TRenderAddon>
1415
extends Omit<CommonTypeaheadProps<T>, "id" | "disabled" | "onChange">,
@@ -47,15 +48,13 @@ const TypeaheadTextField = <T extends FieldValues, TRenderAddon = unknown>(props
4748
formState: { errors },
4849
requiredFields,
4950
hideValidationMessages,
50-
} = useFormContext();
51+
} = useFormContext<T>();
5152

5253
const fieldError = get(errors, name) as FieldError | undefined;
5354
const hasError = useMemo(() => !!fieldError, [fieldError]);
5455
const errorMessage = useMemo(() => String(fieldError?.message), [fieldError]);
5556
const hideErrorMessage = useMemo(() => hideValidationMessages || hideValidationMessage, [hideValidationMessages, hideValidationMessage]);
56-
57-
const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
58-
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
57+
const finalLabel = useMemo(() => getRequiredLabel<T>(label, name, requiredFields), [label, name, requiredFields]);
5958

6059
const startAdornment = useMemo(
6160
() =>

src/lib/context/FormContext.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createContext, useContext } from "react";
2-
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
2+
import { FieldValues, UseFormReturn } from "react-hook-form";
3+
import { RequiredFieldPath } from "../types/Form";
34

45
export interface FormContextProps<T extends FieldValues> extends UseFormReturn<T, unknown> {
5-
requiredFields: FieldPath<T>[];
6+
requiredFields: RequiredFieldPath<T>[];
67
disabled: boolean;
78
hideValidationMessages: boolean;
89
disableAriaAutocomplete: boolean;

src/lib/helpers/form.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ReactNode } from "react";
2+
import { FieldPath, FieldValues } from "react-hook-form";
3+
import { RequiredFieldPath } from "../types/Form";
4+
5+
const matchesWildcard = (rule: string, pathParts: string[]) => {
6+
const ruleParts = rule.split(".");
7+
// remove trailing index placeholder in case of primitive arrays
8+
if (pathParts.length - ruleParts.length === 1 && pathParts.at(-1) === "*") {
9+
pathParts = pathParts.slice(0, -1);
10+
}
11+
return ruleParts.length === pathParts.length && pathParts.every((p, i) => ruleParts[i] === p);
12+
};
13+
14+
const isRequiredField = <T extends FieldValues>(fieldPath: string, requiredFields?: RequiredFieldPath<T>[]) => {
15+
const normalizedPathParts = fieldPath.split(".").map((x) => (Number.isNaN(Number(x)) ? x : "*"));
16+
return !!requiredFields?.some((rule) => rule === fieldPath || matchesWildcard(rule, normalizedPathParts));
17+
};
18+
19+
const getRequiredLabel = <T extends FieldValues>(
20+
label: ReactNode,
21+
fieldPath: FieldPath<T>,
22+
requiredFields?: RequiredFieldPath<T>[],
23+
): ReactNode => (typeof label === "string" ? (isRequiredField(fieldPath, requiredFields) ? `${String(label)} *` : label) : label);
24+
25+
export { getRequiredLabel, isRequiredField };

0 commit comments

Comments
 (0)