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
44 changes: 44 additions & 0 deletions .changeset/flow-actions-dispatch-everywhere.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@object-ui/app-shell": patch
"@object-ui/plugin-dashboard": patch
---

fix(console): dispatch flow actions from every surface, and cover the screen-flow round trip (framework#3528)

The resume half of screen flows is fixed; these are the two launch-side holes
found while mapping every path that dispatches a `type: 'flow'` action — on
both, a screen flow could not even be started.

- **plugin-dashboard** — a dashboard header action only dispatched when its type
was `modal` or `script`. `flow` (and `api` / `form` / `navigation`) fell
through to `console.warn("Unknown header actionType")` and did nothing at all.
The click handler now routes everything that is not a raw `url` navigation
through the ActionRunner, which owns the type registry; there is nothing for
the renderer to second-guess.
- **app-shell** — the console-root `<ActionProvider>` was mounted with no
`handlers` map. It exists to give every field widget a modal handler, but an
`ActionProvider` also decides what a `useAction()` consumer *below* it can
dispatch, so any `action:button` outside ObjectView / RecordDetailView /
PageView / DeclaredActionsBar bound to a runner that could only open modals:
a `flow` action there failed with "Flow handler not registered", and `api` /
`script` were equally dead. The root now carries the shared console runtime's
api / flow / script handlers plus its confirm / param / result / screen-flow
dialogs. `modal` deliberately stays on the client-side `useActionModal`
handler — registering it in `handlers` would take precedence over `onModal`
and reroute the inline-create affordance to `/api/v1/actions/...`.

Both changes ship with regression tests that were verified to fail without them.
Also adds the first coverage of the screen-flow seam itself, which had none:

- `FlowRunner.suspense.test.tsx` — a lazily-loaded screen body must not unwind
past the dialog. Reproduces the real shape (lazy body, route-level boundary
above the host, host state that must survive) and fails against the
pre-boundary runner, which is how a paused run's screen used to vanish before
it could be submitted.
- `e2e/live/screen-flow.spec.ts` — the live round trip: a row flow action
triggers the run, the paused screen renders, Submit POSTs to
`/automation/{flow}/runs/{runId}/resume` with the collected values, and the
flow's downstream `update_record` shows up in the list. The unit tests stub
the runner out of the action runtime and the runner's own tests feed it a
screen directly, so trigger → dialog → resume → refresh was previously only
ever exercised by hand.
64 changes: 64 additions & 0 deletions e2e/live/screen-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import { expectToast } from './helpers';

/**
* Live e2e for the screen-flow round trip (framework ADR-0019).
*
* A `type: 'flow'` action triggers the run; when it pauses at a `screen` node
* the console must render that screen and POST the collected values back to
* `/automation/{flow}/runs/{runId}/resume`. Nothing covered this seam before:
* the unit tests stub the runner out of the action runtime, and the runner's
* own tests feed it a screen directly — so the trigger → dialog → resume →
* refresh chain was only ever exercised by hand. framework#3528 lived in
* exactly that gap (a paused run whose screen never made it to the user, so
* resume was never called and every screen flow looked un-completable).
*
* Drives `showcase_bulk_reassign` (list row action) → `showcase_reassign_wizard`,
* whose `screen` node collects `new_assignee` and writes it back with
* `update_record`, so the assertion runs all the way to persisted data.
*/
test('a row flow action renders its screen and resumes the run', async ({ page }) => {
const assignee = `e2e-${Date.now()}@example.com`;

// Capture the automation traffic: the bug class here is a MISSING request,
// so the resume POST is asserted directly rather than inferred from the UI.
const automation: Array<{ url: string; body: unknown }> = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/v1/automation/')) {
let body: unknown = null;
try { body = r.postDataJSON(); } catch { /* non-JSON body — keep null */ }
automation.push({ url: r.url(), body });
}
});

await page.goto('/apps/showcase_app/showcase_task');
const rowMenu = page.locator('[data-testid="row-action-trigger"]').first();
await rowMenu.waitFor();
await rowMenu.click();
await page.getByTestId('row-action-showcase_bulk_reassign').click();

// The run paused at its screen node — the console renders it as a dialog.
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: /New Assignee/i })).toBeVisible();

await page.locator('#ff-new_assignee').fill(assignee);
await dialog.getByRole('button', { name: 'Submit' }).click();

// Terminal success closes the runner and toasts the flow's completion message.
await expectToast(page, /Done|reassign/i);
await expect(page.getByRole('dialog')).toHaveCount(0);

const trigger = automation.find((r) => /\/automation\/[^/]+\/trigger$/.test(r.url));
expect(trigger, 'the flow action never triggered a run').toBeTruthy();

const resume = automation.find((r) => /\/runs\/[^/]+\/resume$/.test(r.url));
expect(resume, 'Submit did not call the resume endpoint — the run is stranded').toBeTruthy();
expect((resume!.body as { inputs?: Record<string, unknown> })?.inputs).toMatchObject({
new_assignee: assignee,
});

// The flow's downstream `update_record` node ran: the row shows the new owner
// after the runner's completion refresh.
await expect(page.getByText(assignee).first()).toBeVisible({ timeout: 15_000 });
});
56 changes: 45 additions & 11 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AuthGuard, useAuth, createAuthenticatedFetch } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { SchemaRendererProvider, ActionProvider } from '@object-ui/react';
import { useActionModal } from '../hooks/useActionModal';
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
import { withSettleSignal } from '../observability/settleSignal';
Expand Down Expand Up @@ -45,20 +46,53 @@ export function LoadingFallback() {
}

/**
* Provide a MODAL handler at the console ROOT — the same level the global
* SchemaRendererProvider (dataSource) sits — so it reaches EVERY field widget,
* The console ROOT action runtime — the same level the global
* SchemaRendererProvider (dataSource) sits, so it reaches EVERY field widget,
* including a relation field inside a create/edit form that renders in a Radix
* Dialog portal (which sits above the lower per-view ActionProviders). This is
* what lets a lookup's inline "create the referenced record" open that object's
* OWN create form and select the result. Only ADDS the modal capability where
* none existed; richer per-view ActionProviders still override it where they
* apply. Must render inside MetadataProvider (useActionModal reads useMetadata).
* Dialog portal (which sits above the lower per-view ActionProviders).
*
* It started as a modal-only provider (what lets a lookup's inline "create the
* referenced record" open that object's OWN create form). But an
* `ActionProvider` also decides what a `useAction()` consumer BELOW it can
* dispatch, and this one carried no `handlers` map — so every `action:button`
* outside ObjectView / RecordDetailView / PageView / DeclaredActionsBar bound
* to a runner that could only open modals. A `type: 'flow'` action there failed
* with "Flow handler not registered", which is one way a screen flow becomes
* unlaunchable (framework#3528); `api` and `script` were equally dead.
*
* It now carries the shared console runtime's api / flow / script handlers and
* its confirm / param / result / screen-flow dialogs, so those action types
* work anywhere by default. Richer per-view providers still override it where
* they apply.
*
* `modal` deliberately stays on the CLIENT-side `useActionModal` handler rather
* than the runtime's server-action mapping: a modal action at this level is the
* inline-create affordance above, not a server endpoint. Registering it in
* `handlers` would take precedence over `onModal` and reroute those clicks to
* `/api/v1/actions/...`.
*
* Must render inside MetadataProvider (useActionModal reads useMetadata).
*/
function GlobalCreateModalProvider({ dataSource, children }: { dataSource: unknown; children: ReactNode }) {
function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unknown; children: ReactNode }) {
const { modalHandler, modalElement } = useActionModal(dataSource);
const runtime = useConsoleActionRuntime({ dataSource });
return (
<ActionProvider onModal={modalHandler}>
<ActionProvider
context={runtime.actionProviderProps.context}
onConfirm={runtime.actionProviderProps.onConfirm}
onToast={runtime.actionProviderProps.onToast}
onNavigate={runtime.actionProviderProps.onNavigate}
onParamCollection={runtime.actionProviderProps.onParamCollection}
onResultDialog={runtime.actionProviderProps.onResultDialog}
onModal={modalHandler}
handlers={{
api: runtime.apiHandler,
flow: runtime.flowHandler,
script: runtime.serverActionHandler,
}}
>
{children}
{runtime.dialogs}
{modalElement}
</ActionProvider>
);
Expand Down Expand Up @@ -161,9 +195,9 @@ function ConnectedShellInner({ children }: { children: ReactNode }) {
<SchemaRendererProvider dataSource={adapter} apiFetch={apiProviderFetch}>
<MetadataProvider key={language} adapter={adapter}>
<UserStateBridge />
<GlobalCreateModalProvider dataSource={adapter}>
<GlobalActionRuntimeProvider dataSource={adapter}>
{children}
</GlobalCreateModalProvider>
</GlobalActionRuntimeProvider>
</MetadataProvider>
</SchemaRendererProvider>
);
Expand Down
116 changes: 116 additions & 0 deletions packages/app-shell/src/views/__tests__/FlowRunner.suspense.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Regression: mounting the screen-flow runner must never tear down its host.
*
* An `object-form` step renders ObjectForm, whose field widgets are lazy. That
* suspension used to unwind to the HOST's nearest <Suspense> — on surfaces
* where that is the route-level boundary, React swapped the whole page for the
* fallback and remounted it, destroying the host's state along with the dialog
* driving the paused run. The screen vanished before it could be filled in and
* no resume was ever issued: "Submit does nothing", from a cause that has
* nothing to do with the submit handler (framework#3528).
*
* The unit tests around it never caught this because they render FlowRunner
* directly with an eagerly-imported body. This one reproduces the real shape:
* a lazy screen body, a route-level boundary above the host, and host state
* that must still be there afterwards.
*/

import { Suspense, lazy, useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// ObjectForm stands in for any lazily-loaded screen body. It resolves only when
// this deferred promise settles, so the suspension is observable mid-flight.
let releaseForm: () => void = () => {};
const formLoaded = new Promise<void>((resolve) => { releaseForm = resolve; });

vi.mock('@object-ui/plugin-form', () => ({
ObjectForm: lazy(async () => {
await formLoaded;
return { default: ({ schema }: any) => <button onClick={() => schema?.onSuccess?.({ id: 'rec_1' })}>Save &amp; Continue</button> };
}),
}));

const { FlowRunner } = await import('../FlowRunner');

const OBJECT_FORM_STATE = {
flowName: 'convert_lead',
runId: 'run-1',
screen: {
nodeId: 'account',
kind: 'object-form' as const,
title: 'Step 1 · Customer',
objectName: 'crm_account',
mode: 'create' as const,
idVariable: 'account_id',
fields: [],
},
};

/**
* A host that owns state the way a real page does. The mount id is minted once
* per mount, so a remount — which is what a suspension escaping to the route
* boundary causes, taking the host's state and this dialog with it — shows up
* as a bumped id rather than as a subtle missing-state symptom.
*/
let mounts = 0;
function Host({ authFetch }: { authFetch: (url: string, init?: RequestInit) => Promise<Response> }) {
const [mountId] = useState(() => ++mounts);
return (
<div>
<span data-testid="host-mount-id">{mountId}</span>
<FlowRunner
state={OBJECT_FORM_STATE}
authFetch={authFetch}
baseUrl=""
dataSource={{}}
onClose={() => {}}
onComplete={() => {}}
/>
</div>
);
}

describe('FlowRunner — a lazy screen body must not unwind past the dialog', () => {
it('keeps the route-level boundary (and the host) intact while the body loads', async () => {
const user = userEvent.setup();
const authFetch = vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { success: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

render(
<Suspense fallback={<div>ROUTE FALLBACK</div>}>
<Host authFetch={authFetch} />
</Suspense>,
);

const mountId = screen.getByTestId('host-mount-id').textContent;

// Mid-suspension: the runner's own boundary absorbs it. The route-level
// fallback must NOT be showing, and the host must still be the same mount.
expect(screen.queryByText('ROUTE FALLBACK')).not.toBeInTheDocument();
expect(screen.getByTestId('host-mount-id')).toHaveTextContent(mountId!);
expect(screen.getByRole('dialog')).toHaveTextContent('Step 1 · Customer');

releaseForm();

// Body resolves in place; the host was never remounted and the run is
// still resumable.
await waitFor(() => expect(screen.getByRole('button', { name: /Save & Continue/ })).toBeInTheDocument());
expect(screen.getByTestId('host-mount-id')).toHaveTextContent(mountId!);

await user.click(screen.getByRole('button', { name: /Save & Continue/ }));
await waitFor(() =>
expect(authFetch).toHaveBeenCalledWith(
'/api/v1/automation/convert_lead/runs/run-1/resume',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ inputs: { account_id: 'rec_1' } }) }),
),
);
});
});
15 changes: 9 additions & 6 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -816,12 +816,15 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
}
return;
}
if (actionType === 'modal' || actionType === 'script') {
const result = await executeAction(actionUrl || label);
if (!result?.success) console.warn('[DashboardRenderer] action failed', result?.error);
return;
}
console.warn(`[DashboardRenderer] Unknown header actionType="${actionType}" for "${label}"`);
// Everything that is not a raw navigation goes through the
// ActionRunner, which owns the type registry. This used to
// allow-list `modal` / `script` only, so a `flow` header action
// (and `api` / `form` / `navigation`) fell through to a warn and
// never dispatched — a screen flow could not even be launched
// from a dashboard (framework#3528). The runner reports an
// unknown type itself, so there is nothing to second-guess here.
const result = await executeAction(actionUrl || label);
if (!result?.success) console.warn('[DashboardRenderer] action failed', result?.error);
};
return (
<Button key={i} variant="outline" size="sm" onClick={handleClick}>
Expand Down
Loading
Loading