From c1190ab0456c6f62bee8dfe4d249f08cfc2608db Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 11 May 2026 01:04:58 +0200 Subject: [PATCH] feat(forms): add FormField wrapper + useFormError composable per RFC Appendix A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new artifacts that together provide the F4 form-migration target: apps/app/src/components/forms/FormField.vue — project-owned wrapper around @primevue/forms' built-in FormField. The default slot accepts the actual input (e.g. ); the wrapper renders label (with optional required asterisk), error Message, and hint chrome around it. Reads field state from the parent
via the built-in FormField's scoped slot, so call-sites do not need to thread $form manually. apps/app/src/composables/useFormError.ts — the API 422 bridge. Parent component calls useFormError() once; the composable provides an apiErrors ref through Vue inject. Each FormField in the component reads its own field name from that map. applyApiErrors() reads the Crewli backend's { errors: { field: string[] } } shape and surfaces the first message per field; clearApiErrors() resets between submits. Error precedence per RFC Appendix A: explicit apiError prop > inject apiErrors map > Zod resolver error from $field. Signature note: RFC's useFormError(formRef) is implemented as useFormError() — the formRef parameter is unused in the provide/inject implementation, and Crewli convention avoids unused parameters. RFC will be aligned in B9 if it remains a meaningful spec gap during F4. Verification: - pnpm typecheck — clean. - pnpm test — 402 tests pass unchanged. - B8 will exercise the components end-to-end on the login page; F4d validates against the public-registration multi-step form. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/components/forms/FormField.vue | 93 +++++++++++++++++++++ apps/app/src/composables/useFormError.ts | 44 ++++++++++ 2 files changed, 137 insertions(+) create mode 100644 apps/app/src/components/forms/FormField.vue create mode 100644 apps/app/src/composables/useFormError.ts 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 @@ + + + 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 } +}