diff --git a/.changeset/12454-automations-pillar-layout.md b/.changeset/12454-automations-pillar-layout.md new file mode 100644 index 0000000000..90b45065f2 --- /dev/null +++ b/.changeset/12454-automations-pillar-layout.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': minor +--- + +The Studio's Automations pillar takes the redesign's layout (objectui#12454, epic objectui#12446). + +- **The rail groups flows by the object each one is about**, in the order of the objects' labels, with a count per group and a trailing "No object" group. The object is read off the flow's own definition as the metadata list serves it: the Start node's object, else the first data node of the flow on one of the package's objects, else the first record it writes. Groups fold; a search filters within them. +- **Each row** carries a glyph for its trigger kind (record-triggered, scheduled, screen, autolaunched), its label on one line (the row's title holds it whole, with its kind and status), and a 6 px status dot (green On, grey Not running here, amber Refused, a hollow ring Off). A legend under the rail names them. +- **The item header** carries the flow's Trigger, Status, Run as and version, the meta the canvas's info bar used to show, and the Enabled switch, whose title carries "Off by default · review before enabling". +- **A tab row** — Canvas, Variables, Runs, Problems (with its count) and Debug — replaces the info bar's buttons. Variables, Runs and Problems open as the tab's body; picking a problem opens the canvas on its element. Debug keeps the canvas beside the simulator. +- **The canvas** sits flush under the tabs on the sidebar surface with a 20 px dot grid, and its zoom control floats at the bottom right; the metadata-admin designer's flow preview shares the canvas look and keeps its info bar. diff --git a/docs/design/studio-redesign/after/12454-automations-dark.png b/docs/design/studio-redesign/after/12454-automations-dark.png new file mode 100644 index 0000000000..4983815175 Binary files /dev/null and b/docs/design/studio-redesign/after/12454-automations-dark.png differ diff --git a/docs/design/studio-redesign/after/12454-automations-light.png b/docs/design/studio-redesign/after/12454-automations-light.png new file mode 100644 index 0000000000..41a77ece64 Binary files /dev/null and b/docs/design/studio-redesign/after/12454-automations-light.png differ diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index a958477978..0b01db1f24 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3477,6 +3477,14 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.auto.emptyLine2': 'and its configuration appears here.', 'engine.studio.auto.designersMissing': 'No metadata designers are registered in this session, so this flow cannot be designed here.', + // objectui#12454 — the rail's groups and legend: a flow's trigger kind, and + // the trailing group of flows that name no object. + 'engine.studio.auto.group.noObject': 'No object', + 'engine.studio.auto.kind.record': 'Record-triggered', + 'engine.studio.auto.kind.scheduled': 'Scheduled', + 'engine.studio.auto.kind.screen': 'Screen', + 'engine.studio.auto.kind.autolaunched': 'Autolaunched', + 'engine.studio.auto.legend': 'Legend', // Access pillar 'engine.studio.access.created': 'Permission set “{label}” created', 'engine.studio.access.title': 'Permission matrix', @@ -6616,6 +6624,12 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.auto.emptyLine1': '在画布里点选一个节点,', 'engine.studio.auto.emptyLine2': '它的配置会在这里显示。', 'engine.studio.auto.designersMissing': '本次会话没有注册任何元数据设计器,因此无法在这里设计该流程。', + 'engine.studio.auto.group.noObject': '无对象', + 'engine.studio.auto.kind.record': '记录触发', + 'engine.studio.auto.kind.scheduled': '定时', + 'engine.studio.auto.kind.screen': '交互页面', + 'engine.studio.auto.kind.autolaunched': '自动启动', + 'engine.studio.auto.legend': '图例', // Access pillar 'engine.studio.access.created': '权限集「{label}」已创建', 'engine.studio.access.title': '权限矩阵', diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index d42d997b91..d31e40b7b4 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -875,9 +875,9 @@ export function FlowCanvas({ )} )} - {/* Toolbar */} -
- {editable && ( + {/* Toolbar: Add node at the top right, while the canvas is editable. */} + {editable && ( +
addAfter(selectedId, type)}> - - {Math.round(zoom * 100)}% - - -
+ )} + {/* objectui#12454 — the zoom control floats at the bottom right: −, + the percentage, +, then fit after a divider. */} +
+ + + {Math.round(zoom * 100)}% + + + +
{/* Viewport */} @@ -938,18 +941,15 @@ export function FlowCanvas({ onBgPointerUp(e); onNodePointerUp(e); }} - className={cn( - 'h-full w-full cursor-grab outline-none active:cursor-grabbing', - 'bg-muted/15 dark:bg-background/30', - // Subtle inset vignette gives the canvas surface depth. - 'shadow-[inset_0_0_90px_rgba(0,0,0,0.05)]', - )} + // objectui#12454 — the sidebar surface with a 20 px dot grid, as the + // redesign's canvas reads; the dots stay on the border token. + className="h-full w-full cursor-grab bg-sidebar outline-none active:cursor-grabbing" style={{ // Dot grid tied to pan + zoom so the surface tracks the diagram // (rather than floating behind a static texture). backgroundImage: 'radial-gradient(circle at 1px 1px, hsl(var(--border)) 1px, transparent 0)', - backgroundSize: `${18 * zoom}px ${18 * zoom}px`, + backgroundSize: `${20 * zoom}px ${20 * zoom}px`, backgroundPosition: `${pan.x}px ${pan.y}px`, }} > 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 649a6f51bf..42f72b2911 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx @@ -55,6 +55,7 @@ import { describeFlowRunStatus, freshNodeId, type FlowProblem, + type FlowRunStatus, type FlowRunStatusView, } from './flow-problems.js'; import { useConnectorRegistry } from '../inspectors/connector-input-fields.js'; @@ -74,6 +75,48 @@ import { fieldsForNodeType, localizeFlowFields } from '../inspectors/flow-node-c type FlowNode = FlowDesignerNode; type FlowEdge = FlowDesignerEdge; +/** The sections a host's tab row offers over one flow (objectui#12454). */ +export type FlowPreviewSection = 'canvas' | 'variables' | 'runs' | 'problems' | 'debug'; + +/** The problem count a host's Problems tab carries: all of them, and how many are errors. */ +export interface FlowProblemTally { + count: number; + errors: number; +} + +/** + * Host chrome for the flow preview (objectui#12454). + * + * A host whose own item header already carries the flow's meta and a tab row + * over {@link FlowPreviewSection} — the Studio's Automations pillar — provides + * this. The preview then draws no info bar and no frame of its own, and shows + * the section the host's tab row has open: the canvas; the variables, the run + * history or the problem list as the section's body; or the canvas with the + * debug simulator beside it, since the simulator paints its run on the canvas. + * + * Without it — the metadata-admin designer, which has no item header — the + * preview is as it was: its info bar holds the meta pills and the Variables, + * Runs, Problems and Debug buttons, each opening a panel beside the canvas. + */ +export interface FlowPreviewChrome { + section: FlowPreviewSection; + /** Open a section. Picking a problem in the Problems body opens the canvas on the element it reveals. */ + onSectionChange: (section: FlowPreviewSection) => void; + /** + * Receives the problem tally for the host's Problems tab. Called from an + * effect keyed on the tally's numbers and on this function, so hand in a + * state setter (whose identity React keeps), never a fresh closure. + */ + onProblemsChange: (tally: FlowProblemTally) => void; +} + +const FlowPreviewChromeContext = React.createContext(null); + +/** Provides {@link FlowPreviewChrome} to the flow preview below it. */ +export function FlowPreviewChromeProvider({ value, children }: { value: FlowPreviewChrome; children?: React.ReactNode }) { + return {children}; +} + /** * 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 @@ -112,6 +155,94 @@ function flowTriggerLabel(d: Record, nodes: FlowNode[], locale? return typeof object === 'string' && object ? `${label} · ${object}` : label; } +/** + * The flow's meta — Trigger, Status, Run as, version and, when set, On error — + * as the designer's info bar shows it and as the Studio's item header shows it + * (objectui#12454). One component, so the two read one trigger and one run + * status the same way (objectui#11779). `bar` keeps the info bar's glyphs; + * `header` drops them and separates the items with dots, at the header's meta + * size. Each item keeps its `Label:` caption and its value as sibling spans. + */ +export function FlowMetaPills({ + draft, + runStatus, + locale, + variant = 'bar', +}: { + draft: Record; + runStatus: FlowRunStatus; + locale?: string; + variant?: 'bar' | 'header'; +}) { + const nodes = Array.isArray(draft.nodes) ? (draft.nodes as FlowNode[]) : []; + const runView = describeFlowRunStatus(runStatus, locale); + const runAs = String(draft.runAs ?? 'user'); + const version = draft.version != null ? String(draft.version) : undefined; + const errorStrategy = (draft.errorHandling as { strategy?: unknown } | undefined)?.strategy; + const onError = typeof errorStrategy === 'string' && errorStrategy ? errorStrategy : undefined; + const header = variant === 'header'; + const items: React.ReactNode[] = [ + , + , + , + ]; + if (version) { + items.push( + header ? ( + v{version} + ) : ( + + ), + ); + } + if (onError) { + items.push( + , + ); + } + if (!header) return <>{items}; + return ( + + {items.map((item, i) => ( + + {i > 0 && ( + + · + + )} + {item} + + ))} + + ); +} + interface FlowVariable { name: string; type?: string; @@ -138,7 +269,8 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa // refused or unregistered flow's status (the stored `status` stays as read). const registration = React.useContext(FlowRegistrationContext); const runStatus = deriveFlowRunStatus(runtime, d.status, registration); - const runView = describeFlowRunStatus(runStatus, locale); + // objectui#12454 — a host that carries the meta and the tab row itself. + const chrome = React.useContext(FlowPreviewChromeContext); const designMode = !!(editing && onSelectionChange); const canEdit = designMode && !!onPatch; @@ -178,6 +310,12 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa [nodes, edges, diagnostics, d.variables, locale, connectors], ); const errorCount = problems.filter((p) => p.level === 'error').length; + // objectui#12454 — the host's Problems tab carries the count. Keyed on the + // two numbers and the host's setter, never on the memoised list (AGENTS.md #10). + const reportProblems = chrome?.onProblemsChange; + React.useEffect(() => { + reportProblems?.({ count: problems.length, errors: errorCount }); + }, [reportProblems, problems.length, errorCount]); // Red error ring/stroke derived from the same list (errors only; a cycle // paints its whole loop) — no second validateFlowDraft pass. const { invalidNodeIds, invalidEdges } = React.useMemo( @@ -243,28 +381,139 @@ 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 runAs = String(d.runAs ?? 'user'); - const version = d.version != null ? String(d.version) : undefined; - const errorStrategy = (d.errorHandling as any)?.strategy as string | undefined; - if (nodes.length === 0) { + const empty = canEdit ? ( +
+ +
+ ) : ( + {tr('engine.flowPreview.emptyHint', locale)} + ); + if (nodes.length === 0 && !chrome) { + return {empty}; + } + + // The canvas, the same element in both chromes (objectui#12454). Under a + // host's chrome a flow with no node yet keeps its tabs: the canvas section + // offers the first node, and the other sections read as they do. + const canvasEl = nodes.length === 0 ? ( +
{empty}
+ ) : ( + + n + ? onSelectionChange?.({ kind: 'node', id: n.id, label: n.label || n.id }) + : onSelectionChange?.(null) + } + onSelectEdge={(e, key) => + e + ? onSelectionChange?.({ kind: 'edge', id: key, label: `${e.source} → ${e.target}` }) + : onSelectionChange?.(null) + } + selectedNestedPath={selectedNestedPath} + onSelectNested={(path, node) => + path + ? onSelectionChange?.({ + kind: NESTED_NODE_KIND, + id: encodeNestedNodeId(path), + label: node?.label || path.nodeId, + }) + : onSelectionChange?.(null) + } + onPatch={onPatch} + mintNodeId={mintNodeId} + /> + ); + + // The bar's four panel toggles, as the designer has them: one panel at a time beside the canvas. + const togglePanel = (panel: 'vars' | 'runs' | 'problems' | 'debug') => { + if (panel === 'vars') { + setShowVars((v) => !v); + return; + } + setShowRuns((v) => (panel === 'runs' ? !v : false)); + setShowProblems((v) => (panel === 'problems' ? !v : false)); + setShowDebug((v) => (panel === 'debug' ? !v : false)); + }; + + if (chrome) { + // objectui#12454 — the host's item header holds the meta and the tab row, + // so the preview draws neither, and no frame: the section fills the body. + const section = chrome.section; + const panelBody = section === 'variables' || section === 'runs' || section === 'problems'; return ( - - {canEdit ? ( -
- + +
+ {/* Kept mounted under a body section, so its pan, zoom and opened + regions are where the author left them when the canvas returns. */} +
+
{canvasEl}
- ) : ( - {tr('engine.flowPreview.emptyHint', locale)} - )} - + {section === 'debug' && ( +
+ +
+ )} + {section === 'variables' && ( +
+
+ +
+
+ )} + {section === 'runs' && ( +
+
+ {/* The engine keys runs by the flow's name; the host offers this section only with one. */} + {flowName && } +
+
+ )} + {section === 'problems' && ( +
+
+ { + handleSelectProblem(p); + // The problem's element is selected and revealed on the canvas. + if (p.target.kind !== 'flow') chrome.onSectionChange('canvas'); + }} + locale={locale} + runStatus={runStatus} + /> +
+
+ )} +
+
); } @@ -278,22 +527,12 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa {/* Visual canvas */}
- - - - {version && } - {errorStrategy && } +
{!showDebug && !showRuns && !showProblems && (
-
- - n - ? onSelectionChange?.({ kind: 'node', id: n.id, label: n.label || n.id }) - : onSelectionChange?.(null) - } - onSelectEdge={(e, key) => - e - ? onSelectionChange?.({ kind: 'edge', id: key, label: `${e.source} → ${e.target}` }) - : onSelectionChange?.(null) - } - selectedNestedPath={selectedNestedPath} - onSelectNested={(path, node) => - path - ? onSelectionChange?.({ - kind: NESTED_NODE_KIND, - id: encodeNestedNodeId(path), - label: node?.label || path.nodeId, - }) - : onSelectionChange?.(null) - } - onPatch={onPatch} - mintNodeId={mintNodeId} - /> -
+
{canvasEl}
{/* Right side panel: Variables (default), the debug simulator, or @@ -441,43 +628,11 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
) : showVars ? (
-
- {tr('engine.flowPreview.variables', locale)} +
+ {tr('engine.flowPreview.variables', locale)} +
+
- {variables.length === 0 ? ( - {tr('engine.flowPreview.noVars', locale)} - ) : ( -
    - {variables.map((v, i) => ( -
  • -
    - {v.name} - {v.type && ( - - {v.type} - - )} - {v.isInput && ( - - {tr('engine.flowPreview.varIn', locale)} - - )} - {v.isOutput && ( - - {tr('engine.flowPreview.varOut', locale)} - - )} -
    - {v.defaultValue !== undefined && ( -
    - = {String(v.defaultValue)} -
    - )} -
  • - ))} -
- )} -
) : null}
@@ -491,6 +646,7 @@ function Pill({ value, tone = 'plain', title, + compact = false, }: { icon?: React.ComponentType<{ className?: string }>; label: string; @@ -498,20 +654,60 @@ function Pill({ tone?: FlowRunStatusView['tone']; /** Hover text — the run status's reason, for the Status pill. */ title?: string; + /** The item header's meta (objectui#12454): the value at the caption's weight, plain text muted. */ + compact?: boolean; }) { const cls = tone === 'green' ? 'text-emerald-700 dark:text-emerald-400' : tone === 'amber' ? 'text-amber-700 dark:text-amber-300' - : tone === 'muted' + : tone === 'muted' || compact ? 'text-muted-foreground' : 'text-foreground'; return ( - + {Icon && } {label}: - {value} + {value} ); } + +/** The flow's declared variables: the designer's side panel, and the Studio's Variables body (objectui#12454). */ +function FlowVariablesList({ variables, locale }: { variables: FlowVariable[]; locale?: string }) { + if (variables.length === 0) { + return {tr('engine.flowPreview.noVars', locale)}; + } + return ( +
    + {variables.map((v, i) => ( +
  • +
    + {v.name} + {v.type && ( + + {v.type} + + )} + {v.isInput && ( + + {tr('engine.flowPreview.varIn', locale)} + + )} + {v.isOutput && ( + + {tr('engine.flowPreview.varOut', locale)} + + )} +
    + {v.defaultValue !== undefined && ( +
    + = {String(v.defaultValue)} +
    + )} +
  • + ))} +
+ ); +} diff --git a/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx b/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx index c7164f2da5..ab390c0764 100644 --- a/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx +++ b/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx @@ -6,8 +6,14 @@ * * Before: a rail of flows with no search, each label in a `truncate` span in a * narrow rail, so a long name read as its first few words and an ellipsis. Now a - * search box above the list matches the label or the machine name, a search - * that matches nothing says so, and the label wraps. + * search box above the list matches the label or the machine name, and a search + * that matches nothing says so. + * + * objectui#12454 — the redesign groups the rail by object and gives each row + * one 32 px line, so the label truncates again, as the card's rail does; the + * whole name moved to the row's title. The second describe pins that the name + * is still whole on the row, where a pointer reads it. The rows are read by + * their test id, so a group header (also a button) is never counted as a row. * * The REAL `AutomationsPillar`. The rail lists what `loadPackageSurfaces` * merges: published rows by their label, a draft-only row by its name. The @@ -111,10 +117,9 @@ async function renderRail(): Promise { /** The rail rows' visible names, in order. */ function rowNames(rail: HTMLElement): string[] { - return within(rail) - .queryAllByRole('button') - .filter((b) => b.querySelector('svg') && b.title !== t('engine.studio.auto.newTitle', 'en')) - .map((b) => (b.textContent ?? '').trim()); + return Array.from(rail.querySelectorAll('[data-testid^="auto-rail-row-"]')).map( + (b) => b.querySelector('span.min-w-0')?.textContent?.trim() ?? '', + ); } describe('the Automations rail search (objectui#11794)', () => { @@ -154,15 +159,17 @@ describe('the Automations rail search (objectui#11794)', () => { }); }); -describe('a flow’s name in the Automations rail is not cut to a stub (objectui#11794)', () => { - it('the long label is the row’s whole text, in a wrapping span, not a truncating one', async () => { +describe('a flow’s name in the Automations rail is not cut to a stub (objectui#11794, objectui#12454)', () => { + it('the long label is the row’s whole text on one line, and the row’s title holds it whole', async () => { const rail = await renderRail(); const row = within(rail).getByRole('button', { name: new RegExp(LONG_LABEL) }); const label = within(row).getByText(LONG_LABEL); // jsdom lays nothing out, so the class that decides clipping is what is // read: `truncate` is `overflow: hidden; text-overflow: ellipsis; - // white-space: nowrap`, the stub the card measured. - expect(label).not.toHaveClass('truncate'); - expect(label).toHaveClass('break-words'); + // white-space: nowrap` — the 32 px row of objectui#12454. + expect(label).toHaveClass('truncate'); + expect(label).toHaveTextContent(new RegExp(`^${LONG_LABEL}$`)); + // The whole name is still on the row, where hovering it reads it. + expect(row.getAttribute('title') ?? '').toMatch(new RegExp(`^${LONG_LABEL} · `)); }); }); diff --git a/packages/app-shell/src/views/studio-design/AutomationsPillar.tabs-12454.test.tsx b/packages/app-shell/src/views/studio-design/AutomationsPillar.tabs-12454.test.tsx new file mode 100644 index 0000000000..17cdd63bfe --- /dev/null +++ b/packages/app-shell/src/views/studio-design/AutomationsPillar.tabs-12454.test.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12454 — the Automations pillar's rail groups by object and its item + * header carries a tab row over the open flow. + * + * Acceptance 3 of the card: the Problems count shows as a pill on the Problems + * tab, and the designer's four info-bar buttons (Variables, Runs, Problems, + * Debug) have no duplicate anywhere on the page. Beside it: the tabs open their + * sections as the body, a problem picked there opens the canvas on its element, + * and the rail row sits under its object's group with its trigger glyph. + * + * The REAL `AutomationsPillar` with the REAL registered `FlowPreview` and + * `FlowInspector`; the client is a server double that serves full flow rows, + * as the metadata list does. The flow is a record trigger on `crm_lead` whose + * decision has two conditioned out-edges and no default, which the preview's + * problem list reports — the count under test is whatever that list holds. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +const FLOW = { + name: 'route_new_lead', + label: 'Route new lead', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-create', objectName: 'crm_lead' } }, + { id: 'check_hot', type: 'decision', label: 'Hot lead?' }, + { id: 'end_hot', type: 'end', label: 'Hot' }, + { id: 'end_std', type: 'end', label: 'Standard' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'check_hot' }, + { id: 'e2', source: 'check_hot', target: 'end_hot', condition: { dialect: 'cel', source: 'record.rating >= 4' } }, + { id: 'e3', source: 'check_hot', target: 'end_std', condition: { dialect: 'cel', source: 'record.rating < 4' } }, + ], +}; + +const server = vi.hoisted(() => ({ + active: new Map>(), +})); + +const mockClient = vi.hoisted(() => { + const k = (type: string, name: string) => `${type}/${name}`; + const client = { + // The draft-overlaid reads (the rail's definitions, an inspector's object + // fields) answer as the published ones: this server holds no drafts. + withPreviewDrafts: vi.fn((): unknown => client), + list: vi.fn(async (type: string) => + [...server.active.entries()].filter(([key]) => key.startsWith(`${type}/`)).map(([, row]) => row), + ), + 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) => { + 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) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), + }; + return client; +}); + +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 { 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(); + +registerMetadataPreview('flow', FlowPreview); +registerMetadataInspector('flow', FlowInspector); + +const en = (key: string) => t(key, 'en'); + +beforeEach(() => { + server.active.clear(); + server.active.set(`flow/${FLOW.name}`, FLOW); + server.active.set('object/crm_lead', { name: 'crm_lead', label: 'Lead' }); + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function renderOpenFlow(): Promise { + render( + + + , + ); + await screen.findByTestId('auto-tab-canvas', undefined, { timeout: 8000 }); +} + +/** The section the preview body shows. */ +const sectionShown = () => screen.getByTestId('flow-preview-section').getAttribute('data-section'); + +describe('the Automations rail groups by object (objectui#12454)', () => { + it('the flow sits under its object’s group, with its count, its trigger glyph and a legend', async () => { + await renderOpenFlow(); + const group = await screen.findByTestId('auto-rail-group-crm_lead', undefined, { timeout: 8000 }); + expect(within(group).getByRole('button', { name: /Lead\s*1/ })).toHaveAttribute('aria-expanded', 'true'); + const row = within(group).getByTestId(`auto-rail-row-${FLOW.name}`); + expect(row).toHaveAttribute('data-trigger', 'record'); + expect(row.getAttribute('title')).toBe(`Route new lead · ${en('engine.studio.auto.kind.record')}`); + expect(screen.getByTestId('auto-rail-legend')).toHaveTextContent(en('engine.studio.auto.kind.scheduled')); + + // Folding the group hides its row; the count stays on the header. + fireEvent.click(within(group).getByRole('button', { name: /Lead\s*1/ })); + expect(within(group).queryByTestId(`auto-rail-row-${FLOW.name}`)).toBeNull(); + }); +}); + +describe('the item header’s tab row over the open flow (objectui#12454)', () => { + it('offers the five tabs, Canvas open, and the Problems tab carries the problem count as a pill', async () => { + await renderOpenFlow(); + for (const key of ['canvas', 'variables', 'runs', 'problems', 'debug']) { + expect(screen.getByTestId(`auto-tab-${key}`)).toBeInTheDocument(); + } + expect(screen.getByTestId('auto-tab-canvas')).toHaveAttribute('aria-pressed', 'true'); + expect(sectionShown()).toBe('canvas'); + + const pill = await within(screen.getByTestId('auto-tab-problems')).findByTestId('auto-problems-count', undefined, { + timeout: 8000, + }); + fireEvent.click(screen.getByTestId('auto-tab-problems')); + expect(sectionShown()).toBe('problems'); + // The pill counts what the Problems body lists. + const rows = within(screen.getByTestId('flow-preview-section')).getAllByRole('listitem'); + expect(rows.length).toBeGreaterThan(0); + expect(pill).toHaveTextContent(String(rows.length)); + }); + + it('the four former info-bar buttons have no duplicate: the preview draws no bar of its own', async () => { + await renderOpenFlow(); + // The bar's Variables toggle and its Debug button are gone … + expect(screen.queryByTitle(en('engine.flowPreview.showVars'))).toBeNull(); + expect(screen.queryAllByRole('button', { name: en('engine.flowPreview.debug') })).toHaveLength(1); + // … and Runs and Problems each answer by their title once: the tab. + expect(screen.getAllByTitle(en('engine.flowPreview.runsTitle'))).toEqual([screen.getByTestId('auto-tab-runs')]); + expect(screen.getAllByTitle(en('engine.flowPreview.problemsTitle'))).toEqual([screen.getByTestId('auto-tab-problems')]); + // The meta the bar carried is the header's now, once. + expect(screen.getAllByText(`${en('engine.flowPreview.pill.trigger')}:`)).toHaveLength(1); + expect(within(screen.getByTestId('auto-flow-header')).getByTestId('auto-flow-meta')).toHaveTextContent('Record created · crm_lead'); + }); + + it('a problem picked in the Problems body opens the canvas with its element selected', async () => { + await renderOpenFlow(); + fireEvent.click(screen.getByTestId('auto-tab-problems')); + const section = screen.getByTestId('flow-preview-section'); + const nodeProblem = within(section) + .getAllByRole('button') + .find((b) => !b.hasAttribute('disabled') && (b.textContent ?? '').includes('check_hot')); + expect(nodeProblem, 'a problem on the decision node').toBeDefined(); + fireEvent.click(nodeProblem!); + await waitFor(() => expect(sectionShown()).toBe('canvas')); + expect(screen.getByTestId('auto-tab-canvas')).toHaveAttribute('aria-pressed', 'true'); + const card = document.querySelector('[data-node-id="check_hot"] [role="button"]'); + expect(card).toHaveAttribute('aria-pressed', 'true'); + }); + + it('Variables opens the declared variables as the body, and Debug keeps the canvas beside the simulator', async () => { + await renderOpenFlow(); + fireEvent.click(screen.getByTestId('auto-tab-variables')); + expect(sectionShown()).toBe('variables'); + expect(within(screen.getByTestId('flow-preview-section')).getByText(en('engine.flowPreview.noVars'))).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('auto-tab-debug')); + expect(sectionShown()).toBe('debug'); + expect(document.querySelector('[data-node-id="start"]')).not.toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 1fb170765c..6f2e61be82 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -26,7 +26,7 @@ import { useAdapter, SchemaRendererProvider } from '@object-ui/react'; import { dropServedPicklistOptions, extractDraftBody, formatMetadataError } from '@object-ui/data-objectstack'; import type { MetadataClient } from '@object-ui/data-objectstack'; import type { FlowRuntimeState as SpecFlowRuntimeState } from '@objectstack/spec/contracts'; -import type { I18nLabel } from '@objectstack/spec/ui'; +import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; import { StudioChatDock, type StudioSurfaceLabel } from './StudioAiCopilot.js'; import { nextCenterTab, type StudioCenterTab } from './centerTab.js'; import { AUTOMATIONS_CONFIG_FOLD_WIDTH, useIsNarrowerThan, useIsWideViewport } from './wideViewport.js'; @@ -35,7 +35,8 @@ import { STUDIO_HEADER, STUDIO_HEADER_ICON_BUTTON, STUDIO_KIND_CHIP } from './st import { StudioInspector } from './StudioInspector.js'; import { StudioItemHeader, StudioTab } from './StudioItemHeader.js'; import { DATA_CONTENT_CONTAINER, DATA_TAB_FLAT_ONLY, DATA_TAB_FOLDED_ONLY } from './dataTabsFold.js'; -import { StudioRail, StudioRailRow } from './StudioRail.js'; +import { StudioRail, StudioRailGroup, StudioRailRow } from './StudioRail.js'; +import { FLOW_TRIGGER_GLYPHS, flowTriggerGlyph, groupFlowsByObject, type FlowTriggerGlyph } from './flowGrouping.js'; import { GridFieldAuthoringProvider, cn, @@ -111,6 +112,10 @@ import { MoreHorizontal, PanelRightClose, PanelRightOpen, + Zap, + Clock, + MousePointerClick, + CornerDownRight, type LucideIcon, } from 'lucide-react'; import { @@ -199,8 +204,16 @@ import { FlowRuntimeContext, deriveFlowRunStatus, describeFlowRunStatus, + type FlowRunStatus, + type FlowRunStatusView, type FlowRuntimeRow, } from '../metadata-admin/previews/flow-problems.js'; +import { + FlowMetaPills, + FlowPreviewChromeProvider, + type FlowPreviewSection, + type FlowProblemTally, +} from '../metadata-admin/previews/FlowPreview.js'; import type { FlowRegistration } from '@objectstack/spec/api'; import { clearedLabel, @@ -5829,23 +5842,28 @@ function flowRailState(s: FlowRuntimeState): FlowRailState { * * objectui#12349 — and from the engine's registration verdict, which the flow * list serves beside its items. A flow the engine REFUSED has no runtime row, - * so it used to render nothing here; it now reads an amber "Refused by the - * engine" chip titled with the engine's `reason.message`, as sent and never - * parsed. An `unregistered` flow reads the grey "Not running here" chip. A - * `registered` verdict, and none at all, leave the row's reading below. + * so it used to render nothing here; it now reads "Refused by the engine", + * titled with the engine's `reason.message`, as sent and never parsed. An + * `unregistered` flow reads "Not running here". A `registered` verdict, and + * none at all, leave the row's reading below. * * 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". + * - enabled and bound, or enabled with no declared trigger: "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: "Not running + * here". 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: "Off". + * + * objectui#12454 — drawn as a 6 px dot, as the redesign's rail draws it: green + * for On, grey for Not running here, amber for Refused, a hollow ring for Off + * (`flowDotClass`). The words are the element's visually hidden text and the + * row's title (the dot no longer prints them), and the dot's own title is the + * reason it always carried. The rail's legend names each look. * Nothing here is styled as an error: a deployment policy is not a defect. */ export function FlowStatusDot({ @@ -5858,35 +5876,74 @@ export function FlowStatusDot({ registration?: FlowRegistration; locale: string; }): React.ReactElement | null { + const reading = flowRailStatus(state, registration, locale); + if (!reading) return null; + const { status, view } = reading; + // objectui#12454 — a 6 px dot; the status words stay the element's text + // (visually hidden, so a reader and a search still find them) and the hover + // text stays the reason as it was. + return ( + + + {view.label} + + ); +} + +/** + * The rail's reading of one flow: the run status `FlowStatusDot` draws and the + * row's title names, or `null` when there is nothing to say — a flow the engine + * does not know yet and holds no verdict on (the amber "unpublished draft" chip + * already covers that case). + */ +function flowRailStatus( + state: FlowRailState | undefined, + registration: FlowRegistration | undefined, + locale: string, +): { status: FlowRunStatus; view: FlowRunStatusView } | null { const decides = registration?.state === 'refused' || registration?.state === 'unregistered'; if (!state && !decides) return null; const status = deriveFlowRunStatus(state, undefined, registration); - const { label, title } = describeFlowRunStatus(status, locale); - if (status.kind === 'refused') { - return ( - - {label} - - ); - } - if (status.kind === 'not-running' || status.kind === 'unregistered') { - return ( - - {label} - - ); + return { status, view: describeFlowRunStatus(status, locale) }; +} + +/** + * The dot's look per run status (objectui#12454): green when the flow runs + * (bound, or run by hand), grey when this deployment does not run it, amber + * when the engine refused it, and a hollow ring when it is switched off. + * Nothing here is styled as an error: a deployment policy is not a defect. + */ +function flowDotClass(status: FlowRunStatus): string { + switch (status.kind) { + case 'on': + case 'manual': + return 'bg-emerald-500'; + case 'refused': + return 'bg-amber-500'; + case 'off': + return 'border border-muted-foreground/70'; + default: + return 'bg-muted-foreground/50'; } - const enabled = status.kind !== 'off'; - return ( - - - - {label} - - - ); } +/** Each trigger kind's 14 px glyph and its words, in the rail rows and the legend (objectui#12454). */ +const FLOW_GLYPH_ICON: Record = { + record: Zap, + scheduled: Clock, + screen: MousePointerClick, + autolaunched: CornerDownRight, +}; +const FLOW_GLYPH_LABEL_KEY: Record = { + record: 'engine.studio.auto.kind.record', + scheduled: 'engine.studio.auto.kind.scheduled', + screen: 'engine.studio.auto.kind.screen', + autolaunched: 'engine.studio.auto.kind.autolaunched', +}; + +/** The React key of the trailing group, whose grouping key is the empty string. */ +const NO_OBJECT_GROUP_KEY = '__no_object__'; + /** * The engine's registration verdict on each flow of the rail's scope * (objectui#12349): the flow list's `registrations`, which the server serves @@ -5910,6 +5967,57 @@ async function readFlowRegistrations( } } +/** + * Each flow's definition in the rail's scope, by name (objectui#12454): what + * the rail's grouping and trigger glyphs read. Read with the drafts overlaid, + * so a draft-only flow, and a published one whose draft moved its trigger, + * group as the author's draft has them; a server that refuses that read gives + * the published list, and a failing list gives none, which groups every flow + * under "No object" rather than failing the rail. + */ +async function readFlowDefinitions(client: MetadataClient, packageId: string | null): Promise> { + const scope = packageId === null ? {} : { packageId }; + const read = async (): Promise> | null | undefined> => { + try { + return await client.withPreviewDrafts(true).list>('flow', scope); + } catch { + return await client.list>('flow', scope); + } + }; + const definitions = new Map(); + try { + for (const flow of (await read()) ?? []) { + if (flow && typeof flow.name === 'string' && flow.name) definitions.set(flow.name, flow); + } + } catch { + /* no definitions: every flow groups under "No object" */ + } + return definitions; +} + +/** + * The scope's objects, name → label in the designer locale (objectui#12454): + * the rail's group headers and their order, and which objects count as the + * scope's own when a flow is grouped by the data it touches. The package's + * object list, as the Data rail reads it; unscoped in the package-less scope. + * A failing read gives an empty index, which `groupFlowsByObject` reads as + * "every object is the scope's". + */ +async function readObjectLabels(client: MetadataClient, packageId: string | null, locale: string): Promise> { + const labels = new Map(); + try { + const objects = await client.list>('object', packageId === null ? {} : { packageId }); + for (const object of objects ?? []) { + if (!object || typeof object.name !== 'string' || !object.name) continue; + const label = resolveI18nLabel(object.label as I18nLabel | undefined, locale)?.trim(); + labels.set(object.name, label || object.name); + } + } catch { + /* an empty index */ + } + return labels; +} + /** The trigger the New automation dialog chose (objectui#11788). */ export interface NewFlowTrigger { triggerType: string; @@ -6000,9 +6108,8 @@ export function AutomationsPillar({ // or the machine name. Ephemeral (AGENTS.md #8): nobody expects it back. const [railQuery, setRailQuery] = React.useState(''); const railNeedle = railQuery.trim().toLowerCase(); - const shownFlows = railNeedle - ? flows.filter((f) => f.label.toLowerCase().includes(railNeedle) || f.name.toLowerCase().includes(railNeedle)) - : flows; + const flowMatches = (f: Surface) => f.label.toLowerCase().includes(railNeedle) || f.name.toLowerCase().includes(railNeedle); + const shownFlows = railNeedle ? flows.filter(flowMatches) : flows; // objectui#7255 — same live-pulse subscription as the sibling rails; this // one only replaces the flow LIST, so it needs no edit-buffer hold either. const metadataRefreshNonce = useMetadataRefreshNonce(); @@ -6063,6 +6170,8 @@ export function AutomationsPillar({ const newChoiceGroup = React.useId(); // objectui#12303 — the read-only reason the header's status switch is described by. const toggleReadOnlyReasonId = React.useId(); + // objectui#12454 — the canvas hint the header used to print describes the Canvas tab. + const canvasHintId = React.useId(); // objectui#11591 — keyed on the pillar's one type, as the inspector beside // it is, never on the open flow's: with no flow open (a deep link naming one // this rail does not hold, or an empty rail) a selection-keyed read found no @@ -6099,6 +6208,17 @@ export function AutomationsPillar({ // list's `registrations` (served beside the items, keyed by name). A flow // with no entry is one no engine judged, which reads as before. const [flowRegistrations, setFlowRegistrations] = React.useState>({}); + // objectui#12454 — the rail groups flows by the object each is about and + // marks each with its trigger kind, both read off the flow's definition as + // served (`flowGrouping.ts`), with the scope's objects naming the groups. + const [flowDefinitions, setFlowDefinitions] = React.useState>(() => new Map()); + const [objectLabels, setObjectLabels] = React.useState>(() => new Map()); + // Which groups the author folded. Ephemeral (AGENTS.md #8), as the search is. + const [foldedGroups, setFoldedGroups] = React.useState>(() => new Set()); + // objectui#12454 — the tab the item header's tab row has open over the flow, + // and the problem tally its Problems tab carries (reported by the preview). + const [flowSection, setFlowSection] = React.useState('canvas'); + const [problemTally, setProblemTally] = React.useState({ count: 0, errors: 0 }); React.useEffect(() => { let cancelled = false; (async () => { @@ -6134,15 +6254,19 @@ export function AutomationsPillar({ // collapse back into the published rail after a package publish. // objectui#11862 — a draft-only flow reads as the label its `_drafts` // row carries, resolved in the designer locale. - const [items, registrations] = await Promise.all([ + const [items, registrations, definitions, objects] = await Promise.all([ packageId === null ? loadPackageLessSurfaces(client, 'flow', locale) : loadPackageSurfaces(client, 'flow', packageId, locale), readFlowRegistrations(client, packageId), + readFlowDefinitions(client, packageId), + readObjectLabels(client, packageId, locale), ]); if (cancelled) return; setFlows(items); setFlowRegistrations(registrations); + setFlowDefinitions(definitions); + setObjectLabels(objects); const deepLinked = resolveSurfaceDeepLink(items, initialSurface, 'flow'); // objectui#11553 — a deep link that NAMES a flow this rail does not // hold opens no other flow in its place: the first flow under the name @@ -6203,6 +6327,8 @@ export function AutomationsPillar({ await client.save('flow', name, skeleton, { mode: 'draft', packageId: draftPackageId, ifNoneMatch: '*' }); const item: Surface = { type: 'flow', name, label }; setFlows((fs) => [...fs.filter((f) => f.name !== name), item]); + // objectui#12454 — and groups by what it was born with. + setFlowDefinitions((m) => new Map([...m, [name, skeleton]])); setCurrent(item); setHasDraft(true); setCreating(false); @@ -6397,11 +6523,16 @@ export function AutomationsPillar({ // objectui#12459 — the switch and the "off by default" sentence sit in the // item header's actions slot; the full-width band above the rail retires. + // objectui#12454 — the sentence is the switch's own: the first line of its + // title, and a visually hidden line right after it for a reader, instead of a + // second line of header text. It is not wired as the switch's description: + // that stays the read-only reason alone (objectui#12303, owned by #12453). // objectui#11272 — the switch reads and saves the open flow's own // document, so it waits for it: not offered over another's. const flowSwitchEl = current && flowLoaded ? (() => { + const defaultOff = t('engine.studio.auto.defaultOff', locale); const toggle = (