Skip to content

Commit 50d05cb

Browse files
fix(fields): a boolean field draws a checkbox and a toggle field a switch, on the canvas and in the form (#27665)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6eGuL2tQm3aMEz4jsPF5R2)_ Fixes #27519 Clause-②: yes (widening) — `@object-ui/fields` gains two public exports, `resolveBooleanControl` and `type BooleanControl` Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 里以「Checkbox — shown as a checkbox」(`boolean`)创建的字段,现在在 Studio 表单画布、运行时新建/编辑表单和表格行内编辑里都画成复选框;只有「Toggle — shown as a switch」(`toggle`)才画开关。字段显式写了 `widget`(`checkbox`/`boolean` 或 `switch`/`toggle`)时仍以 `widget` 为准。 - **为什么改**:两种类型都落到同一个 `field:boolean` 控件,它只认 `widget: 'checkbox'`,否则一律画开关;画布又各自手写一份,`toggle` 还掉成了文本框。分诊裁定:画布与运行时表单共用一张「类型→控件」映射,不做分面覆盖。 - **风险与回滚**:所有表单里声明为 `boolean` 的字段(含 Setup 的元数据表单)都会从开关变成复选框,这正是分诊要求的行为;想保留开关的字段应声明为 `toggle` 或写 `widget: 'switch'`。只改前端渲染,元数据与存储值不变。回滚即 revert 本 PR。 - **先看**:`ui/packages/fields/src/widgets/booleanControl.ts`(唯一的映射);`BooleanField.tsx` 里改为读它的那一行;`ObjectFormDesigner.tsx` 的 `FieldControlPreview` 布尔分支。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/fields/src/__tests__/booleanControl-27519.test.tsx packages/app-shell/src/views/studio-design/ObjectFormDesigner.booleanControl-27519.test.tsx`,预期全部通过。 - **需要你做的**:无,席位落地。 ## What changes One type-to-widget resolution for the two boolean types, `resolveBooleanControl` (new `ui/packages/fields/src/widgets/booleanControl.ts`, exported from `@object-ui/fields`), answering `'checkbox' | 'switch'`: 1. an explicit `widget` naming a boolean face wins: `checkbox` / `boolean` draw a checkbox, `switch` / `toggle` a switch; 2. otherwise the field's `type`, through the same four names: `boolean` draws a checkbox, `toggle` a switch; 3. anything else that reaches the boolean widget (the bare `field:boolean` id of a hand-authored form entry, no type) draws a checkbox, the `boolean` type's own face. A `widget` naming anything else is not a boolean face and leaves the type to answer, which is the spec's `FieldSchema.widget` degrade. Readers: - `BooleanField` (the record form's `field:boolean` widget, and the grid's inline editor through `FieldEditWidget`'s `boolean` / `toggle` rows) draws the control the resolution names. It used to draw a switch unless `widget === 'checkbox'`, so the authored `boolean` was lost. - The Studio canvas's `FieldControlPreview` (in `ObjectFormDesigner.tsx`; only that function and one import line) previews `boolean` as a checkbox and `toggle` as a switch from the same resolution. It used to draw the switch pill for `boolean` and the empty text box for `toggle`. Widget spellings: `checkbox` and `switch` are what `paramToField` and `ScreenView` stamp and what `form.tsx`'s built-in arms are named; in the form those two still take the built-in `checkbox` / `switch` arms before the registry (`resolveRenderableWidget`, unchanged), and they draw the same control the resolution names. `toggle` and `boolean` are accepted as widget names so a widget naming a type means that type's face. Not changed: `field-type-alias.ts` keeps both types on the one `field:boolean` widget (comment updated to say the widget splits them); `form.tsx` needed no change, since `BOOLEAN_WIDGET_TYPES` only seeds `false` for two-state rows and its built-in arms are the explicit widgets above; `BooleanCellRenderer` (see Acceptance notes). Changeset: `@object-ui/fields` and `@object-ui/app-shell` patch. `Clause-②: yes (widening)`: the published `@object-ui/fields` surface gains `resolveBooleanControl` and `type BooleanControl`; nothing is removed or narrowed. `Metadata-protocol: none`: what an author can write and what `boolean` / `toggle` mean are unchanged (the spec already declares them distinct UIs); the renderer now honours the declared type. ## Tests - New `ui/packages/fields/src/__tests__/booleanControl-27519.test.tsx`: the resolution's table (type defaults, the four widget overrides, a non-boolean widget leaving the type to answer, missing input), `BooleanField` drawing `checkbox` / `switch` per type and per widget override, `FieldEditWidget` drawing the same per type. - New `ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.booleanControl-27519.test.tsx`: a `boolean` and a `toggle` field on the canvas preview a checkbox and a switch, equal to `resolveBooleanControl`'s answer. - Updated, not deleted, to the new faces: `boolean-label-association-e2e.test.tsx` (the form-hosted suite now uses the `ObjectForm` entry shape, `field:boolean` plus the object field, as a `toggle`; the two-field case hosts a `boolean` checkbox beside a `toggle` switch; the standalone and host-id cases take `toggle` for the switch variant and `boolean` for the checkbox variant), `standard-widgets.test.tsx` (BooleanField cases cover both types), `formPageRequiredMarker-10178.test.tsx` and `FormPage.sharedWidgets-10179.test.tsx` (a `boolean` row is a checkbox), `InlineFieldInput.delegation.test.tsx` (`boolean` routes to a checkbox; the `toggle` switch case unchanged), `RequiredFieldsDialog.ariaInvalidText-7126.test.tsx` (one checkbox and one switch carry `aria-invalid`). `field-type-coverage.test.ts` needed no change and is green. Ablation (`scripts/ablation-replace.mjs`, the resolution's return replaced by the old `widget === 'checkbox' ? 'checkbox' : 'switch'`, anchor 1 to 0, restored to the HEAD blob with `git diff HEAD` empty): the two new files and the label-association file go red, 13 of 28 cases; the canvas case is among them, which shows the canvas reads the shared resolution. ## Acceptance notes - **Record detail "empty circle".** Not the same collapse, and not reproduced in a browser here. An editable boolean row on the record page draws `BooleanCellRenderer`'s disabled checkbox (`DetailSection`, "An editable boolean keeps the checkbox"), whose primitive is a square (`rounded-sm`) at `disabled:opacity-50`; a value that is not a boolean draws the em-dash `EmptyValue`. The only round boolean face in the tree is the completion indicator, gated on the names `completed` / `is_completed` / `done` / `is_done`, which `Urgent` is not. Recorded as a finding for the seat, unfiled. - **Grid and detail cells.** `BooleanCellRenderer` draws a disabled checkbox for both `boolean` and `toggle`; a `toggle` cell therefore reads as a checkbox in the grid. The ruling names the canvas and the runtime form, so the read-only cell face is left as it is; whether a `toggle` cell should draw a disabled switch is the seat's call. - **Canvas and an explicit widget.** `FieldControlPreview` is handed the field's `type` only (its caller in `SortableField` passes `type={type}`), so the canvas previews by type; an authored `widget` override would need that one caller line, outside the function agreed with seat 3. - `paramToField` and `ScreenView` still stamp `widget: 'checkbox'` on boolean params; with the type default now a checkbox the stamp is redundant but harmless, and both files are outside this card. ## Verification <details><summary>Targeted tests and typecheck</summary> - Ten boolean-related files (the two new ones and the eight above): `Test Files 10 passed (10)`, `Tests 227 passed (227)`. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/fields/ packages/plugin-detail/ packages/plugin-kanban/ packages/plugin-form/ packages/components/src/renderers/form/`: `Test Files 989 passed | 2 skipped (991)`, `Tests 10835 passed | 43 skipped (10878)` (before the `origin/main` merge). - After merging `origin/main` (head `302bcb25`, closure rebuilt): `packages/components/src/renderers/form/`, `packages/plugin-form/`, `packages/app-shell/src/views/studio-design/` and the ten boolean files: `Test Files 555 passed (555)`, `Tests 4955 passed | 18 skipped (4973)`. - `type-check` for `@object-ui/fields`, `@object-ui/app-shell`, `@object-ui/plugin-detail`, `@object-ui/plugin-kanban`: exit 0. - `eslint --no-inline-config` on the 13 changed ui files: 0 errors (warnings only, pre-existing `any`). - `pnpm check:new-line-citations`: 0 new citations. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` on head `302bcb25`: 34 families, each run with its exit code recorded, all `exit 0` (including `node scripts/check-sdui-manifest.mjs`, `pnpm check:type-check-debt` under the verify lock, and `pnpm --dir ui exec vitest run scripts/`: 169 files passed). `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Current head `f2a547f9` adds one commit on top of `302bcb25` that changes only the changeset's `Clause-②` line; on it the changeset families were re-run, all `exit 0`: `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:changeset-gate-self-tests`, `check:pm-changeset-deadline-census`, `check:closed-ceremony-text`, `check:nul-bytes`. The tests above were not re-run for it (no code or test line moved). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYXVQFhXabW3ZQcxWt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8b3208 commit 50d05cb

14 files changed

Lines changed: 308 additions & 54 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(fields): a `boolean` field draws a checkbox and a `toggle` field a switch, in the record form, the grid's inline editor and the Studio canvas preview
7+
8+
Clause-②: yes (widening) — `@object-ui/fields` gains two public exports, `resolveBooleanControl` and `type BooleanControl`
9+
Metadata-protocol: none
10+
11+
Studio's type picker offers "Checkbox — shown as a checkbox" (`boolean`) and "Toggle — shown as a switch" (`toggle`), but both types reached the one `field:boolean` widget, which drew a switch unless the field carried `widget: 'checkbox'`. A field authored as Checkbox therefore drew a switch on the Studio canvas and in the runtime create form. `resolveBooleanControl` (exported from `@object-ui/fields`) is now the one answer: `boolean` draws a checkbox, `toggle` a switch, and an explicit `widget` of `checkbox` / `boolean` or `switch` / `toggle` overrides the type. `BooleanField` and the canvas's control preview both read it.
12+
13+
Forms that relied on a `boolean` field drawing a switch now draw a checkbox; declare the field as `toggle`, or set `widget: 'switch'`, to keep the switch.

‎ui/apps/console/src/__tests__/formPageRequiredMarker-10178.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ describe('objectui#10178 — FormPage keeps the required `*` out of the accessib
133133
it('a required checkbox has no `*` in its name and announces required via aria-required', async () => {
134134
await renderForm();
135135
// Since objectui#10179 a `boolean` row renders the shared `BooleanField`
136-
// (a switch), and the row's `<label for>` is its ONLY name — the hand-rolled
136+
// (a checkbox for `boolean` since objectstack#27519), and the row's `<label for>` is its ONLY name — the hand-rolled
137137
// arm's own wrapping label, which doubled the name to `Agree * Agree`, is
138138
// gone. So the name is now asserted EXACTLY, not merely `*`-free.
139-
const checkbox = await waitFor(() => screen.getByRole('switch', { name: 'Agree' }));
139+
const checkbox = await waitFor(() => screen.getByRole('checkbox', { name: 'Agree' }));
140140
expect(checkbox.id).toBe('f_agree');
141141
expect(checkbox).toHaveAccessibleName('Agree');
142142
expect(checkbox).toHaveAttribute('aria-required', 'true');
@@ -159,7 +159,7 @@ describe('objectui#10178 — FormPage keeps the required `*` out of the accessib
159159

160160
it('CONTROL — an optional checkbox has no marker and carries no aria-required', async () => {
161161
await renderForm();
162-
const checkbox = await waitFor(() => screen.getByRole('switch', { name: 'Opt in' }));
162+
const checkbox = await waitFor(() => screen.getByRole('checkbox', { name: 'Opt in' }));
163163
expect(markerFor('f_optin')).toBeNull();
164164
expect(checkbox).not.toHaveAttribute('aria-required');
165165
expect(checkbox).not.toHaveAttribute('required');

‎ui/apps/console/src/components/FormPage.sharedWidgets-10179.test.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ afterEach(() => {
164164
describe('a11y — the two defects objectui#10178 measured are gone', () => {
165165
it('a required boolean is named by its label exactly ONCE, and reports required', async () => {
166166
await renderForm();
167-
const agree = screen.getByRole('switch', { name: 'Agree' });
167+
const agree = screen.getByRole('checkbox', { name: 'Agree' });
168168
// Exact: `Agree * Agree` (and `Agree Agree`) was the doubled name.
169169
expect(agree).toHaveAccessibleName('Agree');
170170
expect(agree).toHaveAttribute('aria-required', 'true');
@@ -202,7 +202,7 @@ describe('value shapes — what the shared widgets emit is what the page submits
202202
await userEvent.click(screen.getByTestId('lookup-trigger-account'));
203203
await userEvent.click(await screen.findByRole('option', { name: /Acme/ }));
204204

205-
await userEvent.click(screen.getByRole('switch', { name: 'Agree' }));
205+
await userEvent.click(screen.getByRole('checkbox', { name: 'Agree' }));
206206
await userEvent.click(screen.getByRole('radio', { name: 'High' }));
207207

208208
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
@@ -223,12 +223,12 @@ describe('value shapes — what the shared widgets emit is what the page submits
223223
describe('the page refuses a submit that leaves a required row empty', () => {
224224
it('refuses, names the empty row, and writes nothing', async () => {
225225
await renderForm();
226-
// `agree` is required too, and is switched on and back OFF: `false` is a
226+
// `agree` is required too, and is checked and back OFF: `false` is a
227227
// VALUE for the required rule (`isMissingForRequired`), so it does not
228-
// block — the unpicked radio does. (An untouched switch holds no value at
228+
// block — the unpicked radio does. (An untouched box holds no value at
229229
// all, and is refused like any other empty required row, as the sibling
230230
// chain's `required` rule refuses it.)
231-
const agree = screen.getByRole('switch', { name: 'Agree' });
231+
const agree = screen.getByRole('checkbox', { name: 'Agree' });
232232
await userEvent.click(agree);
233233
await userEvent.click(agree);
234234
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
@@ -239,7 +239,7 @@ describe('the page refuses a submit that leaves a required row empty', () => {
239239

240240
it('CONTROL — the same form with the required radio picked submits', async () => {
241241
await renderForm();
242-
const agree = screen.getByRole('switch', { name: 'Agree' });
242+
const agree = screen.getByRole('checkbox', { name: 'Agree' });
243243
await userEvent.click(agree);
244244
await userEvent.click(agree);
245245
await userEvent.click(screen.getByRole('radio', { name: 'Low' }));
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27519 — the form designer's control preview draws the boolean
5+
* face the runtime form draws.
6+
*
7+
* The defect: `FieldControlPreview` was a hand-written switch on the raw type.
8+
* A field authored as Checkbox (`boolean`) drew the switch pill, and a Toggle
9+
* (`toggle`) fell to the empty text box. The preview now reads
10+
* `resolveBooleanControl`, the same answer `BooleanField` reads in the record
11+
* form, so the case below compares the card with that function rather than
12+
* restating the table.
13+
*/
14+
15+
import '@testing-library/jest-dom/vitest';
16+
import * as React from 'react';
17+
import { afterEach, describe, expect, it } from 'vitest';
18+
import { cleanup, render, screen } from '@testing-library/react';
19+
import { resolveBooleanControl } from '@object-ui/fields';
20+
import { ObjectFormDesigner } from './ObjectFormDesigner';
21+
22+
afterEach(cleanup);
23+
24+
const FIELDS = [
25+
{ name: 'urgent', type: 'boolean', label: 'Urgent' },
26+
{ name: 'notify', type: 'toggle', label: 'Notify' },
27+
];
28+
29+
function cardControl(label: string): string | null {
30+
const card = screen.getByText(label).closest('[aria-roledescription], [role="button"], div.flex-col');
31+
return card?.querySelector('[data-boolean-control]')?.getAttribute('data-boolean-control') ?? null;
32+
}
33+
34+
describe('the canvas previews a boolean field with the runtime form\'s face (objectstack#27519)', () => {
35+
it('Checkbox (`boolean`) previews a checkbox and Toggle (`toggle`) a switch', () => {
36+
render(
37+
<ObjectFormDesigner
38+
draft={{ name: 'work_order', fields: FIELDS }}
39+
systemFieldNames={new Set()}
40+
onChange={() => {}}
41+
onSelectField={() => {}}
42+
/>,
43+
);
44+
45+
expect(cardControl('Urgent')).toBe('checkbox');
46+
expect(cardControl('Notify')).toBe('switch');
47+
// The same answer the record form's widget reads, not a canvas copy.
48+
expect(cardControl('Urgent')).toBe(resolveBooleanControl({ type: 'boolean' }));
49+
expect(cardControl('Notify')).toBe(resolveBooleanControl({ type: 'toggle' }));
50+
});
51+
});

‎ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { GripVertical, Plus, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, Pan
3838
import { cn } from '@object-ui/components';
3939
import { inferColumns, containerGridColsFor, isWideFieldType } from '@object-ui/plugin-form';
4040
import { toFormFieldType } from './formFieldType.js';
41+
import { resolveBooleanControl } from '@object-ui/fields';
4142
import {
4243
readFields,
4344
writeFields,
@@ -362,14 +363,23 @@ function FieldControlPreview({ type }: { type: string }): React.ReactElement {
362363
case 'json':
363364
case 'code':
364365
return <div className={`${box} h-14`} />;
366+
// objectstack#27519 — the runtime form's own answer, not a canvas copy:
367+
// `boolean` previews a checkbox and `toggle` a switch, as the type picker
368+
// promises. `toggle` used to fall to the text box below, and `boolean`
369+
// drew the switch pill.
365370
case 'boolean':
371+
case 'toggle':
366372
case 'checkbox':
367-
case 'switch':
373+
case 'switch': {
374+
const control = resolveBooleanControl({ type });
368375
return (
369-
<div className="flex h-8 items-center">
370-
<span className="h-4 w-7 rounded-full bg-muted" />
376+
<div className="flex h-8 items-center" data-boolean-control={control}>
377+
{control === 'checkbox'
378+
? <span className="h-4 w-4 rounded-sm border border-muted-foreground/40" />
379+
: <span className="h-4 w-7 rounded-full bg-muted" />}
371380
</div>
372381
);
382+
}
373383
case 'number':
374384
case 'currency':
375385
case 'percent':

‎ui/packages/fields/src/__tests__/boolean-label-association-e2e.test.tsx‎

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,23 @@ function labelTargets(): Array<{ text: string; forId: string; resolves: boolean
9090
.filter((l) => l.forId !== '');
9191
}
9292

93-
const switchOf = (name: string): Element => {
94-
const el = document.querySelector(`[data-field="${name}"] [role="switch"]`);
95-
if (!el) throw new Error(`no switch rendered for field "${name}"`);
93+
/** The two-state control a field row drew — a switch or a checkbox. */
94+
const controlOf = (name: string): Element => {
95+
const el = document.querySelector(`[data-field="${name}"] [role="switch"], [data-field="${name}"] [role="checkbox"]`);
96+
if (!el) throw new Error(`no two-state control rendered for field "${name}"`);
9697
return el;
9798
};
9899

100+
// A `toggle`, in the shape `ObjectForm` hands the form: the widget id
101+
// `mapFieldTypeToFormType` resolves (`field:boolean` for both boolean types)
102+
// plus the object's own field metadata, whose `type` decides the face — a
103+
// `toggle` draws a switch, a `boolean` a checkbox (objectstack#27519). The
104+
// two-field case below hosts one of each.
99105
const BOOLEAN_FIELD = {
100106
name: 'notifications',
101107
label: 'Email Notifications',
102-
type: 'boolean',
108+
type: 'field:boolean',
109+
field: { name: 'notifications', label: 'Email Notifications', type: 'toggle' },
103110
};
104111

105112
describe('a form-hosted boolean field is labelled by its HOST (objectui#3952)', () => {
@@ -127,7 +134,7 @@ describe('a form-hosted boolean field is labelled by its HOST (objectui#3952)',
127134
// duplicate is pinned by the label-text count below.
128135
const labelled = screen.getAllByLabelText('Email Notifications');
129136
expect(labelled).toHaveLength(1);
130-
expect(labelled[0]).toBe(switchOf('notifications'));
137+
expect(labelled[0]).toBe(controlOf('notifications'));
131138
expect(labelled[0]).toHaveAttribute('role', 'switch');
132139
});
133140

@@ -142,21 +149,21 @@ describe('a form-hosted boolean field is labelled by its HOST (objectui#3952)',
142149
it('activating the visible label toggles the switch', () => {
143150
renderForm(BOOLEAN_FIELD, false);
144151

145-
const control = switchOf('notifications');
152+
const control = controlOf('notifications');
146153
expect(control).toHaveAttribute('aria-checked', 'false');
147154

148155
// The user-facing symptom of #3952: the normal affordance for a switch row
149156
// is clicking its label. It reaches the control only through the `for`/id
150157
// association the form set up.
151158
fireEvent.click(screen.getByText('Email Notifications'));
152159

153-
expect(switchOf('notifications')).toHaveAttribute('aria-checked', 'true');
160+
expect(controlOf('notifications')).toHaveAttribute('aria-checked', 'true');
154161
});
155162

156163
it('the control keeps the HOST id verbatim — the widget does not rename it', () => {
157164
renderForm(BOOLEAN_FIELD, false);
158165

159-
const control = switchOf('notifications');
166+
const control = controlOf('notifications');
160167
const id = control.getAttribute('id') ?? '';
161168
// The host's id is a `useId()`-derived form-item id; the field name is what
162169
// the widget used to substitute for it.
@@ -185,11 +192,14 @@ describe('a form-hosted boolean field is labelled by its HOST (objectui#3952)',
185192
);
186193

187194
expect(labelTargets().filter((l) => !l.resolves)).toEqual([]);
188-
// Each label reaches its OWN switch (a shared/colliding id would make both
189-
// resolve to the first one).
195+
// Each label reaches its OWN control (a shared/colliding id would make both
196+
// resolve to the first one). The `boolean` row draws a checkbox, the
197+
// `toggle` row a switch (objectstack#27519).
190198
fireEvent.click(screen.getByText('Weekly Digest'));
191-
expect(switchOf('digest')).toHaveAttribute('aria-checked', 'true');
192-
expect(switchOf('notifications')).toHaveAttribute('aria-checked', 'false');
199+
expect(controlOf('digest')).toHaveAttribute('role', 'checkbox');
200+
expect(controlOf('digest')).toHaveAttribute('aria-checked', 'true');
201+
expect(controlOf('notifications')).toHaveAttribute('role', 'switch');
202+
expect(controlOf('notifications')).toHaveAttribute('aria-checked', 'false');
193203
});
194204
});
195205

@@ -203,7 +213,7 @@ describe('a STANDALONE boolean widget keeps its own accessible name (objectui#39
203213
<BooleanField
204214
value={false}
205215
onChange={() => {}}
206-
field={{ name: 'notifications', label: 'Email Notifications', type: 'boolean' } as any}
216+
field={{ name: 'notifications', label: 'Email Notifications', type: 'toggle' } as any}
207217
/>,
208218
);
209219

@@ -223,7 +233,6 @@ describe('a STANDALONE boolean widget keeps its own accessible name (objectui#39
223233
name: 'agreed',
224234
label: 'I agree',
225235
type: 'boolean',
226-
widget: 'checkbox',
227236
} as any
228237
}
229238
/>,
@@ -259,7 +268,7 @@ describe('a host that labels by field NAME keeps its association (objectui#3952)
259268
</div>,
260269
);
261270

262-
const control = document.querySelector('[role="switch"]')!;
271+
const control = document.querySelector('[role="checkbox"]')!;
263272
expect(control).toHaveAttribute('id', 'confirmed');
264273
expect(labelTargets().filter((l) => !l.resolves)).toEqual([]);
265274
// The host's OWN label reaches the control through that id.
@@ -268,23 +277,21 @@ describe('a host that labels by field NAME keeps its association (objectui#3952)
268277
});
269278

270279
describe('a host-supplied id wins for BOTH variants (objectui#3952)', () => {
271-
// The checkbox variant is unreachable through the standalone form renderer —
272-
// `resolvedType = widget || field.widget || type` turns `widget: 'checkbox'`
273-
// into the renderer's own BUILTIN `checkbox` branch before the registry is
274-
// consulted — so its host contract is pinned at the prop boundary, exactly
275-
// as `<FormControl>`'s Slot delivers it.
280+
// Pinned at the prop boundary, exactly as `<FormControl>`'s Slot delivers
281+
// it: a `toggle` draws the switch variant and a `boolean` the checkbox
282+
// variant (objectstack#27519).
276283
it.each([
277-
['switch', undefined, 'switch'],
278-
['checkbox', 'checkbox', 'checkbox'],
279-
] as const)('%s variant uses the id the host handed it', (_name, widget, role) => {
284+
['switch', 'toggle', 'switch'],
285+
['checkbox', 'boolean', 'checkbox'],
286+
] as const)('%s variant uses the id the host handed it', (_name, type, role) => {
280287
render(
281288
<label htmlFor="host-item">
282289
Host Label
283290
<BooleanField
284291
value={false}
285292
onChange={() => {}}
286293
id="host-item"
287-
field={{ name: 'agreed', label: 'I agree', type: 'boolean', widget } as any}
294+
field={{ name: 'agreed', label: 'I agree', type } as any}
288295
/>
289296
</label>,
290297
);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27519 — one type-to-widget map for the two boolean types.
11+
*
12+
* Studio's type picker offers "Checkbox — shown as a checkbox" (`boolean`) and
13+
* "Toggle — shown as a switch" (`toggle`). Both resolve to the one
14+
* `field:boolean` widget, which drew a switch unless the field carried
15+
* `widget: 'checkbox'`, so a field authored as Checkbox drew a switch in the
16+
* record form and in the grid's inline editor. `resolveBooleanControl` is the
17+
* one answer every surface reads (the Studio canvas preview included, pinned
18+
* beside `ObjectFormDesigner`); these pin it and the two runtime hosts.
19+
*/
20+
import { describe, it, expect, afterEach } from 'vitest';
21+
import { render, cleanup } from '@testing-library/react';
22+
import { resolveBooleanControl } from '../index';
23+
import { BooleanField } from '../widgets/BooleanField';
24+
import { FieldEditWidget } from '../FieldEditWidget';
25+
26+
afterEach(cleanup);
27+
28+
describe('resolveBooleanControl', () => {
29+
it.each([
30+
[{ type: 'boolean' }, 'checkbox'],
31+
[{ type: 'toggle' }, 'switch'],
32+
// The bare widget id of a hand-authored form entry, and no type at all:
33+
// the `boolean` type's own face.
34+
[{ type: 'field:boolean' }, 'checkbox'],
35+
[{}, 'checkbox'],
36+
[undefined, 'checkbox'],
37+
] as const)('%o draws a %s by its type', (field, control) => {
38+
expect(resolveBooleanControl(field)).toBe(control);
39+
});
40+
41+
it.each([
42+
[{ type: 'boolean', widget: 'switch' }, 'switch'],
43+
[{ type: 'boolean', widget: 'toggle' }, 'switch'],
44+
[{ type: 'toggle', widget: 'checkbox' }, 'checkbox'],
45+
[{ type: 'toggle', widget: 'boolean' }, 'checkbox'],
46+
] as const)('an explicit boolean widget wins: %o draws a %s', (field, control) => {
47+
expect(resolveBooleanControl(field)).toBe(control);
48+
});
49+
50+
it('a widget that names no boolean face leaves the type to answer', () => {
51+
expect(resolveBooleanControl({ type: 'toggle', widget: 'star_rating' })).toBe('switch');
52+
expect(resolveBooleanControl({ type: 'boolean', widget: '' })).toBe('checkbox');
53+
});
54+
});
55+
56+
const roleDrawn = (): string | null => {
57+
const el = document.querySelector('[role="switch"], [role="checkbox"]');
58+
return el?.getAttribute('role') ?? null;
59+
};
60+
61+
describe('the record form widget and the inline editor draw the same face', () => {
62+
it.each([
63+
['boolean', undefined, 'checkbox'],
64+
['toggle', undefined, 'switch'],
65+
['boolean', 'switch', 'switch'],
66+
['toggle', 'checkbox', 'checkbox'],
67+
] as const)('BooleanField: type %s, widget %s draws a %s', (type, widget, role) => {
68+
render(
69+
<BooleanField
70+
value={false}
71+
onChange={() => {}}
72+
field={{ name: 'urgent', label: 'Urgent', type, widget } as any}
73+
/>,
74+
);
75+
expect(roleDrawn()).toBe(role);
76+
});
77+
78+
it.each([
79+
['boolean', 'checkbox'],
80+
['toggle', 'switch'],
81+
] as const)('FieldEditWidget: type %s draws a %s', (type, role) => {
82+
render(
83+
<FieldEditWidget
84+
value={false}
85+
onChange={() => {}}
86+
field={{ name: 'urgent', label: 'Urgent', type } as any}
87+
/>,
88+
);
89+
expect(roleDrawn()).toBe(role);
90+
});
91+
});

0 commit comments

Comments
 (0)