feat(forms): quiet, metadata-driven validation on record create and edit screens
Fields are checked when the user leaves them (or on save), then live while they show an error. A refused too-long value (ValueTooLongBehavior ERROR) caps typing; a counter appears only in the last 20% of a limit; a trimming field notes extra characters. Whole-number and decimal inputs refuse non-numeric keys; unreadable dates and numbers become field errors. Messages are plain (Required, Enter a whole number, Must be 200 characters or fewer (currently 214)). Save moves focus to the first invalid field and shows a compact summary only when others are out of sight. The backend's record errors are matched to fields by label and shown beside them, keeping the user's input. Read-only fields show as values. Error lines open smoothly, are announced politely and link to the control. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98409c85af
commit
f23e03e417
34 files changed
+1461
-231
No files matched your search
@@ -189,3 +189,13 @@ the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
|
||||
Consult the dossier before deep-diving the code — it covers the API contract this
|
||||
frontend targets (including versioned-middleware endpoint gaps), maturity assessment,
|
||||
known parity gaps, and its relationship to `qqq-frontend-material-dashboard`.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -36,12 +36,15 @@ import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { fileDownload, findAdornment, hasAdornment } from '@/lib/utils/adornment-utils'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { caseTransform, numberAdornments, transformInputValue } from '@/lib/utils/form-layout'
|
||||
import { caseTransform, numberAdornments } from '@/lib/utils/form-layout'
|
||||
import { selectSlotHelpContent } from '@/lib/utils/help-utils'
|
||||
import { tooLongPolicy } from '@/lib/utils/field-validation'
|
||||
import { useHelpHelpActive } from '@/lib/context/q-context'
|
||||
import { HelpContent } from '@/components/records/HelpContent'
|
||||
|
||||
import { TextField } from './field-types/TextField'
|
||||
import { TextAreaField } from './field-types/TextAreaField'
|
||||
import { FieldFooter } from './FieldFooter'
|
||||
import { NumberField } from './field-types/NumberField'
|
||||
import { BooleanField } from './field-types/BooleanField'
|
||||
import { DateField } from './field-types/DateField'
|
||||
@@ -284,6 +287,11 @@ export function DynamicFormField({
|
||||
const fieldError = errors[field.name] as FieldError | undefined
|
||||
const isDisabled = disabled || !field.isEditable
|
||||
const dataQqqId = field.name
|
||||
// Typing stops at the limit only for a field that refuses over-long values (ERROR); otherwise
|
||||
// the counter and the server's policy handle it.
|
||||
const policy = enforceMaxLength ? 'error' : tooLongPolicy(field)
|
||||
const capLength = field.maxLength && policy === 'error' ? field.maxLength : undefined
|
||||
const lengthLimit = field.maxLength ? { control, name: field.name, max: field.maxLength, policy } : undefined
|
||||
|
||||
const roles = helpRoles ?? (possibleValueContext?.type === 'process' ? PROCESS_SCREEN_ROLES : ALL_SCREEN_ROLES)
|
||||
const helpContent = selectSlotHelpContent(field.helpContents, roles, helpKey ?? `field:${field.name}`, helpHelpActive)
|
||||
@@ -402,7 +410,8 @@ export function DynamicFormField({
|
||||
registration={registration()}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={enforceMaxLength ? field.maxLength : undefined}
|
||||
maxLength={capLength}
|
||||
lengthLimit={lengthLimit}
|
||||
required={field.isRequired}
|
||||
describedBy={helpDescribedBy}
|
||||
transform={caseTransform(field)}
|
||||
@@ -414,53 +423,24 @@ export function DynamicFormField({
|
||||
)
|
||||
|
||||
case 'TEXT': {
|
||||
const textRegistration = registration()
|
||||
const transform = caseTransform(field)
|
||||
return (
|
||||
<DirtyWrapper isDirty={isDirty}>
|
||||
<FieldWithHelp field={field} helpId={helpDescribedBy}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={`field-label-${dataQqqId}`}
|
||||
>
|
||||
{field.label}
|
||||
{field.isRequired && (
|
||||
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
|
||||
)}
|
||||
</label>
|
||||
{helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
|
||||
</div>
|
||||
<textarea
|
||||
placeholder={placeholder}
|
||||
id={fieldId}
|
||||
{...textRegistration}
|
||||
onChange={(event) => {
|
||||
if (transform) transformInputValue(event.currentTarget, transform)
|
||||
void textRegistration.onChange(event)
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
aria-required={field.isRequired}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
aria-describedby={
|
||||
[fieldError ? `${fieldId}-error` : undefined, helpDescribedBy]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
data-qqq-id={dataQqqId}
|
||||
rows={4}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm text-foreground bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted transition-colors duration-150 resize-y ${
|
||||
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
|
||||
}`}
|
||||
/>
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TextAreaField
|
||||
placeholder={placeholder}
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={registration()}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={capLength}
|
||||
lengthLimit={lengthLimit}
|
||||
required={field.isRequired}
|
||||
describedBy={helpDescribedBy}
|
||||
transform={caseTransform(field)}
|
||||
data-qqq-id={dataQqqId}
|
||||
labelExtra={helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
|
||||
/>
|
||||
</FieldWithHelp>
|
||||
</DirtyWrapper>
|
||||
)
|
||||
@@ -479,7 +459,7 @@ export function DynamicFormField({
|
||||
>
|
||||
{field.label}
|
||||
{field.isRequired && (
|
||||
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
|
||||
<span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>
|
||||
)}
|
||||
</label>
|
||||
{helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
|
||||
@@ -498,11 +478,7 @@ export function DynamicFormField({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={fieldId} error={fieldError?.message} />
|
||||
</div>
|
||||
</FieldWithHelp>
|
||||
</DirtyWrapper>
|
||||
@@ -627,7 +603,7 @@ export function DynamicFormField({
|
||||
>
|
||||
{field.label}
|
||||
{field.isRequired && (
|
||||
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
|
||||
<span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>
|
||||
)}
|
||||
</label>
|
||||
{helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
|
||||
@@ -650,11 +626,7 @@ export function DynamicFormField({
|
||||
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
|
||||
}`}
|
||||
/>
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={fieldId} error={fieldError?.message} />
|
||||
</div>
|
||||
</FieldWithHelp>
|
||||
</DirtyWrapper>
|
||||
@@ -670,7 +642,7 @@ export function DynamicFormField({
|
||||
registration={registration()}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={enforceMaxLength ? field.maxLength : undefined}
|
||||
maxLength={capLength}
|
||||
required={field.isRequired}
|
||||
placeholder={record && showReadOnly && !hasAdornment(field, 'REVEAL') ? 'Unchanged — type to replace' : placeholder}
|
||||
describedBy={helpDescribedBy}
|
||||
@@ -693,7 +665,8 @@ export function DynamicFormField({
|
||||
registration={registration()}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={enforceMaxLength ? field.maxLength : undefined}
|
||||
maxLength={capLength}
|
||||
lengthLimit={lengthLimit}
|
||||
required={field.isRequired}
|
||||
describedBy={helpDescribedBy}
|
||||
transform={caseTransform(field)}
|
||||
@@ -748,21 +721,13 @@ function ReadOnlyFormField({ field, fieldId, record, helpDescribedBy }: {
|
||||
: field.type === 'BOOLEAN' && raw != null ? (raw === true || raw === 'true' ? 'Yes' : 'No')
|
||||
: (display ?? (raw == null ? '' : String(raw)))
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-muted-foreground" data-qqq-id={`field-label-${field.name}`}>
|
||||
<dl className="flex flex-col gap-1" aria-describedby={helpDescribedBy}>
|
||||
<dt id={`${fieldId}-label`} className="text-sm font-medium text-muted-foreground" data-qqq-id={`field-label-${field.name}`}>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="text"
|
||||
value={text}
|
||||
readOnly
|
||||
disabled
|
||||
aria-readonly="true"
|
||||
aria-describedby={helpDescribedBy}
|
||||
data-qqq-id={field.name}
|
||||
className="w-full cursor-not-allowed rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</dt>
|
||||
<dd id={fieldId} className="min-h-5 whitespace-pre-wrap break-words text-sm text-foreground" data-qqq-id={field.name}>
|
||||
{text || <span className="text-muted-foreground" aria-label="No value">{'\u2014'}</span>}
|
||||
</dd>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -297,7 +297,7 @@ describe('child record lists that manage an association', () => {
|
||||
const addDialog = await screen.findByRole('dialog', { name: 'Creating New Lab Line' })
|
||||
expect(within(addDialog).getByLabelText(/^LabId/)).toBeDisabled()
|
||||
await user.click(within(addDialog).getByRole('button', { name: 'OK' }))
|
||||
expect(await within(addDialog).findByText('SKU is required')).toBeVisible()
|
||||
expect(await within(addDialog).findByText('Required')).toBeVisible()
|
||||
await user.type(within(addDialog).getByLabelText(/^SKU/), 'C-3')
|
||||
await user.click(within(addDialog).getByRole('button', { name: 'OK' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
@@ -401,7 +401,7 @@ describe('dynamic form widget on a record edit screen', () => {
|
||||
|
||||
await user.clear(region)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByText('Region is required')).toBeVisible()
|
||||
expect(await screen.findByText('Required')).toBeVisible()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(region, 'South')
|
||||
|
||||
@@ -76,7 +76,9 @@ describe('Record form regressions (#649)', () => {
|
||||
const put = vi.spyOn(apiClient, 'patch').mockResolvedValue({ record: { tableName: 'lab', values: { id: 7 } } })
|
||||
renderForm(stored)
|
||||
expect(screen.getByLabelText(/^Secret/)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/^Id/)).toBeDisabled()
|
||||
// a read-only field reads as a value, not as a disabled input
|
||||
expect(screen.queryByLabelText(/^Id/)).toBeNull()
|
||||
expect(screen.getByText('Id')).toBeInTheDocument()
|
||||
const title = screen.getByLabelText(/^Title/)
|
||||
await user.clear(title)
|
||||
// Record forms leave maxLength to the server's too-long policy.
|
||||
@@ -172,7 +174,7 @@ describe('Record form regressions (#649)', () => {
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={scheduled} widgets={widgets} /></QueryClientProvider>)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByText('Cron Expression is required')).toBeVisible()
|
||||
expect(await screen.findByText('Required')).toBeVisible()
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('Base copy starts a new record identity', () => {
|
||||
expect(screen.getByLabelText(/Code/)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/Name/)).toHaveValue('Original')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByText('Code is required')).toBeVisible()
|
||||
expect(await screen.findByText('Required')).toBeVisible()
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByLabelText(/Code/), { target: { value: 'draft/key' } })
|
||||
rerender(view(nextSource))
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import type { FieldErrors, Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -42,7 +42,9 @@ import {
|
||||
} from '@/lib/utils/zod-from-metadata'
|
||||
import { applyAdjustedDefinitions, fieldFormAdjusters, hasTableOnLoadAdjuster, tableFieldRules } from '@/lib/utils/form-adjuster-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { getErrorMessage, recordWriteMessages } from '@/lib/utils/error-utils'
|
||||
import { badInputMessage, mapServerErrors } from '@/lib/utils/field-validation'
|
||||
import { anyOffScreen, fieldElement, inPageOrder, revealField } from '@/lib/utils/form-focus'
|
||||
import { MATERIAL_BUTTON_VARIANTS, sanitizeQqqId } from '@/lib/utils/qqq-id'
|
||||
import { EDIT_SCREEN_HELP_ROLES, INSERT_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
|
||||
import { isImplicitSubmitKey } from '@/lib/utils/form-layout'
|
||||
@@ -52,6 +54,7 @@ import { toast } from '@/lib/hooks/use-toast'
|
||||
import { HoverTooltip } from '@/components/widgets/HoverTooltip'
|
||||
import type { WidgetFormHost } from '@/components/widgets/widget-form-host'
|
||||
import { DynamicForm } from './DynamicForm'
|
||||
import { FormErrorSummary } from './FormErrorSummary'
|
||||
import { FormWidgetSection, isEditableFormWidget } from './FormWidgetSection'
|
||||
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
|
||||
|
||||
@@ -332,14 +335,32 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
// Adjusters may change field definitions after the form mounts: validate with the latest schema.
|
||||
const schemaRef = useRef(schema)
|
||||
schemaRef.current = schema
|
||||
// The schema plus what the browser itself could not read (a half-typed date, letters in a number),
|
||||
// which never reaches the value, so the check looks at the input element.
|
||||
const formTableForResolver = useRef(formTable)
|
||||
formTableForResolver.current = formTable
|
||||
const resolver = useCallback<Resolver<Record<string, unknown>>>(
|
||||
(values, context, options) => zodResolver(schemaRef.current)(values, context, options),
|
||||
async (values, context, options) => {
|
||||
const result = await zodResolver(schemaRef.current)(values, context, options)
|
||||
const unreadable: FieldErrors<Record<string, unknown>> = {}
|
||||
for (const [name, config] of Object.entries(options.fields ?? {})) {
|
||||
const input = config?.ref
|
||||
if (input instanceof HTMLInputElement && input.validity?.badInput && !input.disabled) {
|
||||
unreadable[name] = { type: 'badInput', message: badInputMessage(formTableForResolver.current.fields[name], input), ref: input }
|
||||
}
|
||||
}
|
||||
return Object.keys(unreadable).length > 0 ? { values: {}, errors: { ...result.errors, ...unreadable } } : result
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Quiet by default: a field is checked when the user leaves it (or on save), then live while it
|
||||
// shows an error, so fixing it clears the message at once.
|
||||
const form = useForm<Record<string, unknown>>({
|
||||
resolver,
|
||||
defaultValues: mergedDefaults,
|
||||
mode: 'onBlur',
|
||||
shouldFocusError: false,
|
||||
})
|
||||
const {
|
||||
register,
|
||||
@@ -350,7 +371,56 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
setError,
|
||||
trigger,
|
||||
} = form
|
||||
const formElementRef = useRef<HTMLFormElement>(null)
|
||||
// After a failed save the summary shows only when some invalid fields are out of sight.
|
||||
const [summaryOpen, setSummaryOpen] = useState(false)
|
||||
// Fields to move to once the form is unlocked again (its inputs are disabled while a save runs).
|
||||
const [focusRequest, setFocusRequest] = useState<string[] | null>(null)
|
||||
// Server messages that name no field (shown as the form's general error); null when none or not a field-mapped failure.
|
||||
const [serverUnmatched, setServerUnmatched] = useState<string[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((_values, { name }) => {
|
||||
if (name && form.getFieldState(name).error) void trigger(name)
|
||||
})
|
||||
return () => subscription.unsubscribe()
|
||||
}, [form, trigger])
|
||||
|
||||
/**
|
||||
* Takes the user to the first field that needs attention, and opens the summary when others are out of sight.
|
||||
* @param names - Names of the invalid fields.
|
||||
*/
|
||||
const focusInvalid = useCallback((names: string[]) => {
|
||||
const root = formElementRef.current
|
||||
if (!root) return
|
||||
const elements = inPageOrder(root, names).map((name) => fieldElement(root, name)).filter((element): element is HTMLElement => element !== null)
|
||||
if (elements.length === 0) return
|
||||
setSummaryOpen(anyOffScreen(elements[0], elements.slice(1)))
|
||||
revealField(elements[0])
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Puts the backend's refusal beside the fields it names, keeping what the user typed.
|
||||
* @param error - The failed write.
|
||||
* @returns The text still to report as a general error, or `null` when every message found its field.
|
||||
*/
|
||||
function placeServerErrors(error: Error): string | null {
|
||||
const messages = recordWriteMessages(error)
|
||||
const whole = getErrorMessage(error)
|
||||
if (!messages || isWarningMessage(whole)) {
|
||||
setServerUnmatched(null)
|
||||
return whole
|
||||
}
|
||||
const editable = Object.values(formTableRef.current.fields).filter((field) => field.isEditable && !field.isHidden)
|
||||
const { fieldErrors, unmatched } = mapServerErrors(messages, editable)
|
||||
for (const [name, message] of Object.entries(fieldErrors)) setError(name, { type: 'server', message })
|
||||
setServerUnmatched(unmatched)
|
||||
setFocusRequest(Object.keys(fieldErrors))
|
||||
return unmatched.length > 0 ? unmatched.join('; ') : null
|
||||
}
|
||||
|
||||
// Latest dirty-field map for the update mutation (values that cannot round-trip are sent only when changed).
|
||||
const dirtyFieldsRef = useRef<Record<string, unknown>>({})
|
||||
@@ -601,7 +671,8 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const message = getErrorMessage(err)
|
||||
const message = placeServerErrors(err)
|
||||
if (message === null) return
|
||||
// a "warning..." refusal is reported as a warning on the form, not as a failure
|
||||
if (isWarningMessage(message)) toast.warning(message, { duration: WARNING_TOAST_MILLIS })
|
||||
else toast.error(`Failed to create ${tableMetaData.label}: ${message}`)
|
||||
@@ -626,7 +697,8 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
finishUpdate(pk, firstRecordWarning(savedRecord), () => onSuccess?.(savedRecord))
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
const message = getErrorMessage(err)
|
||||
const message = placeServerErrors(err)
|
||||
if (message === null) return
|
||||
// Material: a save error starting with "warning" is a success with that warning, back on the view
|
||||
if (isWarningMessage(message) && record && !onSuccess) {
|
||||
finishUpdate(record.values[tableMetaData.primaryKeyField] as string | number, message)
|
||||
@@ -659,12 +731,24 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
|
||||
const activeMutation = isEdit ? updateMutation : insertMutation
|
||||
const failure = activeMutation.error as Error | null
|
||||
const failureMessage = failure ? getErrorMessage(failure, 'An error occurred while saving.') : null
|
||||
const failureMessage = serverUnmatched !== null
|
||||
? (serverUnmatched.length > 0 ? serverUnmatched.join('; ') : null)
|
||||
: failure ? getErrorMessage(failure, 'An error occurred while saving.') : null
|
||||
// a "warning..." refusal that did not return to the view is shown as a warning, not an error
|
||||
const mutationWarning = failureMessage && isWarningMessage(failureMessage) ? failureMessage : null
|
||||
const mutationError = mutationWarning ? null : failure
|
||||
const mutationError = mutationWarning ? null : failureMessage
|
||||
const isSaving = isSubmitting || activeMutation.isPending
|
||||
useEffect(() => {
|
||||
if (!focusRequest || isSaving) return
|
||||
setFocusRequest(null)
|
||||
focusInvalid(focusRequest)
|
||||
}, [focusRequest, isSaving, focusInvalid])
|
||||
const formDisabledMessage = prepared.disabledMessage
|
||||
const summaryItems = (() => {
|
||||
const root = formElementRef.current
|
||||
const names = Object.keys(errors)
|
||||
return (root ? inPageOrder(root, names) : names).map((name) => ({ name, label: formTable.fields[name]?.label ?? name }))
|
||||
})()
|
||||
|
||||
/**
|
||||
* React Hook Form submit handler — runs the widgets' checks, then delegates to the
|
||||
@@ -676,6 +760,7 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
(values: Record<string, unknown>) => {
|
||||
const problems = [...validatorsRef.current.values()].flatMap((validate) => validate())
|
||||
setWidgetErrors(problems)
|
||||
setServerUnmatched(null)
|
||||
if (problems.length > 0) return
|
||||
if (onSubmitValues) {
|
||||
onSubmitValues(values)
|
||||
@@ -687,9 +772,9 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
)
|
||||
|
||||
/**
|
||||
* Preserve the browser's distinction between malformed numeric input and a blank.
|
||||
* Validates on save: a field with a problem gets its message, and the first one is scrolled into view and focused.
|
||||
* @param event - The form submission event.
|
||||
* @returns Metadata validation when native numeric input is readable.
|
||||
* @returns The validation promise.
|
||||
*/
|
||||
function onFormSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
// A form in a dialog over another form (a child record) must not submit its parent too.
|
||||
@@ -698,14 +783,15 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const invalidNumber = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('input[type="number"]'))
|
||||
// Full copy drafts keep their own inputs outside this form's resolver: the browser reports those.
|
||||
const unreadableDraft = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('fieldset[data-qqq-id^="copy-node-"] input[type="number"]'))
|
||||
.find((input) => !input.matches(':disabled') && input.validity.badInput)
|
||||
if (invalidNumber) {
|
||||
if (unreadableDraft) {
|
||||
event.preventDefault()
|
||||
invalidNumber.reportValidity()
|
||||
unreadableDraft.reportValidity()
|
||||
return
|
||||
}
|
||||
return handleSubmit(onSubmit)(event)
|
||||
return handleSubmit(onSubmit, (invalid) => setFocusRequest(Object.keys(invalid)))(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -844,6 +930,7 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
|
||||
const formContent = (
|
||||
<form
|
||||
ref={formElementRef}
|
||||
onSubmit={onFormSubmit}
|
||||
onKeyDown={(event) => {
|
||||
// Enter in a single-line input does not save the record (Material): only the Save button does
|
||||
@@ -883,7 +970,7 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<strong>Error: </strong>
|
||||
{defaultsError || copyAssociations?.error || (mutationError ? getErrorMessage(mutationError, 'An error occurred while saving.') : 'An error occurred while saving.')}
|
||||
{defaultsError || copyAssociations?.error || mutationError || 'An error occurred while saving.'}
|
||||
</div>
|
||||
)}
|
||||
{mutationWarning && (
|
||||
@@ -908,6 +995,17 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
|
||||
{isCopy && Object.values(tableMetaData.fields).some(field => field.type === 'PASSWORD' && field.isEditable && !field.isHidden && !field.adornments?.some(item => item.type === 'REVEAL')) &&
|
||||
<p className="text-sm text-muted-foreground">Unreadable passwords are not copied. Enter new values before saving.</p>}
|
||||
|
||||
{summaryOpen && (
|
||||
<FormErrorSummary
|
||||
items={summaryItems}
|
||||
onSelect={(name) => {
|
||||
const root = formElementRef.current
|
||||
const element = root && fieldElement(root, name)
|
||||
if (element) revealField(element)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<DynamicForm
|
||||
register={register}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { EntityForm } from './EntityForm'
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn() }) }))
|
||||
|
||||
const base = qInstance.tables.company.fields.name
|
||||
|
||||
function field(name: string, label: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
return { ...base, name, label, isEditable: true, isRequired: false, ...extra }
|
||||
}
|
||||
|
||||
function renderForm(fields: QFieldMetaData[], props: { record?: QRecord } = {}) {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.fields = Object.fromEntries(fields.map((item) => [item.name, item]))
|
||||
table.sections = [{ name: 'main', label: 'Main', isHidden: false, fieldNames: fields.map((item) => item.name) }]
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} {...props} /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('Quiet record form validation', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('shows a required error on blur or save, not while typing, then clears it as soon as the field is fixed', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm([field('code', 'Code', { isRequired: true })])
|
||||
const input = screen.getByLabelText(/Code/)
|
||||
await user.type(input, 'ab')
|
||||
await user.clear(input)
|
||||
expect(screen.queryByText('Required')).toBeNull()
|
||||
await user.tab()
|
||||
expect(await screen.findByText('Required')).toBeVisible()
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(input).toHaveAccessibleDescription('Required')
|
||||
await user.type(input, 'x')
|
||||
await waitFor(() => expect(screen.queryByText('Required')).toBeNull())
|
||||
expect(input).not.toHaveAttribute('aria-invalid')
|
||||
})
|
||||
|
||||
it('moves focus to the first invalid field on save and keeps every error live afterward', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm([field('first', 'First', { isRequired: true }), field('second', 'Second', { isRequired: true })])
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findAllByText('Required')).toHaveLength(2)
|
||||
await waitFor(() => expect(screen.getByLabelText(/First/)).toHaveFocus())
|
||||
await user.type(screen.getByLabelText(/Second/), 'y')
|
||||
await waitFor(() => expect(screen.getAllByText('Required')).toHaveLength(1))
|
||||
})
|
||||
|
||||
it('caps typing and counts only near the limit when too-long values are refused (ERROR)', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm([field('code', 'Code', { maxLength: 10, behaviors: ['ERROR'] })])
|
||||
const input = screen.getByLabelText(/Code/)
|
||||
expect(input).toHaveAttribute('maxlength', '10')
|
||||
await user.type(input, 'abcdefg')
|
||||
expect(screen.queryByText(/ \/ 10/)).toBeNull()
|
||||
await user.type(input, 'h')
|
||||
expect(screen.getByText('8 / 10')).toBeVisible()
|
||||
await user.type(input, 'ijklmn')
|
||||
expect(input).toHaveValue('abcdefghij')
|
||||
expect(screen.getByText('10 / 10')).toBeVisible()
|
||||
})
|
||||
|
||||
it('lets a trimmed value run past the limit, noting that extra characters will be trimmed, without an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'company', values: { id: 1 } } })
|
||||
renderForm([field('code', 'Code', { maxLength: 5, behaviors: ['TRUNCATE'] })])
|
||||
const input = screen.getByLabelText(/Code/)
|
||||
expect(input).not.toHaveAttribute('maxlength')
|
||||
await user.type(input, 'abcdefg')
|
||||
expect(screen.getByText('7 / 5')).toBeVisible()
|
||||
expect(screen.getByText('Extra characters will be trimmed when you save.')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalled())
|
||||
expect(input).not.toHaveAttribute('aria-invalid')
|
||||
})
|
||||
|
||||
it('states the current length when an existing value is over a refused limit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const stored = { tableName: 'company', values: { id: 1, code: 'a-value-that-is-too-long' } }
|
||||
renderForm([field('id', 'Id', { type: 'INTEGER', isEditable: false }), field('code', 'Code', { maxLength: 10, behaviors: ['ERROR'] })], { record: stored })
|
||||
await user.type(screen.getByLabelText(/Code/), '!')
|
||||
await user.tab()
|
||||
expect(await screen.findByText(/Must be 10 characters or fewer \(currently \d+\)/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps non-numeric characters out of a whole-number field and reports a missing range bound plainly', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm([field('qty', 'Quantity', { type: 'INTEGER', minValue: 1, maxValue: 5 })])
|
||||
const input = screen.getByLabelText(/Quantity/)
|
||||
await user.type(input, 'a1e.2')
|
||||
expect(input).toHaveValue(12)
|
||||
await user.tab()
|
||||
expect(await screen.findByText('Must be between 1 and 5')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows a read-only field as its value, not a disabled input', () => {
|
||||
renderForm([field('id', 'Id', { type: 'INTEGER', isEditable: false }), field('code', 'Code')], { record: { tableName: 'company', values: { id: 7, code: 'x' } } })
|
||||
expect(screen.getByText('7')).toBeVisible()
|
||||
expect(screen.queryByLabelText(/^Id/)).toBeNull()
|
||||
})
|
||||
|
||||
it('places the server\'s message beside the field it names, keeping the typed value and showing no general alert', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'company', values: {}, errors: ['The value for Code is too long (max allowed length=5)'] } })
|
||||
renderForm([field('code', 'Code'), field('other', 'Other')])
|
||||
await user.type(screen.getByLabelText(/Code/), 'abcdefgh')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByText('Must be 5 characters or fewer')).toBeVisible()
|
||||
expect(screen.getByLabelText(/Code/)).toHaveValue('abcdefgh')
|
||||
await waitFor(() => expect(screen.getByLabelText(/Code/)).toHaveFocus())
|
||||
expect(screen.queryByText(/Error:/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a message that names no field as the form\'s general error', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'company', values: {}, errors: ['Something else went wrong'] } })
|
||||
renderForm([field('code', 'Code')])
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByText('Something else went wrong')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FieldFooter — the quiet line under a form field: its error, an optional hint that shows
|
||||
* while the field has focus, and a length counter that appears only near a limit. The line opens
|
||||
* and closes smoothly, so the form does not jump when a message arrives.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useWatch } from 'react-hook-form'
|
||||
import type { Control } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { lengthMeter, truncateNote } from '@/lib/utils/field-validation'
|
||||
import type { LengthMeter, TooLongPolicy } from '@/lib/utils/field-validation'
|
||||
|
||||
/** What a text field needs to count its characters against `maxLength`. */
|
||||
export interface LengthLimit {
|
||||
control: Control<Record<string, unknown>>
|
||||
name: string
|
||||
max: number
|
||||
policy: TooLongPolicy
|
||||
}
|
||||
|
||||
/** Props for {@link FieldFooter}. */
|
||||
interface FieldFooterProps {
|
||||
/** The field's id; the error gets `${id}-error` and the hint `${id}-hint`. */
|
||||
id: string
|
||||
/** The field's current error message. */
|
||||
error?: string
|
||||
/** Guidance shown while the field has focus and has no error (the parent needs the `group` class). */
|
||||
hint?: string
|
||||
/** A length counter, shown only when {@link lengthMeter} found one useful. */
|
||||
meter?: LengthMeter
|
||||
/** A quiet note, such as the server trimming extra characters. */
|
||||
note?: string
|
||||
/** Counts a text field's characters; the counter and the trim note then come from the live value. */
|
||||
limit?: LengthLimit
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the field's footer: the error first, then any note or hint, with the counter at the end.
|
||||
* Errors are announced politely; the counter is not announced at all.
|
||||
*
|
||||
* @param props - See {@link FieldFooterProps}.
|
||||
* @returns The footer, collapsed to no height while it has nothing to say.
|
||||
*/
|
||||
export function FieldFooter({ limit, ...props }: FieldFooterProps) {
|
||||
return limit ? <CountingFooter limit={limit} {...props} /> : <FooterLine {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a field that refuses over-long values marks its counter as an error; for others it is just information.
|
||||
*
|
||||
* @param meter - The counter, if one is showing.
|
||||
* @param policy - The field's too-long policy.
|
||||
* @returns The counter with its error styling decided.
|
||||
*/
|
||||
function refusedOnly(meter: LengthMeter | undefined, policy: TooLongPolicy): LengthMeter | undefined {
|
||||
return meter && { ...meter, over: meter.over && policy === 'error' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer of a field with a length limit: it watches the value, so only these fields re-render per keystroke.
|
||||
*
|
||||
* @param props - The footer props with the field's limit.
|
||||
* @returns The footer with its live counter.
|
||||
*/
|
||||
function CountingFooter({ limit, ...props }: FieldFooterProps & { limit: LengthLimit }) {
|
||||
const value: unknown = useWatch({ control: limit.control, name: limit.name })
|
||||
const length = typeof value === 'string' ? value.length : 0
|
||||
return <FooterLine {...props} meter={refusedOnly(lengthMeter(length, limit.max), limit.policy)} note={truncateNote(limit.policy, length, limit.max)} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's markup: the message line and the counter.
|
||||
*
|
||||
* @param props - The footer props without a limit (the counter is already worked out).
|
||||
* @returns The footer line.
|
||||
*/
|
||||
function FooterLine({ id, error, hint, meter, note }: Omit<FieldFooterProps, 'limit'>) {
|
||||
const open = Boolean(error || note || meter)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-mt-1 grid transition-[grid-template-rows] duration-150 motion-reduce:transition-none',
|
||||
open ? 'grid-rows-[1fr]' : hint ? 'grid-rows-[0fr] group-focus-within:grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3 pt-1">
|
||||
<div className="min-w-0" aria-live="polite">
|
||||
{error ? (
|
||||
<p id={`${id}-error`} className="text-sm text-destructive">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
||||
{hint && !note && <p id={`${id}-hint`} className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{meter && (
|
||||
<span aria-hidden="true" className={cn('ml-auto shrink-0 text-xs tabular-nums', meter.over ? 'font-medium text-destructive' : 'text-muted-foreground')}>
|
||||
{meter.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FormErrorSummary — a compact "2 fields need attention" strip with a link to each field,
|
||||
* shown after a failed save only when some of those fields are out of sight.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
/** A field that needs attention. */
|
||||
export interface SummaryItem {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Props for {@link FormErrorSummary}. */
|
||||
interface FormErrorSummaryProps {
|
||||
items: readonly SummaryItem[]
|
||||
/** Moves the user to a field. */
|
||||
onSelect: (name: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the summary strip; it stays in view at the top of the form while the user scrolls.
|
||||
*
|
||||
* @param props - See {@link FormErrorSummaryProps}.
|
||||
* @returns The strip, or nothing when every field is fixed.
|
||||
*/
|
||||
export function FormErrorSummary({ items, onSelect }: FormErrorSummaryProps) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="sticky top-0 z-10 flex flex-wrap items-baseline gap-x-2 gap-y-1 rounded-md border border-destructive/40 bg-background px-3 py-2 text-sm shadow-sm"
|
||||
data-qqq-id="form-error-summary"
|
||||
>
|
||||
<span className="font-medium text-destructive">
|
||||
{items.length === 1 ? '1 field needs attention' : `${items.length} fields need attention`}
|
||||
</span>
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.name}>
|
||||
<button type="button" onClick={() => onSelect(item.name)} className="text-foreground underline underline-offset-2 hover:text-primary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Form validation stories — the quiet states of a record form: clean, near a length limit
|
||||
* (the counter appears only there), and with errors after a failed save.
|
||||
*/
|
||||
import React, { useEffect } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import type { QFieldMetaData } from '@/types'
|
||||
import { zodSchemaFromFields } from '@/lib/utils/zod-from-metadata'
|
||||
import { DynamicForm } from './DynamicForm'
|
||||
|
||||
function makeField(overrides: Partial<QFieldMetaData> & Pick<QFieldMetaData, 'name' | 'label' | 'type'>): QFieldMetaData {
|
||||
return { isEditable: true, isRequired: false, isHeavy: false, isHidden: false, adornments: [], ...overrides }
|
||||
}
|
||||
|
||||
const FIELDS: QFieldMetaData[] = [
|
||||
makeField({ name: 'name', label: 'Name', type: 'STRING', isRequired: true, maxLength: 20, behaviors: ['ERROR'] }),
|
||||
makeField({ name: 'nickname', label: 'Nickname', type: 'STRING', maxLength: 12, behaviors: ['TRUNCATE'] }),
|
||||
makeField({ name: 'quantity', label: 'Quantity', type: 'INTEGER', minValue: 1, maxValue: 100 }),
|
||||
makeField({ name: 'price', label: 'Price', type: 'DECIMAL', displayFormat: '$%.2f' }),
|
||||
makeField({ name: 'due', label: 'Due Date', type: 'DATE', isRequired: true }),
|
||||
]
|
||||
|
||||
function Form({ values, validate }: { values: Record<string, unknown>; validate?: boolean }) {
|
||||
const { register, control, formState: { errors }, trigger } = useForm<Record<string, unknown>>({
|
||||
resolver: zodResolver(zodSchemaFromFields(FIELDS)),
|
||||
defaultValues: values,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
useEffect(() => { if (validate) void trigger() }, [validate, trigger])
|
||||
return (
|
||||
<div className="max-w-xl p-4">
|
||||
<DynamicForm fields={FIELDS} register={register} control={control} errors={errors} enforceMaxLength={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: 'Forms/Validation', component: Form, parameters: { layout: 'fullscreen' } } satisfies Meta<typeof Form>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Nothing to say yet: no counters, no errors, no hints. */
|
||||
export const Clean: Story = { args: { values: { name: '', nickname: '', quantity: '', price: '', due: '' } } }
|
||||
|
||||
/** Within the last 20% of a limit the counter appears; a trimming field notes the extra characters. */
|
||||
export const NearLimit: Story = { args: { values: { name: 'Eighteen characters', nickname: 'A very long nickname', quantity: '5', price: '', due: '2026-10-12' } } }
|
||||
|
||||
/** After a failed save, each field says what is wrong in plain words. */
|
||||
export const WithErrors: Story = { args: { values: { name: '', nickname: '', quantity: '500', price: '', due: '' }, validate: true } }
|
||||
@@ -30,6 +30,7 @@ import { Controller } from 'react-hook-form'
|
||||
import { Minus } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
|
||||
/**
|
||||
* Props for the {@link BooleanField} component.
|
||||
@@ -127,7 +128,7 @@ export function BooleanField({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name={name}
|
||||
@@ -195,14 +196,10 @@ export function BooleanField({
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
|
||||
/**
|
||||
* Props for the {@link DateField} component.
|
||||
@@ -67,14 +68,14 @@ export function DateField({
|
||||
describedBy,
|
||||
}: DateFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
@@ -96,11 +97,7 @@ export function DateField({
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
|
||||
/**
|
||||
* Props for the {@link DateTimeField} component.
|
||||
@@ -73,14 +74,14 @@ export function DateTimeField({
|
||||
const describedByIds = [hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
@@ -106,11 +107,7 @@ export function DateTimeField({
|
||||
<p id={hintId} className="mt-0.5 text-xs text-muted-foreground">
|
||||
Your local time ({timeZone})
|
||||
</p>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { Controller } from 'react-hook-form'
|
||||
import { Upload, X, File as FileIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
|
||||
/**
|
||||
* Props for the {@link FileUploadField} component.
|
||||
@@ -89,10 +90,10 @@ export function FileUploadField({
|
||||
const labelId = `${id}-label`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<span id={labelId} className="text-sm font-medium text-foreground" data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</span>
|
||||
<Controller
|
||||
name={name}
|
||||
@@ -213,11 +214,7 @@ export function FileUploadField({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { blockedNumberKeys } from '@/lib/utils/field-validation'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
|
||||
/**
|
||||
* Props for the {@link NumberField} component.
|
||||
@@ -48,9 +50,9 @@ interface NumberFieldProps {
|
||||
* Use `1` for integers/longs; use `"any"` for decimals.
|
||||
*/
|
||||
step?: string | number
|
||||
/** Minimum allowed value from field metadata; displayed as hint text below the input. */
|
||||
/** Minimum allowed value from field metadata; shown as a hint while the field has focus. */
|
||||
minValue?: number | string | null
|
||||
/** Maximum allowed value from field metadata; displayed as hint text below the input. */
|
||||
/** Maximum allowed value from field metadata; shown as a hint while the field has focus. */
|
||||
maxValue?: number | string | null
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
@@ -69,8 +71,8 @@ interface NumberFieldProps {
|
||||
* (with `step="any"`). Values remain input strings until schema validation,
|
||||
* preserving an empty input separately from a numeric zero.
|
||||
*
|
||||
* When `minValue` or `maxValue` is provided, a hint line is rendered below
|
||||
* the input showing the allowed range or bound before any error message.
|
||||
* When `minValue` or `maxValue` is provided, the allowed range or bound shows quietly
|
||||
* under the input while it has focus.
|
||||
*
|
||||
* @param props - See {@link NumberFieldProps}.
|
||||
* @returns The rendered numeric input field with label, optional constraint hint, and optional error message.
|
||||
@@ -91,21 +93,25 @@ export function NumberField({
|
||||
prefix,
|
||||
suffix,
|
||||
}: NumberFieldProps) {
|
||||
const hintId = (minValue != null || maxValue != null) ? `${id}-hint` : undefined
|
||||
const hint = minValue != null && maxValue != null
|
||||
? `Range: ${minValue} \u2013 ${maxValue}`
|
||||
: minValue != null ? `Minimum: ${minValue}` : maxValue != null ? `Maximum: ${maxValue}` : undefined
|
||||
const hintId = hint && !error ? `${id}-hint` : undefined
|
||||
const blockedKeys = blockedNumberKeys(step === 1, minValue)
|
||||
const prefixId = prefix ? `${id}-prefix` : undefined
|
||||
const suffixId = suffix ? `${id}-suffix` : undefined
|
||||
const describedByIds = [prefixId, suffixId, hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
|
||||
const adornmentClass = 'inline-flex shrink-0 items-center border border-input bg-muted px-3 text-sm text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<div className="flex">
|
||||
{prefix && (
|
||||
@@ -117,9 +123,14 @@ export function NumberField({
|
||||
id={id}
|
||||
type="number"
|
||||
step={step}
|
||||
inputMode={step === 1 ? 'numeric' : 'decimal'}
|
||||
{...registration}
|
||||
// the mouse wheel scrolls the page instead of changing a focused number (as in Material)
|
||||
onWheel={(event) => event.currentTarget.blur()}
|
||||
// typing a letter, a second sign or (for whole numbers) a decimal point does nothing
|
||||
onKeyDown={(event) => {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && blockedKeys.includes(event.key)) event.preventDefault()
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
aria-required={required}
|
||||
@@ -146,20 +157,7 @@ export function NumberField({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(minValue != null || maxValue != null) && (
|
||||
<p id={hintId} className="mt-0.5 text-xs text-muted-foreground">
|
||||
{minValue != null && maxValue != null
|
||||
? `Range: ${minValue} \u2013 ${maxValue}`
|
||||
: minValue != null
|
||||
? `Minimum: ${minValue}`
|
||||
: `Maximum: ${maxValue}`}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} hint={hint} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
import { transformInputValue } from '@/lib/utils/form-layout'
|
||||
|
||||
/**
|
||||
@@ -87,14 +88,14 @@ export function PasswordField({
|
||||
: registration.onChange
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -140,11 +141,7 @@ export function PasswordField({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
import type { QPossibleValue } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -75,14 +76,14 @@ export function SelectField({
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
@@ -114,11 +115,7 @@ export function SelectField({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file TextAreaField — multi-line text form field (QQQ `TEXT`) with label, quiet length counter and error line.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { transformInputValue } from '@/lib/utils/form-layout'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
import type { LengthLimit } from '../FieldFooter'
|
||||
|
||||
/** Props for the {@link TextAreaField} component. */
|
||||
interface TextAreaFieldProps {
|
||||
/** The HTML `id` for the `<textarea>` and its label. */
|
||||
id: string
|
||||
/** Human-readable field label. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the field is non-interactive. */
|
||||
disabled?: boolean
|
||||
/** Placeholder text shown when the field is empty. */
|
||||
placeholder?: string
|
||||
/** Forwarded to the `maxlength` attribute (set only when the field refuses over-long values). */
|
||||
maxLength?: number
|
||||
/** Counts characters against the field's limit; the counter shows only near or past it. */
|
||||
lengthLimit?: LengthLimit
|
||||
/** When `true`, an asterisk is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** `data-qqq-id` forwarded to the textarea. */
|
||||
'data-qqq-id'?: string
|
||||
/** Id of help text that describes this control. */
|
||||
describedBy?: string
|
||||
/** Changes the value as the user types (a case behavior), keeping the caret. */
|
||||
transform?: (value: string) => string
|
||||
/** Content shown beside the label (the field's help button). */
|
||||
labelExtra?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a multi-line text input.
|
||||
*
|
||||
* @param props - See {@link TextAreaFieldProps}.
|
||||
* @returns The labelled textarea with its footer.
|
||||
*/
|
||||
export function TextAreaField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
error,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
maxLength,
|
||||
lengthLimit,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
transform,
|
||||
labelExtra,
|
||||
}: TextAreaFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<label htmlFor={id} className="text-sm font-medium text-foreground" data-qqq-id={`field-label-${dataQqqId}`}>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
{labelExtra}
|
||||
</div>
|
||||
<textarea
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
{...registration}
|
||||
onChange={(event) => {
|
||||
if (transform) transformInputValue(event.currentTarget, transform)
|
||||
void registration.onChange(event)
|
||||
}}
|
||||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
rows={4}
|
||||
className={cn(
|
||||
'w-full resize-y rounded-md border bg-background px-3 py-2 text-sm text-foreground transition-colors duration-150',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted',
|
||||
error ? 'border-destructive focus:ring-destructive' : 'border-input'
|
||||
)}
|
||||
/>
|
||||
<FieldFooter id={id} error={error?.message} limit={lengthLimit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FieldFooter } from '../FieldFooter'
|
||||
import type { LengthLimit } from '../FieldFooter'
|
||||
import { transformInputValue } from '@/lib/utils/form-layout'
|
||||
|
||||
/**
|
||||
@@ -42,8 +44,10 @@ interface TextFieldProps {
|
||||
disabled?: boolean
|
||||
/** Placeholder text shown when the field is empty. */
|
||||
placeholder?: string
|
||||
/** Forwarded to the `<input>` `maxlength` attribute. */
|
||||
/** Forwarded to the `<input>` `maxlength` attribute (set only when the field refuses over-long values). */
|
||||
maxLength?: number
|
||||
/** Counts characters against the field's limit; the counter shows only near or past it. */
|
||||
lengthLimit?: LengthLimit
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
@@ -71,6 +75,7 @@ export function TextField({
|
||||
disabled = false,
|
||||
placeholder,
|
||||
maxLength,
|
||||
lengthLimit,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
@@ -83,14 +88,14 @@ export function TextField({
|
||||
}
|
||||
: registration.onChange
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="group flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
@@ -116,11 +121,7 @@ export function TextField({
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldFooter id={id} error={error?.message} limit={lengthLimit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ describe('ProcessStepScreen', () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderScreen()
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(await screen.findByText('Lab Name is required')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Required')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
await user.type(screen.getByLabelText(/Lab Name/), 'Nova')
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('process screens: fields and chrome (#725)', () => {
|
||||
await user.click(await screen.findByRole('option', { name: 'Cat' }))
|
||||
expect(fetchPossibleValues).toHaveBeenCalledWith('petSpecies', expect.anything())
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(await screen.findByText('Minimum Id is required')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Required')).toBeInTheDocument()
|
||||
await user.type(screen.getByLabelText(/Minimum Id/), '4')
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ minimumId: 4, speciesId: 2 }, undefined))
|
||||
@@ -267,11 +267,11 @@ describe('process screens: blocks (#725)', () => {
|
||||
: { name: 'edit', label: 'Edit', components: [{ type: 'EDIT_FORM' }], formFields: [quantity] }
|
||||
const { onSubmit } = renderStep(step, { quantity: '1.5' })
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(await screen.findByText('Quantity must be a whole number')).toBeVisible()
|
||||
expect(await screen.findByText('Enter a whole number')).toBeVisible()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '9007199254740992' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(await screen.findByText('Quantity must be at least 9007199254740993')).toBeVisible()
|
||||
expect(await screen.findByText('Must be between 9007199254740993 and 9007199254740995')).toBeVisible()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '9007199254740993' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
@@ -339,7 +339,7 @@ describe('process screens: blocks (#725)', () => {
|
||||
] } }] }
|
||||
const { onSubmit } = renderStep(step, {})
|
||||
await user.type(screen.getByLabelText(/Code/), ' {Enter}')
|
||||
expect(await screen.findByText('Code is required')).toBeVisible()
|
||||
expect(await screen.findByText('Required')).toBeVisible()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -501,7 +501,7 @@ describe('process screens: value-producing widgets (#725, #736)', () => {
|
||||
expect(await screen.findByDisplayValue('Bolt')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Add new' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(await screen.findByText('Item is required')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Required')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
const items = screen.getAllByLabelText(/Item/)
|
||||
await user.type(items[1], 'Nut')
|
||||
@@ -523,8 +523,7 @@ describe('process screens: value-producing widgets (#725, #736)', () => {
|
||||
expect(await screen.findByLabelText(/Minimum Id/)).toBeInTheDocument()
|
||||
expect(fetchWidgetData).toHaveBeenCalledWith('renderReportProcessValuesWidget', { savedReportId: '7', processUUID: 'run-1' })
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(await screen.findByText('Minimum Id is required')).toBeInTheDocument()
|
||||
expect(screen.getByText('Species is required')).toBeInTheDocument()
|
||||
expect(await screen.findAllByText('Required')).toHaveLength(2)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
await user.type(screen.getByLabelText(/Minimum Id/), '3')
|
||||
await user.click(screen.getByRole('combobox', { name: /Species/ }))
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('ReportRun', () => {
|
||||
expect(within(form).getByRole('checkbox', { name: 'Include Retired' })).toBeInTheDocument()
|
||||
expect(within(form).getByRole('combobox', { name: /Species/ })).toBeInTheDocument()
|
||||
await user.click(within(form).getByRole('button', { name: 'Generate Report' }))
|
||||
expect(await within(form).findByText('Minimum Id is required')).toBeInTheDocument()
|
||||
expect(await within(form).findByText('Required')).toBeInTheDocument()
|
||||
expect(processStep).not.toHaveBeenCalled()
|
||||
await user.type(within(form).getByLabelText(/Minimum Id/), '3')
|
||||
await user.click(within(form).getByRole('checkbox', { name: 'Include Retired' }))
|
||||
|
||||
@@ -229,7 +229,7 @@ export function RowBuilderEditor({
|
||||
const errors = handle
|
||||
? handle.validate(focusFirstError && found.length === 0)
|
||||
: rowErrors(fields, rowFormValues(fields, row), enforceMaxLength)
|
||||
found.push(...rowErrorMessages(index + 1, errors))
|
||||
found.push(...rowErrorMessages(index + 1, errors, Object.fromEntries(fields.map((field) => [field.name, field.label]))))
|
||||
})
|
||||
return found
|
||||
}, [enforceMaxLength, fields])
|
||||
|
||||
@@ -529,7 +529,7 @@ describe('QqqComposite', () => {
|
||||
] })
|
||||
const input = screen.getByLabelText(/Code/)
|
||||
await user.type(input, ' {Enter}')
|
||||
expect(await screen.findByText('Code is required')).toBeVisible()
|
||||
expect(await screen.findByText('Required')).toBeVisible()
|
||||
expect(actionCallback).not.toHaveBeenCalled()
|
||||
await user.clear(input)
|
||||
await user.type(input, ' SPEC-42 {Enter}')
|
||||
|
||||
@@ -326,10 +326,11 @@ export function rowErrors(fields: QFieldMetaData[], values: Record<string, unkno
|
||||
*
|
||||
* @param rowNumber - The row's position, from 1.
|
||||
* @param errors - The row's errors from {@link rowErrors}.
|
||||
* @param labels - Field labels by name, so a short message such as "Required" still says which field.
|
||||
* @returns One message per invalid field.
|
||||
*/
|
||||
export function rowErrorMessages(rowNumber: number, errors: Record<string, string>): string[] {
|
||||
return Object.values(errors).map((message) => `Row ${rowNumber}: ${message}`)
|
||||
export function rowErrorMessages(rowNumber: number, errors: Record<string, string>, labels: Record<string, string> = {}): string[] {
|
||||
return Object.entries(errors).map(([name, message]) => `Row ${rowNumber}: ${labels[name] ? `${labels[name]}: ` : ''}${message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-3
@@ -266,14 +266,33 @@ const DeleteRecordResponseSchema = z.object({
|
||||
errors: z.array(z.string()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* A record write the backend refused, with its messages kept apart so a form can show each one
|
||||
* beside the field it names. The message joins them, as before, for callers that only show text.
|
||||
*/
|
||||
export class RecordWriteError extends Error {
|
||||
/** The backend's messages for the record. */
|
||||
readonly errors: string[]
|
||||
/** True when the messages belong to the record being saved (not to one of its child records). */
|
||||
readonly forRecord: boolean
|
||||
|
||||
constructor(errors: readonly string[], forRecord: boolean) {
|
||||
super(errors.join('; '))
|
||||
this.name = 'RecordWriteError'
|
||||
this.errors = [...errors]
|
||||
this.forRecord = forRecord
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject partial association failures before announcing a successful save.
|
||||
* @param record - A validated response record and any returned children.
|
||||
* @param isRoot - Whether the record is the one being saved (not a child).
|
||||
*/
|
||||
function checkRecordErrors(record: QRecord): void {
|
||||
if (record.errors?.length) throw new Error(record.errors.join('; '))
|
||||
function checkRecordErrors(record: QRecord, isRoot = true): void {
|
||||
if (record.errors?.length) throw new RecordWriteError(record.errors, isRoot)
|
||||
for (const records of Object.values(record.associatedRecords ?? {})) {
|
||||
for (const child of records) checkRecordErrors(child)
|
||||
for (const child of records) checkRecordErrors(child, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,11 @@ export function copyTreePayload(root: CopyNode, edits: Record<string, Record<str
|
||||
function groups(node: CopyNode, parentValues: Record<string, unknown>): Record<string, QRecordInput[]> {
|
||||
return Object.fromEntries(Object.entries(node.groups).map(([name, children]) => [name, children.map(child => {
|
||||
const parsed = zodSchemaFromTableMetadata(child.table, child.fields, true).safeParse(edits[child.path] ?? child.values)
|
||||
if (!parsed.success) throw new Error(`${child.table.label}: ${parsed.error.issues[0].message}`)
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0]
|
||||
const field = child.table.fields[String(issue.path[0])]
|
||||
throw new Error(`${child.table.label}: ${field ? `${field.label}: ` : ''}${issue.message}`)
|
||||
}
|
||||
validateCopyPasswords(child.table, parsed.data, child.fields)
|
||||
const values = { ...child.companions, ...parsed.data }
|
||||
const key = child.table.primaryKeyField
|
||||
|
||||
@@ -85,3 +85,16 @@ export function recordLoadFailure(tableLabel: string, recordId: string | number,
|
||||
if (status === 403) return `You do not have permission to view ${tableLabel} records`
|
||||
return getErrorMessage(error, `Failed to load ${tableLabel} ${recordId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's own messages when a record write was refused (see `RecordWriteError`), so a form can
|
||||
* place each beside the field it names.
|
||||
*
|
||||
* @param error - The unknown error thrown by an insert or update.
|
||||
* @returns The record's messages, or `undefined` for any other error.
|
||||
*/
|
||||
export function recordWriteMessages(error: unknown): string[] | undefined {
|
||||
if (!(error instanceof Error) || error.name !== 'RecordWriteError') return undefined
|
||||
const { errors, forRecord } = error as Error & { errors?: unknown; forRecord?: unknown }
|
||||
return forRecord === true && Array.isArray(errors) ? errors.filter((item): item is string => typeof item === 'string') : undefined
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { QFieldMetaData } from '@/types'
|
||||
import {
|
||||
badInputMessage, blockedNumberKeys, lengthMeter, mapServerErrors, outOfRangeMessage, tooLongMessage, tooLongPolicy, truncateNote,
|
||||
} from './field-validation'
|
||||
import { zodFieldFromMetadata } from './zod-from-metadata'
|
||||
|
||||
function field(name: string, label: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
return { name, label, type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...extra }
|
||||
}
|
||||
|
||||
describe('tooLongPolicy', () => {
|
||||
it('reads ERROR and the trimming behaviors, and leaves everything else to the server', () => {
|
||||
expect(tooLongPolicy({ behaviors: ['ERROR'] })).toBe('error')
|
||||
expect(tooLongPolicy({ behaviors: ['TRUNCATE'] })).toBe('truncate')
|
||||
expect(tooLongPolicy({ behaviors: ['TRUNCATE_ELLIPSIS'] })).toBe('truncate')
|
||||
expect(tooLongPolicy({ behaviors: ['PASS_THROUGH'] })).toBe('soft')
|
||||
expect(tooLongPolicy({ behaviors: ['TO_UPPER_CASE'] })).toBe('soft')
|
||||
expect(tooLongPolicy({})).toBe('soft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lengthMeter', () => {
|
||||
it('stays hidden below 80% of the limit', () => {
|
||||
expect(lengthMeter(0, 200)).toBeUndefined()
|
||||
expect(lengthMeter(159, 200)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('appears from 80% of the limit and flags a value past it', () => {
|
||||
expect(lengthMeter(160, 200)).toEqual({ text: '160 / 200', over: false })
|
||||
expect(lengthMeter(200, 200)).toEqual({ text: '200 / 200', over: false })
|
||||
expect(lengthMeter(214, 200)).toEqual({ text: '214 / 200', over: true })
|
||||
})
|
||||
|
||||
it('shows nothing for a field without a usable limit', () => {
|
||||
expect(lengthMeter(50, undefined)).toBeUndefined()
|
||||
expect(lengthMeter(50, 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateNote', () => {
|
||||
it('speaks only when a trimming field is over its limit', () => {
|
||||
expect(truncateNote('truncate', 11, 10)).toMatch(/trimmed/)
|
||||
expect(truncateNote('truncate', 10, 10)).toBeUndefined()
|
||||
expect(truncateNote('error', 11, 10)).toBeUndefined()
|
||||
expect(truncateNote('soft', 11, 10)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('field validators', () => {
|
||||
const messages = (schema: ReturnType<typeof zodFieldFromMetadata>, value: unknown) => {
|
||||
const result = schema.safeParse(value)
|
||||
return result.success ? [] : result.error.issues.map((issue) => issue.message)
|
||||
}
|
||||
|
||||
it('requires a value with the plain word Required', () => {
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { isRequired: true })), '')).toEqual(['Required'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'DATE', isRequired: true })), '')).toEqual(['Required'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'INTEGER', isRequired: true })), '')).toEqual(['Required'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'BOOLEAN', isRequired: true })), undefined)).toEqual(['Required'])
|
||||
})
|
||||
|
||||
it('says how long a refused value is, only for fields that refuse over-long values', () => {
|
||||
const refusing = zodFieldFromMetadata(field('a', 'A', { maxLength: 5, behaviors: ['ERROR'] }), { enforceMaxLength: false })
|
||||
expect(messages(refusing, 'abcdefgh')).toEqual([tooLongMessage(5, 8)])
|
||||
expect(tooLongMessage(200, 214)).toBe('Must be 200 characters or fewer (currently 214)')
|
||||
expect(messages(refusing, 'abcde')).toEqual([])
|
||||
const trimmed = zodFieldFromMetadata(field('a', 'A', { maxLength: 5, behaviors: ['TRUNCATE'] }), { enforceMaxLength: false })
|
||||
expect(messages(trimmed, 'abcdefgh')).toEqual([])
|
||||
const undeclared = zodFieldFromMetadata(field('a', 'A', { maxLength: 5 }), { enforceMaxLength: false })
|
||||
expect(messages(undeclared, 'abcdefgh')).toEqual([])
|
||||
})
|
||||
|
||||
it('asks for a whole number or a number in plain words, never a type name', () => {
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'INTEGER' })), '1.5')).toEqual(['Enter a whole number'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'INTEGER' })), 'abc')).toEqual(['Enter a whole number'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'DECIMAL' })), 'abc')).toEqual(['Enter a number'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'DECIMAL' })), '2.75')).toEqual([])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'LONG' })), '1.5')).toEqual(['Enter a whole number'])
|
||||
})
|
||||
|
||||
it('names the range when the metadata gives both ends, and the one bound otherwise', () => {
|
||||
const both = field('a', 'A', { type: 'INTEGER', minValue: 1, maxValue: 5 })
|
||||
expect(messages(zodFieldFromMetadata(both), '0')).toEqual(['Must be between 1 and 5'])
|
||||
expect(messages(zodFieldFromMetadata(both), '6')).toEqual(['Must be between 1 and 5'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'DECIMAL', minValue: 0 })), '-1')).toEqual(['Must be at least 0'])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'INTEGER', maxValue: 9 })), '10')).toEqual(['Must be 9 or less'])
|
||||
expect(outOfRangeMessage('max', null, 9)).toBe('Must be 9 or less')
|
||||
})
|
||||
|
||||
it('does not block on rules the metadata does not express', () => {
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A', { type: 'INTEGER' })), '999999999')).toEqual([])
|
||||
expect(messages(zodFieldFromMetadata(field('a', 'A')), 'anything at all')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('number keys and unreadable input', () => {
|
||||
it('refuses letters and signs, plus the decimal point for whole numbers', () => {
|
||||
expect(blockedNumberKeys(false)).toEqual(expect.arrayContaining(['e', 'E', '+']))
|
||||
expect(blockedNumberKeys(false)).not.toContain('.')
|
||||
expect(blockedNumberKeys(true)).toEqual(expect.arrayContaining(['.', ',']))
|
||||
})
|
||||
|
||||
it('refuses a minus sign only when the minimum is not negative', () => {
|
||||
expect(blockedNumberKeys(true, 0)).toContain('-')
|
||||
expect(blockedNumberKeys(true, -5)).not.toContain('-')
|
||||
expect(blockedNumberKeys(true)).not.toContain('-')
|
||||
})
|
||||
|
||||
it('explains an input the browser could not read', () => {
|
||||
const input = (type: string) => Object.assign(document.createElement('input'), { type })
|
||||
expect(badInputMessage({ type: 'INTEGER' }, input('number'))).toBe('Enter a whole number')
|
||||
expect(badInputMessage({ type: 'DECIMAL' }, input('number'))).toBe('Enter a number')
|
||||
expect(badInputMessage({ type: 'DATE' }, input('date'))).toBe('Enter a valid date')
|
||||
expect(badInputMessage({ type: 'DATE_TIME' }, input('datetime-local'))).toBe('Enter a valid date and time')
|
||||
expect(badInputMessage({ type: 'TIME' }, input('time'))).toBe('Enter a valid time')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapServerErrors', () => {
|
||||
const fields = [field('name', 'Name'), field('fullName', 'Full Name'), field('qty', 'Quantity', { type: 'INTEGER' }), field('code', 'Code')]
|
||||
|
||||
it('reads the backend\'s required, too-long and range sentences', () => {
|
||||
const result = mapServerErrors([
|
||||
'Missing value in required field: Name',
|
||||
'The value for Code is too long (max allowed length=5)',
|
||||
'The value for Quantity is too small (minimum allowed value is 1)',
|
||||
], fields)
|
||||
expect(result).toEqual({
|
||||
fieldErrors: { name: 'Required', code: 'Must be 5 characters or fewer', qty: 'Must be at least 1' },
|
||||
unmatched: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('words exclusive range bounds plainly', () => {
|
||||
expect(mapServerErrors(['The value for Quantity is too small (minimum allowed value is greater than 0)'], fields).fieldErrors).toEqual({ qty: 'Must be greater than 0' })
|
||||
expect(mapServerErrors(['The value for Quantity is too large (maximum allowed value is 9)'], fields).fieldErrors).toEqual({ qty: 'Must be 9 or less' })
|
||||
})
|
||||
|
||||
it('matches any other message by the longest field label it mentions, as whole words', () => {
|
||||
expect(mapServerErrors(['Full Name is already taken'], fields).fieldErrors).toEqual({ fullName: 'Full Name is already taken' })
|
||||
expect(mapServerErrors(['Namespace clash'], fields).unmatched).toEqual(['Namespace clash'])
|
||||
})
|
||||
|
||||
it('leaves messages that name no field, or an ambiguous one, as general errors', () => {
|
||||
const twins = [field('a', 'Title'), field('b', 'Title')]
|
||||
expect(mapServerErrors(['Title is invalid'], twins)).toEqual({ fieldErrors: {}, unmatched: ['Title is invalid'] })
|
||||
expect(mapServerErrors(['The record could not be saved'], fields)).toEqual({ fieldErrors: {}, unmatched: ['The record could not be saved'] })
|
||||
})
|
||||
|
||||
it('keeps the first message for a field', () => {
|
||||
expect(mapServerErrors(['Missing value in required field: Name', 'Name is wrong'], fields).fieldErrors).toEqual({ name: 'Required' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file field-validation — the plain-language rules behind quiet record-form validation: how a
|
||||
* field's too-long policy and range read from metadata, when a length counter is worth showing,
|
||||
* the messages users see, and how the server's record errors are matched back to fields.
|
||||
*
|
||||
* Everything here mirrors what the metadata states; the backend stays the authority, so a rule
|
||||
* the metadata does not express is never enforced on the client.
|
||||
*/
|
||||
|
||||
import type { QFieldMetaData } from '@/types'
|
||||
|
||||
/** What the backend does with a value over `maxLength`: refuse it, trim it, or leave it be. */
|
||||
export type TooLongPolicy = 'error' | 'truncate' | 'soft'
|
||||
|
||||
/** The share of `maxLength` after which a length counter appears (the last 20%). */
|
||||
export const COUNTER_THRESHOLD = 0.8
|
||||
|
||||
/**
|
||||
* Reads the field's too-long policy from its `ValueTooLongBehavior` (sent as a behavior name).
|
||||
*
|
||||
* @param field - Field metadata.
|
||||
* @returns `error` (typing is capped), `truncate` (the server trims), or `soft` when the
|
||||
* field declares no refusal or no policy: nothing is enforced on the client.
|
||||
*/
|
||||
export function tooLongPolicy(field: Pick<QFieldMetaData, 'behaviors'>): TooLongPolicy {
|
||||
const behaviors = field.behaviors ?? []
|
||||
if (behaviors.includes('ERROR')) return 'error'
|
||||
if (behaviors.includes('TRUNCATE') || behaviors.includes('TRUNCATE_ELLIPSIS')) return 'truncate'
|
||||
return 'soft'
|
||||
}
|
||||
|
||||
/** How a length counter should look at the current length. */
|
||||
export interface LengthMeter {
|
||||
/** The counter text, such as `120 / 200`. */
|
||||
text: string
|
||||
/** True once the value is past the limit. */
|
||||
over: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The length counter for a value, or `undefined` while the value is comfortably short: a
|
||||
* counter shows only within the last 20% of the limit, or past it.
|
||||
*
|
||||
* @param length - Current number of characters.
|
||||
* @param max - The field's `maxLength`.
|
||||
* @returns The counter, or `undefined` when none is useful.
|
||||
*/
|
||||
export function lengthMeter(length: number, max: number | undefined): LengthMeter | undefined {
|
||||
if (!max || max <= 0 || length < max * COUNTER_THRESHOLD) return undefined
|
||||
return { text: `${length} / ${max}`, over: length > max }
|
||||
}
|
||||
|
||||
/**
|
||||
* The note shown with the counter when the value is over a limit the server will trim.
|
||||
*
|
||||
* @param policy - The field's too-long policy.
|
||||
* @param length - Current number of characters.
|
||||
* @param max - The field's `maxLength`.
|
||||
* @returns The note, or `undefined` when there is none to give.
|
||||
*/
|
||||
export function truncateNote(policy: TooLongPolicy, length: number, max: number | undefined): string | undefined {
|
||||
return policy === 'truncate' && max && length > max ? 'Extra characters will be trimmed when you save.' : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a missing required value.
|
||||
*
|
||||
* @returns The plain word `Required`.
|
||||
*/
|
||||
export function requiredMessage(): string {
|
||||
return 'Required'
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a value that is too long, with how long it is now.
|
||||
*
|
||||
* @param max - The field's `maxLength`.
|
||||
* @param length - Current number of characters.
|
||||
* @returns The message for a value that is too long.
|
||||
*/
|
||||
export function tooLongMessage(max: number, length: number): string {
|
||||
return `Must be ${max} characters or fewer (currently ${length})`
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for text that is not a number.
|
||||
*
|
||||
* @param isInteger - Whether the field takes whole numbers only.
|
||||
* @returns The message for text that is not a number.
|
||||
*/
|
||||
export function notANumberMessage(isInteger: boolean): string {
|
||||
return isInteger ? 'Enter a whole number' : 'Enter a number'
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a number outside the metadata's range.
|
||||
*
|
||||
* @param side - Which bound was broken.
|
||||
* @param min - The field's `minValue`, if any.
|
||||
* @param max - The field's `maxValue`, if any.
|
||||
* @returns A message that names the range when both ends are known.
|
||||
*/
|
||||
export function outOfRangeMessage(side: 'min' | 'max', min?: number | string | null, max?: number | string | null): string {
|
||||
const hasMin = min !== undefined && min !== null
|
||||
const hasMax = max !== undefined && max !== null
|
||||
if (hasMin && hasMax) return `Must be between ${min} and ${max}`
|
||||
return side === 'min' ? `Must be at least ${min}` : `Must be ${max} or less`
|
||||
}
|
||||
|
||||
/** Messages for native inputs whose browsers keep unreadable text out of the value (`validity.badInput`). */
|
||||
const BAD_INPUT_MESSAGES: Record<string, string> = {
|
||||
date: 'Enter a valid date',
|
||||
time: 'Enter a valid time',
|
||||
'datetime-local': 'Enter a valid date and time',
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for an input the browser could not read (a half-typed date, text in a number).
|
||||
*
|
||||
* @param field - Field metadata, when known.
|
||||
* @param input - The input whose `validity.badInput` is set.
|
||||
* @returns The message to show.
|
||||
*/
|
||||
export function badInputMessage(field: Pick<QFieldMetaData, 'type'> | undefined, input: HTMLInputElement): string {
|
||||
if (input.type === 'number') return notANumberMessage(field?.type === 'INTEGER' || field?.type === 'LONG')
|
||||
return BAD_INPUT_MESSAGES[input.type] ?? 'Enter a valid value'
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys a number input refuses, so non-numeric text never lands in it.
|
||||
*
|
||||
* @param isInteger - Whether the field takes whole numbers only.
|
||||
* @param minValue - The field's `minValue`, if any (a non-negative minimum refuses `-`).
|
||||
* @returns The characters to prevent.
|
||||
*/
|
||||
export function blockedNumberKeys(isInteger: boolean, minValue?: number | string | null): string[] {
|
||||
const keys = ['e', 'E', '+']
|
||||
if (isInteger) keys.push('.', ',')
|
||||
if (minValue !== undefined && minValue !== null && Number(minValue) >= 0) keys.push('-')
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Server messages sorted into those that name a field and those that do not. */
|
||||
export interface MappedServerErrors {
|
||||
/** Plain message per field name. */
|
||||
fieldErrors: Record<string, string>
|
||||
/** Messages that name no known field; these stay on the form as a general error. */
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
const REQUIRED_PATTERN = /^Missing value in required field:\s*(.+?)\s*$/
|
||||
const TOO_LONG_PATTERN = /^The value for (.+) is too long \(max allowed length=(\d+)\)/
|
||||
const TOO_SMALL_PATTERN = /^The value for (.+) is too small \(minimum allowed value is (greater than )?(.+?)\)/
|
||||
const TOO_LARGE_PATTERN = /^The value for (.+) is too large \(maximum allowed value is (less than )?(.+?)\)/
|
||||
|
||||
/**
|
||||
* Escapes text for use inside a regular expression.
|
||||
*
|
||||
* @param text - Plain text.
|
||||
* @returns The text with regex characters escaped.
|
||||
*/
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the backend's per-record error messages to the form's fields. The record write
|
||||
* reports plain sentences that name a field by label, so the known sentences are read exactly
|
||||
* and any other message is matched by the longest field label it mentions.
|
||||
*
|
||||
* @param messages - The record's error messages.
|
||||
* @param fields - The form's editable fields.
|
||||
* @returns Field messages, and what could not be matched.
|
||||
*/
|
||||
export function mapServerErrors(messages: readonly string[], fields: readonly QFieldMetaData[]): MappedServerErrors {
|
||||
const byLabel = new Map<string, QFieldMetaData[]>()
|
||||
for (const field of fields) {
|
||||
const label = (field.label || field.name).trim()
|
||||
byLabel.set(label, [...(byLabel.get(label) ?? []), field])
|
||||
}
|
||||
const exact = (label: string) => {
|
||||
const matches = byLabel.get(label.trim())
|
||||
return matches?.length === 1 ? matches[0] : undefined
|
||||
}
|
||||
const mentioned = (message: string) => {
|
||||
let best: QFieldMetaData[] | undefined
|
||||
let bestLength = 0
|
||||
for (const [label, matches] of byLabel) {
|
||||
if (label.length < bestLength) continue
|
||||
if (!new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(label)}($|[^\\p{L}\\p{N}])`, 'u').test(message)) continue
|
||||
best = label.length === bestLength ? [...(best ?? []), ...matches] : matches
|
||||
bestLength = label.length
|
||||
}
|
||||
return best?.length === 1 ? best[0] : undefined
|
||||
}
|
||||
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
const unmatched: string[] = []
|
||||
for (const message of messages) {
|
||||
let field: QFieldMetaData | undefined
|
||||
let text = message
|
||||
let match: RegExpExecArray | null
|
||||
if ((match = REQUIRED_PATTERN.exec(message))) {
|
||||
field = exact(match[1])
|
||||
text = requiredMessage()
|
||||
} else if ((match = TOO_LONG_PATTERN.exec(message))) {
|
||||
field = exact(match[1])
|
||||
text = `Must be ${match[2]} characters or fewer`
|
||||
} else if ((match = TOO_SMALL_PATTERN.exec(message))) {
|
||||
field = exact(match[1])
|
||||
text = match[2] ? `Must be greater than ${match[3]}` : `Must be at least ${match[3]}`
|
||||
} else if ((match = TOO_LARGE_PATTERN.exec(message))) {
|
||||
field = exact(match[1])
|
||||
text = match[2] ? `Must be less than ${match[3]}` : `Must be ${match[3]} or less`
|
||||
}
|
||||
field ??= mentioned(message)
|
||||
if (!field) {
|
||||
unmatched.push(message)
|
||||
} else if (!(field.name in fieldErrors)) {
|
||||
fieldErrors[field.name] = text
|
||||
}
|
||||
}
|
||||
return { fieldErrors, unmatched }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file form-focus — moving the user to the first field that needs attention after a failed save:
|
||||
* which invalid field comes first on the page, scrolling and focusing it, and whether the other
|
||||
* invalid fields would be out of sight (the only time a summary is worth showing).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Finds a field's control in the form by the `data-qqq-id` the form puts on every control.
|
||||
*
|
||||
* @param form - The form element.
|
||||
* @param name - The field name.
|
||||
* @returns The control, if it is rendered.
|
||||
*/
|
||||
export function fieldElement(form: HTMLElement, name: string): HTMLElement | null {
|
||||
const id = name.split('"').join('')
|
||||
const selector = ['input', 'textarea', 'select', 'button', '[role="combobox"]'].map((tag) => `${tag}[data-qqq-id="${id}"]`).join(', ')
|
||||
return form.querySelector<HTMLElement>(selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders field names as their controls appear on the page; names with no control keep their place at the end.
|
||||
*
|
||||
* @param form - The form element.
|
||||
* @param names - Field names.
|
||||
* @returns The names in page order.
|
||||
*/
|
||||
export function inPageOrder(form: HTMLElement, names: readonly string[]): string[] {
|
||||
const placed = names.map((name) => ({ name, element: fieldElement(form, name) }))
|
||||
return placed
|
||||
.sort((a, b) => {
|
||||
if (!a.element || !b.element) return a.element ? -1 : b.element ? 1 : 0
|
||||
return a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
|
||||
})
|
||||
.map(({ name }) => name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls a control to the middle of the view and focuses it, gently unless the user prefers less motion.
|
||||
*
|
||||
* @param element - The control.
|
||||
*/
|
||||
export function revealField(element: HTMLElement): void {
|
||||
const reduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
element.scrollIntoView?.({ block: 'center', behavior: reduced ? 'auto' : 'smooth' })
|
||||
element.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the other fields sits far enough from the revealed one to be out of sight
|
||||
* once it is centred.
|
||||
*
|
||||
* @param first - The control being revealed (it ends up mid-screen).
|
||||
* @param others - The remaining invalid controls.
|
||||
* @returns `true` when at least one would be off screen.
|
||||
*/
|
||||
export function anyOffScreen(first: HTMLElement, others: readonly HTMLElement[]): boolean {
|
||||
const firstTop = first.getBoundingClientRect().top
|
||||
const reach = window.innerHeight / 2 - 40
|
||||
return others.some((element) => Math.abs(element.getBoundingClientRect().top - firstTop) > reach)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ describe('zodFieldFromMetadata', () => {
|
||||
const schema = zodFieldFromMetadata(makeField({ type: 'LONG', minValue: 0, maxValue: 100 }))
|
||||
const result = schema.safeParse(value)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.error.issues.map(issue => issue.message)).toContain('Test Field must be a whole number')
|
||||
if (!result.success) expect(result.error.issues.map(issue => issue.message)).toContain('Enter a whole number')
|
||||
})
|
||||
|
||||
it('LONG applies exact string bounds beyond the safe-number range', () => {
|
||||
@@ -208,7 +208,7 @@ describe('zodFieldFromMetadata', () => {
|
||||
expect(schema.safeParse(false).success).toBe(true)
|
||||
const missing = schema.safeParse(null)
|
||||
expect(missing.success).toBe(false)
|
||||
expect(missing.error?.issues[0]?.message).toBe('Active is required')
|
||||
expect(missing.error?.issues[0]?.message).toBe('Required')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('zodFieldFromMetadata', () => {
|
||||
expect(required.safeParse(null).success).toBe(false)
|
||||
expect(required.safeParse('').success).toBe(false)
|
||||
expect(required.safeParse(new File(['x'], 'x.txt')).success).toBe(true)
|
||||
expect(required.safeParse(42).error?.issues[0].message).toBe('Scan must be a file')
|
||||
expect(required.safeParse(42).error?.issues[0].message).toBe('Choose a file')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,26 +23,27 @@ import { z } from 'zod'
|
||||
import type { QFieldMetaData, QTableMetaData } from '@/types'
|
||||
import { adornmentString } from './adornment-utils'
|
||||
import { fromLocalDateTimeInput, toLocalDateTimeInput } from './datetime-utils'
|
||||
import { notANumberMessage, outOfRangeMessage, requiredMessage, tooLongMessage, tooLongPolicy } from './field-validation'
|
||||
|
||||
/**
|
||||
* Internal helper used by {@link zodFieldFromMetadata} to build a string-based Zod schema
|
||||
* for STRING, TEXT, HTML, and PASSWORD field types.
|
||||
*
|
||||
* @param isRequired - When `true`, adds a `min(1)` constraint so empty strings fail
|
||||
* validation with a `"${label} is required"` message; `false` makes the field optional.
|
||||
* @param label - The human-readable field label from metadata, used verbatim in error
|
||||
* messages (e.g. `"First Name is required"`).
|
||||
* @param maxLength - When provided (from `QFieldMetaData.maxLength`), adds a `max()`
|
||||
* constraint with a `"${label} must be at most ${maxLength} characters"` message.
|
||||
* validation with a `"Required"` message; `false` makes the field optional.
|
||||
* @param maxLength - When provided (from `QFieldMetaData.maxLength`), a longer value fails
|
||||
* with "Must be N characters or fewer (currently M)".
|
||||
* @returns A `z.ZodString` when required, or `z.ZodOptional<z.ZodString>` when optional.
|
||||
* The returned schema is typed as `z.ZodTypeAny` because the exact generic depends on
|
||||
* the `isRequired` branch.
|
||||
*/
|
||||
function buildStringSchema(isRequired: boolean, label: string, maxLength?: number): z.ZodTypeAny {
|
||||
function buildStringSchema(isRequired: boolean, maxLength?: number): z.ZodTypeAny {
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, `${label} must be at most ${maxLength} characters`)
|
||||
return isRequired ? schema : schema.optional()
|
||||
if (isRequired) schema = schema.min(1, requiredMessage())
|
||||
const limited = maxLength
|
||||
? schema.refine((value) => value.length <= maxLength, (value) => ({ message: tooLongMessage(maxLength, value.length) }))
|
||||
: schema
|
||||
return isRequired ? limited : limited.optional()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,14 +57,13 @@ function buildStringSchema(isRequired: boolean, label: string, maxLength?: numbe
|
||||
* No `maxLength` is applied because the browser constrains the format to a fixed-width string.
|
||||
*
|
||||
* @param isRequired - When `true`, adds a `min(1)` constraint so an unpopulated date input
|
||||
* (which submits as an empty string `""`) fails validation with a `"${label} is required"`
|
||||
* (which submits as an empty string `""`) fails validation with a `"Required"`
|
||||
* message; `false` makes the field optional.
|
||||
* @param label - The human-readable field label from metadata, used verbatim in error messages.
|
||||
* @returns A `z.ZodString` when required, or `z.ZodOptional<z.ZodString>` when optional.
|
||||
*/
|
||||
function buildDateTimeSchema(isRequired: boolean, label: string): z.ZodTypeAny {
|
||||
function buildDateTimeSchema(isRequired: boolean): z.ZodTypeAny {
|
||||
const schema = z.string()
|
||||
if (isRequired) return schema.min(1, `${label} is required`)
|
||||
if (isRequired) return schema.min(1, requiredMessage())
|
||||
return schema.optional()
|
||||
}
|
||||
|
||||
@@ -72,21 +72,20 @@ function buildDateTimeSchema(isRequired: boolean, label: string): z.ZodTypeAny {
|
||||
* a JavaScript number cannot hold every LONG (beyond 2^53 it silently changes).
|
||||
*
|
||||
* @param isRequired - When `true`, blank values are rejected.
|
||||
* @param label - Field label for messages.
|
||||
* @param minValue - Optional lower bound.
|
||||
* @param maxValue - Optional upper bound.
|
||||
* @returns The schema, producing the digits as a string ('' when blank and optional).
|
||||
*/
|
||||
function buildLongSchema(isRequired: boolean, label: string, minValue?: number | string | null, maxValue?: number | string | null): z.ZodTypeAny {
|
||||
function buildLongSchema(isRequired: boolean, minValue?: number | string | null, maxValue?: number | string | null): z.ZodTypeAny {
|
||||
const text = z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value).trim())
|
||||
const integer = text
|
||||
.refine((value) => !isRequired || value !== '', `${label} is required`)
|
||||
.refine((value) => value === '' || /^[+-]?\d+$/.test(value), `${label} must be a whole number`)
|
||||
.refine((value) => !isRequired || value !== '', requiredMessage())
|
||||
.refine((value) => value === '' || /^[+-]?\d+$/.test(value), notANumberMessage(true))
|
||||
// Refinements on one schema all run, even after a previous refinement fails.
|
||||
// Pipe only valid integer text into BigInt comparisons so bad input stays a field error.
|
||||
const checked = integer.pipe(z.string()
|
||||
.refine((value) => value === '' || minValue === undefined || minValue === null || BigInt(value) >= longRangeBound(minValue), `${label} must be at least ${minValue}`)
|
||||
.refine((value) => value === '' || maxValue === undefined || maxValue === null || BigInt(value) <= longRangeBound(maxValue), `${label} must be at most ${maxValue}`))
|
||||
.refine((value) => value === '' || minValue === undefined || minValue === null || BigInt(value) >= longRangeBound(minValue), outOfRangeMessage('min', minValue, maxValue))
|
||||
.refine((value) => value === '' || maxValue === undefined || maxValue === null || BigInt(value) <= longRangeBound(maxValue), outOfRangeMessage('max', minValue, maxValue)))
|
||||
return isRequired ? checked : checked.optional()
|
||||
}
|
||||
|
||||
@@ -99,44 +98,59 @@ function longRangeBound(value: number | string): bigint | number {
|
||||
return typeof value === 'string' && /^[+-]?\d+$/.test(value.trim()) ? BigInt(value) : Number(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A schema that accepts a blank value as it is, and anything else only when it passes `schema`,
|
||||
* keeping that schema's own message (a union would report only "Invalid input").
|
||||
*
|
||||
* @param schema - The schema for a filled-in value.
|
||||
* @returns The blank-or-valid schema.
|
||||
*/
|
||||
function blankOr(schema: z.ZodTypeAny): z.ZodTypeAny {
|
||||
return z.any().transform((value, ctx): unknown => {
|
||||
if (value === undefined || value === '') return value
|
||||
const parsed = schema.safeParse(value)
|
||||
if (parsed.success) return parsed.data
|
||||
for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', message: issue.message })
|
||||
return z.NEVER
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper used by {@link zodFieldFromMetadata} to build a `coerce.number()` schema
|
||||
* for INTEGER, LONG, and DECIMAL field types.
|
||||
*
|
||||
* HTML `<input type="number">` elements submit an empty string when left blank, which would
|
||||
* cause a type error against a plain `z.number()`. The optional branch therefore returns
|
||||
* `z.union([z.literal(''), schema]).optional()` so blank inputs pass validation cleanly.
|
||||
* a blank-or-valid schema (`blankOr`) so blank inputs pass validation cleanly.
|
||||
*
|
||||
* @param isRequired - When `true`, the schema rejects empty strings; `false` wraps the schema
|
||||
* in a union with `z.literal('')` and marks it optional.
|
||||
* @param label - The human-readable field label used verbatim in all error messages.
|
||||
* @param isRequired - When `true`, the schema rejects empty strings; `false` lets a blank value
|
||||
* through and marks it optional.
|
||||
* @param isInteger - When `true`, adds a `.int()` constraint (for INTEGER / LONG types);
|
||||
* `false` allows fractional values (DECIMAL).
|
||||
* @param minValue - Optional lower bound from field metadata; adds a `.min()` constraint when
|
||||
* defined and non-null.
|
||||
* @param maxValue - Optional upper bound from field metadata; adds a `.max()` constraint when
|
||||
* defined and non-null.
|
||||
* @returns A `ZodTypeAny` — a plain `ZodNumber` when required, or a `ZodOptional` union when not.
|
||||
* @returns A `ZodTypeAny` — a plain `ZodNumber` when required, or an optional blank-or-valid schema when not.
|
||||
*/
|
||||
function buildNumberSchema(
|
||||
isRequired: boolean,
|
||||
label: string,
|
||||
isInteger: boolean,
|
||||
minValue?: number | string | null,
|
||||
maxValue?: number | string | null
|
||||
): z.ZodTypeAny {
|
||||
let schema: z.ZodNumber = isInteger
|
||||
? z.coerce.number().int(`${label} must be a whole number`)
|
||||
: z.coerce.number({ message: `${label} must be a number` })
|
||||
? z.coerce.number({ message: notANumberMessage(true) }).int(notANumberMessage(true))
|
||||
: z.coerce.number({ message: notANumberMessage(false) })
|
||||
if (minValue !== undefined && minValue !== null) {
|
||||
schema = schema.min(Number(minValue), `${label} must be at least ${minValue}`)
|
||||
schema = schema.min(Number(minValue), outOfRangeMessage('min', minValue, maxValue))
|
||||
}
|
||||
if (maxValue !== undefined && maxValue !== null) {
|
||||
schema = schema.max(Number(maxValue), `${label} must be at most ${maxValue}`)
|
||||
schema = schema.max(Number(maxValue), outOfRangeMessage('max', minValue, maxValue))
|
||||
}
|
||||
return isRequired
|
||||
? z.union([z.string().trim().min(1, `${label} is required`), z.number()]).pipe(schema)
|
||||
: z.union([z.literal(''), schema]).optional()
|
||||
? z.union([z.string().trim().min(1, requiredMessage()), z.number()]).pipe(schema)
|
||||
: blankOr(schema).optional()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +166,7 @@ function buildNumberSchema(
|
||||
* name: 'firstName', label: 'First Name', type: 'STRING',
|
||||
* isRequired: true, maxLength: 100, isEditable: true, isHidden: false, isHeavy: false, adornments: [],
|
||||
* })
|
||||
* // Produces: z.string().min(1, 'First Name is required').max(100, 'First Name must be at most 100 characters')
|
||||
* // Produces: z.string().min(1, 'Required').refine(length <= 100)
|
||||
*
|
||||
* // An optional integer field with bounds
|
||||
* const schema = zodFieldFromMetadata({
|
||||
@@ -162,30 +176,30 @@ function buildNumberSchema(
|
||||
* // Produces: z.union([z.literal(''), z.coerce.number().int().min(0).max(150)]).optional()
|
||||
*
|
||||
* @param field - The field metadata object containing type, required, maxLength, and label.
|
||||
* @param options - `enforceMaxLength: false` skips the `maxLength` limit (record forms, whose
|
||||
* server applies each field's too-long policy).
|
||||
* @param options - `enforceMaxLength: false` checks `maxLength` only for fields whose
|
||||
* too-long behavior is ERROR (record forms, whose server trims or passes the rest).
|
||||
* @returns A `ZodTypeAny` appropriate for the field's type and constraints.
|
||||
*/
|
||||
export function zodFieldFromMetadata(field: QFieldMetaData, { enforceMaxLength = true }: { enforceMaxLength?: boolean } = {}): z.ZodTypeAny {
|
||||
const { type, isRequired, label } = field
|
||||
// Table writes apply the field's own too-long policy (truncate, ellipsis, pass through
|
||||
// or reject) on the server, so record forms do not pre-empt it with a client limit.
|
||||
const maxLength = enforceMaxLength ? field.maxLength : undefined
|
||||
const { type, isRequired } = field
|
||||
// The field's own too-long policy decides: only a refusing policy (ERROR) is checked here;
|
||||
// truncate and pass-through are left to the server. Process forms (enforceMaxLength) always check.
|
||||
const maxLength = enforceMaxLength || tooLongPolicy(field) === 'error' ? field.maxLength : undefined
|
||||
|
||||
switch (type) {
|
||||
case 'INTEGER':
|
||||
return buildNumberSchema(isRequired ?? false, label ?? type, true, field.minValue, field.maxValue)
|
||||
return buildNumberSchema(isRequired ?? false, true, field.minValue, field.maxValue)
|
||||
|
||||
case 'LONG':
|
||||
return buildLongSchema(isRequired ?? false, label ?? type, field.minValue, field.maxValue)
|
||||
return buildLongSchema(isRequired ?? false, field.minValue, field.maxValue)
|
||||
|
||||
case 'DECIMAL':
|
||||
return buildNumberSchema(isRequired ?? false, label ?? type, false, field.minValue, field.maxValue)
|
||||
return buildNumberSchema(isRequired ?? false, false, field.minValue, field.maxValue)
|
||||
|
||||
case 'BOOLEAN': {
|
||||
// An optional boolean may be unset: a stored null, or the switch cycled back to
|
||||
// unset (QRun-IO/qqq#761). A required one needs true or false, as in Material.
|
||||
const message = `${label ?? type} is required`
|
||||
const message = requiredMessage()
|
||||
return isRequired
|
||||
? z.boolean({ required_error: message, invalid_type_error: message })
|
||||
: z.boolean().nullable().optional()
|
||||
@@ -194,14 +208,14 @@ export function zodFieldFromMetadata(field: QFieldMetaData, { enforceMaxLength =
|
||||
case 'DATE':
|
||||
case 'TIME':
|
||||
case 'DATE_TIME':
|
||||
return buildDateTimeSchema(isRequired ?? false, label ?? type)
|
||||
return buildDateTimeSchema(isRequired ?? false)
|
||||
|
||||
case 'BLOB': {
|
||||
// A new File, the stored value (download URL or bytes) left unchanged, or null when
|
||||
// the stored file is removed. A required file must be present.
|
||||
const file = z.union([z.instanceof(File), z.string(), z.null()], { errorMap: () => ({ message: `${label ?? type} must be a file` }) })
|
||||
const file = z.union([z.instanceof(File), z.string(), z.null()], { errorMap: () => ({ message: 'Choose a file' }) })
|
||||
return isRequired
|
||||
? file.refine((value) => value instanceof File || (typeof value === 'string' && value !== ''), `${label ?? type} is required`)
|
||||
? file.refine((value) => value instanceof File || (typeof value === 'string' && value !== ''), requiredMessage())
|
||||
: file.optional()
|
||||
}
|
||||
|
||||
@@ -210,7 +224,7 @@ export function zodFieldFromMetadata(field: QFieldMetaData, { enforceMaxLength =
|
||||
case 'HTML':
|
||||
case 'STRING':
|
||||
default:
|
||||
return buildStringSchema(isRequired ?? false, label ?? type, maxLength)
|
||||
return buildStringSchema(isRequired ?? false, maxLength)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// E2E: quiet record-form validation — required fields, length limits and counters, number entry,
|
||||
// server errors placed on their fields, and focus moving to the first invalid field.
|
||||
//
|
||||
// Set FORM_SHOTS_DIR to also write the screenshot set (light and dark, 390 and 1440 px).
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { setupApiMocks, METADATA } from './api-mocks'
|
||||
|
||||
type MockField = Record<string, unknown>
|
||||
|
||||
/** Gives the mocked person table the validation metadata these tests need (this file's worker only). */
|
||||
function addValidationRules() {
|
||||
const person = METADATA.tables.person as unknown as { fields: Record<string, MockField>; sections: Array<{ fieldNames: string[] }> }
|
||||
person.fields.firstName = { ...person.fields.firstName, maxLength: 10, behaviors: ['ERROR'] }
|
||||
person.fields.email = { ...person.fields.email, maxLength: 20, behaviors: ['TRUNCATE'] }
|
||||
person.fields.age = {
|
||||
name: 'age', label: 'Age', type: 'INTEGER', isEditable: true, isRequired: false, isHeavy: false, isHidden: false,
|
||||
adornments: [], minValue: 1, maxValue: 120,
|
||||
}
|
||||
person.sections[0].fieldNames = ['firstName', 'lastName', 'email', 'age', 'status']
|
||||
}
|
||||
|
||||
async function openCreate(page: Page) {
|
||||
addValidationRules()
|
||||
await setupApiMocks(page)
|
||||
await page.goto('/app/person/create')
|
||||
await expect(page.locator('[data-qqq-id="entity-form-person"]')).toBeVisible({ timeout: 30000 })
|
||||
}
|
||||
|
||||
const field = (page: Page, name: string) => page.locator(`input[data-qqq-id="${name}"]`)
|
||||
|
||||
test.describe('Record form validation', () => {
|
||||
test('a required field stays quiet while typing, errors on blur, then clears live', async ({ page }) => {
|
||||
await openCreate(page)
|
||||
const first = field(page, 'firstName')
|
||||
await first.fill('Al')
|
||||
await first.fill('')
|
||||
await expect(page.getByText('Required')).toHaveCount(0)
|
||||
await first.blur()
|
||||
await expect(page.getByText('Required')).toHaveCount(1)
|
||||
await expect(first).toHaveAttribute('aria-invalid', 'true')
|
||||
await first.pressSequentially('A')
|
||||
await expect(page.getByText('Required')).toHaveCount(0)
|
||||
await expect(first).not.toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
|
||||
test('a refused length caps typing and the counter appears only near the limit', async ({ page }) => {
|
||||
await openCreate(page)
|
||||
const first = field(page, 'firstName')
|
||||
await first.pressSequentially('abcdefg')
|
||||
await expect(page.getByText(/^\d+ \/ 10$/)).toHaveCount(0)
|
||||
await first.pressSequentially('h')
|
||||
await expect(page.getByText('8 / 10')).toBeVisible()
|
||||
await first.pressSequentially('ijklmn')
|
||||
await expect(first).toHaveValue('abcdefghij')
|
||||
await expect(page.getByText('10 / 10')).toBeVisible()
|
||||
})
|
||||
|
||||
test('number entry rejects letters and signs, and names the range when out of it', async ({ page }) => {
|
||||
await openCreate(page)
|
||||
const age = field(page, 'age')
|
||||
await age.pressSequentially('a1e.2')
|
||||
await expect(age).toHaveValue('12')
|
||||
await age.fill('130')
|
||||
await age.blur()
|
||||
await expect(page.getByText('Must be between 1 and 120')).toBeVisible()
|
||||
})
|
||||
|
||||
test('save moves focus to the first invalid field', async ({ page }) => {
|
||||
await openCreate(page)
|
||||
await page.locator('[data-qqq-id="button-save"]').click()
|
||||
await expect(page.getByText('Required').first()).toBeVisible()
|
||||
await expect(field(page, 'firstName')).toBeFocused()
|
||||
})
|
||||
|
||||
test('a server error lands on its field and keeps what was typed', async ({ page }) => {
|
||||
await openCreate(page)
|
||||
await page.route(/\/qqq\/v1\/table\/person(\?|$)/, (route) => {
|
||||
if (route.request().method() !== 'POST') return route.fallback()
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ record: { tableName: 'person', values: {}, errors: ['The value for Email is too long (max allowed length=20)'] } }),
|
||||
})
|
||||
})
|
||||
await field(page, 'firstName').fill('Ada')
|
||||
await field(page, 'lastName').fill('Lovelace')
|
||||
await field(page, 'status').fill('ACTIVE')
|
||||
await field(page, 'email').fill('ada.lovelace@example.com')
|
||||
await page.locator('[data-qqq-id="button-save"]').click()
|
||||
await expect(page.getByText('Must be 20 characters or fewer')).toBeVisible()
|
||||
await expect(field(page, 'email')).toBeFocused()
|
||||
await expect(field(page, 'email')).toHaveValue('ada.lovelace@example.com')
|
||||
await expect(page.getByText(/Failed to create/)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
const SHOTS = process.env.FORM_SHOTS_DIR
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
for (const width of [390, 1440]) {
|
||||
test(`screenshots ${scheme} ${width}`, async ({ page }) => {
|
||||
test.skip(!SHOTS, 'set FORM_SHOTS_DIR to write screenshots')
|
||||
await page.setViewportSize({ width, height: width < 600 ? 844 : 900 })
|
||||
await page.emulateMedia({ colorScheme: scheme })
|
||||
await page.addInitScript((value) => { try { localStorage.setItem('qqq-dark-mode', String(value === 'dark')) } catch { /* ignore */ } }, scheme)
|
||||
await openCreate(page)
|
||||
const form = page.locator('[data-qqq-id="entity-form-person"]')
|
||||
await form.screenshot({ path: `${SHOTS}/clean-${scheme}-${width}.png` })
|
||||
await field(page, 'firstName').pressSequentially('abcdefgh')
|
||||
await field(page, 'email').pressSequentially('someone.else@example.com')
|
||||
await form.screenshot({ path: `${SHOTS}/near-limit-${scheme}-${width}.png` })
|
||||
await field(page, 'firstName').fill('')
|
||||
await field(page, 'age').fill('130')
|
||||
await page.locator('[data-qqq-id="button-save"]').click()
|
||||
await expect(page.getByText('Required').first()).toBeVisible()
|
||||
await page.waitForTimeout(400)
|
||||
await form.screenshot({ path: `${SHOTS}/errors-${scheme}-${width}.png` })
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user