From 06f640ddbf3f5d10bab3e2b247e78e4e7d71a649 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:38:08 +0000 Subject: [PATCH 1/2] fix(components): the boolean seed leaves a server-owned field to the server (objectui#12121) The form renderer starts an unsupplied two-state control at `false` (cloud#972). On a create form that included a boolean whose declared `defaultValue` is a runtime instruction (a CEL envelope or a runtime token), so the create payload carried `false` and `ObjectQL.applyFieldDefaults`, which fills only a key that arrives absent or null, never evaluated the declaration. The seed loop now skips a field `isServerOwnedValue` calls server-owned, the verdict the "set on save" hint and the `required` suppression already read. `previousRecord` and `isCreateForm` move above the seed so it can read the create signal. An untouched such boolean is empty, shows the hint, and is dropped from the create payload by `omitServerResolvedDefaults`. A plain boolean still seeds `false`, and an edit form still seeds it too. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../12121-runtime-default-boolean-unseeded.md | 11 ++ ...oolean-seed-runtime-default-12121.test.tsx | 167 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 62 ++++--- packages/plugin-form/README.md | 10 +- 4 files changed, 222 insertions(+), 28 deletions(-) create mode 100644 .changeset/12121-runtime-default-boolean-unseeded.md create mode 100644 packages/components/src/renderers/form/__tests__/form-boolean-seed-runtime-default-12121.test.tsx diff --git a/.changeset/12121-runtime-default-boolean-unseeded.md b/.changeset/12121-runtime-default-boolean-unseeded.md new file mode 100644 index 0000000000..903d4abd80 --- /dev/null +++ b/.changeset/12121-runtime-default-boolean-unseeded.md @@ -0,0 +1,11 @@ +--- +'@object-ui/components': patch +--- + +A boolean field whose `defaultValue` is a CEL expression or a runtime token now gets that default on create (objectui#12121). + +The form renderer starts an unsupplied switch or checkbox at `false`, so an untouched one submits a value. On a create form that also happened to a boolean the server fills, such as `defaultValue: { dialect: 'cel', source: 'true' }`. The create payload carried `false`, and the server, which applies a declared default only to a field that arrives empty, stored `false` instead of evaluating the declaration. + +The renderer no longer starts such a field at `false`. An untouched one is left out of the create payload, so the server applies the declared default, and it shows "Set automatically when saved." under the control, as every other field the server fills does. A `required` boolean with such a default is not refused as empty. A value the user sets, `false` included, is submitted as usual. + +Unchanged: a boolean with no runtime default still starts at `false` and submits `false` when untouched, and an edit form still starts an unsupplied boolean at `false`. diff --git a/packages/components/src/renderers/form/__tests__/form-boolean-seed-runtime-default-12121.test.tsx b/packages/components/src/renderers/form/__tests__/form-boolean-seed-runtime-default-12121.test.tsx new file mode 100644 index 0000000000..256acf1a2d --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-boolean-seed-runtime-default-12121.test.tsx @@ -0,0 +1,167 @@ +/** + * 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. + */ + +/** + * objectui#12121 — the boolean seed leaves a server-owned field to the server. + * + * The form renderer starts an unsupplied two-state control at `false` + * (cloud#972), so an untouched switch submits a value instead of nothing. For a + * boolean whose declared `defaultValue` is a runtime instruction — a CEL + * envelope such as `{ dialect: 'cel', source: 'true' }` — that seed was the + * defect: `false` is not empty, so the create payload carried it and + * `ObjectQL.applyFieldDefaults`, which fills only a key that arrives absent or + * null, never evaluated the declaration. + * + * The seed now skips a field `isServerOwnedValue` (from `@object-ui/core`) + * calls server-owned on this submit. Pinned on both sides of that line, on the + * two built-in two-state controls: + * + * 1. a CEL-default boolean left untouched submits no value, so the hosts' + * `omitServerResolvedDefaults` drops the key; it shows the + * "set on save" hint (objectui#12108) and is not refused as empty when + * it is `required`; + * 2. CONTROL, cloud#972 intact: a boolean with no runtime default still + * submits `false` when untouched; + * 3. a value the user sets, `false` included, is submitted as usual; + * 4. create only: an edit form (one with `previousValues`) still seeds + * `false`, because the default was resolved at insert. + * + * The declared default rides in `field`, the resolved object-field metadata + * the object-bound builders stash there — the only spelling of a declared + * `defaultValue` that reaches this renderer. + */ + +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'; +// Module scope, not `beforeAll` — the cold transform must not be billed to +// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010). +import '../../../renderers'; + +beforeEach(() => { + if (!(Element.prototype as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const HINT = 'Set automatically when saved.'; + +/** The canonical wire shape of a CEL default, as the server serves it. */ +const cel = (source: string) => ({ dialect: 'cel', source }); + +function renderForm( + fields: any[], + onSubmit: (data: any) => Promise | unknown, + extra: Record = {}, +) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +const submit = () => fireEvent.click(screen.getByRole('button', { name: /save/i })); +const row = (name: string) => document.body.querySelector(`[data-field="${name}"]`)!; +const control = (name: string) => row(name).querySelector('button')!; +const hintOf = (name: string) => row(name).querySelector('[data-server-default-hint]'); + +describe.each(['switch', 'checkbox'])( + '%s — the boolean seed skips a server-owned field (objectui#12121)', + (type) => { + /** A boolean the server fills on insert, beside a plain one. */ + const fields = (required = false) => [ + { + name: 'active', + label: 'Active', + type, + required, + field: { type: 'boolean', defaultValue: cel('true') }, + }, + { name: 'archived', label: 'Archived', type }, + ]; + + it('submits no value for an untouched CEL-default boolean, and false for a plain one', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm(fields(), onSubmit); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + const data = onSubmit.mock.calls[0][0]; + // Not `false`: an empty value is what `omitServerResolvedDefaults` drops. + expect(data.active).toBeUndefined(); + // CONTROL — cloud#972: the boolean with no runtime default still seeds. + expect(data).toMatchObject({ archived: false }); + }); + + it('shows the "set on save" hint under the unseeded control only', async () => { + renderForm(fields(), vi.fn()); + + expect(control('active').getAttribute('aria-checked')).toBe('false'); + expect(hintOf('active')?.textContent).toBe(HINT); + expect(hintOf('archived')).toBeNull(); + }); + + it('does not refuse a required CEL-default boolean as empty', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm(fields(true), onSubmit); + + expect(row('active').querySelectorAll('[data-required-marker]')).toHaveLength(0); + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0].active).toBeUndefined(); + expect(screen.queryByText(/is required/i)).toBeNull(); + }); + + it('submits a value the user sets, false included', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm(fields(), onSubmit); + + fireEvent.click(control('active')); + await waitFor(() => expect(control('active').getAttribute('aria-checked')).toBe('true')); + expect(hintOf('active')).toBeNull(); + submit(); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ active: true }); + + fireEvent.click(control('active')); + await waitFor(() => expect(control('active').getAttribute('aria-checked')).toBe('false')); + submit(); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit.mock.calls[1][0]).toMatchObject({ active: false }); + }); + + it('still seeds false on an edit form, where the default was resolved at insert', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderForm(fields(), onSubmit, { previousValues: { id: 'r1' } }); + + submit(); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ active: false, archived: false }); + expect(hintOf('active')).toBeNull(); + }); + }, +); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 7996a31dd3..00d96b1236 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -1485,6 +1485,31 @@ ComponentRegistry.register('form', } }, [rawFields]); + // The `previous` binding — the persisted record, seeded the same way as + // `ruleRecord` so a predicate naming a column the read didn't return + // compares against `null` instead of faulting (objectui#3484). `undefined` + // when the host supplied no `previousValues`, which is how an INSERT is + // told apart from an UPDATE: the server exempts inserts from the + // `readonlyWhen` strip entirely, so the renderer must not invent a + // `previous` for them. + const previousRecord = React.useMemo(() => { + if (!previousValues || typeof previousValues !== 'object') return undefined; + const out: Record = {}; + for (const f of fields) if (f?.name) out[f.name] = null; + for (const k of Object.keys(previousValues)) { + if (previousValues[k] !== undefined) out[k] = previousValues[k]; + } + return out; + }, [fields, previousValues]); + + // Is this an INSERT? Same signal the memo above documents and the read-only + // strip already gates on: `FormSchema.previousValues` is set by an + // edit-mode host only, and its ABSENCE is the declared "this is an insert" + // (objectui#3484). Read once here, above the boolean seed that needs it + // first, so the seed and the rule-state call sites below cannot each + // answer the mode differently. + const isCreateForm = previousRecord === undefined; + // 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 @@ -1497,6 +1522,16 @@ ComponentRegistry.register('form', // 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. + // + // Nor is a field the server fills on this insert (objectui#12121): a + // runtime `defaultValue` (a CEL envelope or a runtime token) is the + // producer's to resolve, and `ObjectQL.applyFieldDefaults` resolves it + // only for a key that arrives absent or null. A seeded `false` is + // neither, so it was submitted and the declaration never ran. Left + // unseeded, the untouched control is empty: `omitServerResolvedDefaults` + // drops the key from the create payload, and the "set on save" hint and + // the `required` suppression below read this same `isServerOwnedValue` + // verdict. A value the user sets, `false` included, is submitted as usual. const defaultValues = React.useMemo(() => { const seeded: Record = { ...(authoredDefaultValues as Record) }; let added = false; @@ -1504,11 +1539,12 @@ ComponentRegistry.register('form', if (typeof f?.name !== 'string') continue; if (hasOwn(seeded, f.name)) continue; if (!BOOLEAN_WIDGET_TYPES.has(resolveWidgetType(f))) continue; + if (isServerOwnedValue(f, isCreateForm)) continue; seeded[f.name] = false; added = true; } return added ? seeded : authoredDefaultValues; - }, [authoredDefaultValues, fields]); + }, [authoredDefaultValues, fields, isCreateForm]); // Initialize react-hook-form. `shouldFocusError: false` because RHF's // native focus-on-error only works for fields whose registered ref is a @@ -1570,30 +1606,6 @@ ComponentRegistry.register('form', // full merged record. const watched = form.watch() as Record; - // The `previous` binding — the persisted record, seeded the same way as - // `ruleRecord` so a predicate naming a column the read didn't return - // compares against `null` instead of faulting (objectui#3484). `undefined` - // when the host supplied no `previousValues`, which is how an INSERT is - // told apart from an UPDATE: the server exempts inserts from the - // `readonlyWhen` strip entirely, so the renderer must not invent a - // `previous` for them. - const previousRecord = React.useMemo(() => { - if (!previousValues || typeof previousValues !== 'object') return undefined; - const out: Record = {}; - for (const f of fields) if (f?.name) out[f.name] = null; - for (const k of Object.keys(previousValues)) { - if (previousValues[k] !== undefined) out[k] = previousValues[k]; - } - return out; - }, [fields, previousValues]); - - // Is this an INSERT? Same signal the memo above documents and the read-only - // strip already gates on: `FormSchema.previousValues` is set by an - // edit-mode host only, and its ABSENCE is the declared "this is an insert" - // (objectui#3484). Read once here so the three rule-state call sites below - // cannot each answer the mode differently. - const isCreateForm = previousRecord === undefined; - // Global predicate scope (from the host shell's ExpressionProvider) — carries // `current_user` (plus the ADR-0068 D1 `user` / `ctx.user` / `os.user` aliases, // `data`, `features`) so a `visibleWhen` can gate on role/context in diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 109ebecbc5..bd21557229 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -304,9 +304,13 @@ client cannot evaluate the expression. It is not drawn: - on an edit form, where the default was resolved at insert; - on a control that takes no input (disabled or read-only). The containers send `previousValues` in edit mode only, so the renderer reads a `mode: 'view'` form - as create, and this is what keeps the hint off it; -- on a boolean. The renderer starts an unsupplied two-state control at `false` - (cloud#972), so the field is never empty and the submit sends that value. + as create, and this is what keeps the hint off it. + +A boolean is no exception (objectui#12121). The renderer starts an unsupplied +two-state control at `false` (cloud#972) only when the field is not +server-owned, so a boolean with a runtime default opens unchecked with the hint +under it, and an untouched one is omitted from the submit. A value the user +sets, `false` included, is submitted as usual. An option's `default: true` is read the way the server's insert path reads it (`ObjectQL.applyFieldDefaults`, which falls back to the marked option), so the From fb4685821c59e1ce100ec4e5d2d8fe051fd84bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:14:52 +0000 Subject: [PATCH 2/2] test(plugin-form): pin a CEL-default boolean as server-filled in every layout (objectui#12121) The objectui#12108 hint suite said a boolean never shows the hint because the renderer seeds an unsupplied two-state control `false`. objectui#12121 makes that seed skip a server-owned field, so the docblock now states the new rule, and a boolean with a CEL default joins OBJECT_SCHEMA and SERVER_FILLED: the hint is drawn under it in every layout, and the create payload omits it. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../src/__tests__/serverDefaultHint-12108.test.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx b/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx index 1610686b20..94a49fe0a4 100644 --- a/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx +++ b/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx @@ -20,10 +20,12 @@ * Pinned here, through the public `ObjectForm` entry in every layout it * routes to, with the real registered field widgets: * - * 1. the hint on a CEL default (text, date, select, number) and on a - * runtime token, in every layout. Not a boolean: the renderer seeds an - * unsupplied two-state control `false` (cloud#972), so it is never empty - * and the submit sends that value — no hint is the true answer there; + * 1. the hint on a CEL default (text, date, select, number, boolean) and + * on a runtime token, in every layout. A boolean is server-filled too + * (objectui#12121): the renderer seeds an unsupplied two-state control + * `false` (cloud#972) only when its default is not a runtime one, so a + * CEL-default boolean opens empty, with the hint, and is omitted from the + * create payload, while a plain boolean still seeds `false`; * 2. CONTROL: a literal default is seeded as today, and carries no hint; * a field with no default carries none either; * 3. the hint follows the live value — a typed value removes it; @@ -64,6 +66,8 @@ const OBJECT_SCHEMA = { close_date: { type: 'date', label: 'Close date', required: true, defaultValue: cel('today()') }, stage: { type: 'select', label: 'Stage', required: true, defaultValue: cel("'prospect'"), options: STAGES }, amount: { type: 'number', label: 'Amount', defaultValue: cel('0') }, + // A two-state control the server fills: not seeded `false` (objectui#12121). + active: { type: 'boolean', label: 'Active', defaultValue: cel('true') }, // A runtime token: the same classifier, so the same hint. reminded_at: { type: 'datetime', label: 'Reminded at', defaultValue: 'NOW()' }, // An authored description beside a CEL default: both are drawn. @@ -88,7 +92,7 @@ const OBJECT_SCHEMA = { }; /** The fields whose default the server resolves. */ -const SERVER_FILLED = ['code', 'close_date', 'stage', 'amount', 'reminded_at', 'region']; +const SERVER_FILLED = ['code', 'close_date', 'stage', 'amount', 'active', 'reminded_at', 'region']; const NOT_SERVER_FILLED = ['title', 'status']; const SECTIONS = [{ name: 'main', label: 'Main', fields: Object.keys(OBJECT_SCHEMA.fields) }];