diff --git a/.changeset/11865-h-console-pages-shared-select.md b/.changeset/11865-h-console-pages-shared-select.md new file mode 100644 index 0000000000..229e091a48 --- /dev/null +++ b/.changeset/11865-h-console-pages-shared-select.md @@ -0,0 +1,13 @@ +--- +'@object-ui/console': patch +--- + +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). + +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. + +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. + +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. + +**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. diff --git a/apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx b/apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx index 1fbbbd6ee7..45ba544ecd 100644 --- a/apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx +++ b/apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx @@ -66,13 +66,17 @@ const { STORED, approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => { reject: async () => ({ data: ROW, finalized: true }), }; const listRuns = async () => ({ runs: [{ id: 'run_1', status: 'completed', startedAt: STORED, durationMs: 9 }] }); + // One client, as the real adapter's `getClient()` hands out: Flow Runs keys + // its flow and run loads on it, so a fresh one per call re-ran them on every + // render and its runs table never settled (objectui#11865 surfaced it). + const client = { + meta: { getItems: async (type: string) => (type === 'flow' ? [{ spec: { name: 'sync_flow', label: 'Sync' } }] : []) }, + automation: { execute: async () => ({ success: true }), listRuns, getRun: async () => ({ run: null }) }, + }; const ADAPTER = { find: async () => ({ data: [{ id: 'po_1' }] }), getObjectSchema: async () => ({ fields: {} }), - getClient: () => ({ - meta: { getItems: async (type: string) => (type === 'flow' ? [{ spec: { name: 'sync_flow', label: 'Sync' } }] : []) }, - automation: { execute: async () => ({ success: true }), listRuns, getRun: async () => ({ run: null }) }, - }), + getClient: () => client, }; const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; const I18N = { diff --git a/apps/console/src/pages/developer/FlowRunsPage.sharedSelect-11865.test.tsx b/apps/console/src/pages/developer/FlowRunsPage.sharedSelect-11865.test.tsx new file mode 100644 index 0000000000..8104e7a02c --- /dev/null +++ b/apps/console/src/pages/developer/FlowRunsPage.sharedSelect-11865.test.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Flow Runs flow picker is the shared `Select` (objectui#11865). + * + * The page picked the flow to test with a browser-native select, beside the + * shared Radix `Select` the rest of the console picks with. The card asks for + * one control for one kind of choice, surface by surface. + * + * What is pinned: + * - the picker IS the primitive (a Radix combobox trigger), shows the picked + * flow, and no native select is left; + * - the trigger's accessible name is what the native control's was; + * - every flow, picked, is the one Run Flow executes and whose runs are + * listed, the same calls the native control led to, compared as JSON text; + * - a flow a Refresh no longer lists is what the trigger shows; + * - the keyboard alone opens the picker and selects. + * + * Read-only: the page has no such state. + * + * DIRECTION, observed against the native control: every pin here but the name + * pin is red there, because each one reads the picker as the primitive's + * trigger. What makes the call pins guards of "the conversion changed nothing + * the page runs" is the literal each compares against: a `change` event on the + * pre-conversion page's native control, then the same Run Flow click, led to + * those same calls, read once on that page with these fixtures. The name and + * the Refresh reading were taken there the same way. + * + * A pick reloads the flow list (the page's `loadFlows` depends on the picked + * name), so the picker unmounts while the list loads and mounts again: each + * case finds the trigger afresh after a pick, as a user's next look would. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +const { execute, listRuns, catalog, ADAPTER } = vi.hoisted(() => { + const catalog = { flows: [] as Array<{ spec: Record }> }; + const execute = vi.fn(async () => ({ success: true, status: 'completed', durationMs: 1 })); + const listRuns = vi.fn(async () => ({ runs: [] })); + // A STABLE singleton: a fresh object per render loops the page's fetch effects. + const client = { + meta: { getItems: async (type: string) => (type === 'flow' ? catalog.flows : []) }, + automation: { execute, listRuns }, + }; + return { execute, listRuns, catalog, ADAPTER: { getClient: () => client } }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + useMetadata: () => ({ objects: [] }), +})); + +// Imported AFTER the mocks so the page picks them up. +import { FlowRunsPage } from './FlowRunsPage'; + +const REASSIGN = { spec: { name: 'reassign_wizard', label: 'Reassign', variables: [] } }; +const NIGHTLY = { spec: { name: 'nightly_sync', variables: [] } }; + +beforeEach(() => { + catalog.flows = [REASSIGN, NIGHTLY]; + execute.mockClear(); + listRuns.mockClear(); +}); +afterEach(cleanup); + +/** The picker, once the flow list has loaded (again). */ +async function picker(): Promise { + await screen.findByRole('button', { name: /Run Flow/i }); + return screen.findByRole('combobox'); +} + +/** Open the picker from the keyboard and return the options it lists, in order. */ +async function openPicker(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + const listbox = await screen.findByRole('listbox'); + return within(listbox).getAllByRole('option'); +} + +async function pick(label: string): Promise { + const options = await openPicker(await picker()); + const option = options.find((o) => o.textContent === label); + if (!option) throw new Error(`no "${label}" listed: ${options.map((o) => o.textContent).join(' | ')}`); + fireEvent.click(option); + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); +} + +describe('the flow picker is the shared Select (objectui#11865)', () => { + it('renders the picker as the Radix combobox trigger, showing the first flow', async () => { + render(); + const trigger = await picker(); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveTextContent('Reassign (reassign_wizard)'); + expect(document.querySelector('select')).toBeNull(); + }); + + it('lists the flows in the order the native control did', async () => { + render(); + expect((await openPicker(await picker())).map((o) => o.textContent)).toEqual([ + 'Reassign (reassign_wizard)', + 'nightly_sync', + ]); + }); + + // Green against the native control too, by design. That control had no + // label, id or aria-label, so its accessible name was empty; the swap keeps + // that, and adds none. + it('keeps the accessible name the native control had: none', async () => { + render(); + const trigger = await picker(); + expect(screen.getByRole('combobox', { name: '' })).toBe(trigger); + }); +}); + +/** + * [the flow picked away from, its label, option label, Run Flow's `execute` + * calls, the panel's `listRuns` calls once the pick has settled]. + */ +const RUN_WRITES: ReadonlyArray< + readonly [from: string, fromLabel: string, label: string, execute: string, listRuns: string] +> = [ + ['nightly_sync', 'nightly_sync', 'Reassign (reassign_wizard)', '[["reassign_wizard",{"params":{}}]]', '[["reassign_wizard",{"limit":20}]]'], + ['reassign_wizard', 'Reassign (reassign_wizard)', 'nightly_sync', '[["nightly_sync",{"params":{}}]]', '[["nightly_sync",{"limit":20}]]'], +]; + +describe('every flow, picked, is the one the page runs', () => { + it.each(RUN_WRITES)('from %s (shown as %s), picking %s', async (from, fromLabel, label, executed, listed) => { + render(); + await picker(); + if (from !== 'reassign_wizard') await pick(fromLabel); + // Settled on the flow picked away from before anything is counted. + expect(await picker()).toHaveTextContent(fromLabel); + await waitFor(() => expect(listRuns).toHaveBeenLastCalledWith(from, { limit: 20 })); + execute.mockClear(); + listRuns.mockClear(); + await pick(label); + await waitFor(() => expect(listRuns).toHaveBeenCalled()); + expect(await picker()).toHaveTextContent(label); + fireEvent.click(screen.getByRole('button', { name: /Run Flow/i })); + await waitFor(() => expect(execute).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(execute.mock.calls)).toBe(executed); + expect(JSON.stringify(listRuns.mock.calls)).toBe(listed); + }); +}); + +describe('a flow no option carries is what the trigger shows', () => { + it('a picked flow a Refresh no longer lists: shown, not the first flow, and re-picking it runs nothing', async () => { + render(); + await pick('nightly_sync'); + await picker(); + catalog.flows = [REASSIGN]; + fireEvent.click(screen.getAllByRole('button', { name: /Refresh/i })[0]); + // The page still holds `nightly_sync` and shows no runner for it. The + // native control showed "Reassign (reassign_wizard)" here. + await waitFor(() => expect(screen.queryByRole('button', { name: /Run Flow/i })).not.toBeInTheDocument()); + const trigger = await screen.findByRole('combobox'); + expect(trigger).toHaveTextContent('nightly_sync'); + const listed = (await openPicker(trigger)).map((o) => o.textContent); + expect(listed).toEqual(['nightly_sync', 'Reassign (reassign_wizard)']); + execute.mockClear(); + listRuns.mockClear(); + fireEvent.click(screen.getAllByRole('option')[0]); + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); + expect(screen.getByRole('combobox')).toHaveTextContent('nightly_sync'); + expect(screen.queryByRole('button', { name: /Run Flow/i })).not.toBeInTheDocument(); + expect(listRuns).not.toHaveBeenCalled(); + }); +}); + +describe('the keyboard alone picks', () => { + it('Enter opens the picker and Enter on a flow selects it', async () => { + render(); + fireEvent.keyDown(await picker(), { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: 'nightly_sync' }), { key: 'Enter' }); + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); + expect(await picker()).toHaveTextContent('nightly_sync'); + fireEvent.click(screen.getByRole('button', { name: /Run Flow/i })); + await waitFor(() => expect(execute).toHaveBeenCalledWith('nightly_sync', { params: {} })); + }); +}); diff --git a/apps/console/src/pages/developer/FlowRunsPage.tsx b/apps/console/src/pages/developer/FlowRunsPage.tsx index b5c6471b6c..5b3af536c6 100644 --- a/apps/console/src/pages/developer/FlowRunsPage.tsx +++ b/apps/console/src/pages/developer/FlowRunsPage.tsx @@ -45,6 +45,11 @@ import { SheetTitle, SheetDescription, ScrollArea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@object-ui/components'; import { Play, Loader2, CheckCircle2, XCircle, Clock, AlertCircle, RefreshCw, Workflow, @@ -130,6 +135,61 @@ function JsonBlock({ data }: { data: unknown }) { ); } +/** The item a value none of the picker's options carries is shown by. */ +const OUTSIDE_OPTIONS = 'outside'; + +/** + * objectui#11865 — the flow picker, drawn with the shared `Select` the rest of + * the console picks with. It used to be a browser-native ` { + // `undefined` for the outside item: it is the page's own value, so there is nothing to write. + const picked = options[Number(token)]; + if (picked) onPick(picked.value); + }} + > + + + + + {at === -1 && {value}} + {options.map((o, i) => ( + + {o.label} + + ))} + + + ); +} + export function FlowRunsPage() { const adapter = useAdapter(); const client: any = adapter?.getClient?.(); @@ -216,17 +276,14 @@ export function FlowRunsPage() {

No flow definitions found.

)} {!flowsLoading && !flowsError && flows.length > 0 && ( - + options={flows.map(f => ({ + value: f.name, + label: f.label ? `${f.label} (${f.name})` : f.name, + }))} + onPick={setSelectedFlowName} + /> )} diff --git a/apps/console/src/pages/developer/PublicFormsPage.redirect.test.tsx b/apps/console/src/pages/developer/PublicFormsPage.redirect.test.tsx index ad0b479722..faedcd6419 100644 --- a/apps/console/src/pages/developer/PublicFormsPage.redirect.test.tsx +++ b/apps/console/src/pages/developer/PublicFormsPage.redirect.test.tsx @@ -48,7 +48,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // The page asks the router where the anonymous route is served (objectui#11769), // so it renders inside one, as it does in the app. @@ -119,14 +119,19 @@ function setUrl(field: HTMLElement, value: string) { fireEvent.change(field, { target: { value } }); } -/** Open the row's editor and switch the post-submit behavior to `redirect`. */ +/** + * Open the row's editor and switch the post-submit behavior to `redirect`, + * through the shared `Select` the picker is (objectui#11865): open it from the + * keyboard, choose the option. + */ async function openRedirectEditor(user: ReturnType) { render(, { wrapper: MemoryRouter }); await user.click( await screen.findByRole('button', { name: /Edit sharing & post-submit behavior/i }), ); - await user.selectOptions(await screen.findByLabelText(/After submit/i), 'redirect'); - return screen.getByLabelText(/Redirect URL/i); + fireEvent.keyDown(await screen.findByRole('combobox', { name: /After submit/i }), { key: 'ArrowDown' }); + fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Redirect to a URL' })); + return screen.findByLabelText(/Redirect URL/i); } beforeEach(() => saveItem.mockClear()); diff --git a/apps/console/src/pages/developer/PublicFormsPage.sharedSelect-11865.test.tsx b/apps/console/src/pages/developer/PublicFormsPage.sharedSelect-11865.test.tsx new file mode 100644 index 0000000000..06405eea71 --- /dev/null +++ b/apps/console/src/pages/developer/PublicFormsPage.sharedSelect-11865.test.tsx @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Public Forms dialogs pick with the shared `Select` (objectui#11865). + * + * *Publish a FormView* asked for the view, and the edit dialog for what happens + * after submit, with browser-native selects, beside the shared Radix `Select` + * the rest of the console picks with. The card asks for one control for one + * kind of choice, surface by surface; this suite covers this page's two + * pickers. + * + * What is pinned: + * - each picker IS the primitive (a Radix combobox trigger), shows the + * dialog's value, and no native select is left; + * - each picker keeps the name its `