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,93 @@
<script setup lang="ts">
// Crewli FormField wrapper — terse call-site syntax that hides the
// @primevue/forms validation plumbing. Per RFC Appendix A.
//
// Usage:
// <Form :resolver="zodResolver(schema)" @submit="onSubmit">
// <FormField name="email" label="E-mailadres" required>
// <InputText name="email" />
// </FormField>
// </Form>
//
// The default slot accepts any PrimeVue input whose `name` matches the
// FormField's `name` prop. The parent <Form> auto-registers each named
// input. This wrapper renders the surrounding label, error message,
// and hint, reading the field state from the parent Form context via
// @primevue/forms' built-in <FormField> scoped slot.
//
// Error precedence: apiError prop > useFormError() provide/inject map >
// Zod resolver error. The first non-empty source wins.
import { type Ref, computed, inject } from 'vue'
import { FormField as PrimeFormField } from '@primevue/forms'
import Message from 'primevue/message'
import {
FORM_API_ERRORS_KEY,
type FormApiErrorsMap,
} from '@/composables/useFormError'
interface Props {
name: string
label?: string
required?: boolean
hint?: string
apiError?: string | null
}
const props = withDefaults(defineProps<Props>(), {
label: undefined,
required: false,
hint: undefined,
apiError: null,
})
const injectedApiErrors = inject<Ref<FormApiErrorsMap> | null>(
FORM_API_ERRORS_KEY,
null,
)
const resolvedApiError = computed<string | null>(() => {
if (props.apiError)
return props.apiError
return injectedApiErrors?.value[props.name] ?? null
})
</script>
<template>
<PrimeFormField
v-slot="$field"
:name="name"
>
<div class="crewli-field mb-4 flex flex-col gap-1">
<label
v-if="label"
:for="name"
class="text-sm font-medium"
>
{{ label }}
<span
v-if="required"
aria-label="verplicht"
class="text-red-500"
>*</span>
</label>
<slot />
<Message
v-if="resolvedApiError || ($field?.invalid && $field?.error)"
severity="error"
size="small"
variant="simple"
>
{{ resolvedApiError ?? $field.error?.message ?? $field.errors?.[0]?.message }}
</Message>
<small
v-else-if="hint"
class="text-surface-500"
>{{ hint }}</small>
</div>
</PrimeFormField>
</template>

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 }
}