diff --git a/.changeset/required-is-presence-not-truthiness.md b/.changeset/required-is-presence-not-truthiness.md new file mode 100644 index 000000000..790c1f1dc --- /dev/null +++ b/.changeset/required-is-presence-not-truthiness.md @@ -0,0 +1,55 @@ +--- +"@object-ui/components": patch +"@object-ui/plugin-form": patch +"@object-ui/core": patch +"@object-ui/fields": patch +--- + +A required boolean must be savable in its UNCHECKED state — `false` and `0` are values. + +Reported against an AI-built task tracker whose 任务 object has a required +`是否完成` boolean: the create form showed the switch OFF, answered "是否完成不能 +为空", and saved instantly once the switch was turned ON. The app could only ever +create ALREADY-DONE tasks — the one state the control shows by default was the +one value it refused to save (cloud#972). + +Two defects stacked, and either alone is enough to break it: + +**The `required` verdict read truthiness, not presence.** `@objectstack/spec` +FieldSchema.required (ADR-0113) is "an insert must provide a NON-NULL value", +and objectql's record validator implements exactly that. react-hook-form's +built-in rule instead fails whenever `isBoolean(value) && !value` — its +accept-the-terms checkbox heritage — silently redefining every required boolean +as "must be TRUE", including a select whose chosen option value is `false`. It +also disagreed the other way, letting a whitespace-only string through for the +server to reject with a 400. The form renderer no longer hands RHF its own +`required`: the check is now a `validate` entry keyed `required` (so the error +still surfaces as `type: 'required'`, which the conditional-required cleanup +keys on) backed by a new shared `isMissingForRequired` in `@object-ui/core`, a +deliberate mirror of objectql `record-validator.isMissing` — `undefined`, +`null`, blank-after-trim string, empty array. Deleting the inherited rule also +stops a `required` that rode in on `validation` from outliving a `requiredWhen` +that resolved to FALSE. + +**A boolean field held `undefined` while displaying "off".** A two-state control +has no third state, but a field with no entry in `defaultValues` rendered an OFF +switch backed by nothing: the create payload omitted the column (it lands null, +which reads as unchecked but isn't) and the presence check above would still +refuse it. The form renderer now folds `false` into `defaultValues` for every +boolean-widget field the caller left unset — in `defaultValues` itself, not +per-Controller, because that object is also the dirty-check baseline and what +the defaults-reset window replays. Every surface gets it, including the +modal/drawer create dialogs that start from a bare `{}`. An authored default +(or a loaded record, `null` included) still wins. + +`WizardForm`'s cross-step gate had its own copy of the empty-value predicate; it +now imports the shared one so it cannot drift from the per-field verdict. And +the field-demo renderer read `schema.defaultValue || schema.value`, throwing +away an authored default of `false` / `0` / `''` — same falsy-as-empty class, +now `??`. + +Verified end to end on a local stack against the exact metadata shape +`apply_blueprint` materializes (`{ type: 'boolean', required: true }`, no +default): a 是否完成 = 否 task with 工时 = 0 now creates and persists as +`{ hours: 0, is_done: false }`, turning the switch on still stores `true`, and a +blank required text is still refused. diff --git a/packages/components/src/renderers/form/__tests__/form-required-falsy-values.test.tsx b/packages/components/src/renderers/form/__tests__/form-required-falsy-values.test.tsx new file mode 100644 index 000000000..1f6c9e609 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-required-falsy-values.test.tsx @@ -0,0 +1,269 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `required` is a PRESENCE contract, not a truthiness one. + * + * `@objectstack/spec` FieldSchema.required (ADR-0113) reads "an insert must + * provide a NON-NULL value", and the server's record validator implements + * exactly that — `isMissing` is `undefined | null | blank string`, nothing + * else. `false` and `0` are values. + * + * react-hook-form's built-in `required` rule disagrees on one point: it fails + * whenever the value `isBoolean(v) && !v`, i.e. it reads every boolean field + * as "must be checked" (its accept-the-terms checkbox heritage). Handing a + * required boolean field to that rule turns `required` into `must be true`, + * and the only value the control can express by default becomes unsubmittable + * — an AI-built task tracker literally could not create an UNFINISHED task + * (cloud#972). + * + * These tests pin the client verdict to the server's for every falsy value: + * `false` and `0` pass, `null` / `undefined` / `''` still fail. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import '../../../renderers'; + +beforeEach(() => { + if (!(Element.prototype as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function renderForm( + fields: any[], + defaultValues: Record, + onSubmit: (data: any) => Promise | unknown, +) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i })); + +describe('form renderer — required + falsy values', () => { + it('accepts an unchecked required switch (cloud#972: "未完成" must be creatable)', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { name: 'title', label: 'Title', type: 'input', required: true }, + { name: 'is_done', label: 'Done', type: 'switch', required: true }, + ], + { title: 'Write the report', is_done: false }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ title: 'Write the report', is_done: false }); + expect(screen.queryByText(/is required/i)).toBeNull(); + }); + + it('accepts an unchecked required checkbox', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [{ name: 'agreed', label: 'Agreed', type: 'checkbox', required: true }], + { agreed: false }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ agreed: false }); + }); + + it('accepts a required select whose chosen option value is boolean false', async () => { + // Option values round-trip TYPED (#3090), so a 是/否 picklist hands the form + // a real `false` — the same RHF branch that breaks the switch. + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { + name: 'billable', + label: 'Billable', + type: 'select', + required: true, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + ], + { billable: false }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ billable: false }); + }); + + it('accepts a required number whose value is 0', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [{ name: 'hours', label: 'Hours', type: 'number', required: true }], + { hours: 0 }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ hours: 0 }); + }); + + it('still rejects an empty string — the server counts blank text as missing', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [{ name: 'title', label: 'Title', type: 'input', required: true }], + { title: '' }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(screen.getByText(/Title is required/i)).toBeTruthy()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('still rejects a whitespace-only string, as the server does', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [{ name: 'title', label: 'Title', type: 'input', required: true }], + { title: ' ' }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(screen.getByText(/Title is required/i)).toBeTruthy()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('still rejects null and undefined', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { name: 'title', label: 'Title', type: 'input', required: true }, + { name: 'stage', label: 'Stage', type: 'input', required: true }, + ], + { title: null, stage: undefined }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(screen.getByText(/Title is required/i)).toBeTruthy()); + expect(screen.getByText(/Stage is required/i)).toBeTruthy(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('still rejects an empty multiselect array', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { + name: 'tags', + label: 'Tags', + type: 'multiselect', + required: true, + options: [{ label: 'A', value: 'a' }], + }, + ], + { tags: [] }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(screen.getByText(/Tags is required/i)).toBeTruthy()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a field-authored required message still wins over the generated one', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { + name: 'title', + label: 'Title', + type: 'input', + required: true, + validation: { required: 'Give the task a name' }, + }, + ], + { title: '' }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(screen.getByText('Give the task a name')).toBeTruthy()); + }); + + it('a field-authored validate rule still runs alongside the required check', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { + name: 'title', + label: 'Title', + type: 'input', + required: true, + validation: { validate: (v: unknown) => (String(v).startsWith('T') ? true : 'Must start with T') }, + }, + ], + { title: 'nope' }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(screen.getByText('Must start with T')).toBeTruthy()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a field that is NOT required accepts every falsy value', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm( + [ + { name: 'title', label: 'Title', type: 'input' }, + { name: 'is_done', label: 'Done', type: 'switch' }, + ], + { title: '', is_done: false }, + onSubmit, + ); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 298dd6089..116f01f20 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, isValueStillOffered } from '@object-ui/core'; +import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, isValueStillOffered, isMissingForRequired } from '@object-ui/core'; import type { FormSchema, FormField as FormFieldConfig, FormFieldTab, FormFieldPane, FieldValidationRules, FieldCondition, SelectOption } from '@object-ui/types'; import { useForm } from 'react-hook-form'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from '../../ui/form'; @@ -169,6 +169,24 @@ const computeDirty = ( return false; }; +/** + * Widget types that render a two-state control, whose empty state IS `false` + * (see the `defaultValues` seeding below). Covers the bare builtin spellings + * and the registry ids `mapFieldTypeToFormType` produces for the object-schema + * `boolean` / `toggle` types (both → `field:boolean`). + */ +const BOOLEAN_WIDGET_TYPES = new Set([ + 'checkbox', 'switch', 'boolean', 'toggle', + 'field:boolean', 'field:switch', 'field:checkbox', +]); + +/** + * Which control a field row will render as — the same precedence + * `resolvedType` applies further down (explicit form-config `widget`, then the + * resolved field metadata's own widget hint, then the bare `type`). + */ +const resolveWidgetType = (f: any): string => f?.widget || f?.field?.widget || f?.type; + const BUILTIN_FIELD_TYPES = new Set(['input', 'textarea', 'checkbox', 'switch', 'select']); const DATA_SOURCE_FIELD_TYPES = new Set([ 'lookup', 'master_detail', 'tree', 'capability-multiselect', @@ -345,7 +363,7 @@ ComponentRegistry.register('form', ({ schema, className, onAction, ...props }: { schema: FormSchema; className?: string; onAction?: (action: any) => void; [key: string]: any }) => { const { t } = useSafeFormTranslation(); const { - defaultValues = {}, + defaultValues: authoredDefaultValues = {}, fields: rawFields = [], submitLabel = 'Submit', cancelLabel = 'Cancel', @@ -406,6 +424,31 @@ ComponentRegistry.register('form', } }, [rawFields, specVocabularyFields]); + // A two-state control has no third state, so a boolean field that nobody + // supplied a value for starts at `false` — not `undefined`. Without this + // the switch renders OFF while the form holds NOTHING: the create payload + // omits the column (it lands null, which reads as unchecked but isn't), + // and a `required` boolean is refused as empty while the user is looking + // at a perfectly valid answer — an AI-built tracker could not create an + // UNFINISHED task (cloud#972). It has to be folded into `defaultValues` + // itself, not seeded per-Controller, because this is also the baseline the + // dirty check and the defaults-reset window below compare against; a value + // that only lived in the field would be wiped by the first `form.reset`. + // Callers that DO supply a value (including `null` from a loaded record) + // are untouched. + const defaultValues = React.useMemo(() => { + const seeded: Record = { ...(authoredDefaultValues as Record) }; + let added = false; + for (const f of fields as any[]) { + if (typeof f?.name !== 'string') continue; + if (hasOwn(seeded, f.name)) continue; + if (!BOOLEAN_WIDGET_TYPES.has(resolveWidgetType(f))) continue; + seeded[f.name] = false; + added = true; + } + return added ? seeded : authoredDefaultValues; + }, [authoredDefaultValues, fields]); + // Initialize react-hook-form. `shouldFocusError: false` because RHF's // native focus-on-error only works for fields whose registered ref is a // focusable native input — it silently no-ops for custom widgets @@ -1074,10 +1117,42 @@ ComponentRegistry.register('form', ...validation, }; + // `required` is a PRESENCE contract, not a truthiness one — and the + // referee it has to agree with is the server. `@objectstack/spec` + // FieldSchema.required (ADR-0113) reads "an insert must provide a + // NON-NULL value", and objectql's record validator implements exactly + // that: `isMissing` = undefined | null | blank string. `false` and `0` + // are values. + // + // react-hook-form's own `required` rule breaks that contract in both + // directions: it fails whenever `isBoolean(value) && !value` (its + // accept-the-terms checkbox heritage), so a required boolean silently + // becomes "must be TRUE" and the only state the control shows by + // default is the one value that cannot be saved — an AI-built task + // tracker could not create an UNFINISHED task (cloud#972); and it lets + // a whitespace-only string through, which the server then rejects. + // + // So never hand RHF its `required` (`delete` also drops the copy that + // rode in on `validation` from buildValidationRules, which must not + // outlive a `requiredWhen` that resolved to FALSE) and express the + // check as a `validate` entry keyed `required` — RHF reports an + // object-form validate failure under its key, so the error still + // surfaces as `type: 'required'` for the conditional-required cleanup + // above. + delete rules.required; if (required) { - rules.required = typeof validation.required === 'string' + const requiredMessage = typeof validation.required === 'string' ? validation.required : t('validation.required', { field: label || name }); + const authoredValidate = rules.validate; + rules.validate = { + // A field-authored `validate` keeps running, and keeps its own + // `type: 'validate'` error key. + ...(typeof authoredValidate === 'function' + ? { validate: authoredValidate } + : (authoredValidate ?? {})), + required: (value: unknown) => !isMissingForRequired(value) || requiredMessage, + }; } // Localize the standard validation messages emitted by diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts index cad011e74..405989165 100644 --- a/packages/core/src/validation/index.ts +++ b/packages/core/src/validation/index.ts @@ -13,6 +13,7 @@ * is client-side form validation, not a mirror of a server rule. */ +export * from './required-presence.js'; export * from './validation-engine.js'; export * from './schema-validator.js'; export * from './validators/index.js'; diff --git a/packages/core/src/validation/required-presence.ts b/packages/core/src/validation/required-presence.ts new file mode 100644 index 000000000..11971aa6e --- /dev/null +++ b/packages/core/src/validation/required-presence.ts @@ -0,0 +1,39 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The ONE client-side answer to "is this field's value absent?" for a + * `required` check. + * + * `required` is a PRESENCE contract, not a truthiness one. `@objectstack/spec` + * FieldSchema.required (ADR-0113) reads "an insert must provide a NON-NULL + * value", and objectql's record validator implements exactly that — its + * `isMissing` is `undefined | null | blank string`, nothing more. This mirrors + * that verdict so the form, the wizard's cross-step gate, and the server all + * agree instead of each inventing a dialect. + * + * What this deliberately does NOT treat as absent: + * - `false` — a boolean's second value. react-hook-form's own `required` rule + * fails on `isBoolean(v) && !v`, which quietly turns a required checkbox into + * "must be TRUE": an AI-built task tracker could not create an UNFINISHED + * task, because the state the switch shows by default was the one value it + * refused to save (cloud#972). + * - `0` — a number's identity element. "Zero hours logged" is data. + * + * An empty ARRAY *is* absent: the server has no array-valued scalar to + * disagree about, and "nothing picked" is a multiselect's own empty state, + * exactly as a blank string is a text input's. + */ +export function isMissingForRequired(value: unknown): boolean { + return ( + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') || + (Array.isArray(value) && value.length === 0) + ); +} diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 06a7ab7fe..3dc8b4ef7 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -2048,7 +2048,9 @@ function createFieldRenderer(FieldWidget: React.ComponentType) { readonly: schema?.readonly || schema?.readOnly, help: schema?.help, description: schema?.description, - defaultValue: schema?.defaultValue || schema?.value, + // `??`, not `||` — an authored default of `false` / `0` / `''` is a value, + // and `||` silently threw it away (same falsy-as-empty class as cloud#972). + defaultValue: schema?.defaultValue ?? schema?.value, ...schema, }; diff --git a/packages/plugin-form/src/ObjectForm.requiredBoolean.test.tsx b/packages/plugin-form/src/ObjectForm.requiredBoolean.test.tsx new file mode 100644 index 000000000..7bb6de15f --- /dev/null +++ b/packages/plugin-form/src/ObjectForm.requiredBoolean.test.tsx @@ -0,0 +1,125 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * A required boolean must be creatable in its UNCHECKED state (cloud#972). + * + * The reported app is an AI-built task tracker whose 是否完成 field is + * `{ type: 'boolean', required: true }` — the blueprint field schema cannot + * carry a `defaultValue`, so the column has none. Two defects stacked up on the + * create form: the field was absent from the form's values (an OFF switch + * backed by `undefined`), and react-hook-form's `required` rule reads a boolean + * `false` as empty anyway. Either one alone makes "未完成" unsavable, so both + * halves are pinned here, end to end through ObjectForm. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { ObjectForm } from './ObjectForm'; +import { registerAllFields } from '@object-ui/fields'; + +registerAllFields(); + +/** The shape apply_blueprint materializes: required boolean, NO defaultValue. */ +const taskSchema = { + name: 'task', + fields: { + title: { type: 'text', label: '任务名称', required: true }, + hours: { type: 'number', label: '工时', required: true }, + is_done: { type: 'boolean', label: '是否完成', required: true }, + }, +}; + +const makeDS = (schema: unknown = taskSchema) => ({ + getObjectSchema: vi.fn().mockResolvedValue(schema), + create: vi.fn(async (_o: string, d: any) => ({ id: 'r1', ...d })), + update: vi.fn(), + findOne: vi.fn(), +}); + +const waitInput = (c: HTMLElement, name: string) => + waitFor(() => { + const el = c.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; + if (!el) throw new Error(`${name} not ready`); + return el; + }); + +describe('ObjectForm — required boolean (cloud#972)', () => { + it('creates a task with 是否完成 = false, leaving the switch untouched', async () => { + const ds = makeDS(); + const { container } = render( + , + ); + + fireEvent.change(await waitInput(container, 'title'), { target: { value: '写周报' } }); + fireEvent.change(await waitInput(container, 'hours'), { target: { value: '2' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + expect(ds.create.mock.calls[0][1]).toMatchObject({ title: '写周报', is_done: false }); + }); + + it('a required number still accepts 0', async () => { + const ds = makeDS(); + const { container } = render( + , + ); + + fireEvent.change(await waitInput(container, 'title'), { target: { value: '写周报' } }); + fireEvent.change(await waitInput(container, 'hours'), { target: { value: '0' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + expect(ds.create.mock.calls[0][1]).toMatchObject({ hours: 0 }); + }); + + it('a blank required text is still refused — the server would reject it too', async () => { + const ds = makeDS(); + const { container } = render( + , + ); + + await waitInput(container, 'title'); + fireEvent.change(await waitInput(container, 'hours'), { target: { value: '2' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + + await waitFor(() => + expect(container.querySelector('[data-field="title"]')?.textContent).toMatch( + /任务名称 is required/, + ), + ); + expect(ds.create).not.toHaveBeenCalled(); + }); + + it("honours an authored default of `true` rather than forcing false", async () => { + const ds = makeDS({ + name: 'task', + fields: { + title: { type: 'text', label: '任务名称' }, + notify: { type: 'boolean', label: '通知我', defaultValue: true }, + }, + }); + const { container } = render( + , + ); + + fireEvent.change(await waitInput(container, 'title'), { target: { value: '写周报' } }); + fireEvent.submit(container.querySelector('form') as HTMLFormElement); + + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + expect(ds.create.mock.calls[0][1]).toMatchObject({ notify: true }); + }); +}); diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index a840e7c00..4ba108e3f 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -17,7 +17,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import type { FormField, DataSource } from '@object-ui/types'; import { Button, cn, toast } from '@object-ui/components'; import { AlertCircle, Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; -import { resolveFieldRuleState, evalFieldPredicate } from '@object-ui/core'; +import { resolveFieldRuleState, evalFieldPredicate, isMissingForRequired } from '@object-ui/core'; import { createSafeTranslation } from '@object-ui/i18n'; import { FormSection } from './FormSection'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; @@ -35,9 +35,12 @@ const useWizardTranslation = createSafeTranslation( 'wizard.missingRequired', ); -/** Empty for the purposes of a required check: '' / null / undefined / []. */ -const isEmptyValue = (v: unknown): boolean => - v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); +/** + * Empty for the purposes of a required check. Shared with the form renderer — + * one predicate, mirroring the server's `isMissing`, so the wizard's + * cross-step gate can't drift from the per-field verdict (cloud#972). + */ +const isEmptyValue = isMissingForRequired; export interface WizardFormSchema { type: 'object-form';