All files / lib forms.ts

100% Statements 13/13
91.66% Branches 11/12
100% Functions 2/2
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46                          1x         4x 1x   1x       3x 4x   3x   3x 4x       3x   1x       3x 1x      
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form';
 
import { type ApiError, toUserMessage, ValidationError } from '@/lib/api/errors';
 
/**
 * Shows an API failure on a React Hook Form form.
 *
 * A `ValidationError` with field messages puts each message under its input. Pass the form's
 * values (`form.getValues()`) so a message for a name the form has no input for (`account`, a
 * server-only rule) goes to `errors.root.server` instead of nowhere: React Hook Form keeps an error
 * under any name, but no input shows it. Anything else (or a validation error without field
 * details) goes to `errors.root.server`, which the form renders above the submit button.
 */
export const setFormErrors = <T extends FieldValues>(
  setError: UseFormSetError<T>,
  error: ApiError,
  values?: T,
) => {
  if (!(error instanceof ValidationError) || Object.keys(error.fields).length === 0) {
    setError('root.server', { type: 'server', message: toUserMessage(error) });
 
    return;
  }
 
  // A nested name (`address.city`) belongs to the form when its first segment does.
  const hasInput = (field: string) =>
    values === undefined || Object.hasOwn(values, field.split('.')[0] ?? field);
 
  const unshown: string[] = [];
 
  for (const [field, message] of Object.entries(error.fields)) {
    if (hasInput(field)) {
      // Field names come from the server; they match the form when the API mirrors its schema.
      // SAFETY: `field` is one of the form's own names (hasInput checked it against the values),
      // or the caller passed no values and asks for every server name to be set as is.
      setError(field as Path<T>, { type: 'server', message });
    } else {
      unshown.push(message);
    }
  }
 
  if (unshown.length > 0) {
    setError('root.server', { type: 'server', message: unshown.join(' ') });
  }
};