Skip to content

Commit 2855785

Browse files
feat(console): Public Forms, Flow Runs and Profile pick with the shared Select (#12012)
Part of #11865 Clause-②: no Slice h of claim `6069665700`, the console's own pages: four browser-native select elements on three pages become the shared Radix `Select` (`packages/components/src/ui/select`, used, not edited), following objectui#11976, #11990, #11995, #12000 and #12005. This PR converts these files and no others: - `apps/console/src/pages/developer/PublicFormsPage.tsx`: "FormView" in *Publish a FormView* and "After submit" in the edit dialog, through one module-private `FormsPicker`. - `apps/console/src/pages/developer/FlowRunsPage.tsx`: the flow picker, through a module-private `FlowPicker`. - `apps/console/src/pages/system/ProfilePage.tsx`: "Preferred language", through a module-private `LanguagePicker`. Each picker follows the card's earlier pickers. Items carry their option's index, so the two options whose value is the empty string ("— Select a FormView —", "Use the deployment default") need no empty `SelectItem`. A value no option carries gets an item of its own and is shown as itself. No picker is exported, and no listing logic on Public Forms moved (objectui#11545 is untouched). ## Hypotheses, measured - **H1, value parity: holds.** A scratch probe (not committed) on base `902fbe190`, with the native selects and the same fixtures as the pins, read what every option led to. The pins compare against those readings as JSON text: - Public Forms, publish: each view writes the same `meta.saveItem` call (`["view","lead.intake",{…,"sharing":{"enabled":true,"allowAnonymous":true,"publicLink":"/forms/contact-us"}}]`, and the same for the view with no label or object). "— Select a FormView —" leaves Publish disabled with no call. - Public Forms, "After submit": 12 rows (stored behaviour unset, `redirect` with `/done`, or `continue`, by each of the four options), each the same full `saveItem` call, `submitBehavior` included. - Flow Runs: each flow, picked, is the one Run Flow executes (`[["nightly_sync",{"params":{}}]]`) and whose runs are listed (`[["nightly_sync",{"limit":20}]]`), and the same for `reassign_wizard`. - Profile: 13 rows (stored tag none, `ja` or `pt-BR`, by each option), each the same `adapter.update` call; "Use the deployment default" still saves `{"locale":null}`. Re-picking the current option leaves Save disabled, as before. - **H2, names, keyboard and read-only: holds.** - Names, read on base through `getByRole('combobox', { name })`: "FormView", "After submit" and "Preferred language" come from each field's Label htmlFor. Each trigger now carries that `id`, and the name pins are green on base and head by design. - The flow picker had no label, `id` or `aria-label`, so its name was empty. It is still empty, and is pinned so (see Acceptance notes). - Keyboard: Enter opens and Enter on an option selects, pinned once per page. - Read-only: only the Profile card has such a state. Its trigger is the primitive's disabled trigger (objectui#11781), pinned by `ProfilePage.language`'s "degrades to read-only" case. Public Forms' two dialogs open only from the page's own buttons, and Flow Runs has no such state. - **H3, a value no option carries: holds.** On base the native control showed its first option in each case. Head shows the value itself, pinned three times: - Flow Runs, a picked flow that a Refresh no longer lists: base showed "Reassign (reassign_wizard)" while the page held `nightly_sync` and showed no runner. - "After submit" with a stored kind none of the four names: base showed "Show a thank-you panel" with no thank-you fields. - Profile, a pick the offered list has since dropped: base showed "Use the deployment default" while Save wrote `zh`. - **H4, first-load bytes: neutral.** - Measured before the first edit, on base: the three pages ship in lazy chunks (`PublicFormsPage-*`, `FlowRunsPage-*` and `ProfilePage-*`), none among the 336 members of `dist/eager-closure.json`. The positive controls were the entry chunk and `ui-components`, both eager. - On head: eager gzip 3,391,326 B on base and 3,391,327 B on head, and eager raw 11,496,190 B on both, so the delta is +1 B gzip and 0 B raw. - The one eager member that changed is the entry chunk. It names the three lazy chunks, whose hashes moved. `ui-components` keeps its file name, so the shared `Select` was already in it. ## Tests New pins (one per page): `PublicFormsPage.sharedSelect-11865.test.tsx`, `FlowRunsPage.sharedSelect-11865.test.tsx`, `ProfilePage.sharedSelect-11865.test.tsx`. Suites that drove or mount these pages, changed only in their harness: - `PublicFormsPage.redirect`: the editor helper picks "Redirect to a URL" through the primitive. - `ProfilePage.language`: picks through the primitive. Its adapter mock is now one object, as the app's provider hands out. A fresh one per render re-read the row and reset the pick before Save could be reached. The native control's synchronous change event used to win that race. - `developerMetadataEnvelope.contractEnvelope-6917` and `consoleFaces.displayLocale-9909`: `getClient()` now returns one client, as the real adapter's does. With a fresh client per call, Flow Runs re-ran its loads on every render. Measured with that harness shape: 251 `meta.getItems` calls in 600 ms on the base page and 73 on this branch, against 2 with one client. The native option text was readable between reloads; the shared trigger's value never settled, so their Flow Runs cases went red. Reverse leg, from the committed head `699224076`: - The three page files were put back on their base blobs (hash-checked on disk) and the new pins run: `Tests 42 failed | 3 passed (45)`. The three passing are the name pins, green on both sides by design. - Restored with `git checkout HEAD`. The blobs on disk equal HEAD's, and `git diff HEAD` is empty. A trap restored on EXIT, INT and TERM. ## Gates (head `f0ec3325f`, worktree clean) - Repo-root `pnpm exec vitest run` over the 3 new pins, the 8 suites that mount these pages, `submitRedirect.test.ts`, `column-identity.ratchet.test.ts` and `one-authority-per-exported-name-6273.test.ts`: `Test Files 14 passed (14)`, `Tests 180 passed (180)`. - `pnpm --filter @object-ui/console type-check`, after `turbo run build --filter='@object-ui/console^...'` (`Tasks: 34 successful, 34 total`): exit 0. `tsc --listFilesOnly` lists every touched file. - `pnpm exec eslint` on the 10 touched ts/tsx files: 0 errors and 32 warnings, all pre-existing rule kinds. The base blobs carry 33, and `PublicFormsPage` loses one `no-explicit-any`. - `pnpm check:eager-closure` on the head build: "Console eager closure is 3311.8 KB gzipped across 336 of 2458 chunks (budget: 3312.0 KB, headroom: 0.2 KB)." - Each exit 0: - `node scripts/check-changeset-presence.mjs`: "10 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)". - `node scripts/check-changeset-no-major.mjs`. - `pnpm check:new-line-citations`: "0 new citation(s)". - `pnpm check:control-bytes`. - Derived from the touched paths: `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:i18n-keys`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:phantom-deps`, `check:metadata-write-doors`, `check:unreferenced-sources` and `check:handler-key-reads`. Changeset: `.changeset/11865-h-console-pages-shared-select.md`, `'@object-ui/console': patch`. Docs: no guide or README describes these controls. A `git grep` of `content/docs` and the console README for the page names and labels finds only `public-forms.md`, which documents the route and the redirect contract, not the dialog's controls. So no doc change. ## Acceptance notes None of these is filed. Each is noted with no carrier. - The flow picker has no accessible name, before and after: no label, `id` or `aria-label` is associated with it. Kept for parity, as the claim asks. Naming it is a separate change. - Picking a flow reloads the flow list, because `loadFlows` depends on the picked name. The picker unmounts while the list loads, so focus does not come back to it after a pick. Read in jsdom, the same before and after. - Flow Runs keys its flow and run loads on the identity of `getClient()`. The real adapter returns one client, so this is dormant in the app; only a harness that builds a fresh client loops. `FlowRunsPage.test.tsx` still builds one per call and passes. - A stored `submitBehavior.kind` outside the spec's four is saved away: Save writes the view without `submitBehavior`, before and after. The spec refuses such a kind, so reaching this needs an off-spec stored view. - The FormView option for a view with no object keeps the trailing space the native option had. 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 5ad8183 commit 2855785

11 files changed

Lines changed: 1023 additions & 91 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
The console's Public Forms, Flow Runs and Profile pages pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the console pages' part of that card).
6+
7+
Four pickers were browser-native selects, so they looked and behaved differently from the console's other dropdowns: on Public Forms, the FormView to publish and "After submit"; on Flow Runs, the flow to test; on Profile, the preferred language. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour.
8+
9+
What they write is unchanged. Publishing saves the picked view as before, and "— Select a FormView —" still leaves nothing to publish. Each "After submit" option saves the same `submitBehavior` as before. The picked flow is still the one Run Flow executes and whose runs are listed. Each language saves the same `locale` as before, and "Use the deployment default" still saves `null`. Re-picking the current option writes nothing. Each picker keeps the accessible name it had: "FormView", "After submit" and "Preferred language" from their labels; the flow picker had no label and still has none. The language picker's read-only state is the shared control's disabled trigger.
10+
11+
One display change: a value none of a picker's options carries now shows as itself. The native select showed its first option instead, which is not what the page holds: for example, a flow picked before a Refresh that no longer lists it.
12+
13+
**Clause-②: no.** No published face moves: the console's package entry is unchanged, and no i18n key is added. What moves is these pages' own markup, described above.

‎apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,17 @@ const { STORED, approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => {
6666
reject: async () => ({ data: ROW, finalized: true }),
6767
};
6868
const listRuns = async () => ({ runs: [{ id: 'run_1', status: 'completed', startedAt: STORED, durationMs: 9 }] });
69+
// One client, as the real adapter's `getClient()` hands out: Flow Runs keys
70+
// its flow and run loads on it, so a fresh one per call re-ran them on every
71+
// render and its runs table never settled (objectui#11865 surfaced it).
72+
const client = {
73+
meta: { getItems: async (type: string) => (type === 'flow' ? [{ spec: { name: 'sync_flow', label: 'Sync' } }] : []) },
74+
automation: { execute: async () => ({ success: true }), listRuns, getRun: async () => ({ run: null }) },
75+
};
6976
const ADAPTER = {
7077
find: async () => ({ data: [{ id: 'po_1' }] }),
7178
getObjectSchema: async () => ({ fields: {} }),
72-
getClient: () => ({
73-
meta: { getItems: async (type: string) => (type === 'flow' ? [{ spec: { name: 'sync_flow', label: 'Sync' } }] : []) },
74-
automation: { execute: async () => ({ success: true }), listRuns, getRun: async () => ({ run: null }) },
75-
}),
79+
getClient: () => client,
7680
};
7781
const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } };
7882
const I18N = {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Flow Runs flow picker is the shared `Select` (objectui#11865).
5+
*
6+
* The page picked the flow to test with a browser-native select, beside the
7+
* shared Radix `Select` the rest of the console picks with. The card asks for
8+
* one control for one kind of choice, surface by surface.
9+
*
10+
* What is pinned:
11+
* - the picker IS the primitive (a Radix combobox trigger), shows the picked
12+
* flow, and no native select is left;
13+
* - the trigger's accessible name is what the native control's was;
14+
* - every flow, picked, is the one Run Flow executes and whose runs are
15+
* listed, the same calls the native control led to, compared as JSON text;
16+
* - a flow a Refresh no longer lists is what the trigger shows;
17+
* - the keyboard alone opens the picker and selects.
18+
*
19+
* Read-only: the page has no such state.
20+
*
21+
* DIRECTION, observed against the native control: every pin here but the name
22+
* pin is red there, because each one reads the picker as the primitive's
23+
* trigger. What makes the call pins guards of "the conversion changed nothing
24+
* the page runs" is the literal each compares against: a `change` event on the
25+
* pre-conversion page's native control, then the same Run Flow click, led to
26+
* those same calls, read once on that page with these fixtures. The name and
27+
* the Refresh reading were taken there the same way.
28+
*
29+
* A pick reloads the flow list (the page's `loadFlows` depends on the picked
30+
* name), so the picker unmounts while the list loads and mounts again: each
31+
* case finds the trigger afresh after a pick, as a user's next look would.
32+
*/
33+
34+
import '@testing-library/jest-dom/vitest';
35+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
36+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
37+
38+
const { execute, listRuns, catalog, ADAPTER } = vi.hoisted(() => {
39+
const catalog = { flows: [] as Array<{ spec: Record<string, unknown> }> };
40+
const execute = vi.fn(async () => ({ success: true, status: 'completed', durationMs: 1 }));
41+
const listRuns = vi.fn(async () => ({ runs: [] }));
42+
// A STABLE singleton: a fresh object per render loops the page's fetch effects.
43+
const client = {
44+
meta: { getItems: async (type: string) => (type === 'flow' ? catalog.flows : []) },
45+
automation: { execute, listRuns },
46+
};
47+
return { execute, listRuns, catalog, ADAPTER: { getClient: () => client } };
48+
});
49+
50+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
51+
...(await importOriginal<Record<string, unknown>>()),
52+
useAdapter: () => ADAPTER,
53+
useMetadata: () => ({ objects: [] }),
54+
}));
55+
56+
// Imported AFTER the mocks so the page picks them up.
57+
import { FlowRunsPage } from './FlowRunsPage';
58+
59+
const REASSIGN = { spec: { name: 'reassign_wizard', label: 'Reassign', variables: [] } };
60+
const NIGHTLY = { spec: { name: 'nightly_sync', variables: [] } };
61+
62+
beforeEach(() => {
63+
catalog.flows = [REASSIGN, NIGHTLY];
64+
execute.mockClear();
65+
listRuns.mockClear();
66+
});
67+
afterEach(cleanup);
68+
69+
/** The picker, once the flow list has loaded (again). */
70+
async function picker(): Promise<HTMLElement> {
71+
await screen.findByRole('button', { name: /Run Flow/i });
72+
return screen.findByRole('combobox');
73+
}
74+
75+
/** Open the picker from the keyboard and return the options it lists, in order. */
76+
async function openPicker(trigger: HTMLElement): Promise<HTMLElement[]> {
77+
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
78+
const listbox = await screen.findByRole('listbox');
79+
return within(listbox).getAllByRole('option');
80+
}
81+
82+
async function pick(label: string): Promise<void> {
83+
const options = await openPicker(await picker());
84+
const option = options.find((o) => o.textContent === label);
85+
if (!option) throw new Error(`no "${label}" listed: ${options.map((o) => o.textContent).join(' | ')}`);
86+
fireEvent.click(option);
87+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
88+
}
89+
90+
describe('the flow picker is the shared Select (objectui#11865)', () => {
91+
it('renders the picker as the Radix combobox trigger, showing the first flow', async () => {
92+
render(<FlowRunsPage />);
93+
const trigger = await picker();
94+
expect(trigger.tagName).toBe('BUTTON');
95+
expect(trigger).toHaveTextContent('Reassign (reassign_wizard)');
96+
expect(document.querySelector('select')).toBeNull();
97+
});
98+
99+
it('lists the flows in the order the native control did', async () => {
100+
render(<FlowRunsPage />);
101+
expect((await openPicker(await picker())).map((o) => o.textContent)).toEqual([
102+
'Reassign (reassign_wizard)',
103+
'nightly_sync',
104+
]);
105+
});
106+
107+
// Green against the native control too, by design. That control had no
108+
// label, id or aria-label, so its accessible name was empty; the swap keeps
109+
// that, and adds none.
110+
it('keeps the accessible name the native control had: none', async () => {
111+
render(<FlowRunsPage />);
112+
const trigger = await picker();
113+
expect(screen.getByRole('combobox', { name: '' })).toBe(trigger);
114+
});
115+
});
116+
117+
/**
118+
* [the flow picked away from, its label, option label, Run Flow's `execute`
119+
* calls, the panel's `listRuns` calls once the pick has settled].
120+
*/
121+
const RUN_WRITES: ReadonlyArray<
122+
readonly [from: string, fromLabel: string, label: string, execute: string, listRuns: string]
123+
> = [
124+
['nightly_sync', 'nightly_sync', 'Reassign (reassign_wizard)', '[["reassign_wizard",{"params":{}}]]', '[["reassign_wizard",{"limit":20}]]'],
125+
['reassign_wizard', 'Reassign (reassign_wizard)', 'nightly_sync', '[["nightly_sync",{"params":{}}]]', '[["nightly_sync",{"limit":20}]]'],
126+
];
127+
128+
describe('every flow, picked, is the one the page runs', () => {
129+
it.each(RUN_WRITES)('from %s (shown as %s), picking %s', async (from, fromLabel, label, executed, listed) => {
130+
render(<FlowRunsPage />);
131+
await picker();
132+
if (from !== 'reassign_wizard') await pick(fromLabel);
133+
// Settled on the flow picked away from before anything is counted.
134+
expect(await picker()).toHaveTextContent(fromLabel);
135+
await waitFor(() => expect(listRuns).toHaveBeenLastCalledWith(from, { limit: 20 }));
136+
execute.mockClear();
137+
listRuns.mockClear();
138+
await pick(label);
139+
await waitFor(() => expect(listRuns).toHaveBeenCalled());
140+
expect(await picker()).toHaveTextContent(label);
141+
fireEvent.click(screen.getByRole('button', { name: /Run Flow/i }));
142+
await waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
143+
expect(JSON.stringify(execute.mock.calls)).toBe(executed);
144+
expect(JSON.stringify(listRuns.mock.calls)).toBe(listed);
145+
});
146+
});
147+
148+
describe('a flow no option carries is what the trigger shows', () => {
149+
it('a picked flow a Refresh no longer lists: shown, not the first flow, and re-picking it runs nothing', async () => {
150+
render(<FlowRunsPage />);
151+
await pick('nightly_sync');
152+
await picker();
153+
catalog.flows = [REASSIGN];
154+
fireEvent.click(screen.getAllByRole('button', { name: /Refresh/i })[0]);
155+
// The page still holds `nightly_sync` and shows no runner for it. The
156+
// native control showed "Reassign (reassign_wizard)" here.
157+
await waitFor(() => expect(screen.queryByRole('button', { name: /Run Flow/i })).not.toBeInTheDocument());
158+
const trigger = await screen.findByRole('combobox');
159+
expect(trigger).toHaveTextContent('nightly_sync');
160+
const listed = (await openPicker(trigger)).map((o) => o.textContent);
161+
expect(listed).toEqual(['nightly_sync', 'Reassign (reassign_wizard)']);
162+
execute.mockClear();
163+
listRuns.mockClear();
164+
fireEvent.click(screen.getAllByRole('option')[0]);
165+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
166+
expect(screen.getByRole('combobox')).toHaveTextContent('nightly_sync');
167+
expect(screen.queryByRole('button', { name: /Run Flow/i })).not.toBeInTheDocument();
168+
expect(listRuns).not.toHaveBeenCalled();
169+
});
170+
});
171+
172+
describe('the keyboard alone picks', () => {
173+
it('Enter opens the picker and Enter on a flow selects it', async () => {
174+
render(<FlowRunsPage />);
175+
fireEvent.keyDown(await picker(), { key: 'Enter' });
176+
const listbox = await screen.findByRole('listbox');
177+
fireEvent.keyDown(within(listbox).getByRole('option', { name: 'nightly_sync' }), { key: 'Enter' });
178+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
179+
expect(await picker()).toHaveTextContent('nightly_sync');
180+
fireEvent.click(screen.getByRole('button', { name: /Run Flow/i }));
181+
await waitFor(() => expect(execute).toHaveBeenCalledWith('nightly_sync', { params: {} }));
182+
});
183+
});

‎apps/console/src/pages/developer/FlowRunsPage.tsx‎

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ import {
4545
SheetTitle,
4646
SheetDescription,
4747
ScrollArea,
48+
Select,
49+
SelectContent,
50+
SelectItem,
51+
SelectTrigger,
52+
SelectValue,
4853
} from '@object-ui/components';
4954
import {
5055
Play, Loader2, CheckCircle2, XCircle, Clock, AlertCircle, RefreshCw, Workflow,
@@ -130,6 +135,61 @@ function JsonBlock({ data }: { data: unknown }) {
130135
);
131136
}
132137

138+
/** The item a value none of the picker's options carries is shown by. */
139+
const OUTSIDE_OPTIONS = 'outside';
140+
141+
/**
142+
* objectui#11865 — the flow picker, drawn with the shared `Select` the rest of
143+
* the console picks with. It used to be a browser-native `<select>`. What a
144+
* pick writes is unchanged: `onPick` receives the picked flow's `name`, the
145+
* string the native control's `change` carried. Re-picking the current flow
146+
* writes nothing, as it did there.
147+
*
148+
* - Items carry their option's INDEX, not the flow's name, as the card's other
149+
* pickers do; an index cannot collide with a name.
150+
* - A value none of the options carries gets an item of its own, labelled with
151+
* the value: a flow picked before a Refresh that no longer lists it. The
152+
* trigger then shows the flow the page still holds (no runner is shown for
153+
* it). The native control showed the first flow there. Picking that item
154+
* writes nothing.
155+
* - Like the native control, the trigger has no label: none is associated
156+
* with it today, and this swap keeps the name it had.
157+
* - The page has no read-only state.
158+
*/
159+
function FlowPicker({
160+
value,
161+
options,
162+
onPick,
163+
}: {
164+
value: string;
165+
options: ReadonlyArray<{ value: string; label: string }>;
166+
onPick: (value: string) => void;
167+
}) {
168+
const at = options.findIndex((o) => o.value === value);
169+
return (
170+
<Select
171+
value={at !== -1 ? String(at) : OUTSIDE_OPTIONS}
172+
onValueChange={(token) => {
173+
// `undefined` for the outside item: it is the page's own value, so there is nothing to write.
174+
const picked = options[Number(token)];
175+
if (picked) onPick(picked.value);
176+
}}
177+
>
178+
<SelectTrigger className="h-9 max-w-md">
179+
<SelectValue />
180+
</SelectTrigger>
181+
<SelectContent>
182+
{at === -1 && <SelectItem value={OUTSIDE_OPTIONS}>{value}</SelectItem>}
183+
{options.map((o, i) => (
184+
<SelectItem key={`${i}:${o.value}`} value={String(i)}>
185+
{o.label}
186+
</SelectItem>
187+
))}
188+
</SelectContent>
189+
</Select>
190+
);
191+
}
192+
133193
export function FlowRunsPage() {
134194
const adapter = useAdapter();
135195
const client: any = adapter?.getClient?.();
@@ -216,17 +276,14 @@ export function FlowRunsPage() {
216276
<p className="text-sm text-muted-foreground">No flow definitions found.</p>
217277
)}
218278
{!flowsLoading && !flowsError && flows.length > 0 && (
219-
<select
279+
<FlowPicker
220280
value={selectedFlowName}
221-
onChange={e => setSelectedFlowName(e.target.value)}
222-
className="flex h-9 w-full max-w-md rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
223-
>
224-
{flows.map(f => (
225-
<option key={f.name} value={f.name}>
226-
{f.label ? `${f.label} (${f.name})` : f.name}
227-
</option>
228-
))}
229-
</select>
281+
options={flows.map(f => ({
282+
value: f.name,
283+
label: f.label ? `${f.label} (${f.name})` : f.name,
284+
}))}
285+
onPick={setSelectedFlowName}
286+
/>
230287
)}
231288
</CardContent>
232289
</Card>

‎apps/console/src/pages/developer/PublicFormsPage.redirect.test.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
*/
4949

5050
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
51-
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
51+
import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react';
5252
import userEvent from '@testing-library/user-event';
5353
// The page asks the router where the anonymous route is served (objectui#11769),
5454
// so it renders inside one, as it does in the app.
@@ -119,14 +119,19 @@ function setUrl(field: HTMLElement, value: string) {
119119
fireEvent.change(field, { target: { value } });
120120
}
121121

122-
/** Open the row's editor and switch the post-submit behavior to `redirect`. */
122+
/**
123+
* Open the row's editor and switch the post-submit behavior to `redirect`,
124+
* through the shared `Select` the picker is (objectui#11865): open it from the
125+
* keyboard, choose the option.
126+
*/
123127
async function openRedirectEditor(user: ReturnType<typeof userEvent.setup>) {
124128
render(<PublicFormsPage />, { wrapper: MemoryRouter });
125129
await user.click(
126130
await screen.findByRole('button', { name: /Edit sharing & post-submit behavior/i }),
127131
);
128-
await user.selectOptions(await screen.findByLabelText(/After submit/i), 'redirect');
129-
return screen.getByLabelText(/Redirect URL/i);
132+
fireEvent.keyDown(await screen.findByRole('combobox', { name: /After submit/i }), { key: 'ArrowDown' });
133+
fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Redirect to a URL' }));
134+
return screen.findByLabelText(/Redirect URL/i);
130135
}
131136

132137
beforeEach(() => saveItem.mockClear());

0 commit comments

Comments
 (0)