Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8a72d3a
export typeahead utils
Michele-Masciave Apr 11, 2025
f8dc2b0
integrate placeholder fix and changelog
Michele-Masciave Apr 16, 2025
a857384
adjust typescript
Michele-Masciave Apr 16, 2025
e1267ef
prepare-pr
Michele-Masciave Apr 28, 2025
12211f1
fixed
Michele-Masciave Apr 30, 2025
92e5c21
Merge branch 'main' of https://github.com/Michele-Masciave/react-hook…
Michele-Masciave Apr 30, 2025
a9347da
Merge branch 'neolution-ch:main' into main
Michele-Masciave May 7, 2025
2987413
Merge branch 'neolution-ch:main' into main
Michele-Masciave Jun 3, 2025
acad69e
Merge branch 'main' of https://github.com/Michele-Masciave/react-hook…
Michele-Masciave Jun 13, 2025
de96e5c
Merge branch 'neolution-ch:main' into main
Michele-Masciave Jun 18, 2025
fbfcc8e
Merge branch 'neolution-ch:main' into main
Michele-Masciave Jun 25, 2025
b5c2533
Merge branch 'main' of https://github.com/Michele-Masciave/react-hook…
Michele-Masciave Jul 7, 2025
7843d38
Merge branch 'neolution-ch:main' into main
Michele-Masciave Jul 16, 2025
6b81771
Merge branch 'neolution-ch:main' into main
Michele-Masciave Sep 26, 2025
743acf1
Merge branch 'neolution-ch:main' into main
Michele-Masciave Sep 29, 2025
022db3c
Merge branch 'neolution-ch:main' into main
Michele-Masciave Oct 1, 2025
bbd78a7
Merge branch 'neolution-ch:main' into main
Michele-Masciave Oct 21, 2025
5d28ea3
Merge branch 'neolution-ch:main' into main
Michele-Masciave Dec 3, 2025
754638e
solution
Michele-Masciave Dec 3, 2025
a7d05fe
fix comment
Michele-Masciave Dec 3, 2025
46bb41f
expo helper form
Michele-Masciave Dec 4, 2025
2aa21fc
change change log
Michele-Masciave Dec 4, 2025
0c4d765
x
Michele-Masciave Dec 4, 2025
e6b3a71
solution
Michele-Masciave Dec 4, 2025
e0f4dbe
change changelog
Michele-Masciave Dec 4, 2025
0c24265
expone also FieldPathArray
Michele-Masciave Dec 4, 2025
8b9f562
x
Michele-Masciave Dec 4, 2025
7f6ef0c
x
Michele-Masciave Dec 4, 2025
b457209
improve search of required fields
Michele-Masciave Dec 10, 2025
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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- support into `requiredFields` property of `Form` component, for nested objects and arrays.
- `form` helper functions

### Fixed

- Required field label on `FormGroupLayoutLabel`, `ColorPicker`, `TelephoneNumberInput`, `TypeaheadTextField` (hence `StaticTypeaheadInput` and `AsyncTypeaheadInput`) in order to display \* also on nested and array fields.

1. `requiredFields` can still accept a `FieldPath<T>[]`
2. In order to be complaint with `FieldPath` react-hook-form type (`object.${number}.property`) array properties provide a wildcard:

```tsx
requiredFields = [
`object`,
`object.nestedObjects`,
`objects.*.property`,
`object.nestedObject.property`,
`object.nestedObjects.*.property`,
];
```

is going to consider as required:

```tsx
name="object"
name="object.nestedObjects.0", "object.nestedObjects.1", etc.
name="objects.0.property", name="objects.1.property", etc.
name="object.nestedObject.property"
name="object.nestedObjects.0.property", name="object.nestedObjects.1.property", etc.
```

## [3.13.1] - 2025-11-13

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,54 @@ it("testing existing * on nested object", () => {
cy.get(`label[for=${fakePerson.city.address.street}]`).should("have.text", `${fakePerson.city.address.street} *`);
});

it("testing existing * on arrays", () => {
const fakePerson = {
city: {
address: {
streetsAsString: [faker.random.alpha(10), faker.random.alpha(10), faker.random.alpha(10)],
streetsAsObject: [
{ name: faker.random.alpha(10), date: new Date() },
{ name: faker.random.alpha(10), date: new Date() },
{ name: faker.random.alpha(10), date: new Date() },
],
},
},
};

const schema = yup.object().shape({
city: yup.object().shape({
address: yup.object().shape({
streetsAsString: yup.array().of(yup.string().required()),
streetsAsObject: yup.array().of(
yup.object().shape({
name: yup.string().required(),
date: yup.date(),
}),
),
}),
}),
});

mount(
<Form<typeof fakePerson>
onSubmit={() => {
// Nothing to do
}}
defaultValues={fakePerson}
resolver={yupResolver(schema)}
requiredFields={["city.address.streetsAsString", "city.address.streetsAsObject.*.name", "city.address.streetsAsObject.*.date"]}
>
<Input<typeof fakePerson> name="city.address.streetsAsObject.0.name" label="Street as object" />
<Input<typeof fakePerson> name="city.address.streetsAsString.0" label="Street as string" />
<DatePickerInput<typeof fakePerson> name="city.address.streetsAsObject.0.date" label="Date as object" />
</Form>,
);

cy.get(`label[for="city.address.streetsAsObject.0.name"`).should("have.text", "Street as object *");
cy.get(`label[for="city.address.streetsAsString.0"`).should("have.text", "Street as string *");
cy.get(`label[for="city.address.streetsAsObject.0.date"`).should("have.text", "Date as object *");
});

const ValidationForm = (props: { hideValidationMessage?: boolean; hideValidationMessages?: boolean }) => {
const { hideValidationMessage, hideValidationMessages } = props;
const schema = yup.object().shape({
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ export * from "./lib/TelephoneNumberInput";
export * from "./lib/AsyncTypeaheadInput";
export * from "./lib/types/Typeahead";
export * from "./lib/types/LabelValueOption";
export * from "./lib/types/Form";
export * from "./lib/DatePickerInput";
export * from "./lib/ColorPickerInput";
export * from "./lib/RatingInput";
export * from "./lib/helpers/dateUtils";
export * from "./lib/helpers/form";
export * from "./lib/helpers/mui";
export * from "./lib/helpers/typeahead";
export * from "./lib/hooks/useDebounceHook";
Expand Down
7 changes: 4 additions & 3 deletions src/lib/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ReactNode } from "react";
import { DeepPartial, FieldPath, FieldValues, Resolver, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
import { DeepPartial, FieldValues, Resolver, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
import { jsonIsoDateReviver } from "./helpers/dateUtils";
import { FormContext, FormContextProps } from "./context/FormContext";
import { AutoSubmitConfig, useAutoSubmit } from "./hooks/useAutoSubmit";
import { RequiredFieldPath } from "./types/Form";

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

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

/**
* passed fieldnames will be marked with "*"
* passed field names will be marked with "*"
*/
requiredFields?: FieldPath<T>[];
requiredFields?: RequiredFieldPath<T>[];

/**
* disable all fields inside the form making it readonly
Expand Down
5 changes: 2 additions & 3 deletions src/lib/FormGroupLayoutLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CSSProperties, ReactNode } from "react";
import { useFormContext } from "./context/FormContext";
import { FieldPath, FieldValues } from "react-hook-form";
import { Label, UncontrolledTooltip } from "reactstrap";
import { getRequiredLabel } from "./helpers/form";

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

const fieldIsRequired = typeof label === "string" && requiredFields.includes(fieldName);
const finalLabel = fieldIsRequired ? `${String(label)} *` : label;

const finalLabel = getRequiredLabel<T>(label, fieldName, requiredFields);
const switchLayout = layout === "switch";
const checkboxLayout = layout === "checkbox";

Expand Down
6 changes: 3 additions & 3 deletions src/lib/components/ColorPicker/ColorPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useMemo } from "react";
import { TinyColor } from "@ctrl/tinycolor";
import Popover from "@mui/material/Popover";
import Colorful from "@uiw/react-color-colorful"; // must be imported as default, otherwise it will provide a runtime error in nextjs
import { getRequiredLabel } from "../../helpers/form";

const getColorByFormat = <T extends FieldValues>(color: TinyColor, format: ColorPickerInputProps<T>["format"]) => {
switch (format) {
Expand Down Expand Up @@ -49,7 +50,7 @@ const ColorPicker = <T extends FieldValues>(props: ColorPickerInputProps<T>) =>
requiredFields,
formState: { errors },
hideValidationMessages,
} = useFormContext();
} = useFormContext<T>();
const focusHandler = useMarkOnFocusHandler(markAllOnFocus);
const {
field: { ref, ...field },
Expand All @@ -69,8 +70,7 @@ const ColorPicker = <T extends FieldValues>(props: ColorPickerInputProps<T>) =>
const hideErrorMessage = useMemo(() => hideValidationMessages || hideValidationMessage, [hideValidationMessages, hideValidationMessage]);
const hasError = useMemo(() => !!fieldError, [fieldError]);
const errorMessage = useMemo(() => String(fieldError?.message), [fieldError]);
const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
const finalLabel = useMemo(() => getRequiredLabel<T>(label, name, requiredFields), [label, name, requiredFields]);

return (
<PopupState variant="popover" popupId={`popover-${name}`}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useFormContext } from "../../context/FormContext";
import { useEffect, useMemo, useRef, useState } from "react";
import Popover from "@mui/material/Popover";
import { TelephoneNumberInputProps } from "../../TelephoneNumberInput";

import { getRequiredLabel } from "../../helpers/form";
import { Country, extractCountryCodeFromTelephoneNumber, extractNationalNumberFromTelephoneNumber } from "../../helpers/telephoneNumber";
import { TelephoneNumberInputAdornment } from "./TelephoneNumberInputAdornment";
import { isNullOrWhitespace } from "@neolution-ch/javascript-utils";
Expand Down Expand Up @@ -58,8 +58,7 @@ const TelephoneNumberInputInternal = <T extends FieldValues>(props: TelephoneNum
const hideErrorMessage = useMemo(() => hideValidationMessages || hideValidationMessage, [hideValidationMessages, hideValidationMessage]);
const hasError = useMemo(() => !!fieldError, [fieldError]);
const errorMessage = useMemo(() => String(fieldError?.message), [fieldError]);
const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
const finalLabel = useMemo(() => getRequiredLabel<T>(label, name, requiredFields), [label, name, requiredFields]);

// we need to control the country in the case the value inside the form is undefined
const [country, setCountry] = useState<Country>(extractCountryCodeFromTelephoneNumber(field.value as string | undefined, defaultCountry));
Expand Down
7 changes: 3 additions & 4 deletions src/lib/components/Typeahead/TypeaheadTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CommonTypeaheadProps } from "../../types/Typeahead";
import { FieldError, FieldValues, get } from "react-hook-form";
import { MergedAddonProps } from "../../types/CommonInputProps";
import { useFormContext } from "../../context/FormContext";
import { getRequiredLabel } from "../../helpers/form";

interface TypeaheadTextFieldProps<T extends FieldValues, TRenderAddon>
extends Omit<CommonTypeaheadProps<T>, "id" | "disabled" | "onChange">,
Expand Down Expand Up @@ -47,15 +48,13 @@ const TypeaheadTextField = <T extends FieldValues, TRenderAddon = unknown>(props
formState: { errors },
requiredFields,
hideValidationMessages,
} = useFormContext();
} = useFormContext<T>();

const fieldError = get(errors, name) as FieldError | undefined;
const hasError = useMemo(() => !!fieldError, [fieldError]);
const errorMessage = useMemo(() => String(fieldError?.message), [fieldError]);
const hideErrorMessage = useMemo(() => hideValidationMessages || hideValidationMessage, [hideValidationMessages, hideValidationMessage]);

const fieldIsRequired = label && typeof label === "string" && requiredFields.includes(name);
const finalLabel = useMemo(() => (fieldIsRequired ? `${String(label)} *` : label), [fieldIsRequired, label]);
const finalLabel = useMemo(() => getRequiredLabel<T>(label, name, requiredFields), [label, name, requiredFields]);

const startAdornment = useMemo(
() =>
Expand Down
5 changes: 3 additions & 2 deletions src/lib/context/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createContext, useContext } from "react";
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
import { FieldValues, UseFormReturn } from "react-hook-form";
import { RequiredFieldPath } from "../types/Form";

export interface FormContextProps<T extends FieldValues> extends UseFormReturn<T, unknown> {
requiredFields: FieldPath<T>[];
requiredFields: RequiredFieldPath<T>[];
disabled: boolean;
hideValidationMessages: boolean;
disableAriaAutocomplete: boolean;
Expand Down
25 changes: 25 additions & 0 deletions src/lib/helpers/form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ReactNode } from "react";
import { FieldPath, FieldValues } from "react-hook-form";
import { RequiredFieldPath } from "../types/Form";

const matchesWildcard = (rule: string, pathParts: string[]) => {
const ruleParts = rule.split(".");
// remove trailing index placeholder in case of primitive arrays
if (pathParts.length - ruleParts.length === 1 && pathParts.at(-1) === "*") {
pathParts = pathParts.slice(0, -1);
}
return ruleParts.length === pathParts.length && pathParts.every((p, i) => ruleParts[i] === p);
};

const isRequiredField = <T extends FieldValues>(fieldPath: string, requiredFields?: RequiredFieldPath<T>[]) => {
const normalizedPathParts = fieldPath.split(".").map((x) => (Number.isNaN(Number(x)) ? x : "*"));
return !!requiredFields?.some((rule) => rule === fieldPath || matchesWildcard(rule, normalizedPathParts));
};

const getRequiredLabel = <T extends FieldValues>(
label: ReactNode,
fieldPath: FieldPath<T>,
requiredFields?: RequiredFieldPath<T>[],
): ReactNode => (typeof label === "string" ? (isRequiredField(fieldPath, requiredFields) ? `${String(label)} *` : label) : label);

export { getRequiredLabel, isRequiredField };
15 changes: 15 additions & 0 deletions src/lib/types/Form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FieldPath, FieldValues, BrowserNativeObject } from "react-hook-form";

type FieldPathArray<T> = T extends Blob | BrowserNativeObject | Promise<unknown>
? never
: T extends Array<infer U>
? `*.${FieldPathArray<U>}`
: T extends object
? {
[K in keyof T & (string | number)]: `${K}` | `${K}.${FieldPathArray<T[K]>}`;
}[keyof T & (string | number)]
: never;

type RequiredFieldPath<T extends FieldValues> = FieldPath<T> | FieldPathArray<T>;

export { RequiredFieldPath, FieldPathArray };
Loading