Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/12121-runtime-default-boolean-unseeded.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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> | unknown,
extra: Record<string, unknown> = {},
) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form
schema={{
type: 'form',
showSubmit: true,
showCancel: false,
submitLabel: 'Save',
defaultValues: {},
fields,
onSubmit,
...extra,
}}
/>,
);
}

const submit = () => fireEvent.click(screen.getByRole('button', { name: /save/i }));
const row = (name: string) => document.body.querySelector<HTMLElement>(`[data-field="${name}"]`)!;
const control = (name: string) => row(name).querySelector<HTMLButtonElement>('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();
});
},
);
62 changes: 37 additions & 25 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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
Expand All @@ -1497,18 +1522,29 @@ 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<string, unknown> = { ...(authoredDefaultValues as Record<string, unknown>) };
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;
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
Expand Down Expand Up @@ -1570,30 +1606,6 @@ ComponentRegistry.register('form',
// full merged record.
const watched = form.watch() as Record<string, unknown>;

// 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<string, unknown> = {};
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
Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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) }];
Expand Down
Loading