Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/webapp/src/api/api.client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/webapp/src/components/scope-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function ScopeBar({
if (tabs.length <= 1 && !actions) return null

return (
<div className="sticky top-(--app-header-height) z-10 flex items-center gap-4 border-b bg-card px-3 md:px-6">
<div className="sticky top-(--app-header-height) z-10 flex shrink-0 items-center gap-4 border-b bg-card px-3 md:px-6">
<NavTabs label={label} tabs={tabs} organizationSlug={organizationSlug} />
{actions ? (
<div className="flex shrink-0 items-center gap-2 py-2">{actions}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
aggregateConnectorStatuses,
connectorOutputsFromSteps,
latestRunId,
representativeStepByConnector,
} from '#/pages/automation/lib/workflow-runs'
import {
type LastRunData,
Expand Down Expand Up @@ -132,6 +133,9 @@ function WorkflowCanvasWorkspace({
const runStatuses = runDetail
? aggregateConnectorStatuses(runDetail.steps)
: new Map<string, string>()
const runSteps = runDetail
? representativeStepByConnector(runDetail.steps)
: new Map<string, Schemas.RunStepResponse>()

return (
<WorkflowCanvasLoaded
Expand All @@ -146,6 +150,7 @@ function WorkflowCanvasWorkspace({
events={eventsQuery.data?.data ?? []}
lastRun={lastRun}
runStatuses={runStatuses}
runSteps={runSteps}
/>
)
}
Expand All @@ -160,6 +165,7 @@ function WorkflowCanvasLoaded({
events,
lastRun,
runStatuses,
runSteps,
}: {
organizationId: string
workflowId: string
Expand All @@ -170,6 +176,7 @@ function WorkflowCanvasLoaded({
events: Schemas.EventDescriptorResponse[]
lastRun: LastRunData | null
runStatuses: Map<string, string>
runSteps: Map<string, Schemas.RunStepResponse>
}) {
const saveVersion = useSaveWorkflowVersion()
const createCredential = useCreateCredential(organizationId)
Expand Down Expand Up @@ -269,6 +276,7 @@ function WorkflowCanvasLoaded({
events={events}
lastRun={lastRun}
runStatuses={runStatuses}
runSteps={runSteps}
onEvaluateExpression={handleEvaluateExpression}
credentials={credentials}
authSchemes={authSchemes}
Expand Down
30 changes: 30 additions & 0 deletions apps/webapp/src/pages/automation/lib/data-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,33 @@ describe('resolveTriggerExample', () => {
expect(resolveTriggerExample(events, ['unknown.event'])).toBeUndefined()
})
})

describe('buildAvailableDataTree — a connector exposing nothing', () => {
it('explains the empty branch instead of offering a chevron to nowhere', () => {
const tree = buildAvailableDataTree({
trigger: null,
connectors: [{ id: 'c2', label: 'Configuration', output: {} }],
})

const connectorNode = tree.find(
(node) => node.path === 'connectors.c2.output',
)

expect(connectorNode?.kind).toBe('notice')
})

it('still branches when the connector exposes something', () => {
const tree = buildAvailableDataTree({
trigger: null,
connectors: [
{ id: 'c2', label: 'Configuration', output: { base_url: 'https://x' } },
],
})

const connectorNode = tree.find(
(node) => node.path === 'connectors.c2.output',
)

expect(connectorNode?.kind).toBe('branch')
})
})
21 changes: 14 additions & 7 deletions apps/webapp/src/pages/automation/lib/data-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,22 @@ export function buildAvailableDataTree(data: AvailableData): DataTreeNode[] {
children: valueChildren('trigger', data.trigger),
}

const connectorNodes: DataTreeBranch[] = data.connectors.map((connector) => {
const connectorNodes: DataTreeNode[] = data.connectors.map((connector) => {
const path = `connectors.${connector.id}.output`
return {
kind: 'branch',
key: path,
label: `${connector.id} · ${connector.label}`,
path,
children: valueChildren(path, connector.output),
const label = `${connector.id} · ${connector.label}`
const children = valueChildren(path, connector.output)

if (children.length === 0) {
return {
kind: 'notice',
key: path,
label,
path,
message: 'Ce connecteur n’expose aucune donnée pour l’instant.',
}
}

return { kind: 'branch', key: path, label, path, children }
})

return [triggerNode, ...connectorNodes]
Expand Down
48 changes: 48 additions & 0 deletions apps/webapp/src/pages/automation/lib/workflow-runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isTerminalRunStatus,
latestRunByWorkflow,
latestRunId,
representativeStepByConnector,
runDurationMs,
runStatusBorderClass,
runTriggerLabel,
Expand Down Expand Up @@ -386,3 +387,50 @@ describe('runStatusBorderClass', () => {
expect(runStatusBorderClass(undefined)).toBeNull()
})
})

describe('representativeStepByConnector', () => {
function step(
connectorId: string,
status: string,
overrides: Partial<Schemas.RunStepResponse> = {},
): Schemas.RunStepResponse {
return {
attempts: 1,
connector_id: connectorId,
created_at: '2026-09-17T10:00:00Z',
id: `${connectorId}-${status}`,
iteration_path: '',
status,
...overrides,
}
}

it('keeps the step whose status the node border shows', () => {
const steps = [
step('c1', 'succeeded', { output: { ok: true } }),
step('c1', 'failed', { error: 'connection refused' }),
]

expect(representativeStepByConnector(steps).get('c1')?.error).toBe(
'connection refused',
)
})

it('agrees with the status the border is painted from', () => {
const steps = [
step('c1', 'succeeded'),
step('c1', 'failed', { error: 'boom' }),
step('c2', 'running'),
]
const statuses = aggregateConnectorStatuses(steps)
const representative = representativeStepByConnector(steps)

for (const [id, status] of statuses) {
expect(representative.get(id)?.status).toBe(status)
}
})

it('has nothing to say about a connector with no step', () => {
expect(representativeStepByConnector([]).get('c1')).toBeUndefined()
})
})
19 changes: 19 additions & 0 deletions apps/webapp/src/pages/automation/lib/workflow-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@ export function runStatusBorderClass(
return RUN_STATUS_BORDER[status] ?? null
}

export function representativeStepByConnector(
steps: Schemas.RunStepResponse[],
): Map<string, Schemas.RunStepResponse> {
const best = new Map<string, Schemas.RunStepResponse>()

for (const step of steps) {
const current = best.get(step.connector_id)
if (
!current ||
(STATUS_PRIORITY[step.status] ?? 0) >
(STATUS_PRIORITY[current.status] ?? 0)
) {
best.set(step.connector_id, step)
}
}

return best
}

export function aggregateConnectorStatuses(
steps: Schemas.RunStepResponse[],
): Map<string, string> {
Expand Down
5 changes: 3 additions & 2 deletions apps/webapp/src/pages/automation/ui/available-data-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ function TreeNodeItem({

if (node.kind === 'notice') {
return (
<li className="px-1.5 py-1 text-xs text-muted-foreground">
{node.message}
<li className="flex flex-col gap-0.5 px-1.5 py-1 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{node.label}</span>
<span>{node.message}</span>
</li>
)
}
Expand Down
121 changes: 54 additions & 67 deletions apps/webapp/src/pages/automation/ui/connector-config-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ function baseProps(
onConfigChange: vi.fn(),
onCredentialChange: vi.fn(),
onCreateCredential: vi.fn(),
exampleData: {
availableData: {
tree: [],
context: { trigger: null, connectors: {}, loop: null },
},
lastRunData: null,
lastStep: null,
onEvaluateExpression: vi.fn().mockResolvedValue(null),
...overrides,
}
Expand All @@ -118,7 +118,7 @@ describe('ConnectorConfigPanel — layout', () => {
expect(screen.getByText('Requête HTTP')).toBeDefined()
expect(screen.getByText('Données disponibles')).toBeDefined()
expect(screen.getByText('Paramètres')).toBeDefined()
expect(screen.getByText('Dernière sortie')).toBeDefined()
expect(screen.getByText('Dernière exécution')).toBeDefined()

await user.click(screen.getByRole('button', { name: /Fermer/ }))
expect(onClose).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -413,28 +413,12 @@ const TRIGGER_BRANCH = {
],
}

const LAST_RUN_TRIGGER_BRANCH = {
kind: 'branch' as const,
key: 'trigger',
label: 'trigger',
path: 'trigger',
children: [
{
kind: 'leaf' as const,
key: 'confirmed_name',
label: 'confirmed_name',
path: 'trigger.confirmed_name',
value: 'Real customer',
},
],
}

describe('ConnectorConfigPanel — the available-data tree', () => {
it('shows the tree open by default, fed from the given branches', () => {
render(
<ConnectorConfigPanel
{...baseProps({
exampleData: {
availableData: {
tree: [TRIGGER_BRANCH],
context: { trigger: { name: 'Julie' }, connectors: {}, loop: null },
},
Expand All @@ -444,52 +428,6 @@ describe('ConnectorConfigPanel — the available-data tree', () => {

expect(screen.getByRole('button', { name: /trigger/ })).toBeDefined()
})

it('shows the real values rather than the example once a run exists', async () => {
const user = userEvent.setup()
render(
<ConnectorConfigPanel
{...baseProps({
exampleData: {
tree: [TRIGGER_BRANCH],
context: { trigger: { name: 'Julie' }, connectors: {}, loop: null },
},
lastRunData: {
tree: [LAST_RUN_TRIGGER_BRANCH],
context: {
trigger: { name: 'Real customer' },
connectors: {},
loop: null,
},
},
})}
/>,
)

await user.click(screen.getByRole('button', { name: 'trigger' }))

expect(screen.getByText('confirmed_name')).toBeDefined()
expect(screen.queryByText('name')).toBeNull()
})

it('falls back to the example while no run has happened', async () => {
const user = userEvent.setup()
render(
<ConnectorConfigPanel
{...baseProps({
exampleData: {
tree: [TRIGGER_BRANCH],
context: { trigger: { name: 'Julie' }, connectors: {}, loop: null },
},
lastRunData: null,
})}
/>,
)

await user.click(screen.getByRole('button', { name: 'trigger' }))

expect(screen.getByText('name')).toBeDefined()
})
})

describe('ConnectorConfigPanel — inserting an expression', () => {
Expand All @@ -508,7 +446,7 @@ describe('ConnectorConfigPanel — inserting an expression', () => {
}),
config: { url: 'Hello ' },
onConfigChange,
exampleData: {
availableData: {
tree: [TRIGGER_BRANCH],
context: { trigger: { name: 'Julie' }, connectors: {}, loop: null },
},
Expand Down Expand Up @@ -652,3 +590,52 @@ describe('ConnectorConfigPanel — switching connectors', () => {
expect(screen.queryByText(/Aperçu/)).toBeNull()
})
})

describe('ConnectorConfigPanel — what the last run did to this connector', () => {
function step(
overrides: Partial<Schemas.RunStepResponse>,
): Schemas.RunStepResponse {
return {
attempts: 1,
connector_id: 'c1',
created_at: '2026-09-17T10:00:00Z',
id: 'step-1',
iteration_path: '',
status: 'succeeded',
...overrides,
}
}

it('says so when the connector has never run', () => {
render(<ConnectorConfigPanel {...baseProps({ lastStep: null })} />)

expect(screen.getByText('Aucune exécution.')).toBeDefined()
})

it('shows the error when the step failed, instead of claiming nothing ran', () => {
render(
<ConnectorConfigPanel
{...baseProps({
lastStep: step({ status: 'failed', error: 'connection refused' }),
})}
/>,
)

expect(screen.getByRole('alert').textContent).toContain(
'connection refused',
)
expect(screen.queryByText('Aucune exécution.')).toBeNull()
})

it('shows the output when the step succeeded', () => {
render(
<ConnectorConfigPanel
{...baseProps({
lastStep: step({ status: 'succeeded', output: { id: 42 } }),
})}
/>,
)

expect(screen.getByText(/"id": 42/)).toBeDefined()
})
})
Loading
Loading