diff --git a/.changeset/11092-flow-label-reader.md b/.changeset/11092-flow-label-reader.md new file mode 100644 index 0000000000..d41aa3d33c --- /dev/null +++ b/.changeset/11092-flow-label-reader.md @@ -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. diff --git a/apps/console/src/pages/developer/FlowRunsPage.test.tsx b/apps/console/src/pages/developer/FlowRunsPage.test.tsx index da41ae3673..9295d58460 100644 --- a/apps/console/src/pages/developer/FlowRunsPage.test.tsx +++ b/apps/console/src/pages/developer/FlowRunsPage.test.tsx @@ -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 @@ -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', @@ -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(); + + 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(); + }); }); diff --git a/apps/console/src/pages/developer/FlowRunsPage.tsx b/apps/console/src/pages/developer/FlowRunsPage.tsx index 7718dfda61..b5c6471b6c 100644 --- a/apps/console/src/pages/developer/FlowRunsPage.tsx +++ b/apps/console/src/pages/developer/FlowRunsPage.tsx @@ -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) { @@ -394,7 +396,14 @@ function FlowTestRunner({ diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx index 19e8a95920..ade5e2b468 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.refusedLaunch-9973.test.tsx @@ -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(); + }); +}); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 57ec2c11fa..09acb8569b 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -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. diff --git a/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/packages/app-shell/src/utils/__tests__/flowResponse.test.ts index 24ec9dfed6..6b9da43215 100644 --- a/packages/app-shell/src/utils/__tests__/flowResponse.test.ts +++ b/packages/app-shell/src/utils/__tests__/flowResponse.test.ts @@ -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 }); + }); +}); diff --git a/packages/app-shell/src/utils/flowResponse.ts b/packages/app-shell/src/utils/flowResponse.ts index a8de1d0952..3aeb0451e1 100644 --- a/packages/app-shell/src/utils/flowResponse.ts +++ b/packages/app-shell/src/utils/flowResponse.ts @@ -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) * } * } * ``` @@ -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 @@ -193,8 +206,8 @@ export type FlowResponseOutcome = */ 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) + | ({ kind: 'done'; data: FlowRunResult | undefined; successMessage?: string } & Pick); /** * A flow declares a friendly `errorMessage`; prefer it over the raw `error`, @@ -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; @@ -289,7 +312,7 @@ export function interpretFlowResponse( } 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): @@ -314,6 +337,7 @@ export function interpretFlowResponse( kind: 'done', data: json?.data as FlowRunResult | undefined, successMessage: typeof data.successMessage === 'string' ? data.successMessage : undefined, + flowLabel: servedFlowLabel(data), }; } @@ -322,8 +346,11 @@ export function interpretFlowResponse( * caller's screen type, as on {@link FlowResponseOutcome}. */ export type FlowLaunchFollowUp = - /** 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) /** * Ended `refused` without pausing: the host opens its Close-only refusal * notice. `message` is the engine-rendered sentence, `''` when the producer @@ -373,7 +400,12 @@ export function judgeFlowLaunch( 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': diff --git a/packages/app-shell/src/views/FlowRunner.tsx b/packages/app-shell/src/views/FlowRunner.tsx index 3215083b55..02bcd71f8b 100644 --- a/packages/app-shell/src/views/FlowRunner.tsx +++ b/packages/app-shell/src/views/FlowRunner.tsx @@ -110,8 +110,10 @@ * long as the spec's per-field face leaves it out of * `FLOW_SCREEN_FIELD_COPY_KEYS`. The overlay walks that constant, so the day * the spec lists it the help text is translated with no edit here; - * - the runner chrome (Cancel / Submit / Submitting… / the terminal toast) — - * the console's own words, ruled into its message catalog (objectstack#7646); + * - the runner chrome (Cancel / Submit / Submitting… / the terminal toast's + * sentence) — the console's own words, ruled into its message catalog + * (objectstack#7646). The flow NAME inside that toast is not chrome; see the + * objectui#11092 section below; * - `FlowSchema.successMessage` — off the translation surface by design. * * ⚠️ One boundary of the client-side pick: the server interpolates `{var}` @@ -119,6 +121,23 @@ * the variables, so a translated heading is drawn exactly as the bundle wrote * it — a token inside it renders literally. * + * ## The flow is named by its label, in the user's language (objectui#11092) + * + * The runner used to name the flow only by its API name, and only in the + * completion toast. Every answer that evaluated the flow now carries the + * flow's authored label (`AutomationResult.flowLabel`, objectstack#20633), + * lifted once by `interpretFlowResponse` and handed in on + * {@link ScreenFlowState}; a resume answer that pauses again replaces it. The + * name the user reads resolves as: the active language's `flows..label`, + * then the served `flowLabel`, then the API name (an answer from a backend + * that serves no label). The first two steps are the spec's `translateFlow`, + * read from the same bundle the screen copy above is, so the address is the + * spec's and there is still no second loader. + * + * It is drawn in two places: a line above the screen heading in the dialog + * header (the heading stays the step's own title, and stays the dialog's + * accessible name), and the completion toast, in place of the API name. + * * Chrome goes through `@object-ui/i18n` (via the `@object-ui/react` re-export) * like its neighbours; the only English left in this file is the inline * `defaultValue` each key carries, which `check:i18n-keys` pins to its `en` @@ -141,9 +160,11 @@ import { notifyDataChanged, useObjectTranslation } from '@object-ui/react'; import { FLOW_SCREEN_FIELD_COPY_KEYS, resolveFlowScreenTitle, + translateFlow, type TranslationBundle, type TranslationData, } from '@objectstack/spec/system'; +import type { AutomationResult } from '@objectstack/spec/contracts'; import { toast } from 'sonner'; import { ScreenView, @@ -237,7 +258,29 @@ function localizeScreen( return { ...screen, title, fields }; } -export interface ScreenFlowState { +/** + * The name the user reads for the flow (objectui#11092 — see the header): the + * active language's `flows..label`, then the served `flowLabel`, then + * `flowName`. `translateFlow` takes the first two steps: handed the served + * label as the authored one, it answers the bundle's string where the bundle + * has one and that label otherwise. + */ +function displayFlowLabel( + flowName: string, + flowLabel: string | undefined, + bundle: TranslationBundle | undefined, + language: string, +): string { + return translateFlow({ name: flowName, label: flowLabel }, bundle, { locale: language }).label || flowName; +} + +/** + * The paused run a host opens the runner on. `flowLabel` is the flow's + * authored label exactly as the trigger answer served it + * (`AutomationResult.flowLabel`), absent when the answer carried none; the + * runner localizes it (see the header). + */ +export interface ScreenFlowState extends Pick { flowName: string; runId: string; screen: ScreenSpec; @@ -301,6 +344,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat const [screen, setScreen] = useState(null); const [runId, setRunId] = useState(''); const [flowName, setFlowName] = useState(''); + const [flowLabel, setFlowLabel] = useState(undefined); const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); const [resumeError, setResumeError] = useState(null); @@ -311,6 +355,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat setScreen(state.screen); setRunId(state.runId); setFlowName(state.flowName); + setFlowLabel(state.flowLabel); setValues(initialScreenValues(state.screen)); // A fresh run must not open under the previous run's refusal. setResumeError(null); @@ -326,7 +371,10 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat // to keep stable (AGENTS.md §5 #10). `screen` stays the payload the run is // driven by; `shown` differs from it in copy only, so every DISPLAY read — // including the names in the missing-fields toast below — goes through it. - const shown = localizeScreen(screen, flowName, activeFlowsBundle(i18n, language, flowName), language); + const bundle = activeFlowsBundle(i18n, language, flowName); + const shown = localizeScreen(screen, flowName, bundle, language); + // The flow's name as the user reads it (objectui#11092), from the same bundle. + const shownFlowLabel = displayFlowLabel(flowName, flowLabel, bundle, language); const setVal = (name: string, v: unknown) => { setValues((p) => ({ ...p, [name]: v })); @@ -381,13 +429,18 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat if (outcome.kind === 'paused') { setScreen(outcome.screen); setRunId(outcome.runId || runId); + setFlowLabel(outcome.flowLabel); setValues(initialScreenValues(outcome.screen)); toast.success(t('flowRunner.nextStep', { defaultValue: 'Saved — next step' })); } else { - // Terminal success — show the flow's declared completion message. + // Terminal success — show the flow's declared completion message, else + // the catalog's sentence naming the flow by the label this answer served. toast.success( outcome.successMessage - || t('flowRunner.completed', { flow: flowName, defaultValue: 'Flow "{{flow}}" completed' }), + || t('flowRunner.completed', { + flow: displayFlowLabel(flowName, outcome.flowLabel, bundle, language), + defaultValue: 'Flow "{{flow}}" completed', + }), ); // The flow may have written ANY object — a quote created from an // Opportunity page lands in a related list this component cannot name. @@ -483,6 +536,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat { if (!o && !submitting) onClose(); }}> + {/* The flow being run (objectui#11092), above the step's own heading, + which stays the dialog's title. */} +

{shownFlowLabel}

{shown.title || t('flowRunner.title', { defaultValue: 'Input' })} {/* Authored and untranslated on purpose: `description` is outside the spec's flows face (see the header). */} diff --git a/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx b/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx index c888440854..6db8a3d6f1 100644 --- a/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.refusedLaunch-9973.test.tsx @@ -213,3 +213,24 @@ describe('objectui#9973 — record-page flow launch that ends refused', () => { expect(document.body.textContent).not.toContain(SENTENCE); }); }); + +// objectui#11092 — the same harness, on a launch that PAUSES: the record +// page's own flow handler hands the trigger answer's `flowLabel` to the +// `FlowRunner` it mounts, and the runner names the flow by it. +describe('objectui#11092 — a paused record-page 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(); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 80e2edb3c9..2fd5bd2969 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -1113,7 +1113,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // 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. diff --git a/packages/app-shell/src/views/__tests__/FlowRunner.flowLabel-11092.test.tsx b/packages/app-shell/src/views/__tests__/FlowRunner.flowLabel-11092.test.tsx new file mode 100644 index 0000000000..cf5eccafce --- /dev/null +++ b/packages/app-shell/src/views/__tests__/FlowRunner.flowLabel-11092.test.tsx @@ -0,0 +1,188 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The screen-flow runner names the flow by its label, in the user's language + * (objectui#11092, the objectui half of objectstack#20318). + * + * Before this, the runner drew no name for the flow in its header, and its + * completion toast named the flow by its API name. Every answer that evaluated + * the flow now carries the flow's authored label (`AutomationResult.flowLabel`), + * and the app bundle can translate it under `flows..label`. + * + * The bundle reaches the runner exactly as in `FlowRunner.flowsTranslation-5920`: + * the server's `TranslationData` payload through `transformSpecTranslations`, + * added to the i18next instance the provider binds. + * + * Pins, one per acceptance line of the card, each on both display slots (the + * header line and the completion toast): + * - a zh-CN bundle carrying `flows..label` → the translation; + * - `en`, where the bundle carries no `flows` → the served label; + * - a flow the bundle does not translate → its authored (served) label; + * plus the two ends of the chain: an answer with no label falls back to the + * API name, and a resume answer that pauses again carries the label forward. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createI18n, I18nProvider, isSpecTranslationData, transformSpecTranslations } from '@object-ui/i18n'; +import type { TranslationData } from '@objectstack/spec/system'; +import { FlowRunner, type ScreenFlowState } from '../FlowRunner'; + +const toasts = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +vi.mock('sonner', () => ({ toast: toasts })); + +const SERVED_LABEL = 'Lead Conversion'; + +/** A paused `lead_conversion` run, as a launch host hands it to the runner. */ +const PAUSED: ScreenFlowState = { + flowName: 'lead_conversion', + flowLabel: SERVED_LABEL, + runId: 'run-1', + screen: { + nodeId: 'screen_1', + title: 'Conversion Details', + fields: [{ name: 'note', label: 'Note', type: 'text' }], + }, +}; + +/** The zh-CN payload: the flow's label AND the screen heading are translated. */ +const ZH_CN: TranslationData = { + objects: { crm_lead: { label: '线索' } }, + flows: { + lead_conversion: { + label: '线索转化', + screens: { screen_1: { title: '转化详情' } }, + }, + }, +}; + +/** A zh-CN payload that translates this flow's screen but not its label. */ +const ZH_CN_SCREEN_ONLY: TranslationData = { + objects: { crm_lead: { label: '线索' } }, + flows: { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } }, +}; + +function i18nWith(language: string, payloadLanguage: string, payload: TranslationData) { + const instance = createI18n({ defaultLanguage: language, detectBrowserLanguage: false }); + const raw = payload as Record; + expect(isSpecTranslationData(raw)).toBe(true); + instance.addResourceBundle(payloadLanguage, 'translation', transformSpecTranslations(raw), true, true); + return instance; +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +/** A terminal-success resume answer, carrying `flowLabel` when given one. */ +function completedAnswer(flowLabel?: string) { + return jsonResponse({ success: true, data: { success: true, ...(flowLabel ? { flowLabel } : {}) } }); +} + +function renderRunner( + state: ScreenFlowState, + instance: ReturnType, + authFetch: (url: string, init?: RequestInit) => Promise = vi.fn(async () => completedAnswer(SERVED_LABEL)), +) { + render( + + + , + ); + return within(screen.getByRole('dialog')); +} + +/** Submit the screen and return the completion toast's message. */ +async function completionToast(instance: ReturnType): Promise { + const user = userEvent.setup(); + // Chrome from the console's own catalogue, asked of the instance rather than spelled. + await user.click(screen.getByRole('button', { name: instance.t('common.submit') })); + await waitFor(() => expect(toasts.success).toHaveBeenCalledTimes(1)); + return String(toasts.success.mock.calls[0][0]); +} + +beforeEach(() => { + toasts.error.mockClear(); + toasts.success.mockClear(); +}); + +describe('FlowRunner — the flow is named by its label (objectui#11092)', () => { + it('zh-CN: the header and the completion toast name the flow by `flows..label`', async () => { + const instance = i18nWith('zh-CN', 'zh-CN', ZH_CN); + const dialog = renderRunner(PAUSED, instance); + + expect(dialog.getByText('线索转化')).toBeInTheDocument(); + // The screen heading is still the dialog's title, and still translated. + expect(dialog.getByRole('heading', { name: '转化详情' })).toBeInTheDocument(); + expect(dialog.queryByText(SERVED_LABEL)).not.toBeInTheDocument(); + expect(dialog.queryByText('lead_conversion')).not.toBeInTheDocument(); + + const message = await completionToast(instance); + expect(message).toBe(instance.t('flowRunner.completed', { flow: '线索转化' })); + expect(message).not.toContain('lead_conversion'); + expect(message).not.toContain(SERVED_LABEL); + }); + + it('en: where the bundle carries no `flows`, the header and the toast fall back to the served label', async () => { + const instance = i18nWith('en', 'zh-CN', ZH_CN); + const dialog = renderRunner(PAUSED, instance); + + expect(dialog.getByText(SERVED_LABEL)).toBeInTheDocument(); + expect(dialog.queryByText('线索转化')).not.toBeInTheDocument(); + expect(dialog.queryByText('lead_conversion')).not.toBeInTheDocument(); + + const message = await completionToast(instance); + expect(message).toBe(instance.t('flowRunner.completed', { flow: SERVED_LABEL })); + expect(message).not.toContain('lead_conversion'); + }); + + it('a flow the bundle does not translate shows its authored label, while the bundle is live for the screen', async () => { + const instance = i18nWith('zh-CN', 'zh-CN', ZH_CN_SCREEN_ONLY); + const dialog = renderRunner(PAUSED, instance); + + // The heading proves the bundle IS read for this flow … + expect(dialog.getByRole('heading', { name: '转化详情' })).toBeInTheDocument(); + // … and the label, which it does not carry, is the author's. + expect(dialog.getByText(SERVED_LABEL)).toBeInTheDocument(); + expect(dialog.queryByText('lead_conversion')).not.toBeInTheDocument(); + + const message = await completionToast(instance); + expect(message).toBe(instance.t('flowRunner.completed', { flow: SERVED_LABEL })); + }); + + it('an answer that served no label (a backend older than the label) falls back to the API name', async () => { + const instance = i18nWith('zh-CN', 'zh-CN', ZH_CN_SCREEN_ONLY); + const unlabelled: ScreenFlowState = { flowName: PAUSED.flowName, runId: PAUSED.runId, screen: PAUSED.screen }; + const dialog = renderRunner(unlabelled, instance, vi.fn(async () => completedAnswer())); + + expect(dialog.getByText('lead_conversion')).toBeInTheDocument(); + + const message = await completionToast(instance); + expect(message).toBe(instance.t('flowRunner.completed', { flow: 'lead_conversion' })); + }); + + it('a resume answer that pauses again carries its served label into the header', async () => { + const instance = i18nWith('en', 'zh-CN', ZH_CN); + const unlabelled: ScreenFlowState = { flowName: PAUSED.flowName, runId: PAUSED.runId, screen: PAUSED.screen }; + const nextStep = { + success: true, + data: { + success: true, + status: 'paused', + runId: 'run-1', + flowLabel: SERVED_LABEL, + screen: { nodeId: 'screen_2', title: 'Confirm', fields: [] }, + }, + }; + const dialog = renderRunner(unlabelled, instance, vi.fn(async () => jsonResponse(nextStep))); + expect(dialog.getByText('lead_conversion')).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: instance.t('common.submit') })); + + const next = within(await screen.findByRole('dialog')); + expect(await next.findByRole('heading', { name: 'Confirm' })).toBeInTheDocument(); + expect(next.getByText(SERVED_LABEL)).toBeInTheDocument(); + expect(next.queryByText('lead_conversion')).not.toBeInTheDocument(); + }); +});