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:
93
apps/app/src/components/forms/FormField.vue
Normal file
93
apps/app/src/components/forms/FormField.vue
Normal 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>
|
||||
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