diff --git a/apps/app/src/components/forms/FormField.vue b/apps/app/src/components/forms/FormField.vue new file mode 100644 index 00000000..04a8057c --- /dev/null +++ b/apps/app/src/components/forms/FormField.vue @@ -0,0 +1,93 @@ + + + + + + + {{ label }} + * + + + + + + {{ resolvedApiError ?? $field.error?.message ?? $field.errors?.[0]?.message }} + + + {{ hint }} + + + diff --git a/apps/app/src/composables/useFormError.ts b/apps/app/src/composables/useFormError.ts new file mode 100644 index 00000000..05a225b7 --- /dev/null +++ b/apps/app/src/composables/useFormError.ts @@ -0,0 +1,44 @@ +// useFormError — merges Laravel 422 validation responses into the +// surrounding Form's per-field error display. +// +// Pattern: a parent component calls useFormError() once. The composable +// provides an apiErrors map via Vue inject. Each FormField inside the +// component reads its own field name from that map and falls through to +// its $field.error (the Zod resolver result) when no API error is set. +// apiError precedence > Zod error per RFC Appendix A. +// +// Crewli backend 422 shape: { message, errors: { field: string[] } }. +// We surface the first message per field. + +import { type Ref, provide, ref } from 'vue' + +export const FORM_API_ERRORS_KEY = Symbol('crewli-form-api-errors') + +export type FormApiErrorsMap = Record + +export interface UseFormErrorReturn { + applyApiErrors: (errors: Record) => void + clearApiErrors: () => void + apiErrors: Ref +} + +export function useFormError(): UseFormErrorReturn { + const apiErrors = ref({}) + + provide(FORM_API_ERRORS_KEY, apiErrors) + + function applyApiErrors(errors: Record): void { + const next: FormApiErrorsMap = {} + for (const [field, messages] of Object.entries(errors)) { + if (Array.isArray(messages) && messages.length > 0) + next[field] = messages[0] + } + apiErrors.value = next + } + + function clearApiErrors(): void { + apiErrors.value = {} + } + + return { applyApiErrors, clearApiErrors, apiErrors } +}