Skip to content

Commit 5ad8183

Browse files
refactor(plugin-designer): the dashboard designer picks with the shared Select (objectui#11865) (#12011)
Part of #11865 Clause-②: no This is slice i of claim `6069665700` on objectui#11865: the dashboard designer. `DashboardEditor`'s widget property panel picks a widget's Type and Color Variant, and `PropertyEditor` draws a `select` field, with the shared `Select` (`packages/components/src/ui/select`) instead of a browser-native select element. The card stays open for the other native selects; slices h and j of the same claim are separate PRs. ## What changed - `packages/plugin-designer/src/DashboardEditor.tsx`: the Type and Color Variant pickers go through one module-private `WidgetPropPicker`, modelled on the earlier slices' pickers (objectui#11976 `RulePicker`, #12005 `CreatePicker`). - Items carry their option's index, not its value, so the picked option is resolved against the list that rendered the items. - An option may be disabled, as a native option element was: the Type picker still disables the types the widget door refuses the widget's measures under (objectui#8894), and the write guard beside it is kept. - A stored value no option carries gets an item of its own, labelled with the value. - `id` lands on the trigger, so each label `htmlFor` still names its control. - The colour options are a typed list (`COLOR_VARIANTS`), so a pick reaches `onChange` as the widget's own `colorVariant` type, with no cast of a DOM string. The value written is the same string. - `packages/plugin-designer/src/components/PropertyEditor.tsx`: a `select` field goes through one module-private `PropertySelect`, the same index-token pattern, so an option whose value is empty needs no empty `SelectItem`. The value is matched as the native control matched it: the field's value as a string, empty when it has none. - New pins: `DashboardEditor.sharedSelect-11865.test.tsx` (31 tests) and `PropertyEditor.sharedSelect-11865.test.tsx` (13 tests). - Three existing suites drove the native controls and now pick through the trigger, with their assertions unchanged in meaning: - `DashboardEditor.measureDoor-8894`: it reads the options off the opened list (each item carries its type as `data-option-value`, a disabled one says so with `aria-disabled`), and a pick of a refused type still writes nothing; - `DashboardEditor.i18nTitle` and `DashboardEditor.slotComponentArm-11598`: one colour pick each. - `.changeset/11865-i-dashboard-editor-shared-select.md`: `@object-ui/plugin-designer: patch`. No export, prop or i18n key moves: `DashboardEditorProps`, `PropertyEditorProps` and `PropertyField` are unchanged, and `packages/components/src/ui/**` is not edited. ## The dispatch's hypotheses, measured - **H1, value parity: holds.** A scratch probe (uncommitted, deleted before the first commit) fired a change event on every native option on base `902fbe190` and read what `onChange` received. The new pins compare each row as JSON text against those literals: - Type: 5 options on a one-measure widget, 5 on a two-measure widget. On the two-measure widget KPI Metric and Pie Chart are disabled and write nothing, on base as now. - Color Variant: 8 options on a widget with none set, 3 on a `teal` widget. Default writes `colorVariant: "default"`, as before. - `PropertyEditor`: 6 rows, the empty-value option included (it writes `onChange("kind", "")`). - Caveat: the probe's change event fired for the current option too, which a browser's native select does not do, so the re-pick rows ("writes nothing") pin the primitive. - **H2, names, keyboard and read-only.** - Names: Type and Color Variant are found by `getByRole('combobox', { name })`, read on base the same way. `PropertyEditor`'s field had no accessible name on base (its caption is a sibling label that names no control), and it keeps none, as the dispatch asks. - Keyboard: Enter opens a picker and Enter on an option selects it, pinned on both surfaces. - Read-only: `DashboardEditor`'s `readOnly` disables both triggers. They wear the primitive's own `disabled:` classes and do not open, with a writable CONTROL. `PropertyEditor` has no read-only state (its props and fields declare none). - **H3, a stored value outside the options: holds.** A stored `area` type shows `area`, where base showed "KPI Metric". A `metric-card` entry shows `metric-card` (base: "KPI Metric"). A stored colour outside the list is shown (base: "Default"). A `PropertyEditor` value outside its options is shown (base: the first option), and a field with no value and no empty option shows nothing (base: the first option). Re-picking the outside item writes nothing. - **H4, first-load bytes: byte-neutral.** It was measured before the first edit: base `902fbe190`, `apps/console` `CI=true vite build` under the lock, then `dist/eager-closure.json`. - Both files ship in the lazy chunk `src-Bg0yZV49.js`, which the entry chunk reaches only through `lazy(() => import(...))`. It is not among the 336 eager members. - Base reads eagerGzipBytes 3391326, eagerRawBytes 11496190. - Head `56dea3f5e` reads 3391325 and 11496190, with the same 336 eager chunks: 0 raw bytes and −1 gzip byte. The one eager chunk that moves is the entry chunk: identical raw bytes, with the lazy designer chunk's hashed name inside it changed. The designer code ships in the lazy chunk `src-kZPNwCPn.js` on head. - `pnpm check:eager-closure` on head: "Console eager closure is 3311.8 KB gzipped across 336 of 2458 chunks (budget: 3312.0 KB, headroom: 0.2 KB)." - The budget bot's reading on this PR is for the seat to quote after CI. ## Evidence on `56dea3f5e` - Repo-root `pnpm exec vitest run --maxWorkers=2 packages/plugin-designer/ packages/core/src/utils/__tests__/column-identity.ratchet.test.ts scripts/__tests__/one-authority-per-exported-name-6273.test.ts`: "Test Files 47 passed (47)", "Tests 369 passed (369)". That is every plugin-designer suite (45 files: everything that mounts `DashboardEditor`, `DashboardDesignPage` or `PropertyEditor`) plus the two repo-wide pins. Lock VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-designer type-check` (echoed `tsc --noEmit && tsc -p tsconfig.test.json`), after the `@object-ui/plugin-designer^...` build (Scope: 17 of 47): VERDICT command-exit 0. By `--listFilesOnly`, both sources are in the main program and all five touched suites are in the test program. - `pnpm exec eslint --format json` on the 7 touched files: 0 errors, 4 warnings. All 4 are `react-hooks/exhaustive-deps` in `DashboardEditor`'s untouched main component, and the base blob gives the same 4 via `--stdin`. - `node scripts/check-changeset-presence.mjs`: "7 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)". `node scripts/check-changeset-no-major.mjs`: exit 0. - `pnpm check:new-line-citations`: "0 new citation(s)". `pnpm check:control-bytes`: OK. - Derived by hand from objectui's `package.json` and workflows, all exit 0: - `check-changeset-fixed`, `check-changeset-overwrite`, `check-changeset-claims`, `check-lint-coverage`, `check-type-check-coverage`; - `check:pending-changeset-literals`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:phantom-deps`, `check:unused-deps`, `check:unreferenced-sources`, `check:esm-specifiers`, `check:self-import`, `check:designer-field-key-parity`, `check:metadata-write-doors`, `check:handler-key-reads`, `check:i18n-keys`; - `check:component-surface-parity` (report-only). - Not measured locally: `check:readme-exports` needs every package's dist and stopped on that prerequisite. This diff touches no README and no export. Repo-wide `pnpm lint` and the full test farm are CI's. **Reverse leg.** The implementation was committed first (`6a01f72f3`). Both source files were then written from their base blobs: `08bce46f` and `776f26e8`, landing proven by hash, with 2 and 1 native selects and 0 pickers. The new pins and the three converted suites read "Tests 48 failed | 12 passed (60)", VITEST-EXIT 1. - The green 12: both name pins, the writable CONTROL, and the nine untouched cases of the converted suites. That is the predicted direction. - `56dea3f5e` corrected the `DashboardEditor` pin's header, which had named the name pin alone. - The trap restored with `git checkout HEAD --` on absolute paths. Hashes `99f0f1a3` and `24606bf0` equal the HEAD blobs; `git diff HEAD` was 0 bytes and the status had 0 entries. **ProcessDesigner markup.** `ProcessDesigner.readers-11434` compares drawn markup with React ids normalised, and its property panel now draws a Radix trigger. A scratch probe (deleted) rendered one document twice with the panel open: raw markup equal, no generated id in it. That suite needs no change, and it passes in the run above. ## Acceptance notes - `carrier: none`: `PropertyEditor`'s field caption is a label beside the control that names no control, so a `select` field's trigger has no accessible name, on base as now. Measured with testing-library's name computation. This is a11y polish with no public door measured. - `carrier: none`, read-only inference: the Type picker is offered on a `metric-card` entry too, and a pick writes `type` onto that entry. Whether the resulting entry is refused at publish was not measured. This PR leaves that write path as it was. - Browser: NOT MEASURED in this slice. Keyboard and read-only are pinned in the test DOM only. Commits carry objectui `AGENTS.md`'s model-free trailer pair. Session: `https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU`. --- _Generated by [Claude Code](https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ebff39 commit 5ad8183

8 files changed

Lines changed: 666 additions & 66 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-designer': patch
3+
---
4+
5+
The dashboard designer's widget property panel picks a widget's Type and Color Variant with the shared `Select`, and the designers' property panel (`PropertyEditor`) draws a `select` field with it too: the control the rest of the console picks with (objectui#11865, the dashboard designer's part of that card).
6+
7+
The three pickers were browser-native selects, so they looked and behaved differently from the console's other dropdowns. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour.
8+
9+
What they write is unchanged. Each option hands the panel the same value as before: a type pick still writes `type`, a colour pick still writes `colorVariant`, and a `select` field still calls `onChange` with the field's name and the option's value, an option whose value is empty included. A type the widget door refuses the widget's measures under is still a disabled option and still writes nothing. Re-picking the current option writes nothing. The Type and Color Variant pickers keep the accessible name their labels gave the native selects; a `select` field's caption named no control before and names none now. Read-only disables the Type and Color Variant triggers in the shared `Select`'s own disabled look; `PropertyEditor` has no read-only state, as before.
10+
11+
One display change: a value none of a picker's options carries now shows as itself (a stored `area` type, a `metric-card` entry's type, a field value no option carries). The native select showed its first option instead ("KPI Metric", "Default"), which is not what the widget or the field holds. A `select` field with no value and no empty option shows nothing rather than its first option.
12+
13+
**Clause-②: no.** No published face moves: the package entry exports the same names, `DashboardEditorProps`, `PropertyEditorProps` and `PropertyField` take the same members, and no i18n key is added. What moves is the panels' own markup, described above: a test that drove one of these controls as a native select now picks through the trigger.

‎packages/plugin-designer/src/DashboardEditor.tsx‎

Lines changed: 124 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ import {
5050
} from 'lucide-react';
5151
import { clsx } from 'clsx';
5252
import { twMerge } from 'tailwind-merge';
53+
import {
54+
Select,
55+
SelectContent,
56+
SelectItem,
57+
SelectTrigger,
58+
SelectValue,
59+
} from '@object-ui/components';
5360
import { DASHBOARD_COMPONENT_WIDGET_TYPES, completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
5461
import { DashboardWidgetSchema as DashboardWidgetDoor } from '@object-ui/types/zod';
5562
import { pickLocalized, setLocalized } from '@object-ui/i18n';
@@ -385,6 +392,102 @@ function WidgetCard({
385392
// Widget Property Panel
386393
// ============================================================================
387394

395+
/**
396+
* The colour variants the panel offers, in the order the native control
397+
* listed them. TYPED to the widget's own `colorVariant`, so a pick reaches
398+
* `onChange` as that type with no cast of a DOM string.
399+
*/
400+
const COLOR_VARIANTS: ReadonlyArray<{
401+
value: NonNullable<DashboardWidgetSchema['colorVariant']>;
402+
label: string;
403+
}> = [
404+
{ value: 'default', label: 'Default' },
405+
{ value: 'blue', label: 'Blue' },
406+
{ value: 'teal', label: 'Teal' },
407+
{ value: 'orange', label: 'Orange' },
408+
{ value: 'purple', label: 'Purple' },
409+
{ value: 'success', label: 'Success' },
410+
{ value: 'warning', label: 'Warning' },
411+
{ value: 'danger', label: 'Danger' },
412+
];
413+
414+
/** The item a stored value none of a picker's options carries is shown by. */
415+
const OUTSIDE_OPTIONS = 'outside';
416+
417+
/** The classes a picker takes, at the size of the inputs beside it. */
418+
const PICKER = 'h-auto border-gray-300 px-2.5 py-1.5 text-sm';
419+
420+
/**
421+
* objectui#11865 — one of the property panel's pickers (the widget's type and
422+
* its colour variant), drawn with the shared `Select`, the control the rest of
423+
* the console picks with. They used to be browser-native `<select>`s. What a
424+
* pick writes is unchanged: `onPick` receives the picked option's own `value`,
425+
* the string the native control's `change` carried, and each caller turns it
426+
* into the same `onChange` update as before. Re-picking the current option
427+
* writes nothing, as it did there.
428+
*
429+
* - Items carry their option's INDEX, not its value, so the picked option is
430+
* resolved against the very list that rendered the items, and no option
431+
* value can collide with the outside item below.
432+
* - An option may be `disabled`, as a native `<option>` was: the type picker
433+
* disables the types the widget door refuses this widget's measures under.
434+
* - A stored value none of the options carries gets an item of its own,
435+
* labelled with the value, so the trigger shows what the widget holds (a
436+
* stored `area`, a `metric-card` entry). The native control showed its first
437+
* option there ("KPI Metric", "Default"), which is not what the widget says.
438+
* Picking that item writes nothing. An empty value gets no such item: the
439+
* trigger shows nothing.
440+
* - Read-only follows the primitive (objectui#11781): `disabled` disables the
441+
* trigger, which wears `SelectTrigger`'s own disabled look.
442+
* - `id` lands on the trigger, so the caller's `<label htmlFor>` names it as it
443+
* named the native control.
444+
*/
445+
function WidgetPropPicker<V extends string>({
446+
id,
447+
testId,
448+
value,
449+
options,
450+
onPick,
451+
disabled,
452+
}: {
453+
id: string;
454+
testId: string;
455+
value: string;
456+
options: ReadonlyArray<{ value: V; label: string; disabled?: boolean }>;
457+
onPick: (value: V) => void;
458+
disabled: boolean;
459+
}) {
460+
const at = options.findIndex((o) => o.value === value);
461+
const outside = at === -1 && value !== '';
462+
return (
463+
<Select
464+
value={at !== -1 ? String(at) : outside ? OUTSIDE_OPTIONS : ''}
465+
onValueChange={(token) => {
466+
// `undefined` for the outside item: it is the stored value, so there is nothing to write.
467+
const picked = options[Number(token)];
468+
if (picked) onPick(picked.value);
469+
}}
470+
disabled={disabled}
471+
>
472+
<SelectTrigger id={id} data-testid={testId} className={PICKER}>
473+
<SelectValue />
474+
</SelectTrigger>
475+
<SelectContent>
476+
{outside && (
477+
<SelectItem value={OUTSIDE_OPTIONS} data-option-value={value}>
478+
{value}
479+
</SelectItem>
480+
)}
481+
{options.map((o, i) => (
482+
<SelectItem key={`${i}:${o.value}`} value={String(i)} disabled={o.disabled} data-option-value={o.value}>
483+
{o.label}
484+
</SelectItem>
485+
))}
486+
</SelectContent>
487+
</Select>
488+
);
489+
}
490+
388491
interface WidgetPropertyPanelProps {
389492
widget: DashboardWidgetEntry;
390493
/** The widget's position in `widgets[]`, which the grid auto-places it by. */
@@ -478,30 +581,27 @@ function WidgetPropertyPanel({
478581
{/* Type */}
479582
<div className="space-y-1">
480583
<label htmlFor="widget-type" className="text-xs font-medium text-gray-600">Type</label>
481-
<select
584+
<WidgetPropPicker
482585
id="widget-type"
483-
data-testid="widget-prop-type"
484-
value={widget.type ?? 'metric'}
485-
onChange={(e) => {
486-
// Resolve the DOM string against the very list that rendered the
487-
// options, rather than casting it onto the closed type. No cast, no
488-
// tolerance: a value not in the palette writes nothing at all,
586+
testId="widget-prop-type"
587+
value={currentType}
588+
options={WIDGET_TYPES.map((t) => ({
589+
value: t.type,
590+
label: t.label,
591+
disabled: t.type !== currentType && refusedUnder(t.type),
592+
}))}
593+
onPick={(type) => {
594+
// The picker resolves its item against the very list that rendered
595+
// the options, so the type arrives typed off the palette, never as a
596+
// cast DOM string: a value not in the palette writes nothing at all,
489597
// instead of storing a `type` the platform refuses at publish.
490598
// A type the door refuses this widget's measures under writes
491-
// nothing either (objectui#8894): its option is disabled, and this
492-
// keeps a programmatic change from storing what publish refuses.
493-
const picked = WIDGET_TYPES.find((t) => t.type === e.target.value);
494-
if (picked && (picked.type === currentType || !refusedUnder(picked.type))) onChange({ type: picked.type });
599+
// nothing either (objectui#8894): its item is disabled, and this
600+
// keeps any other route to it from storing what publish refuses.
601+
if (type === currentType || !refusedUnder(type)) onChange({ type });
495602
}}
496603
disabled={readOnly}
497-
className="block w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50"
498-
>
499-
{WIDGET_TYPES.map((t) => (
500-
<option key={t.type} value={t.type} disabled={t.type !== currentType && refusedUnder(t.type)}>
501-
{t.label}
502-
</option>
503-
))}
504-
</select>
604+
/>
505605
{currentRefusal && (
506606
<p role="alert" data-testid="widget-prop-measure-refusal" className="break-words text-xs text-red-600">
507607
{currentRefusal}
@@ -522,23 +622,14 @@ function WidgetPropertyPanel({
522622
{widgetArm && (
523623
<div className="space-y-1">
524624
<label htmlFor="widget-color" className="text-xs font-medium text-gray-600">Color Variant</label>
525-
<select
625+
<WidgetPropPicker
526626
id="widget-color"
527-
data-testid="widget-prop-color"
627+
testId="widget-prop-color"
528628
value={widgetArm.colorVariant ?? 'default'}
529-
onChange={(e) => onChange({ colorVariant: e.target.value as DashboardWidgetSchema['colorVariant'] })}
629+
options={COLOR_VARIANTS}
630+
onPick={(colorVariant) => onChange({ colorVariant })}
530631
disabled={readOnly}
531-
className="block w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50"
532-
>
533-
<option value="default">Default</option>
534-
<option value="blue">Blue</option>
535-
<option value="teal">Teal</option>
536-
<option value="orange">Orange</option>
537-
<option value="purple">Purple</option>
538-
<option value="success">Success</option>
539-
<option value="warning">Warning</option>
540-
<option value="danger">Danger</option>
541-
</select>
632+
/>
542633
</div>
543634
)}
544635

‎packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
import { describe, it, expect, vi } from 'vitest';
4343
import React from 'react';
44-
import { render, screen, fireEvent } from '@testing-library/react';
44+
import { render, screen, fireEvent, within } from '@testing-library/react';
4545
import type { DashboardComponentSchema } from '@object-ui/types';
4646
import { DashboardEditor } from '../DashboardEditor';
4747

@@ -184,12 +184,14 @@ describe('DashboardEditor — the title INPUT is a write path, not a display (#4
184184
expect(reopened.value).toBe('Pipelinex');
185185
});
186186

187-
it('an inline map survives an UNRELATED edit-and-save round trip untouched', () => {
187+
it('an inline map survives an UNRELATED edit-and-save round trip untouched', async () => {
188188
// The ruling's acceptance criterion, end to end: the author changes
189189
// something else entirely on the same widget, and the stored map comes back
190-
// byte-identical rather than flattened to one locale.
190+
// byte-identical rather than flattened to one locale. The colour is picked
191+
// through the shared Select's trigger (objectui#11865).
191192
const onChange = openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1');
192-
fireEvent.change(screen.getByTestId('widget-prop-color'), { target: { value: 'blue' } });
193+
fireEvent.keyDown(screen.getByTestId('widget-prop-color'), { key: 'ArrowDown' });
194+
fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Blue' }));
193195

194196
expect(onChange).toHaveBeenCalled();
195197
const saved = lastSchema(onChange);

‎packages/plugin-designer/src/__tests__/DashboardEditor.measureDoor-8894.test.tsx‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,25 @@
1818
* save swallows the error. Now the picker asks the door (`measureRefusal`):
1919
*
2020
* - a type the door refuses this widget's measures under is a DISABLED
21-
* option, and a change to it writes nothing;
21+
* option, and a pick of it writes nothing;
2222
* - a stored widget the door already refuses shows the door's message.
2323
*
2424
* ## Which options must be disabled is read off the SPEC, per option
2525
*
26-
* Every `<option>` the editor renders is judged by the spec's own
26+
* Every option the editor renders is judged by the spec's own
2727
* `DashboardWidgetSchema` with the widget's measures: refused at `values` ⇒ the
2828
* option must be disabled, accepted ⇒ enabled. No type list is written here, so
2929
* the rows move with the spec and with the editor's palette.
30+
*
31+
* The picker is the shared `Select` (objectui#11865), so the options are read
32+
* off its opened list: each item carries its type as `data-option-value` and a
33+
* disabled item says so with `aria-disabled`, as a native `<option>` did with
34+
* `disabled`.
3035
*/
3136

3237
import { describe, it, expect, vi } from 'vitest';
3338
import React from 'react';
34-
import { render, screen, fireEvent } from '@testing-library/react';
39+
import { render, screen, fireEvent, within } from '@testing-library/react';
3540
import { DashboardWidgetSchema as SpecDashboardWidgetSchema } from '@objectstack/spec/ui';
3641
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
3742
import { DashboardEditor } from '../DashboardEditor';
@@ -65,14 +70,22 @@ function specArityIssue(widget: DashboardWidgetSchema, type: string): Issue | un
6570
return (r.error.issues as Issue[]).find((i) => i.code === 'custom' && i.path.map(String).join('.') === 'values');
6671
}
6772

68-
const typeOptions = () =>
69-
Array.from((screen.getByTestId('widget-prop-type') as HTMLSelectElement).options);
73+
/** The type picker's options, read off its opened list, in order. */
74+
async function typeOptions(): Promise<Array<{ value: string; disabled: boolean; element: HTMLElement }>> {
75+
fireEvent.keyDown(screen.getByTestId('widget-prop-type'), { key: 'ArrowDown' });
76+
const listbox = await screen.findByRole('listbox');
77+
return within(listbox).getAllByRole('option').map((element) => {
78+
const value = element.getAttribute('data-option-value');
79+
if (!value) throw new Error(`option "${element.textContent}" carries no data-option-value`);
80+
return { value, disabled: element.getAttribute('aria-disabled') === 'true', element };
81+
});
82+
}
7083

7184
describe('objectui#8894 — the type picker offers only what the door accepts', () => {
72-
it('SUBJECT: a two-measure bar cannot be turned into a type the spec refuses two measures on', () => {
85+
it('SUBJECT: a two-measure bar cannot be turned into a type the spec refuses two measures on', async () => {
7386
const widget = bound('w1', { values: ['revenue', 'deal_count'] });
7487
openPanelFor(widget);
75-
const options = typeOptions();
88+
const options = await typeOptions();
7689
const refused = options.filter((o) => specArityIssue(widget, o.value) !== undefined);
7790
// Not vacuous: the editor's palette offers at least one metric-family type.
7891
expect(refused.length).toBeGreaterThan(0);
@@ -83,37 +96,38 @@ describe('objectui#8894 — the type picker offers only what the door accepts',
8396
expect(screen.queryByTestId('widget-prop-measure-refusal')).toBeNull();
8497
});
8598

86-
it('SUBJECT: a change to a refused type writes nothing', () => {
99+
it('SUBJECT: a pick of a refused type writes nothing', async () => {
87100
const widget = bound('w1', { values: ['revenue', 'deal_count'] });
88101
const onChange = openPanelFor(widget);
89-
const refused = typeOptions().find((o) => specArityIssue(widget, o.value) !== undefined)!;
90-
fireEvent.change(screen.getByTestId('widget-prop-type'), { target: { value: refused.value } });
102+
const refused = (await typeOptions()).find((o) => specArityIssue(widget, o.value) !== undefined)!;
103+
fireEvent.click(refused.element);
104+
fireEvent.keyDown(refused.element, { key: 'Enter' });
91105
expect(onChange).not.toHaveBeenCalled();
92106
});
93107

94-
it('CONTROL: a change to an accepted type is written as before', () => {
108+
it('CONTROL: a pick of an accepted type is written as before', async () => {
95109
const widget = bound('w1', { values: ['revenue', 'deal_count'] });
96110
const onChange = openPanelFor(widget);
97-
fireEvent.change(screen.getByTestId('widget-prop-type'), { target: { value: 'line' } });
111+
fireEvent.click((await typeOptions()).find((o) => o.value === 'line')!.element);
98112
const schema = onChange.mock.calls[onChange.mock.calls.length - 1][0] as DashboardComponentSchema;
99113
expect((schema.widgets as DashboardWidgetSchema[])[0].type).toBe('line');
100114
});
101115

102-
it('CONTROL: a one-measure widget is offered every type', () => {
116+
it('CONTROL: a one-measure widget is offered every type', async () => {
103117
openPanelFor(bound('w1'));
104-
expect(typeOptions().filter((o) => o.disabled)).toEqual([]);
118+
expect((await typeOptions()).filter((o) => o.disabled)).toEqual([]);
105119
});
106120

107-
it('SUBJECT: a stored widget the door already refuses shows the door\'s own message, and stays what it is', () => {
121+
it('SUBJECT: a stored widget the door already refuses shows the door\'s own message, and stays what it is', async () => {
108122
const widget = bound('w1', { type: 'metric', dimensions: undefined, values: ['revenue', 'deal_count'] });
109123
openPanelFor(widget);
110124
const shown = screen.getByTestId('widget-prop-measure-refusal');
111125
expect(shown).toHaveAttribute('role', 'alert');
112126
expect(shown.textContent).toBe(specArityIssue(widget, 'metric')!.message);
113127
// Its own type stays selected and selectable — the editor reports, it does
114128
// not rewrite the stored widget.
115-
const select = screen.getByTestId('widget-prop-type') as HTMLSelectElement;
116-
expect(select.value).toBe('metric');
117-
expect(typeOptions().find((o) => o.value === 'metric')!.disabled).toBe(false);
129+
const metric = (await typeOptions()).find((o) => o.value === 'metric')!;
130+
expect(metric.element).toHaveAttribute('data-state', 'checked');
131+
expect(metric.disabled).toBe(false);
118132
});
119133
});

0 commit comments

Comments
 (0)