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
19 changes: 19 additions & 0 deletions .changeset/11092-flow-label-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': minor
'@object-ui/console': patch
---

The screen-flow runner names the flow by its label, in the user's language (objectui#11092, the objectui half of objectstack#20318).

Since `@objectstack/spec` 17.6.0 every answer that evaluated a flow carries the flow's authored label as `AutomationResult.flowLabel` (objectstack#20633). `FlowRunner` now resolves the flow's display name in this order: the active language's `flows.FLOW.label` from the app's translation bundle, then the served `flowLabel`, then the flow's API name. The bundle is the one the runner already reads for screen headings and field copy, and the lookup is the spec's own `translateFlow`.

Where it shows:

- **The runner header.** A line above the screen's heading names the flow. The heading is still the step's own title (or the `flowRunner.title` fallback), and it is still the dialog's accessible name.
- **The completion toast.** `Flow "{{flow}}" completed` names the flow by the resolved label instead of its API name. A flow's own `successMessage` still takes precedence. The message key and its translations are unchanged.

All three places that open the runner pass the label through: a flow action on a list or toolbar, a flow action on a record page, and the developer Flow Runs page's Test Run panel. A resume answer that pauses on a further screen carries the label forward.

Against a server older than objectstack#20633 no label is served, so the header line and the toast show the flow's API name, unless the app's bundle translates `flows.FLOW.label` for the active language.

**Clause-②: yes (widening).** The exported `ScreenFlowState` type gains one optional member, `flowLabel`, typed by the contract as `Pick` of `AutomationResult`'s `flowLabel` (an optional string). `FlowRunnerProps.state` accepts it through that type. No prop, export or i18n key is added or removed, and no existing member changes type.
23 changes: 22 additions & 1 deletion apps/console/src/pages/developer/FlowRunsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import { render, screen, cleanup, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// Hoisted so the vi.mock factories below can close over them, and so the
Expand All @@ -30,6 +30,8 @@ const { execute, ADAPTER, authFetch } = vi.hoisted(() => {
status: 'paused',
runId: 'run_1',
durationMs: 3,
// The flow's served label (objectui#11092): the runner names the flow by it.
flowLabel: 'Reassign',
screen: {
nodeId: 'collect',
title: 'New Assignee',
Expand Down Expand Up @@ -123,4 +125,23 @@ describe('Flow Runs — screen flows are completable from the Test Run panel', (
await user.click(resume);
expect(await screen.findByRole('dialog')).toHaveTextContent('New Assignee');
});

it('names the flow by the trigger answer\'s served label, on the run and on Continue run (objectui#11092)', async () => {
const user = userEvent.setup();
render(<FlowRunsPage />);

await screen.findByRole('button', { name: /Run Flow/i });
await user.click(screen.getByRole('button', { name: /Run Flow/i }));
const first = within(await screen.findByRole('dialog'));
expect(first.getByText('Reassign')).toBeInTheDocument();
expect(first.queryByText('reassign_wizard')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());

await user.click(await screen.findByRole('button', { name: /Continue run/i }));
const reopened = within(await screen.findByRole('dialog'));
expect(reopened.getByText('Reassign')).toBeInTheDocument();
expect(reopened.queryByText('reassign_wizard')).not.toBeInTheDocument();
});
});
15 changes: 12 additions & 3 deletions apps/console/src/pages/developer/FlowRunsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,11 @@ function FlowTestRunner({
const res = await client.automation.execute(flow.name, { params });
setResult(res);
// Screen flow: the run is suspended at a `screen` node. Open the runner
// so the tester can fill it in and the run can actually finish.
// so the tester can fill it in and the run can actually finish. `res` is
// the trigger door's `AutomationResult`, so it carries the flow's served
// `flowLabel` the runner names the flow by (objectui#11092).
if (res?.status === 'paused' && res?.screen && res?.runId) {
setScreenFlow({ flowName: flow.name, runId: res.runId, screen: res.screen });
setScreenFlow({ flowName: flow.name, flowLabel: res.flowLabel, runId: res.runId, screen: res.screen });
}
onExecuted?.();
} catch (e: any) {
Expand Down Expand Up @@ -394,7 +396,14 @@ function FlowTestRunner({
<Button
size="sm"
variant="outline"
onClick={() => setScreenFlow({ flowName: flow.name, runId: result.runId, screen: result.screen })}
onClick={() =>
setScreenFlow({
flowName: flow.name,
flowLabel: result.flowLabel,
runId: result.runId,
screen: result.screen,
})
}
>
Continue run
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,24 @@ describe('objectui#9973 — list-action flow launch that ends refused', () => {
expect(document.body.textContent).not.toContain(SENTENCE);
});
});

// objectui#11092 — the same harness, on a launch that PAUSES: the provider's
// flow handler hands the trigger answer's `flowLabel` to the `FlowRunner` it
// mounts, and the runner names the flow by it rather than by its API name.
describe('objectui#11092 — a paused list-action launch names the flow by its served label', () => {
it('opens the runner headed by the served flowLabel', async () => {
const { result } = await launch({
success: true,
status: 'paused',
runId: 'run-1',
flowLabel: 'Duplicate Screening',
screen: { nodeId: 'confirm', title: 'Confirm', fields: [] },
});

expect(result).toEqual({ success: true, silent: true });
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('heading', { name: 'Confirm' })).toBeTruthy();
expect(within(dialog).getByText('Duplicate Screening')).toBeTruthy();
expect(within(dialog).queryByText('check_dup')).toBeNull();
});
});
7 changes: 6 additions & 1 deletion packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,12 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// Paused at a `screen` node: FlowRunner renders the form + resumes, and
// refreshes on completion.
if (judged.followUp?.kind === 'screen') {
setScreenFlow({ flowName, runId: judged.followUp.runId, screen: judged.followUp.screen });
setScreenFlow({
flowName,
flowLabel: judged.followUp.flowLabel,
runId: judged.followUp.runId,
screen: judged.followUp.screen,
});
}
// Ended `refused`: the Close-only notice carries the engine's sentence,
// titled with the action the user clicked.
Expand Down
42 changes: 42 additions & 0 deletions packages/app-shell/src/utils/__tests__/flowResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,45 @@ describe('judgeFlowLaunch — one launch decision for both hosts', () => {
expect(out.refresh).toBe(false);
});
});

// objectui#11092 — the flow's served label (`AutomationResult.flowLabel`) is
// lifted here once, so both launch hosts and the runner's resume read it the
// same way. The arms are asserted by kind, and the launch follow-up with
// `toEqual`, as above: the label rides along with the screen, nothing else does.
describe('the served flowLabel is lifted once (objectui#11092)', () => {
const screen = { nodeId: 'collect', title: 'New Assignee', fields: [] };

it('a paused answer carries it', () => {
const out = interpretFlowResponse(ok, {
success: true,
data: { success: true, status: 'paused', runId: 'run-7', screen, flowLabel: 'Reassign' },
}, 'Flow "reassign"');
expect(out).toMatchObject({ kind: 'paused', flowLabel: 'Reassign' });
});

it('a terminal-success answer carries it', () => {
const out = interpretFlowResponse(ok, {
success: true, data: { success: true, flowLabel: 'Reassign' },
}, 'Flow "reassign"');
expect(out).toMatchObject({ kind: 'done', flowLabel: 'Reassign' });
});

it('is undefined when the answer serves no label, and when what it serves is not a string', () => {
const none = interpretFlowResponse(ok, { success: true, data: { success: true } }, 'Flow "x"');
expect((none as { flowLabel?: unknown }).flowLabel).toBeUndefined();
const notString = interpretFlowResponse(ok, {
success: true, data: { success: true, status: 'paused', runId: 'r', screen, flowLabel: { en: 'x' } },
}, 'Flow "x"');
expect(notString.kind).toBe('paused');
expect((notString as { flowLabel?: unknown }).flowLabel).toBeUndefined();
});

it('a paused LAUNCH hands it to the runner with the screen', () => {
const out = judgeFlowLaunch(interpretFlowResponse(ok, {
success: true,
data: { success: true, status: 'paused', runId: 'run-7', screen, flowLabel: 'Reassign' },
}, 'Flow "reassign"'), undefined);
expect(out.followUp).toEqual({ kind: 'screen', runId: 'run-7', screen, flowLabel: 'Reassign' });
expect(out.result).toEqual({ success: true, silent: true });
});
});
46 changes: 39 additions & 7 deletions packages/app-shell/src/utils/flowResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
* success: boolean, ← required; FALSE when the run failed
* status?: 'completed' | 'paused' | 'failed',
* runId?, screen?, ← set when paused at a `screen` node
* error?, errorMessage?, successMessage?
* error?, errorMessage?, successMessage?,
* flowLabel? ← the flow's authored label (see below)
* }
* }
* ```
Expand Down Expand Up @@ -123,11 +124,23 @@
* objectstack#14945 ruling (Close only, no completion toast) broken on the one
* route `FlowRunner` never sees. {@link judgeFlowLaunch} is that decision,
* once, so the next change cannot land in only one of the two hosts.
*
* ## The flow's label is lifted here, once (objectui#11092)
*
* Every 200 that evaluated a registered flow carries `flowLabel`, the flow
* definition's authored label (`AutomationResult.flowLabel`, objectstack#20633).
* `FlowRunner` names the flow by it, so it has to reach the runner from both
* launch hosts and from the runner's own resume. It is read in ONE place,
* {@link servedFlowLabel}, and carried on the `paused` and `done` arms and on
* the `screen` follow-up, typed by the contract rather than by a local copy.
* The `refused` arm does not carry it: nothing that arm opens names the flow
* (the launch notice is titled with the action's own label).
*/

import { actionErrorDetail } from '@object-ui/core';
import type { ActionResult } from '@object-ui/core';
import { errorCodeIs } from '@object-ui/types';
import type { AutomationResult } from '@objectstack/spec/contracts';

/**
* The `AutomationResult` fields the console reads. Deliberately loose: this is
Expand Down Expand Up @@ -193,8 +206,8 @@ export type FlowResponseOutcome<S = unknown> =
*/
retryable: boolean;
}
| { kind: 'paused'; runId?: string; screen: S; data: FlowRunResult }
| { kind: 'done'; data: FlowRunResult | undefined; successMessage?: string };
| ({ kind: 'paused'; runId?: string; screen: S; data: FlowRunResult } & Pick<AutomationResult, 'flowLabel'>)
| ({ kind: 'done'; data: FlowRunResult | undefined; successMessage?: string } & Pick<AutomationResult, 'flowLabel'>);

/**
* A flow declares a friendly `errorMessage`; prefer it over the raw `error`,
Expand All @@ -212,6 +225,16 @@ function flowFailureMessage(data: FlowRunResult, fallback: string): string {
* has always been able to arrive as a bare string, and a non-2xx may carry no
* parseable body at all, so nothing here may assume an object.
*/
/**
* The flow's authored label as the answer serves it (objectui#11092 — see the
* header). Read from the ONE member the contract declares, with no alias chain
* (commandment #0.1). `undefined` when the body carries no string there: a
* backend older than objectstack#20633, or a body that is not an evaluation.
*/
function servedFlowLabel(data: FlowRunResult): AutomationResult['flowLabel'] {
return typeof data.flowLabel === 'string' ? data.flowLabel : undefined;
}

function errorEnvelopeDetails(json: unknown): FlowRunResult {
const envelope = (json as { error?: { details?: unknown } } | null | undefined)?.error;
const details = envelope?.details;
Expand Down Expand Up @@ -289,7 +312,7 @@ export function interpretFlowResponse<S = unknown>(
}

if (data.status === 'paused' && data.screen) {
return { kind: 'paused', runId: data.runId, screen: data.screen as S, data };
return { kind: 'paused', runId: data.runId, screen: data.screen as S, data, flowLabel: servedFlowLabel(data) };
}

// The run reached an `end` node declaring `outcome: 'refused'` (objectstack#14945):
Expand All @@ -314,6 +337,7 @@ export function interpretFlowResponse<S = unknown>(
kind: 'done',
data: json?.data as FlowRunResult | undefined,
successMessage: typeof data.successMessage === 'string' ? data.successMessage : undefined,
flowLabel: servedFlowLabel(data),
};
}

Expand All @@ -322,8 +346,11 @@ export function interpretFlowResponse<S = unknown>(
* caller's screen type, as on {@link FlowResponseOutcome}.
*/
export type FlowLaunchFollowUp<S = unknown> =
/** Paused at a `screen` node: the host opens `FlowRunner` on this run. */
| { kind: 'screen'; runId: string; screen: S }
/**
* Paused at a `screen` node: the host opens `FlowRunner` on this run,
* handing it the served `flowLabel` with the screen (objectui#11092).
*/
| ({ kind: 'screen'; runId: string; screen: S } & Pick<AutomationResult, 'flowLabel'>)
/**
* Ended `refused` without pausing: the host opens its Close-only refusal
* notice. `message` is the engine-rendered sentence, `''` when the producer
Expand Down Expand Up @@ -373,7 +400,12 @@ export function judgeFlowLaunch<S = unknown>(
case 'paused':
return {
result: { success: true, silent: true },
followUp: { kind: 'screen', runId: outcome.runId ?? '', screen: outcome.screen },
followUp: {
kind: 'screen',
runId: outcome.runId ?? '',
screen: outcome.screen,
flowLabel: outcome.flowLabel,
},
refresh: false,
};
case 'refused':
Expand Down
Loading
Loading