diff --git a/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx b/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx index 811080d089..17fa19295b 100644 --- a/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx +++ b/packages/client/src/components/common/business-trip-report/parts/attendee-stay-input.tsx @@ -2,9 +2,7 @@ import { useEffect, useState, type ReactElement } from 'react'; import { ListPlus, Trash2 } from 'lucide-react'; import { Controller, - useFieldArray, type ArrayPath, - type FieldArray, type FieldValues, type Path, type UseFormReturn, @@ -12,6 +10,10 @@ import { import { useQuery } from 'urql'; import { NumberInput, Select } from '@mantine/core'; import { AttendeesByBusinessTripDocument } from '../../../../gql/graphql.js'; +import { + useControlledFieldArray, + type FieldArrayItem, +} from '../../../../hooks/use-controlled-field-array.js'; import { Button } from '../../../ui/button.js'; type Props = { @@ -32,22 +34,14 @@ export function AttendeesStayInput({ fetchingAttendees = false, businessTripId, }: Props): ReactElement { - const { control, watch, trigger } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: attendeesStayPath, - }); + const { control, trigger } = formManager; + const { controlledFields, append, remove } = useControlledFieldArray( + formManager, + attendeesStayPath, + ); const [fetching, setFetching] = useState(fetchingAttendees); const [attendees, setAttendees] = useState(attendeesData ?? []); - const watchFieldArray = watch(attendeesStayPath as Path); - const controlledFields = fields.map((field, index) => { - return { - ...field, - ...watchFieldArray[index], - }; - }); - useEffect(() => { if (attendeesData) { setAttendees(attendeesData); @@ -169,7 +163,7 @@ export function AttendeesStayInput({ size="icon" className="size-7.5" onClick={(): void => { - append({} as FieldArray>); + append({} as FieldArrayItem); trigger(attendeesStayPath as Path); }} > diff --git a/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx b/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx index 50328d92d0..143cfa87c9 100644 --- a/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx +++ b/packages/client/src/components/common/business-trip-report/parts/flight-path-input.tsx @@ -1,13 +1,10 @@ import type { ReactElement } from 'react'; import { ListPlus, Trash2 } from 'lucide-react'; +import { type ArrayPath, type FieldValues, type Path, type UseFormReturn } from 'react-hook-form'; import { - useFieldArray, - type ArrayPath, - type FieldArray, - type FieldValues, - type Path, - type UseFormReturn, -} from 'react-hook-form'; + useControlledFieldArray, + type FieldArrayItem, +} from '../../../../hooks/use-controlled-field-array.js'; import { Button } from '../../../ui/button.js'; import { FormControl, FormField, FormItem, FormMessage } from '../../../ui/form'; import { Input } from '../../../ui/input'; @@ -24,19 +21,11 @@ export function FlightPathInput({ flightPathPath, flightPathData, }: Props): ReactElement { - const { control, watch, trigger } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: flightPathPath as ArrayPath, - }); - - const watchFieldArray = watch(flightPathPath); - const controlledFields = fields.map((field, index) => { - return { - ...field, - ...watchFieldArray[index], - }; - }); + const { control, trigger } = formManager; + const { controlledFields, append, remove } = useControlledFieldArray( + formManager, + flightPathPath as ArrayPath, + ); if (!flightPathData) { return
Cannot edit flight path stay, lacking some mandatory information!
; @@ -87,7 +76,7 @@ export function FlightPathInput({ size="icon" className="size-7.5" onClick={(): void => { - append('' as FieldArray>); + append('' as FieldArrayItem); trigger(flightPathPath); }} > diff --git a/packages/client/src/components/common/inputs/charge-spread-input.tsx b/packages/client/src/components/common/inputs/charge-spread-input.tsx index fbc4a5bdcc..391756ca4b 100644 --- a/packages/client/src/components/common/inputs/charge-spread-input.tsx +++ b/packages/client/src/components/common/inputs/charge-spread-input.tsx @@ -3,15 +3,17 @@ import { format } from 'date-fns'; import { ListPlus, Trash2 } from 'lucide-react'; import { Controller, - useFieldArray, type ArrayPath, - type FieldArray, type FieldValues, type Path, type UseFormReturn, } from 'react-hook-form'; import { NumberInput } from '@mantine/core'; import { YearPickerInput } from '@mantine/dates'; +import { + useControlledFieldArray, + type FieldArrayItem, +} from '../../../hooks/use-controlled-field-array.js'; import { Button } from '../../ui/button.js'; type Props = { @@ -23,19 +25,11 @@ export function ChargeSpreadInput({ formManager, chargeSpreadPath, }: Props): ReactElement { - const { control, watch, trigger } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: chargeSpreadPath, - }); - - const watchFieldArray = watch(chargeSpreadPath as Path); - const controlledFields = fields.map((field, index) => { - return { - ...field, - ...watchFieldArray[index], - }; - }); + const { control, trigger } = formManager; + const { controlledFields, append, remove, watchFieldArray } = useControlledFieldArray( + formManager, + chargeSpreadPath, + ); return (
@@ -50,14 +44,13 @@ export function ChargeSpreadInput({ rules={{ required: 'Required', validate: (value: string): boolean | string => { - const years = watchFieldArray.map((record: { year: string }) => record.year); - if (years.filter((year: string) => year === value).length > 1) { + const years = watchFieldArray.map(record => (record as { year?: string }).year); + if (years.filter(year => year === value).length > 1) { return 'Years must be unique'; } return true; }, }} - // defaultValue={`${new Date().getFullYear()}-01-01` as PathValue>} render={({ field: { value, ...field }, fieldState }): ReactElement => { return ( ({ size="icon" className="size-7.5" onClick={(): void => { - append({ year: `${new Date().getFullYear()}-01-01` } as FieldArray>); + append({ year: `${new Date().getFullYear()}-01-01` } as FieldArrayItem); trigger(chargeSpreadPath as Path); }} > diff --git a/packages/client/src/components/common/inputs/string-array-input.tsx b/packages/client/src/components/common/inputs/string-array-input.tsx index 53550dbef1..f8d2f7443d 100644 --- a/packages/client/src/components/common/inputs/string-array-input.tsx +++ b/packages/client/src/components/common/inputs/string-array-input.tsx @@ -1,13 +1,10 @@ import type { ReactElement } from 'react'; import { ListPlus, Trash2 } from 'lucide-react'; +import { type ArrayPath, type FieldValues, type Path, type UseFormReturn } from 'react-hook-form'; import { - useFieldArray, - type ArrayPath, - type FieldArray, - type FieldValues, - type Path, - type UseFormReturn, -} from 'react-hook-form'; + useControlledFieldArray, + type FieldArrayItem, +} from '../../../hooks/use-controlled-field-array.js'; import { Button } from '../../ui/button.js'; import { FormControl, FormField, FormItem, FormMessage } from '../../ui/form.js'; import { Input } from '../../ui/input.js'; @@ -23,19 +20,11 @@ export function StringArrayInput({ formManager, arrayPath, }: Props): ReactElement { - const { control, watch } = formManager; - const { fields, append, remove } = useFieldArray({ - control, - name: arrayPath as ArrayPath, - }); - - const watchStringsArray = watch(arrayPath); - const controlledFields = fields.map((field, index) => { - return { - id: field.id, - ...watchStringsArray[index], - }; - }); + const { control } = formManager; + const { controlledFields, append, remove } = useControlledFieldArray( + formManager, + arrayPath as ArrayPath, + ); return (
@@ -82,7 +71,7 @@ export function StringArrayInput({ variant="ghost" size="icon" className="size-7.5" - onClick={() => append(undefined as FieldArray)} + onClick={() => append(undefined as FieldArrayItem)} > diff --git a/packages/client/src/hooks/use-controlled-field-array.ts b/packages/client/src/hooks/use-controlled-field-array.ts new file mode 100644 index 0000000000..3462f6198f --- /dev/null +++ b/packages/client/src/hooks/use-controlled-field-array.ts @@ -0,0 +1,58 @@ +import { + useFieldArray, + type ArrayPath, + type FieldArrayPathValue, + type FieldValues, + type Path, + type UseFieldArrayReturn, + type UseFormReturn, +} from 'react-hook-form'; + +/** + * Item type of the field array at the given path. Reconstruction of react-hook-form's + * `FieldArray` type, which is shadowed since v7.82 by the new `FieldArray` component + * and can no longer be imported from the package root. + */ +export type FieldArrayItem< + TFieldValues extends FieldValues, + TPath extends ArrayPath = ArrayPath, +> = + FieldArrayPathValue extends ReadonlyArray | null | undefined + ? TItem + : never; + +type UseControlledFieldArrayReturn< + TFieldValues extends FieldValues, + TPath extends ArrayPath, +> = Pick, 'append' | 'remove'> & { + controlledFields: UseFieldArrayReturn['fields']; + watchFieldArray: FieldArrayItem[]; +}; + +/** + * Wraps `useFieldArray` and merges the registered fields with the watched values, + * so consumers render always-current values while keeping stable field ids as keys. + */ +export function useControlledFieldArray< + TFieldValues extends FieldValues, + TPath extends ArrayPath = ArrayPath, +>( + formManager: UseFormReturn, + arrayPath: TPath, +): UseControlledFieldArrayReturn { + const { control, watch } = formManager; + const { fields, append, remove } = useFieldArray({ control, name: arrayPath }); + + const watchFieldArray = (watch(arrayPath as unknown as Path) ?? + []) as FieldArrayItem[]; + + const controlledFields = fields.map((field, index) => { + const watchedValue = watchFieldArray[index]; + return { + ...field, + ...(typeof watchedValue === 'object' && watchedValue !== null ? watchedValue : {}), + }; + }); + + return { controlledFields, append, remove, watchFieldArray }; +}