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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/11865-h-console-pages-shared-select.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 8 additions & 4 deletions apps/console/src/pages/consoleFaces.displayLocale-9909.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }> };
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<Record<string, unknown>>()),
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<HTMLElement> {
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<HTMLElement[]> {
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
const listbox = await screen.findByRole('listbox');
return within(listbox).getAllByRole('option');
}

async function pick(label: string): Promise<void> {
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(<FlowRunsPage />);
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(<FlowRunsPage />);
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(<FlowRunsPage />);
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(<FlowRunsPage />);
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(<FlowRunsPage />);
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(<FlowRunsPage />);
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: {} }));
});
});
77 changes: 67 additions & 10 deletions apps/console/src/pages/developer/FlowRunsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<select>`. What a
* pick writes is unchanged: `onPick` receives the picked flow's `name`, the
* string the native control's `change` carried. Re-picking the current flow
* writes nothing, as it did there.
*
* - Items carry their option's INDEX, not the flow's name, as the card's other
* pickers do; an index cannot collide with a name.
* - A value none of the options carries gets an item of its own, labelled with
* the value: a flow picked before a Refresh that no longer lists it. The
* trigger then shows the flow the page still holds (no runner is shown for
* it). The native control showed the first flow there. Picking that item
* writes nothing.
* - Like the native control, the trigger has no label: none is associated
* with it today, and this swap keeps the name it had.
* - The page has no read-only state.
*/
function FlowPicker({
value,
options,
onPick,
}: {
value: string;
options: ReadonlyArray<{ value: string; label: string }>;
onPick: (value: string) => void;
}) {
const at = options.findIndex((o) => o.value === value);
return (
<Select
value={at !== -1 ? String(at) : OUTSIDE_OPTIONS}
onValueChange={(token) => {
// `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);
}}
>
<SelectTrigger className="h-9 max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
{at === -1 && <SelectItem value={OUTSIDE_OPTIONS}>{value}</SelectItem>}
{options.map((o, i) => (
<SelectItem key={`${i}:${o.value}`} value={String(i)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

export function FlowRunsPage() {
const adapter = useAdapter();
const client: any = adapter?.getClient?.();
Expand Down Expand Up @@ -216,17 +276,14 @@ export function FlowRunsPage() {
<p className="text-sm text-muted-foreground">No flow definitions found.</p>
)}
{!flowsLoading && !flowsError && flows.length > 0 && (
<select
<FlowPicker
value={selectedFlowName}
onChange={e => setSelectedFlowName(e.target.value)}
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"
>
{flows.map(f => (
<option key={f.name} value={f.name}>
{f.label ? `${f.label} (${f.name})` : f.name}
</option>
))}
</select>
options={flows.map(f => ({
value: f.name,
label: f.label ? `${f.label} (${f.name})` : f.name,
}))}
onPick={setSelectedFlowName}
/>
)}
</CardContent>
</Card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof userEvent.setup>) {
render(<PublicFormsPage />, { 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());
Expand Down
Loading
Loading