feat(forms): add FormField wrapper + useFormError composable per RFC Appendix A

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. <InputText name="email" />); the wrapper renders
label (with optional required asterisk), error Message, and hint chrome
around it. Reads field state from the parent <Form> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 01:04:58 +02:00
parent 7660d12a8c
commit c1190ab045
2 changed files with 137 additions and 0 deletions

View File

@@ -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<string, string>
export interface UseFormErrorReturn {
applyApiErrors: (errors: Record<string, string[]>) => void
clearApiErrors: () => void
apiErrors: Ref<FormApiErrorsMap>
}
export function useFormError(): UseFormErrorReturn {
const apiErrors = ref<FormApiErrorsMap>({})
provide(FORM_API_ERRORS_KEY, apiErrors)
function applyApiErrors(errors: Record<string, string[]>): 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 }
}