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:
44
apps/app/src/composables/useFormError.ts
Normal file
44
apps/app/src/composables/useFormError.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user