diff --git a/.changeset/11779-flow-status-truth.md b/.changeset/11779-flow-status-truth.md new file mode 100644 index 0000000000..4f59927fd0 --- /dev/null +++ b/.changeset/11779-flow-status-truth.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio's Automations pillar now shows the state a flow actually runs in, and shows it the same way in the rail, the flow header and the Problems panel (objectui#11779). + +- **Trigger.** The flow header's Trigger pill reads the trigger the flow's Start node declares, in the Start node inspector's own words. A record-triggered flow reads its event and object (for example *Record updated · task*), where it read the flow type *autolaunched*. A flow with no trigger on its Start node still reads its type. +- **Status.** The header's Status pill reads the engine's runtime state for the flow, as the rail does: *On*, *Off*, *Not running here*, or *Unpublished draft* for a flow the engine does not have. A published flow without a `status` key used to read *draft*, although the engine runs it. Where no runtime state is available (the metadata-admin page, or a backend without `GET /api/v1/automation/_status`), the pill reads the flow's own switch: *Enabled* or *Disabled*. +- **Not running here.** An enabled flow whose declared trigger is not armed on this deployment, such as a scheduled flow while package-authored scheduled work is switched off, reads a grey *Not running here* in the rail and the header instead of a green *On*. Its hover text is the platform's reason, and the Problems panel shows the same reason as a note, which is not counted as a problem. A flow that declares no trigger still reads *On*. +- **New flows start off.** A flow created with *New* is saved with `status: 'obsolete'`, so its switch reads *Disabled*, as the "Off by default · review before enabling" bar says. It used to read *Enabled*, and a package publish armed it as soon as it had a trigger. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index c4ed1fef80..2f453c3e97 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3133,6 +3133,12 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.auto.offTitle': 'Disabled — will not run', 'engine.studio.auto.onBound': 'Enabled · bound to its trigger', 'engine.studio.auto.onUnbound': 'Enabled · no trigger (run manually)', + // objectui#11779 — an enabled flow whose declared trigger is not armed on this + // deployment; the title is for a backend that sends no `reason` (with one, + // the platform's sentence is shown instead). + 'engine.studio.auto.notRunning': 'Not running here', + 'engine.studio.auto.notRunningTitle': 'Enabled, but its trigger is not armed on this deployment', + 'engine.studio.auto.unpublishedTitle': 'Not deployed yet — publish to deploy it', 'engine.studio.auto.enabled': 'Enabled', 'engine.studio.auto.disabled': 'Disabled', 'engine.studio.auto.enableTitle': 'Enable this automation (publish to apply)', @@ -6023,6 +6029,9 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.auto.offTitle': '已停用 — 不会运行', 'engine.studio.auto.onBound': '已启用 · 已绑定触发器', 'engine.studio.auto.onUnbound': '已启用 · 无触发器(手动运行)', + 'engine.studio.auto.notRunning': '未在此运行', + 'engine.studio.auto.notRunningTitle': '已启用,但它的触发器在此部署中未生效', + 'engine.studio.auto.unpublishedTitle': '尚未部署 — 发布后才会部署', 'engine.studio.auto.enabled': '已启用', 'engine.studio.auto.disabled': '已停用', 'engine.studio.auto.enableTitle': '启用此自动化(发布后生效)', diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.headerPills-11779.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.headerPills-11779.test.tsx new file mode 100644 index 0000000000..2da67fbce2 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.headerPills-11779.test.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11779 — the flow header's Trigger and Status pills, on a host that + * reads no runtime state (the metadata-admin page renders this same preview, + * with no `FlowRuntimeContext` provider). + * + * - Trigger is read from the Start node, never from the flow-level `type` + * alone: a record trigger reads its event and object; a time-relative sweep + * reads as one. + * - Status, with no runtime reading, is the draft's own switch as the engine + * reads it: a flow with no `status` key (or `draft`) is armed like `active`, + * so it reads "Enabled" — the old header read it "draft". + * + * The Studio-hosted readings (runtime rows through the real pillar) are pinned + * in `StudioDesignSurface.flowStatusTruth-11779.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +vi.mock('./useFlowNodePalette', () => ({ + useActionConfigSchemas: () => ({}), + useFlowNodePalette: () => [], +})); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not found', { status: 404 })), +); + +import { FlowPreview } from './FlowPreview'; +import { t } from '../i18n'; + +afterEach(cleanup); + +const end = { id: 'end', type: 'end', label: 'End' }; +const edges = [{ id: 'e1', source: 'start', target: 'end' }]; + +function flow(start: Record, extra: Record = {}): Record { + return { name: 'f1', label: 'F1', type: 'autolaunched', nodes: [{ id: 'start', type: 'start', label: 'Start', ...start }, end], edges, ...extra }; +} + +function pill(label: string, draft: Record, locale = 'en-US'): { value: string; pill: HTMLElement } { + render(); + const labelEl = screen.getByText(`${label}:`); + return { value: labelEl.nextElementSibling?.textContent ?? '', pill: labelEl.parentElement as HTMLElement }; +} + +describe('the Trigger pill reads the Start node (objectui#11779)', () => { + it('a record trigger reads its event and object, not "autolaunched"', () => { + const { value } = pill('Trigger', flow({ config: { triggerType: 'record-after-update', objectName: 'task' } })); + expect(value).toBe('Record updated · task'); + }); + + it('in zh, the same words the Start node inspector uses', () => { + const { value } = pill('触发', flow({ config: { triggerType: 'record-after-update', objectName: 'task' } }), 'zh-CN'); + expect(value).toBe('记录更新后 · task'); + }); + + it('a time-relative sweep reads as one, with the object it sweeps', () => { + const { value } = pill( + 'Trigger', + flow({ config: { timeRelative: { object: 'contract', dateField: 'end_date', withinDays: 30 }, schedule: { type: 'cron', expression: '0 7 * * *' } } }), + ); + expect(value).toBe('Time-relative (date sweep) · contract'); + }); + + it('a flow with no trigger on its Start node falls back to the flow type — the control', () => { + expect(pill('Trigger', flow({})).value).toBe('autolaunched'); + }); +}); + +describe('the Status pill without a runtime reading (objectui#11779)', () => { + it('a flow with no `status` key reads Enabled, not "draft"', () => { + const { value } = pill('Status', flow({})); + expect(value).toBe(t('engine.studio.auto.enabled', 'en')); + expect(value).not.toMatch(/draft/i); + }); + + it('`status: draft` reads Enabled too — the engine arms a draft flow', () => { + expect(pill('Status', flow({}, { status: 'draft' })).value).toBe(t('engine.studio.auto.enabled', 'en')); + }); + + it('`status: obsolete` reads Disabled — the control', () => { + expect(pill('Status', flow({}, { status: 'obsolete' })).value).toBe(t('engine.studio.auto.disabled', 'en')); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx index f3e5b5747d..172f1cbf70 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx @@ -7,7 +7,9 @@ * pull in ReactFlow + its deps every time the metadata-admin loads, * which is too heavy for a glance-preview. Instead we render: * - * 1. A header strip with type / status / runAs / version. + * 1. A header strip with trigger / status / runAs / version — the trigger + * the Start node declares, and the status the deployment reports + * (objectui#11779). * 2. A topologically ordered step list inferred from `nodes` + * `edges`. Each step shows label, action type, branch markers, * and outgoing edge conditions so authors can sanity-check the @@ -19,6 +21,7 @@ */ import * as React from 'react'; +import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; import { AlertCircle, Bug, @@ -43,9 +46,19 @@ import { NESTED_NODE_KIND, parseNestedNodeId, encodeNestedNodeId } from '../insp import { FlowSimulatorPanel } from './FlowSimulatorPanel.js'; import { FlowRunsPanel } from './FlowRunsPanel.js'; import { ProblemsPanel } from './ProblemsPanel.js'; -import { buildFlowProblems, deriveInvalidElements, freshNodeId, type FlowProblem } from './flow-problems.js'; +import { + FlowRuntimeContext, + buildFlowProblems, + deriveFlowRunStatus, + deriveInvalidElements, + describeFlowRunStatus, + freshNodeId, + type FlowProblem, + type FlowRunStatusView, +} from './flow-problems.js'; import { useConnectorRegistry } from '../inspectors/connector-input-fields.js'; import { hasCommittedConnectorAction } from '../inspectors/flow-scope.js'; +import { fieldsForNodeType, localizeFlowFields } from '../inspectors/flow-node-config.js'; /** * This preview reads the draft's nodes and edges and hands them straight to @@ -60,6 +73,44 @@ import { hasCommittedConnectorAction } from '../inspectors/flow-scope.js'; type FlowNode = FlowDesignerNode; type FlowEdge = FlowDesignerEdge; +/** + * The header's Trigger pill (objectui#11779): the trigger the engine arms this + * flow on, read from its Start node by the spec's own resolver + * (`resolveFlowTriggerKind`, the authoring-time mirror of the engine's binding + * resolution, in the engine's precedence). The flow-level `type` used to be + * shown instead, so a record-triggered flow read "autolaunched". + * + * - a record trigger reads as its event (the Start node's `triggerType`, e.g. + * "Record updated"), with the object it watches; + * - a time-relative sweep reads as one, with the object it sweeps; + * - a schedule (a `schedule` cadence on the Start node, or `type: 'schedule'`) + * and an inbound API hook each read as that kind; + * - a flow the resolver finds no trigger on — a manual / autolaunched Start, + * a screen flow — falls back to the flow's `type`, as before. + * + * The words are the Start node inspector's own option labels for the same + * tokens (`flow-node-config`), localized the way the inspector localizes them, + * so the header and the inspector name one trigger one way. + */ +function flowTriggerLabel(d: Record, nodes: FlowNode[], locale?: string): string { + const kind = resolveFlowTriggerKind(d); + if (!kind) return translateFlowMeta('type', String(d.type ?? 'autolaunched'), locale); + const config = nodes.find((n) => n?.type === 'start')?.config ?? {}; + // A record kind means the Start node's `triggerType` IS a `record-*` token — + // the resolver read it to answer. + const token = kind === 'record_change' ? String(config.triggerType) : kind; + const options = localizeFlowFields('start', fieldsForNodeType('start'), locale).find((f) => f.id === 'triggerType')?.options; + const label = options?.find((o) => o.value === token)?.label ?? token; + const sweep = config.timeRelative as { object?: unknown } | undefined; + const object = + kind === 'record_change' + ? config.objectName + : kind === 'time_relative' + ? (typeof sweep?.object === 'string' ? sweep.object : config.objectName) + : undefined; + return typeof object === 'string' && object ? `${label} · ${object}` : label; +} + interface FlowVariable { name: string; type?: string; @@ -77,6 +128,14 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa const edges = React.useMemo(() => (Array.isArray(d.edges) ? (d.edges as FlowEdge[]) : []), [d.edges]); const variables: FlowVariable[] = Array.isArray(d.variables) ? (d.variables as FlowVariable[]) : []; + // objectui#11779 — the host's runtime row for this flow (see + // `FlowRuntimeContext`): the ONE run status the header's Status pill and the + // Problems panel show, derived as the host's rail derives it. Read before the + // empty-flow return below, so the hook order never depends on the draft. + const runtime = React.useContext(FlowRuntimeContext); + const runStatus = deriveFlowRunStatus(runtime, d.status); + const runView = describeFlowRunStatus(runStatus, locale); + const designMode = !!(editing && onSelectionChange); const canEdit = designMode && !!onPatch; const selectedId = selection && selection.kind === 'node' ? selection.id : null; @@ -180,8 +239,6 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa // Run history needs the published flow name (the engine keys runs by it). const flowName = typeof d.name === 'string' && d.name ? d.name : ''; - const flowType = String(d.type ?? 'autolaunched'); - const status = String(d.status ?? (d.active ? 'active' : 'draft')); const runAs = String(d.runAs ?? 'user'); const version = d.version != null ? String(d.version) : undefined; const errorStrategy = (d.errorHandling as any)?.strategy as string | undefined; @@ -217,8 +274,14 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa {/* Visual canvas */}
- - + + {version && } {errorStrategy && } @@ -355,6 +418,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa selectedKey={selectedKey} onSelectProblem={handleSelectProblem} locale={locale} + runStatus={runStatus} />
) : showDebug ? ( @@ -421,21 +485,26 @@ function Pill({ icon: Icon, label, value, - tone = 'gray', + tone = 'plain', + title, }: { icon?: React.ComponentType<{ className?: string }>; label: string; value: string; - tone?: 'gray' | 'green' | 'amber'; + tone?: FlowRunStatusView['tone']; + /** Hover text — the run status's reason, for the Status pill. */ + title?: string; }) { const cls = tone === 'green' - ? 'text-emerald-700' + ? 'text-emerald-700 dark:text-emerald-400' : tone === 'amber' - ? 'text-amber-700' - : 'text-foreground'; + ? 'text-amber-700 dark:text-amber-300' + : tone === 'muted' + ? 'text-muted-foreground' + : 'text-foreground'; return ( - + {Icon && } {label}: {value} diff --git a/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx b/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx index 5c4fc3c811..814c94fc05 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx @@ -11,9 +11,9 @@ */ import * as React from 'react'; -import { AlertCircle, AlertTriangle, CheckCircle2, CircleDot, GitBranch } from 'lucide-react'; +import { AlertCircle, AlertTriangle, CheckCircle2, CircleDot, CircleSlash, GitBranch } from 'lucide-react'; import { cn } from '@object-ui/components'; -import type { FlowProblem } from './flow-problems.js'; +import { describeFlowRunStatus, type FlowProblem, type FlowRunStatus } from './flow-problems.js'; import { t as tr } from '../i18n.js'; export interface ProblemsPanelProps { @@ -23,6 +23,14 @@ export interface ProblemsPanelProps { onSelectProblem: (problem: FlowProblem) => void; /** UI locale for the panel chrome. */ locale?: string; + /** + * The flow's run status (objectui#11779), the one the header's Status pill + * shows. A flow the deployment does not run on its trigger (`not-running`) + * gets a note above the list: a deployment fact, not an authoring problem, + * so it is never counted, never styled as an error, and the structural + * verdict below it stands as it is. + */ + runStatus?: FlowRunStatus; } function targetLabel(p: FlowProblem): string { @@ -31,9 +39,10 @@ function targetLabel(p: FlowProblem): string { return 'flow'; } -export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale }: ProblemsPanelProps) { +export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale, runStatus }: ProblemsPanelProps) { const errorCount = problems.filter((p) => p.level === 'error').length; const warningCount = problems.length - errorCount; + const runNote = runStatus?.kind === 'not-running' ? describeFlowRunStatus(runStatus, locale) : null; return (
@@ -50,6 +59,19 @@ export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale } )}
+ {runNote && ( +
+ + + {runNote.label} + {runNote.title && {runNote.title}} + +
+ )} {problems.length === 0 ? (
diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-problems.runStatus-11779.test.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-problems.runStatus-11779.test.ts new file mode 100644 index 0000000000..903201d2b7 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-problems.runStatus-11779.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11779 — `deriveFlowRunStatus`, the one run status the Automations + * rail, the flow header and the Problems panel read, case by case. + * + * The rail and header pins through the real pillar live in + * `StudioDesignSurface.flowStatusTruth-11779.test.tsx`; this file pins the + * derivation's table on its own, including the two readings the pillar cannot + * show: a disabled row, and the draft-only reading a host without `_status` + * gets (the metadata-admin page renders the same preview with no runtime). + */ + +import { describe, it, expect } from 'vitest'; +import { deriveFlowRunStatus, describeFlowRunStatus } from './flow-problems'; +import { t } from '../i18n'; + +const REASON = 'FIXTURE: the platform sentence, shown as sent.'; + +describe('deriveFlowRunStatus — from a runtime row (objectui#11779)', () => { + it('enabled and bound: on', () => { + expect(deriveFlowRunStatus({ enabled: true, bound: true, triggerType: 'record_change' })).toEqual({ kind: 'on' }); + }); + + it('enabled with no declared trigger: manual, which reads On — never "not running"', () => { + const s = deriveFlowRunStatus({ enabled: true, bound: false }); + expect(s).toEqual({ kind: 'manual' }); + expect(describeFlowRunStatus(s, 'en').label).toBe(t('engine.studio.auto.on', 'en')); + }); + + it('enabled, a declared trigger, not bound: not-running, with the platform reason when sent', () => { + expect(deriveFlowRunStatus({ enabled: true, bound: false, triggerType: 'schedule', reason: REASON })).toEqual({ + kind: 'not-running', + reason: REASON, + }); + expect(deriveFlowRunStatus({ enabled: true, bound: false, triggerType: 'schedule' })).toEqual({ kind: 'not-running' }); + }); + + it('the reason is the hover text verbatim, in every locale; without one, the designer row says only "not armed"', () => { + const withReason = deriveFlowRunStatus({ enabled: true, bound: false, triggerType: 'schedule', reason: REASON }); + expect(describeFlowRunStatus(withReason, 'zh-CN').title).toBe(REASON); + const without = deriveFlowRunStatus({ enabled: true, bound: false, triggerType: 'schedule' }); + expect(describeFlowRunStatus(without, 'en').title).toBe(t('engine.studio.auto.notRunningTitle', 'en')); + expect(describeFlowRunStatus(without, 'en').label).toBe(t('engine.studio.auto.notRunning', 'en')); + }); + + it('disabled: off, whatever the binding says', () => { + expect(deriveFlowRunStatus({ enabled: false, bound: false, triggerType: 'schedule' })).toEqual({ kind: 'off' }); + expect(deriveFlowRunStatus({ enabled: false, bound: true })).toEqual({ kind: 'off' }); + }); + + it('the runtime row outranks the draft: a row decides, whatever `status` the draft holds', () => { + expect(deriveFlowRunStatus({ enabled: true, bound: true }, 'obsolete')).toEqual({ kind: 'on' }); + }); +}); + +describe('deriveFlowRunStatus — without a runtime row (objectui#11779)', () => { + it('`null` (the engine has no row for the flow): unpublished', () => { + expect(deriveFlowRunStatus(null, 'active')).toEqual({ kind: 'unpublished' }); + }); + + it('no runtime reading: the draft switch as the engine reads it — no key and `draft` are ON, never "draft"', () => { + for (const status of [undefined, 'draft', 'active']) { + const s = deriveFlowRunStatus(undefined, status); + expect(s, String(status)).toEqual({ kind: 'enabled' }); + expect(describeFlowRunStatus(s, 'en').label).toBe(t('engine.studio.auto.enabled', 'en')); + } + for (const status of ['obsolete', 'invalid']) { + expect(deriveFlowRunStatus(undefined, status), status).toEqual({ kind: 'disabled' }); + } + }); + + it('no reading is ever worded as an error', () => { + const all = [ + deriveFlowRunStatus({ enabled: true, bound: true }), + deriveFlowRunStatus({ enabled: true, bound: false }), + deriveFlowRunStatus({ enabled: true, bound: false, triggerType: 'schedule', reason: REASON }), + deriveFlowRunStatus({ enabled: false, bound: false }), + deriveFlowRunStatus(null), + deriveFlowRunStatus(undefined, 'active'), + deriveFlowRunStatus(undefined, 'obsolete'), + ]; + for (const s of all) { + const view = describeFlowRunStatus(s, 'en'); + expect(['green', 'muted', 'amber', 'plain']).toContain(view.tone); + expect(view.label, s.kind).not.toBe(''); + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts index e2ec897f92..d186e3947a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts @@ -19,11 +19,12 @@ * too: listed in the panel, but without a badge. */ +import { createContext } from 'react'; import { validateFlowDraft } from './simulator/flow-sim-validate.js'; import type { Diagnostic, DiagnosticLevel, SimEdge, SimNode } from './simulator/flow-sim-types.js'; import { conditionText, edgeKey, type FlowDesignerEdge, type FlowDesignerNode } from './flow-canvas-layout.js'; import { flowExpressionProblems } from './flow-expr-problems.js'; -import { tFormat } from '../i18n.js'; +import { t, tFormat } from '../i18n.js'; import { uniqueId } from '../inspectors/unique-id.js'; /** What a problem points at on the canvas — drives badge placement + reveal. */ @@ -288,6 +289,146 @@ export function buildFlowProblems({ nodes, edges, serverDiagnostics, variables, .map(([p]) => p); } +// ── The flow's run status (objectui#11779) ───────────────────────────────── +// +// One derivation, three readers: the Automations rail's status chip +// (`FlowStatusDot`), the flow header's Status pill (`FlowPreview`) and the +// Problems panel's note (`ProblemsPanel`). Each used to read something of its +// own: the rail the engine's runtime row, the header the draft's persisted +// `status` (an absent key read as "draft", although the engine arms a draft +// flow exactly like an active one), the panel nothing at all. So one flow could +// read On in the rail, draft in the header and "no problems" in the panel while +// the deployment never ran it. +// +// It lives here, beside the problem list, because this module is the +// component-free home the preview and the panel already share; the host that +// reads `_status` imports it from here too, so the host takes on none of the +// preview's canvas to hand it a row. + +/** + * One flow's runtime row as Studio keeps it: the engine's `FlowRuntimeState` + * from `GET /api/v1/automation/_status`, narrowed by the host that read it + * (`flowRailState` in the Studio surface) — `enabled` / `bound`, plus + * `triggerType` and `reason` only as non-empty strings (objectui#11281). + */ +export interface FlowRuntimeRow { + enabled: boolean; + bound: boolean; + /** + * The flow's declared trigger type. Absent when the flow declares no trigger + * (the engine omits the field then), and on a backend that never sends it. + */ + triggerType?: string; + /** + * The platform's sentence for why this flow is not armed. RENDERED verbatim, + * ⛔ never parsed, compared or restyled (the contract: "Consumers RENDER it; + * ⛔ do not parse it"). + */ + reason?: string; +} + +/** + * What one flow's state reads as, everywhere Studio shows it. + * + * From a runtime row (the engine's answer): + * - `on` — enabled and bound to its declared trigger; + * - `manual` — enabled, and declares no trigger: it runs when something + * invokes it, so it is never called "not running"; + * - `not-running` — enabled, declares a trigger, and that trigger is not + * armed on this deployment (`bound: false` with a `triggerType`: the + * contract's "declared trigger type has no registered trigger", or a + * deployment policy). `reason` is the platform's sentence when it sent one; + * - `off` — disabled: registered, and never runs. + * Without one: + * - `unpublished` — the host read `_status` and the engine has no row for + * this flow: nothing of it is deployed; + * - `enabled` / `disabled` — no runtime reading at all (no host that reads + * `_status`, a backend without the route, offline): only the draft's own + * switch can be read, the way the engine reads it — `obsolete` / `invalid` + * off, anything else (`active`, `draft`, no key) on. + */ +export type FlowRunStatus = + | { kind: 'on' } + | { kind: 'manual' } + | { kind: 'not-running'; reason?: string } + | { kind: 'off' } + | { kind: 'unpublished' } + | { kind: 'enabled' } + | { kind: 'disabled' }; + +/** + * Derive a flow's {@link FlowRunStatus}. + * + * @param runtime the flow's runtime row; `null` when the host read `_status` + * and the engine has no row for the flow; `undefined` when there is no + * runtime reading to consult. + * @param draftStatus the draft's persisted `status`, read only when `runtime` + * is `undefined`. + */ +export function deriveFlowRunStatus(runtime: FlowRuntimeRow | null | undefined, draftStatus?: unknown): FlowRunStatus { + if (runtime === undefined) { + return draftStatus === 'obsolete' || draftStatus === 'invalid' ? { kind: 'disabled' } : { kind: 'enabled' }; + } + if (runtime === null) return { kind: 'unpublished' }; + if (!runtime.enabled) return { kind: 'off' }; + if (runtime.bound) return { kind: 'on' }; + if (!runtime.triggerType) return { kind: 'manual' }; + return runtime.reason ? { kind: 'not-running', reason: runtime.reason } : { kind: 'not-running' }; +} + +/** + * The open flow's runtime row, handed from a host that read + * `GET /api/v1/automation/_status` (Studio's Automations pillar) to the flow + * preview it renders, which derives the header's Status pill and the Problems + * panel's note from it with {@link deriveFlowRunStatus} — the derivation the + * host's rail reads. + * + * - a row: the engine's answer for this flow; + * - `null`: the host read `_status` and the engine has no row for this flow; + * - `undefined` (the default, no provider): no runtime reading at all. + * + * A context rather than a preview prop because `MetadataPreviewProps` is the + * package's published preview contract, and this row is one host's hand-off to + * one preview. + */ +export const FlowRuntimeContext = createContext(undefined); + +/** How a {@link FlowRunStatus} reads: the visible words, the hover text, and a tone. */ +export interface FlowRunStatusView { + label: string; + title?: string; + /** `plain` is ordinary foreground text; nothing here is styled as an error. */ + tone: 'green' | 'muted' | 'amber' | 'plain'; +} + +/** + * The words for a {@link FlowRunStatus}, so the rail, the header and the panel + * cannot word one state two ways. A platform `reason` is the hover text as + * sent; every other string is a row of the designer table. + */ +export function describeFlowRunStatus(status: FlowRunStatus, locale?: string): FlowRunStatusView { + switch (status.kind) { + case 'on': + return { label: t('engine.studio.auto.on', locale), title: t('engine.studio.auto.onBound', locale), tone: 'green' }; + case 'manual': + return { label: t('engine.studio.auto.on', locale), title: t('engine.studio.auto.onUnbound', locale), tone: 'green' }; + case 'not-running': + return { + label: t('engine.studio.auto.notRunning', locale), + title: status.reason ?? t('engine.studio.auto.notRunningTitle', locale), + tone: 'muted', + }; + case 'off': + return { label: t('engine.studio.auto.off', locale), title: t('engine.studio.auto.offTitle', locale), tone: 'muted' }; + case 'unpublished': + return { label: t('engine.studio.unpublishedDraft', locale), title: t('engine.studio.auto.unpublishedTitle', locale), tone: 'amber' }; + case 'enabled': + return { label: t('engine.studio.auto.enabled', locale), tone: 'plain' }; + case 'disabled': + return { label: t('engine.studio.auto.disabled', locale), tone: 'muted' }; + } +} + /** A folded badge for one canvas element (errors dominate warnings). */ export interface ProblemBadge { level: DiagnosticLevel; @@ -359,7 +500,7 @@ export function deriveInvalidElements(problems: FlowProblem[]): { // connection — so the two writers that can are here, beside the key they // share: the id a new node gets, and the edges a node removal keeps. The // canvas (`FlowCanvas`, `FlowPreview`) and the node inspector import them from -// this React-free module rather than from a component. +// this component-free module rather than from a component. /** * A fresh node id (objectui#11772): `uniqueId('node', …)` over every id the diff --git a/packages/app-shell/src/views/metadata-admin/studioAutomationCopy.plainLanguage-11659.test.ts b/packages/app-shell/src/views/metadata-admin/studioAutomationCopy.plainLanguage-11659.test.ts index 94ee303abd..1e1dec9939 100644 --- a/packages/app-shell/src/views/metadata-admin/studioAutomationCopy.plainLanguage-11659.test.ts +++ b/packages/app-shell/src/views/metadata-admin/studioAutomationCopy.plainLanguage-11659.test.ts @@ -29,6 +29,10 @@ const PILLAR_ROWS = [ 'engine.studio.auto.offTitle', 'engine.studio.auto.onBound', 'engine.studio.auto.onUnbound', + // objectui#11779 — the run status's "not running here" and "not deployed" words. + 'engine.studio.auto.notRunning', + 'engine.studio.auto.notRunningTitle', + 'engine.studio.auto.unpublishedTitle', 'engine.studio.auto.enabled', 'engine.studio.auto.disabled', 'engine.studio.auto.enableTitle', diff --git a/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx b/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx index cc5ed77497..5b20449c69 100644 --- a/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx +++ b/packages/app-shell/src/views/studio-design/FlowStatusDot.test.tsx @@ -62,10 +62,13 @@ describe('FlowStatusDot — a declared trigger the engine has not armed (objectu const el = dot(container); expect(el.getAttribute('title')).toBe(REASON); expect(el.getAttribute('title')).not.toBe(t('engine.studio.auto.onUnbound', 'en')); - // Only the title changes: the visible text and the dot's colour stay those of - // an enabled flow, and nothing is styled as an error. - expect(el.textContent).toBe(t('engine.studio.auto.on', 'en')); - expect(el.querySelector('.bg-emerald-500')).not.toBeNull(); + // objectui#11779 — and it says, without hovering, that the deployment does + // not run it: a grey "Not running here", not the green "On" of a flow that + // runs. Still nothing is styled as an error: a policy is not a defect. + expect(el.textContent).toBe(t('engine.studio.auto.notRunning', 'en')); + expect(el.textContent).not.toBe(t('engine.studio.auto.on', 'en')); + expect(container.querySelector('.bg-emerald-500')).toBeNull(); + expect(container.innerHTML).not.toMatch(/emerald/); expect(container.querySelector('[role="alert"]')).toBeNull(); expect(container.innerHTML).not.toMatch(/destructive/); }); @@ -77,16 +80,23 @@ describe('FlowStatusDot — a declared trigger the engine has not armed (objectu expect(dot(container).getAttribute('title')).toBe(REASON); }); - it('a flow with no declared trigger (no `triggerType`) still says "no trigger" — the control', () => { + it('a flow with no declared trigger (no `triggerType`) still says "no trigger", and still reads "On" — the control', () => { const { container } = render(); expect(dot(container).getAttribute('title')).toBe(t('engine.studio.auto.onUnbound', 'en')); + // objectui#11779 — a manual flow runs when invoked: never "not running". + expect(dot(container).textContent).toBe(t('engine.studio.auto.on', 'en')); + expect(container.querySelector('.bg-emerald-500')).not.toBeNull(); }); - it('a declared trigger with no reason (a backend older than `reason`) says only "Enabled", never "no trigger"', () => { + it('a declared trigger with no reason (a backend older than `reason`) reads "Not running here", never "no trigger"', () => { const { container } = render( , ); - expect(dot(container).getAttribute('title')).toBe(t('engine.studio.auto.enabled', 'en')); + // objectui#11779 — `bound: false` WITH a `triggerType` is the contract's + // "declared trigger type has no registered trigger": not armed here, whatever + // the reason. Without the platform's sentence the title says only that. + expect(dot(container).textContent).toBe(t('engine.studio.auto.notRunning', 'en')); + expect(dot(container).getAttribute('title')).toBe(t('engine.studio.auto.notRunningTitle', 'en')); expect(dot(container).getAttribute('title')).not.toBe(t('engine.studio.auto.onUnbound', 'en')); }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx index a384e3716f..79b153f82f 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx @@ -99,6 +99,7 @@ import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { t } from '../metadata-admin/i18n'; const dataSource = createEmptyDataSource(); failOnAbsorbedFetchError(); @@ -135,13 +136,20 @@ function renderPillar(readOnly: boolean) { ); } -/** The canvas's own status pill — it reads the same `draft.status` the switch reads. */ +/** + * The canvas's own status pill. The `_status` probe is answered 404 here, so + * there is no runtime reading and the pill reads the same `draft.status` the + * switch reads, in the switch's words (objectui#11779: `active` reads + * "Enabled", `obsolete` "Disabled"). + */ function canvasStatus(): string { return screen.getByText('Status:').nextElementSibling?.textContent ?? ''; } +const STATUS_ON = t('engine.studio.auto.enabled', 'en'); +const STATUS_OFF = t('engine.studio.auto.disabled', 'en'); async function openFlow(): Promise { - await waitFor(() => expect(canvasStatus()).toBe('active'), { timeout: 8000 }); + await waitFor(() => expect(canvasStatus()).toBe(STATUS_ON), { timeout: 8000 }); return screen.getByRole('switch'); } @@ -165,7 +173,7 @@ describe('Automations pillar on a read-only package (objectui#11124)', () => { await new Promise((r) => setTimeout(r, 50)); expect(server.saves).toHaveLength(0); expect(toggle).toHaveAttribute('aria-checked', 'true'); - expect(canvasStatus()).toBe('active'); + expect(canvasStatus()).toBe(STATUS_ON); }); it('opens the flow inspector read-only: the node inputs are disabled', async () => { @@ -201,7 +209,7 @@ describe('Automations pillar on a writable package — the control (objectui#111 await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 }); expect(server.saves[0]!.body.status).toBe('obsolete'); await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'), { timeout: 8000 }); - expect(canvasStatus()).toBe('obsolete'); + expect(canvasStatus()).toBe(STATUS_OFF); }); }); @@ -218,7 +226,7 @@ describe('A refused status toggle rolls back to the server state (objectui#11124 await screen.findByText(LOCKED_MESSAGE, undefined, { timeout: 8000 }); await waitFor(() => expect(toggle).toBeEnabled(), { timeout: 8000 }); expect(toggle).toHaveAttribute('aria-checked', 'true'); - expect(canvasStatus()).toBe('active'); + expect(canvasStatus()).toBe(STATUS_ON); }); it('puts back only `status`: an edit made while the refused save was in flight survives', async () => { @@ -230,7 +238,7 @@ describe('A refused status toggle rolls back to the server state (objectui#11124 fireEvent.click(toggle); await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 }); - expect(canvasStatus()).toBe('obsolete'); + expect(canvasStatus()).toBe(STATUS_OFF); const label = await inspectStartNode(); fireEvent.change(label, { target: { value: 'Kick-off' } }); @@ -239,7 +247,7 @@ describe('A refused status toggle rolls back to the server state (objectui#11124 open(); await screen.findByText(LOCKED_MESSAGE, undefined, { timeout: 8000 }); - await waitFor(() => expect(canvasStatus()).toBe('active'), { timeout: 8000 }); + await waitFor(() => expect(canvasStatus()).toBe(STATUS_ON), { timeout: 8000 }); expect(toggle).toHaveAttribute('aria-checked', 'true'); expect(document.querySelector('[data-node-id="start"]')).toHaveTextContent('Kick-off'); }); @@ -260,12 +268,12 @@ describe('A refused status toggle rolls back to the server state (objectui#11124 // pending toggle wrote — then let the first flow's save be refused. fireEvent.click(screen.getByRole('button', { name: /Nightly digest/ })); await waitFor(() => expect(screen.getByText('flow · nightly_digest')).toBeInTheDocument(), { timeout: 8000 }); - await waitFor(() => expect(canvasStatus()).toBe('obsolete'), { timeout: 8000 }); + await waitFor(() => expect(canvasStatus()).toBe(STATUS_OFF), { timeout: 8000 }); open(); await screen.findByText(LOCKED_MESSAGE, undefined, { timeout: 8000 }); await new Promise((r) => setTimeout(r, 50)); - expect(canvasStatus()).toBe('obsolete'); + expect(canvasStatus()).toBe(STATUS_OFF); expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false'); }); }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnlySelect-11546.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnlySelect-11546.test.tsx index 249b913241..a8d943676b 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnlySelect-11546.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnlySelect-11546.test.tsx @@ -95,6 +95,7 @@ import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { t } from '../metadata-admin/i18n'; import { browserClick } from '../metadata-admin/previews/__tests__/browserClick'; const dataSource = createEmptyDataSource(); @@ -129,7 +130,9 @@ function renderPillar(readOnly: boolean) { } async function openFlow(): Promise { - await waitFor(() => expect(screen.getByText('Status:').nextElementSibling?.textContent).toBe('active'), { timeout: 8000 }); + // The flow is loaded once the header's Status pill reads its `active` status, + // in the switch's words with no runtime reading (objectui#11779). + await waitFor(() => expect(screen.getByText('Status:').nextElementSibling?.textContent).toBe(t('engine.studio.auto.enabled', 'en')), { timeout: 8000 }); } function startNodeCard(): HTMLElement { diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx index c321897a4d..001d582427 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx @@ -162,6 +162,7 @@ import { registerMetadataInspector } from '../metadata-admin/inspector-registry' import { registerMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { t } from '../metadata-admin/i18n'; import { PagePreview } from '../metadata-admin/previews/PagePreview'; import { PageBlockInspector } from '../metadata-admin/inspectors/PageBlockInspector'; import { PageDefaultInspector } from '../metadata-admin/inspectors/PageDefaultInspector'; @@ -239,7 +240,9 @@ describe('Automations pillar — an edit taken while a flow save is in flight (o , ); - await waitFor(() => expect(screen.getByText('Status:').nextElementSibling).toHaveTextContent('active'), { timeout: 8000 }); + // Loaded once the Status pill reads the flow's `active` status — in the + // switch's words, with no runtime reading (objectui#11779). + await waitFor(() => expect(screen.getByText('Status:').nextElementSibling).toHaveTextContent(t('engine.studio.auto.enabled', 'en')), { timeout: 8000 }); fireEvent.click(document.querySelector('[data-node-id="start"] [role="button"]') as HTMLElement); const label = await within(screen.getByRole('complementary')).findByLabelText('Label', undefined, { timeout: 8000 }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowStatusTruth-11779.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowStatusTruth-11779.test.tsx new file mode 100644 index 0000000000..db4282b9f8 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowStatusTruth-11779.test.tsx @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11779 — the flow state Studio shows is the state the deployment + * runs, and it reads the same in the Automations rail, the flow header and the + * Problems panel. + * + * Measured in a browser QA pass before this card: + * - a record-triggered flow's header read "Trigger: autolaunched" (the + * flow-level `type`, not the Start node's `triggerType`); + * - a published flow with no `status` key read "Status: draft" in the header + * while the rail read "On"; + * - a schedule flow the deployment does not arm read a green "On" in the + * rail, "active" in the header and "no problems" in Problems; + * - the bar promised "Off by default", and a new flow's switch read Enabled. + * + * Driven through the REAL `AutomationsPillar` with the REAL registered + * `FlowPreview` (its header pills and its Problems panel) — the value under + * test is what the author reads, so neither is stubbed. The `_status` double + * is a router: it serves the engine's runtime rows on the one route, in the + * shape `getFlowRuntimeStates()` puts on the wire (`triggerType` / `reason` + * omitted where the engine omits them), and answers every other URL 404. The + * reason is fixture text, ⛔ not a platform sentence: the surfaces show + * whatever string arrives. + * + * The assertions name the designer-table KEY each string must come from, so a + * reworded row turns nothing green or red. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { FlowSchema } from '@objectstack/spec/automation'; + +const PKG = 'com.acme.app'; + +const REASON = 'FIXTURE: the platform sentence for why this flow is not armed, rendered as sent.'; + +const edges = [{ id: 'e1', source: 'start', target: 'end' }]; +const end = { id: 'end', type: 'end', label: 'End' }; + +/** Published flows. None carries a `status` key — the shape the QA pass read. */ +const FLOWS: Array> = [ + { + name: 'notify_on_update', + label: 'Notify on update', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-update', objectName: 'task' } }, + end, + ], + edges, + }, + { + name: 'task_due_reminder', + label: 'Task due reminder', + type: 'schedule', + nodes: [{ id: 'start', type: 'start', label: 'Start', config: { schedule: { type: 'cron', expression: '0 7 * * *' } } }, end], + edges, + }, + { + name: 'approve_manually', + label: 'Approve manually', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, end], + edges, + }, + { + name: 'never_published', + label: 'Never published', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, end], + edges, + }, +]; + +/** The engine's runtime rows. `never_published` has none: the engine does not have it. */ +const RUNTIME_ROWS = [ + // Bound to its record trigger and running — the control. + { name: 'notify_on_update', enabled: true, bound: true, triggerType: 'record_change', object: 'task' }, + // A schedule flow the deployment holds unarmed. + { name: 'task_due_reminder', enabled: true, bound: false, triggerType: 'schedule', reason: REASON }, + // No declared trigger: runs when invoked. + { name: 'approve_manually', enabled: true, bound: false }, +]; + +const server = vi.hoisted(() => ({ + active: new Map>(), + drafts: new Map>(), + saves: [] as Array<{ type: string; name: string; body: Record }>, +})); + +const mockClient = vi.hoisted(() => { + const k = (type: string, name: string) => `${type}/${name}`; + return { + list: vi.fn(async (type: string) => + [...server.active.entries()] + .filter(([key]) => key.startsWith(`${type}/`)) + .map(([, row]) => ({ name: row.name, label: row.label ?? row.name })), + ), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + const eff = server.active.get(k(type, name)) ?? null; + return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + const body = server.drafts.get(k(type, name)); + if (body) return { item: JSON.parse(JSON.stringify(body)) }; + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => { + const body = JSON.parse(JSON.stringify(item)) as Record; + server.saves.push({ type, name, body }); + server.drafts.set(k(type, name), body); + return { type, name, item }; + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), + }; +}); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { t } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); +failOnAbsorbedFetchError(); + +registerMetadataPreview('flow', FlowPreview); + +const AUTOMATION_STATUS_ROUTE = '/api/v1/automation/_status'; +const routeOf = (url: string) => url.split('?')[0]; +let fetchCalls: string[] = []; + +beforeEach(() => { + fetchCalls = []; + server.active.clear(); + server.drafts.clear(); + server.saves.length = 0; + for (const f of FLOWS) server.active.set(`flow/${String(f.name)}`, JSON.parse(JSON.stringify(f))); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input); + fetchCalls.push(url); + if (routeOf(url) !== AUTOMATION_STATUS_ROUTE) { + return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ success: true, data: { flows: RUNTIME_ROWS, total: RUNTIME_ROWS.length } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + ); +}); + +afterEach(() => { + expect(fetchCalls.filter((url) => routeOf(url) === AUTOMATION_STATUS_ROUTE).length).toBeGreaterThan(0); + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderPillar() { + return render( + + + , + ); +} + +const en = (key: string) => t(key, 'en'); + +/** The rail row's status element (the dot or the chip) for the flow labelled `label`. */ +async function railStatus(label: string): Promise { + const row = await screen.findByRole('button', { name: new RegExp(label) }, { timeout: 8000 }); + let el: HTMLElement | null = null; + await waitFor( + () => { + el = row.querySelector('span[title]'); + expect(el).not.toBeNull(); + }, + { timeout: 8000 }, + ); + return el!; +} + +/** The header pill whose label is `label` (e.g. "Status"): the pill element and its value. */ +function headerPill(label: string): { pill: HTMLElement; value: string } { + const labelEl = screen.getByText(`${label}:`); + return { pill: labelEl.parentElement as HTMLElement, value: labelEl.nextElementSibling?.textContent ?? '' }; +} + +/** Open the flow labelled `label` from the rail and wait until its header shows `flow · name`. */ +async function openFlow(label: string, name: string): Promise { + fireEvent.click(await screen.findByRole('button', { name: new RegExp(label) }, { timeout: 8000 })); + await screen.findByText(`flow · ${name}`, undefined, { timeout: 8000 }); + await screen.findByText('Status:', undefined, { timeout: 8000 }); +} + +/** The header Status pill once it reads `value` — the runtime answer is fetched asynchronously. */ +async function statusPillReads(value: string): Promise { + await waitFor(() => expect(headerPill('Status').value).toBe(value), { timeout: 8000 }); + return headerPill('Status').pill; +} + +/** Open the header's Problems panel (one flow preview is on screen, so one panel). */ +function openProblems(): void { + fireEvent.click(screen.getByTitle(en('engine.flowPreview.problemsTitle'))); +} + +describe('the header Trigger pill reads the Start node (objectui#11779)', () => { + it('a record-triggered flow shows its trigger event and object, not the flow type "autolaunched"', async () => { + renderPillar(); + await openFlow('Notify on update', 'notify_on_update'); + const { value } = headerPill('Trigger'); + expect(value).toBe('Record updated · task'); + expect(value).not.toMatch(/autolaunched/); + }); + + it('a schedule flow reads as a schedule; a manual flow falls back to its type', async () => { + renderPillar(); + await openFlow('Task due reminder', 'task_due_reminder'); + expect(headerPill('Trigger').value).toBe('Schedule (cron)'); + await openFlow('Approve manually', 'approve_manually'); + expect(headerPill('Trigger').value).toBe('autolaunched'); + }); +}); + +describe('one run status in the rail, the header and Problems (objectui#11779)', () => { + it('a published flow with no `status` key reads On in the rail and the header, never "draft" — the control', async () => { + renderPillar(); + const dot = await railStatus('Notify on update'); + expect(dot).toHaveTextContent(en('engine.studio.auto.on')); + expect(dot).toHaveAttribute('title', en('engine.studio.auto.onBound')); + + await openFlow('Notify on update', 'notify_on_update'); + const pill = await statusPillReads(en('engine.studio.auto.on')); + expect(pill).toHaveAttribute('title', en('engine.studio.auto.onBound')); + expect(headerPill('Status').value).not.toMatch(/draft/i); + expect(pill.innerHTML).toMatch(/emerald/); + }); + + it('an unbound schedule flow reads "Not running here" in the rail and the header alike, titled with the reason, and not On', async () => { + renderPillar(); + const chip = await railStatus('Task due reminder'); + expect(chip).toHaveTextContent(en('engine.studio.auto.notRunning')); + expect(chip).toHaveAttribute('title', REASON); + expect(chip).not.toHaveTextContent(en('engine.studio.auto.on')); + + await openFlow('Task due reminder', 'task_due_reminder'); + const pill = await statusPillReads(en('engine.studio.auto.notRunning')); + expect(pill).toHaveAttribute('title', REASON); + expect(pill.innerHTML).not.toMatch(/emerald|destructive/); + }); + + it('Problems says so too: a note carrying the reason, not counted as a problem and not an error', async () => { + renderPillar(); + await openFlow('Task due reminder', 'task_due_reminder'); + await statusPillReads(en('engine.studio.auto.notRunning')); + openProblems(); + const note = await screen.findByTestId('flow-run-status-note'); + expect(note).toHaveTextContent(en('engine.studio.auto.notRunning')); + expect(note).toHaveTextContent(REASON); + expect(note.innerHTML).not.toMatch(/destructive/); + // The structural verdict stands: a deployment policy is not an authoring problem. + expect(screen.getByText(en('engine.flowProblems.empty'))).toBeInTheDocument(); + }); + + it('a flow with no declared trigger reads On, says "no trigger", and gets no Problems note — the control', async () => { + renderPillar(); + const dot = await railStatus('Approve manually'); + expect(dot).toHaveTextContent(en('engine.studio.auto.on')); + expect(dot).toHaveAttribute('title', en('engine.studio.auto.onUnbound')); + + await openFlow('Approve manually', 'approve_manually'); + const pill = await statusPillReads(en('engine.studio.auto.on')); + expect(pill).toHaveAttribute('title', en('engine.studio.auto.onUnbound')); + openProblems(); + await screen.findByText(en('engine.flowProblems.empty')); + expect(screen.queryByTestId('flow-run-status-note')).toBeNull(); + }); + + it('a flow the engine does not have reads as an unpublished draft in the header, and carries no rail status', async () => { + renderPillar(); + await railStatus('Notify on update'); // the runtime rows are in + await openFlow('Never published', 'never_published'); + await statusPillReads(en('engine.studio.unpublishedDraft')); + const row = screen.getByRole('button', { name: /Never published/ }); + expect(row.querySelector('span[title]')).toBeNull(); + }); +}); + +describe('a new flow is born switched off, as the bar promises (objectui#11779)', () => { + it('"New" saves `status: obsolete`, a spec-valid flow, and the switch reads Disabled under "Off by default"', async () => { + renderPillar(); + await railStatus('Notify on update'); + expect(screen.getByText(en('engine.studio.auto.defaultOff'))).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle(en('engine.studio.auto.newTitle'))); + fireEvent.change(await screen.findByPlaceholderText(en('engine.studio.auto.namePlaceholder')), { + target: { value: 'Offer notice' }, + }); + fireEvent.click(screen.getByRole('button', { name: en('engine.studio.createDraft') })); + + await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 }); + const { name, body } = server.saves[0]!; + expect(name).toBe('offer_notice'); + expect(body.status).toBe('obsolete'); + const parsed = FlowSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + + const toggle = await screen.findByRole('switch', undefined, { timeout: 8000 }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(toggle).toHaveTextContent(en('engine.studio.auto.disabled')); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx index c6d07475c7..76007f6ed9 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx @@ -136,6 +136,7 @@ import { getMetadataPreview, registerMetadataPreview } from '../metadata-admin/p import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { t } from '../metadata-admin/i18n'; const dataSource = createEmptyDataSource(); failOnAbsorbedFetchError(); @@ -216,14 +217,17 @@ function renderAt(url: string) { const location = () => screen.getByTestId('location').textContent ?? ''; -/** The canvas's own status pill, which reads the open flow's `draft.status`. */ +/** + * The canvas's own status pill. With the `_status` probe answered "absent" it + * reads the open flow's `draft.status`, in the switch's words (objectui#11779). + */ function canvasStatus(): string { return screen.getByText('Status:').nextElementSibling?.textContent ?? ''; } async function openedFlow(name: string): Promise { await screen.findByText(`flow · ${name}`, undefined, { timeout: 8000 }); - await waitFor(() => expect(canvasStatus()).toBe('active'), { timeout: 8000 }); + await waitFor(() => expect(canvasStatus()).toBe(t('engine.studio.auto.enabled', 'en')), { timeout: 8000 }); } describe('the package-less scope lists a package-less flow and opens it editable (objectui#11553)', () => { diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.railUnboundReason-11281.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.railUnboundReason-11281.test.tsx index 0308f5d354..de8c035de4 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.railUnboundReason-11281.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.railUnboundReason-11281.test.tsx @@ -141,7 +141,7 @@ async function railDot(label: string): Promise { } describe('the Automations rail reads `triggerType` and `reason` from the runtime rows (objectui#11281)', () => { - it('titles a policy-unbound schedule flow with the platform reason, verbatim, and keeps it "On"', async () => { + it('titles a policy-unbound schedule flow with the platform reason, verbatim, and reads "Not running here", not "On"', async () => { render( @@ -150,7 +150,9 @@ describe('the Automations rail reads `triggerType` and `reason` from the runtime const dot = await railDot('Nightly digest'); expect(dot).toHaveAttribute('title', REASON); expect(dot).not.toHaveAttribute('title', t('engine.studio.auto.onUnbound', 'en')); - expect(within(dot).getByText(t('engine.studio.auto.on', 'en'))).toBeInTheDocument(); + // objectui#11779 — visible without hovering: the deployment does not run it. + expect(dot).toHaveTextContent(t('engine.studio.auto.notRunning', 'en')); + expect(within(dot).queryByText(t('engine.studio.auto.on', 'en'))).toBeNull(); }); it('a flow with no declared trigger still says "no trigger"; bound and disabled flows are unchanged — the controls', async () => { diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 845ff8fd68..245c26dbe3 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -130,6 +130,12 @@ import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadat import { useDisplayLocale } from '@object-ui/i18n'; import { SuggestedBindingsPanel } from '../../components/SuggestedBindingsPanel.js'; import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas.js'; +import { + FlowRuntimeContext, + deriveFlowRunStatus, + describeFlowRunStatus, + type FlowRuntimeRow, +} from '../metadata-admin/previews/flow-problems.js'; import { clearedLabel, inheritedNavEntryText, @@ -4581,22 +4587,12 @@ type FlowRuntimeState = Partial; * distinguishes the two". `reason` is the platform's one sentence for why such a * flow is not armed: a deployment policy and a binding failure each arrive in * the platform's own words. So the rail keeps both beside `enabled` / `bound`. + * + * The shape is `flow-problems`' `FlowRuntimeRow` (objectui#11779): the rail, + * the flow header and the Problems panel derive one run status from it + * (`deriveFlowRunStatus`), so it is declared once, where that derivation is. */ -interface FlowRailState { - enabled: boolean; - bound: boolean; - /** - * The flow's declared trigger type. Absent when the flow declares no trigger - * (the engine omits the field then), and on a backend that never sends it. - */ - triggerType?: string; - /** - * The platform's sentence for why this flow is not armed. RENDERED verbatim, - * ⛔ never parsed, compared or restyled here (the contract: "Consumers RENDER - * it; ⛔ do not parse it"), the way the Setup page shows it (objectui#9217). - */ - reason?: string; -} +type FlowRailState = FlowRuntimeRow; /** * Narrow one unvalidated runtime row into the rail's state. `triggerType` and @@ -4615,38 +4611,42 @@ function flowRailState(s: FlowRuntimeState): FlowRailState { } /** - * A flow's live status in the Automations rail: a colored dot + On/Off, from the - * engine's runtime state (persisted `status` is intent; this is what's actually - * live). Renders nothing for a flow the engine doesn't know yet (never published) - * — the amber "unpublished draft" chip already covers that case. + * A flow's live status in the Automations rail, from the engine's runtime state + * (persisted `status` is intent; this is what's actually live). Renders nothing + * for a flow the engine doesn't know yet (never published) — the amber + * "unpublished draft" chip already covers that case. * - * The title of an enabled, unbound flow (objectui#11281): - * - with a `reason`: that sentence, verbatim, and nothing else; - * - with no `triggerType`: "no trigger (run manually)", the one case the - * contract lets `bound: false` mean that, and what every older backend got; - * - a declared trigger with no `reason` (a backend that predates the field): - * only "Enabled". It claims nothing about the binding, as the Setup page - * claims nothing for the same row. - * The visible text and the dot's colour follow `enabled` alone: a reason is - * never styled as an error. + * The state is the run status `deriveFlowRunStatus` derives, worded by + * `describeFlowRunStatus` — the derivation the flow header's Status pill and the + * Problems panel read too (objectui#11779), so the three cannot disagree: + * - enabled and bound, or enabled with no declared trigger: a green dot + + * "On", titled "bound to its trigger" / "no trigger (run manually)" — a flow + * that runs when invoked is never called "not running"; + * - enabled, with a declared trigger the engine has not armed: a grey + * "Not running here" chip — visible without hovering, since the deployment + * will never run it on that trigger. Its title is the platform's `reason`, + * verbatim (objectui#11281), or, from a backend that predates the field, the + * contract's own reading of the row: its trigger is not armed here; + * - disabled: a grey dot + "Off". + * Nothing here is styled as an error: a deployment policy is not a defect. */ export function FlowStatusDot({ state, locale }: { state?: FlowRailState; locale: string }): React.ReactElement | null { if (!state) return null; - const { enabled, bound, triggerType, reason } = state; - const title = !enabled - ? t('engine.studio.auto.offTitle', locale) - : bound - ? t('engine.studio.auto.onBound', locale) - : reason - ? reason - : triggerType - ? t('engine.studio.auto.enabled', locale) - : t('engine.studio.auto.onUnbound', locale); + const status = deriveFlowRunStatus(state); + const { label, title } = describeFlowRunStatus(status, locale); + if (status.kind === 'not-running') { + return ( + + {label} + + ); + } + const enabled = status.kind !== 'off'; return ( - {enabled ? t('engine.studio.auto.on', locale) : t('engine.studio.auto.off', locale)} + {label} ); @@ -4745,6 +4745,11 @@ export function AutomationsPillar({ // behind the rail's status dots. Refetched after a publish (publishNonce); // degrades silently on an older backend / offline (dots just don't render). const [flowStatus, setFlowStatus] = React.useState>({}); + // objectui#11779 — whether `flowStatus` holds an answer from the engine. With + // one, a flow the map has no row for is a flow the engine does not have + // (nothing of it is deployed); without one, there is nothing to say about any + // flow's live state, and the header reads the draft's own switch instead. + const [flowStatusRead, setFlowStatusRead] = React.useState(false); React.useEffect(() => { let cancelled = false; (async () => { @@ -4757,6 +4762,7 @@ export function AutomationsPillar({ const map: Record = {}; for (const s of list) if (s?.name) map[s.name] = flowRailState(s); setFlowStatus(map); + setFlowStatusRead(true); } catch { /* offline / older backend → no dots */ } @@ -4818,12 +4824,23 @@ export function AutomationsPillar({ try { // Minimal valid, autolaunched skeleton: start → end. The designer fills in // the trigger + nodes; publishing it is a separate, user-initiated step. - const skeleton = buildFlowSkeleton( - name, - label, - t('engine.studio.auto.nodeStart', locale), - t('engine.studio.auto.nodeEnd', locale), - ); + // + // objectui#11779 — and it is born switched OFF, as the bar above the + // rail promises ("Off by default · review before enabling"). With no + // `status` the spec's default is `draft`, which the engine arms like + // `active`: the switch read "Enabled" on a flow nobody had reviewed, and + // a package publish armed it as soon as it had a trigger. `obsolete` is + // what the switch itself writes for Off; enabling it is the author's + // own flip. + const skeleton = { + ...buildFlowSkeleton( + name, + label, + t('engine.studio.auto.nodeStart', locale), + t('engine.studio.auto.nodeEnd', locale), + ), + status: 'obsolete', + }; await client.save('flow', name, skeleton, { mode: 'draft', packageId: draftPackageId }); const item: Surface = { type: 'flow', name, label }; setFlows((fs) => [...fs.filter((f) => f.name !== name), item]); @@ -5124,20 +5141,26 @@ export function AutomationsPillar({
) ) : Preview ? ( - React.createElement(Preview, { - type: current.type, - name: current.name, - draft, - editing: true, - selection, - onSelectionChange: setSelection, - // objectui#11124 — a read-only package gets no `onPatch`: per - // the preview contract the canvas is then read-only (no add, - // insert, drag or delete — each a doomed write), while node and - // edge selection still open the inspector read-only below. - onPatch: readOnly ? undefined : onPatch, - locale, - }) + // objectui#11779 — the open flow's runtime row, so the flow + // header and its Problems panel read the run status this rail + // reads: the row, `null` when the engine has none for this flow, + // nothing while no runtime answer is in. + + {React.createElement(Preview, { + type: current.type, + name: current.name, + draft, + editing: true, + selection, + onSelectionChange: setSelection, + // objectui#11124 — a read-only package gets no `onPatch`: per + // the preview contract the canvas is then read-only (no add, + // insert, drag or delete — each a doomed write), while node and + // edge selection still open the inspector read-only below. + onPatch: readOnly ? undefined : onPatch, + locale, + })} + ) : (
                 {JSON.stringify(draft, null, 2)}