diff --git a/apps/webapp/src/pages/automation/feature/run-now-feature.test.tsx b/apps/webapp/src/pages/automation/feature/run-now-feature.test.tsx index 9a03001d..417a9222 100644 --- a/apps/webapp/src/pages/automation/feature/run-now-feature.test.tsx +++ b/apps/webapp/src/pages/automation/feature/run-now-feature.test.tsx @@ -166,7 +166,7 @@ describe('RunNowFeature — opening the dialog', () => { }) describe('RunNowFeature — confirming a run', () => { - it('starts the run through the real mutation and navigates to its inspector', async () => { + it('starts the run through the real mutation without leaving the editor', async () => { const user = userEvent.setup() renderFeature((api) => { api.mockMutation('post', WORKFLOW_RUNS_PATH, () => ({ @@ -184,12 +184,9 @@ describe('RunNowFeature — confirming a run', () => { ) await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { workflowId: 'workflow-1', runId: 'run-42' }, - }), - ) + expect(screen.queryByRole('dialog')).toBeNull() }) + expect(navigateSpy).not.toHaveBeenCalled() }) it('shows the failure and keeps the dialog open when the start is refused', async () => { diff --git a/apps/webapp/src/pages/automation/feature/run-now-feature.tsx b/apps/webapp/src/pages/automation/feature/run-now-feature.tsx index 8988f69c..e8a66fa0 100644 --- a/apps/webapp/src/pages/automation/feature/run-now-feature.tsx +++ b/apps/webapp/src/pages/automation/feature/run-now-feature.tsx @@ -61,21 +61,11 @@ function RunNowWorkspace({ async function handleConfirm(payload: unknown) { setStartError(null) try { - const result = await startRun.mutateAsync({ + await startRun.mutateAsync({ path: { organization_id: organizationId, workflow_id: workflowId }, body: { trigger_payload: payload }, }) setOpen(false) - await navigate({ - to: buildOrgPath( - organizationSlug, - '/automatisation/$workflowId/executions/$runId', - ), - params: { - workflowId, - runId: (result.data as { run_id: string }).run_id, - }, - }) } catch (error) { setStartError( error instanceof Error && error.message diff --git a/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.test.tsx b/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.test.tsx index 9c33527d..6f88d0b3 100644 --- a/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.test.tsx +++ b/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.test.tsx @@ -955,11 +955,8 @@ describe('WorkflowCanvasFeature — the last run', () => { const node = await screen.findByTestId('rf__node-c1') node.click() - const lastRunButton = (await screen.findByRole('button', { - name: 'Dernière exécution', - })) as HTMLButtonElement await waitFor(() => { - expect(lastRunButton.disabled).toBe(false) + expect(screen.getByRole('button', { name: 'trigger' })).toBeDefined() }) }) }) diff --git a/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.tsx b/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.tsx index 5b8d2685..5a5c79af 100644 --- a/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.tsx +++ b/apps/webapp/src/pages/automation/feature/workflow-canvas-feature.tsx @@ -10,7 +10,7 @@ import { useConnectorCatalogue, useCreateCredential, useEvaluateExpression, - useRun, + useRunPolling, useSaveWorkflowVersion, useWorkflow, type WorkflowDetail, @@ -23,6 +23,7 @@ import { projectGraphErrors, } from '#/pages/automation/lib/validation' import { + aggregateConnectorStatuses, connectorOutputsFromSteps, latestRunId, } from '#/pages/automation/lib/workflow-runs' @@ -85,7 +86,7 @@ function WorkflowCanvasWorkspace({ const eventsQuery = useAutomationEvents(organizationId) const runsQuery = useAutomationRuns(organizationId) const latestId = latestRunId(runsQuery.data?.data ?? [], workflowId) - const runDetailQuery = useRun(organizationId, latestId) + const runDetailQuery = useRunPolling(organizationId, latestId) if ( workflowQuery.isLoading || @@ -128,6 +129,10 @@ function WorkflowCanvasWorkspace({ } : null + const runStatuses = runDetail + ? aggregateConnectorStatuses(runDetail.steps) + : new Map() + return ( ) } @@ -153,6 +159,7 @@ function WorkflowCanvasLoaded({ credentials, events, lastRun, + runStatuses, }: { organizationId: string workflowId: string @@ -162,6 +169,7 @@ function WorkflowCanvasLoaded({ credentials: Schemas.CredentialResponse[] events: Schemas.EventDescriptorResponse[] lastRun: LastRunData | null + runStatuses: Map }) { const saveVersion = useSaveWorkflowVersion() const createCredential = useCreateCredential(organizationId) @@ -260,6 +268,7 @@ function WorkflowCanvasLoaded({ connectorErrors={validation.connectorErrors} events={events} lastRun={lastRun} + runStatuses={runStatuses} onEvaluateExpression={handleEvaluateExpression} credentials={credentials} authSchemes={authSchemes} diff --git a/apps/webapp/src/pages/automation/lib/workflow-runs.test.ts b/apps/webapp/src/pages/automation/lib/workflow-runs.test.ts index 01e3d4ab..261c875b 100644 --- a/apps/webapp/src/pages/automation/lib/workflow-runs.test.ts +++ b/apps/webapp/src/pages/automation/lib/workflow-runs.test.ts @@ -11,6 +11,7 @@ import { latestRunByWorkflow, latestRunId, runDurationMs, + runStatusBorderClass, runTriggerLabel, } from '#/pages/automation/lib/workflow-runs' @@ -361,3 +362,27 @@ describe('canReplay', () => { expect(canReplay('running')).toBe(false) }) }) + +describe('runStatusBorderClass', () => { + it('marks a step in progress amber', () => { + expect(runStatusBorderClass('running')).toBe('border-amber-500') + }) + + it('marks a succeeded step green', () => { + expect(runStatusBorderClass('succeeded')).toBe('border-emerald-500') + }) + + it('marks a failed step red', () => { + expect(runStatusBorderClass('failed')).toBe('border-destructive') + }) + + it('leaves a pending or cancelled step unmarked', () => { + expect(runStatusBorderClass('pending')).toBeNull() + expect(runStatusBorderClass('cancelled')).toBeNull() + }) + + it('leaves a connector with no step at all unmarked', () => { + expect(runStatusBorderClass(null)).toBeNull() + expect(runStatusBorderClass(undefined)).toBeNull() + }) +}) diff --git a/apps/webapp/src/pages/automation/lib/workflow-runs.ts b/apps/webapp/src/pages/automation/lib/workflow-runs.ts index 93f4d843..529837d1 100644 --- a/apps/webapp/src/pages/automation/lib/workflow-runs.ts +++ b/apps/webapp/src/pages/automation/lib/workflow-runs.ts @@ -92,6 +92,20 @@ const STATUS_PRIORITY: Record = { succeeded: 0, } +const RUN_STATUS_BORDER: Record = { + running: 'border-amber-500', + succeeded: 'border-emerald-500', + failed: 'border-destructive', +} + +export function runStatusBorderClass( + status: string | null | undefined, +): string | null { + if (!status) return null + + return RUN_STATUS_BORDER[status] ?? null +} + export function aggregateConnectorStatuses( steps: Schemas.RunStepResponse[], ): Map { diff --git a/apps/webapp/src/pages/automation/ui/available-data-tree.test.tsx b/apps/webapp/src/pages/automation/ui/available-data-tree.test.tsx index 7879436f..57ba155f 100644 --- a/apps/webapp/src/pages/automation/ui/available-data-tree.test.tsx +++ b/apps/webapp/src/pages/automation/ui/available-data-tree.test.tsx @@ -48,18 +48,14 @@ function renderTree( overrides: Partial[0]> = {}, ) { const onInsert = vi.fn() - const onSourceChange = vi.fn() render( , ) - return { onInsert, onSourceChange } + return { onInsert } } describe('AvailableDataTree — structure', () => { @@ -131,33 +127,3 @@ describe('AvailableDataTree — insertion', () => { ) }) }) - -describe('AvailableDataTree — source toggle', () => { - it('disables the last-run option until a run exists', () => { - renderTree({ hasLastRun: false }) - - const button = screen.getByRole('button', { - name: 'Dernière exécution', - }) as HTMLButtonElement - expect(button.disabled).toBe(true) - }) - - it('reports the requested source once a run exists', async () => { - const user = userEvent.setup() - const { onSourceChange } = renderTree({ hasLastRun: true }) - - await user.click(screen.getByRole('button', { name: 'Dernière exécution' })) - - expect(onSourceChange).toHaveBeenCalledWith('last_run') - }) - - it('marks the active source', () => { - renderTree({ source: 'example' }) - - expect( - screen - .getByRole('button', { name: 'Exemple' }) - .getAttribute('aria-pressed'), - ).toBe('true') - }) -}) diff --git a/apps/webapp/src/pages/automation/ui/available-data-tree.tsx b/apps/webapp/src/pages/automation/ui/available-data-tree.tsx index dc35fd39..52e2299c 100644 --- a/apps/webapp/src/pages/automation/ui/available-data-tree.tsx +++ b/apps/webapp/src/pages/automation/ui/available-data-tree.tsx @@ -1,40 +1,18 @@ import { ChevronDown, ChevronRight } from 'lucide-react' import { useState } from 'react' -import { cn } from '#/lib/utils' import type { DataTreeNode } from '#/pages/automation/lib/data-tree' -export type DataTreeSource = 'example' | 'last_run' - export interface AvailableDataTreeProps { branches: DataTreeNode[] - source: DataTreeSource - hasLastRun: boolean - onSourceChange: (source: DataTreeSource) => void onInsert: (path: string) => void } export function AvailableDataTree({ branches, - source, - hasLastRun, - onSourceChange, onInsert, }: AvailableDataTreeProps) { return (
-
- onSourceChange('example')} - /> - onSourceChange('last_run')} - /> -
{branches.length === 0 ? (

Aucune donnée disponible. @@ -50,34 +28,6 @@ export function AvailableDataTree({ ) } -function SourceButton({ - label, - active, - disabled = false, - onClick, -}: { - label: string - active: boolean - disabled?: boolean - onClick: () => void -}) { - return ( - - ) -} - function TreeNodeItem({ node, onInsert, diff --git a/apps/webapp/src/pages/automation/ui/connector-config-panel.test.tsx b/apps/webapp/src/pages/automation/ui/connector-config-panel.test.tsx index d10da52a..7c471cdc 100644 --- a/apps/webapp/src/pages/automation/ui/connector-config-panel.test.tsx +++ b/apps/webapp/src/pages/automation/ui/connector-config-panel.test.tsx @@ -445,26 +445,34 @@ describe('ConnectorConfigPanel — the available-data tree', () => { expect(screen.getByRole('button', { name: /trigger/ })).toBeDefined() }) - it('disables the last-run toggle until a run exists', () => { + it('shows the real values rather than the example once a run exists', async () => { + const user = userEvent.setup() render( , ) - const button = screen.getByRole('button', { - name: 'Dernière exécution', - }) as HTMLButtonElement - expect(button.disabled).toBe(true) + await user.click(screen.getByRole('button', { name: 'trigger' })) + + expect(screen.getByText('confirmed_name')).toBeDefined() + expect(screen.queryByText('name')).toBeNull() }) - it('switches the same tree to the real values once a run exists', async () => { + it('falls back to the example while no run has happened', async () => { const user = userEvent.setup() render( { tree: [TRIGGER_BRANCH], context: { trigger: { name: 'Julie' }, connectors: {}, loop: null }, }, - lastRunData: { - tree: [LAST_RUN_TRIGGER_BRANCH], - context: { - trigger: { name: 'Real customer' }, - connectors: {}, - loop: null, - }, - }, + lastRunData: null, })} />, ) await user.click(screen.getByRole('button', { name: 'trigger' })) - expect(screen.getByText('name')).toBeDefined() - expect(screen.queryByText('confirmed_name')).toBeNull() - - await user.click(screen.getByRole('button', { name: 'Dernière exécution' })) - expect(screen.getByText('confirmed_name')).toBeDefined() - expect(screen.queryByText('name')).toBeNull() + expect(screen.getByText('name')).toBeDefined() }) }) diff --git a/apps/webapp/src/pages/automation/ui/connector-config-panel.tsx b/apps/webapp/src/pages/automation/ui/connector-config-panel.tsx index 2f0688ae..414a4315 100644 --- a/apps/webapp/src/pages/automation/ui/connector-config-panel.tsx +++ b/apps/webapp/src/pages/automation/ui/connector-config-panel.tsx @@ -19,10 +19,7 @@ import { type ConnectorValidationError, connectorLevelErrors, } from '#/pages/automation/lib/validation' -import { - AvailableDataTree, - type DataTreeSource, -} from '#/pages/automation/ui/available-data-tree' +import { AvailableDataTree } from '#/pages/automation/ui/available-data-tree' import { ConnectorConfigForm, type CredentialCreationPurpose, @@ -102,7 +99,6 @@ export function ConnectorConfigPanel({ const [createState, setCreateState] = useState(null) const [createPending, setCreatePending] = useState(false) const [createError, setCreateError] = useState(null) - const [dataSource, setDataSource] = useState('example') const [activeField, setActiveField] = useState( null, ) @@ -118,8 +114,7 @@ export function ConnectorConfigPanel({ fieldRefs.current.clear() } - const activeSource = - dataSource === 'last_run' && lastRunData ? lastRunData : exampleData + const activeSource = lastRunData ?? exampleData useEffect(() => { if (!activeField) { @@ -297,9 +292,6 @@ export function ConnectorConfigPanel({ { label: string branches: Schemas.BranchDto[] errors: ConnectorValidationError[] + runStatus: string | null connector: Schemas.PlacedConnectorDto } @@ -21,6 +23,7 @@ export function ConnectorNode({ }: NodeProps & { data: ConnectorNodeData }) { const actions = useWorkflowCanvasActions() const hasError = data.errors.length > 0 + const runBorder = runStatusBorderClass(data.runStatus) return (

diff --git a/apps/webapp/src/pages/automation/ui/workflow-canvas.test.tsx b/apps/webapp/src/pages/automation/ui/workflow-canvas.test.tsx index 54807562..6a8a17b3 100644 --- a/apps/webapp/src/pages/automation/ui/workflow-canvas.test.tsx +++ b/apps/webapp/src/pages/automation/ui/workflow-canvas.test.tsx @@ -108,6 +108,7 @@ function Harness({ layout, descriptors, connectorErrors = new Map(), + runStatuses = new Map(), events = [], lastRun = null, onEvaluateExpression = () => Promise.resolve(null), @@ -120,6 +121,7 @@ function Harness({ layout: Map descriptors: Map connectorErrors?: Map + runStatuses?: Map events?: Schemas.EventDescriptorResponse[] lastRun?: LastRunData | null onEvaluateExpression?: WorkflowCanvasProps['onEvaluateExpression'] @@ -140,6 +142,7 @@ function Harness({ layout, descriptors, connectorErrors, + runStatuses, events, lastRun, onEvaluateExpression, @@ -1421,7 +1424,7 @@ describe('WorkflowCanvas — the available-data tree', () => { expect(screen.getByText('quote_id')).toBeDefined() }) - it('switches to the real values once a last run is supplied', async () => { + it('shows the real values once a last run is supplied', async () => { renderHarness({ graph: { connectors: [connector('c1', SIMPLE_KIND)], @@ -1441,12 +1444,6 @@ describe('WorkflowCanvas — the available-data tree', () => { fireEvent.click(node) await screen.findByText('Paramètres') - const lastRunButton = screen.getByRole('button', { - name: 'Dernière exécution', - }) as HTMLButtonElement - expect(lastRunButton.disabled).toBe(false) - - fireEvent.click(lastRunButton) fireEvent.click(screen.getByRole('button', { name: 'trigger' })) expect(screen.getByText('quote_id')).toBeDefined() @@ -1691,3 +1688,72 @@ describe('WorkflowCanvas — the pane context menu', () => { expect(screen.getByTestId('connector-config-panel')).toBeDefined() }) }) + +describe('WorkflowCanvas — a run in progress', () => { + function borderOf(nodeId: string): string { + const node = screen.getByTestId(`rf__node-${nodeId}`) + return (node.firstElementChild as HTMLElement).className + } + + it('borders each connector by the status of its step', async () => { + const graph: Schemas.GraphDto = { + connectors: [ + connector('c1', SIMPLE_KIND), + connector('c2', SIMPLE_KIND), + connector('c3', SIMPLE_KIND), + ], + edges: [ + { from: 't1', to: 'c1', branch: null }, + { from: 'c1', to: 'c2', branch: null }, + { from: 'c2', to: 'c3', branch: null }, + ], + triggers: [trigger('t1', 'Manual')], + } + + renderHarness({ + graph, + layout: new Map([ + ['t1', { x: -220, y: 0 }], + ['c1', { x: 0, y: 0 }], + ['c2', { x: 200, y: 0 }], + ['c3', { x: 400, y: 0 }], + ]), + descriptors: descriptorMap(SIMPLE_DESCRIPTOR), + runStatuses: new Map([ + ['c1', 'succeeded'], + ['c2', 'running'], + ['c3', 'failed'], + ]), + }) + + await screen.findByTestId('rf__node-c1') + + expect(borderOf('c1')).toContain('border-emerald-500') + expect(borderOf('c2')).toContain('border-amber-500') + expect(borderOf('c3')).toContain('border-destructive') + }) + + it('leaves a connector with no step of its own unmarked', async () => { + const graph: Schemas.GraphDto = { + connectors: [connector('c1', SIMPLE_KIND)], + edges: [{ from: 't1', to: 'c1', branch: null }], + triggers: [trigger('t1', 'Manual')], + } + + renderHarness({ + graph, + layout: new Map([ + ['t1', { x: -220, y: 0 }], + ['c1', { x: 0, y: 0 }], + ]), + descriptors: descriptorMap(SIMPLE_DESCRIPTOR), + runStatuses: new Map([['c1', 'pending']]), + }) + + await screen.findByTestId('rf__node-c1') + + expect(borderOf('c1')).not.toContain('border-amber-500') + expect(borderOf('c1')).not.toContain('border-emerald-500') + expect(borderOf('c1')).not.toContain('border-destructive') + }) +}) diff --git a/apps/webapp/src/pages/automation/ui/workflow-canvas.tsx b/apps/webapp/src/pages/automation/ui/workflow-canvas.tsx index d074530e..4c1ab8d9 100644 --- a/apps/webapp/src/pages/automation/ui/workflow-canvas.tsx +++ b/apps/webapp/src/pages/automation/ui/workflow-canvas.tsx @@ -104,6 +104,7 @@ export interface WorkflowCanvasProps { connectorErrors: Map events: Schemas.EventDescriptorResponse[] lastRun: LastRunData | null + runStatuses: Map onEvaluateExpression: ( template: unknown, context: Schemas.EvaluateContextBody, @@ -134,6 +135,7 @@ function buildInitialNodes( layout: Map, descriptors: Map, connectorErrors: Map, + runStatuses: Map, ): Node[] { const triggerNodes: Node[] = graph.triggers.map((trigger, index) => ({ id: trigger.id, @@ -154,6 +156,7 @@ function buildInitialNodes( label: descriptor?.label ?? connector.id, branches: descriptor?.branches ?? [], errors: connectorErrors.get(connector.id) ?? [], + runStatus: runStatuses.get(connector.id) ?? null, connector, } @@ -240,6 +243,7 @@ export function WorkflowCanvas({ connectorErrors, events, lastRun, + runStatuses, onEvaluateExpression, credentials = [], authSchemes = [], @@ -250,7 +254,7 @@ export function WorkflowCanvas({ onSave, }: WorkflowCanvasProps) { const [nodes, setNodes, onNodesChangeInternal] = useNodesState( - buildInitialNodes(graph, layout, descriptors, connectorErrors), + buildInitialNodes(graph, layout, descriptors, connectorErrors, runStatuses), ) const [edges, setEdges] = useEdgesState(buildInitialEdges(graph)) const [pendingDeleteId, setPendingDeleteId] = useState(null) @@ -276,11 +280,17 @@ export function WorkflowCanvas({ current.map((node) => { const data = node.data as ConnectorNodeData | TriggerNodeData const errors = connectorErrors.get(node.id) ?? [] - if (data.errors === errors) return node - return { ...node, data: { ...data, errors } } + const runStatus = runStatuses.get(node.id) ?? null + if ( + data.errors === errors && + (data as ConnectorNodeData).runStatus === runStatus + ) { + return node + } + return { ...node, data: { ...data, errors, runStatus } } }), ) - }, [connectorErrors, setNodes]) + }, [connectorErrors, runStatuses, setNodes]) const handleNodesChange = useCallback( (changes: NodeChange[]) => { @@ -374,6 +384,7 @@ export function WorkflowCanvas({ label: descriptor.label, branches: descriptor.branches, errors: [], + runStatus: null, connector: newConnector, } const nextNodes = [ @@ -510,6 +521,7 @@ export function WorkflowCanvas({ label: descriptor.label, branches: descriptor.branches, errors: [], + runStatus: null, connector: newConnector, } const nextNodes = [