@shipfox/client-workflows 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +10 -0
- package/.storybook/preview.tsx +97 -0
- package/.swcrc +42 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +162 -0
- package/LICENSE +21 -0
- package/dist/components/identifier/identifier.d.ts +6 -0
- package/dist/components/identifier/identifier.d.ts.map +1 -0
- package/dist/components/identifier/identifier.js +144 -0
- package/dist/components/identifier/identifier.js.map +1 -0
- package/dist/components/identifier/index.d.ts +2 -0
- package/dist/components/identifier/index.d.ts.map +1 -0
- package/dist/components/identifier/index.js +3 -0
- package/dist/components/identifier/index.js.map +1 -0
- package/dist/components/job-graph/graph-model.d.ts +27 -0
- package/dist/components/job-graph/graph-model.d.ts.map +1 -0
- package/dist/components/job-graph/graph-model.js +107 -0
- package/dist/components/job-graph/graph-model.js.map +1 -0
- package/dist/components/job-graph/index.d.ts +3 -0
- package/dist/components/job-graph/index.d.ts.map +1 -0
- package/dist/components/job-graph/index.js +3 -0
- package/dist/components/job-graph/index.js.map +1 -0
- package/dist/components/job-graph/job-duration-format.d.ts +4 -0
- package/dist/components/job-graph/job-duration-format.d.ts.map +1 -0
- package/dist/components/job-graph/job-duration-format.js +41 -0
- package/dist/components/job-graph/job-duration-format.js.map +1 -0
- package/dist/components/job-graph/job-duration-label.d.ts +5 -0
- package/dist/components/job-graph/job-duration-label.d.ts.map +1 -0
- package/dist/components/job-graph/job-duration-label.js +33 -0
- package/dist/components/job-graph/job-duration-label.js.map +1 -0
- package/dist/components/job-graph/job-graph-content.d.ts +9 -0
- package/dist/components/job-graph/job-graph-content.d.ts.map +1 -0
- package/dist/components/job-graph/job-graph-content.js +181 -0
- package/dist/components/job-graph/job-graph-content.js.map +1 -0
- package/dist/components/job-graph/job-graph-view.d.ts +11 -0
- package/dist/components/job-graph/job-graph-view.d.ts.map +1 -0
- package/dist/components/job-graph/job-graph-view.js +24 -0
- package/dist/components/job-graph/job-graph-view.js.map +1 -0
- package/dist/components/job-graph/job-graph.d.ts +3 -0
- package/dist/components/job-graph/job-graph.d.ts.map +1 -0
- package/dist/components/job-graph/job-graph.js +21 -0
- package/dist/components/job-graph/job-graph.js.map +1 -0
- package/dist/components/job-graph/job-node.d.ts +16 -0
- package/dist/components/job-graph/job-node.d.ts.map +1 -0
- package/dist/components/job-graph/job-node.js +196 -0
- package/dist/components/job-graph/job-node.js.map +1 -0
- package/dist/components/job-graph/types.d.ts +9 -0
- package/dist/components/job-graph/types.d.ts.map +1 -0
- package/dist/components/job-graph/types.js +3 -0
- package/dist/components/job-graph/types.js.map +1 -0
- package/dist/components/step-list/index.d.ts +3 -0
- package/dist/components/step-list/index.d.ts.map +1 -0
- package/dist/components/step-list/index.js +3 -0
- package/dist/components/step-list/index.js.map +1 -0
- package/dist/components/step-list/step-list-model.d.ts +36 -0
- package/dist/components/step-list/step-list-model.d.ts.map +1 -0
- package/dist/components/step-list/step-list-model.js +135 -0
- package/dist/components/step-list/step-list-model.js.map +1 -0
- package/dist/components/step-list/step-list.d.ts +32 -0
- package/dist/components/step-list/step-list.d.ts.map +1 -0
- package/dist/components/step-list/step-list.js +409 -0
- package/dist/components/step-list/step-list.js.map +1 -0
- package/dist/components/workflow-run-duration-label.d.ts +8 -0
- package/dist/components/workflow-run-duration-label.d.ts.map +1 -0
- package/dist/components/workflow-run-duration-label.js +84 -0
- package/dist/components/workflow-run-duration-label.js.map +1 -0
- package/dist/components/workflow-run-list/index.d.ts +2 -0
- package/dist/components/workflow-run-list/index.d.ts.map +1 -0
- package/dist/components/workflow-run-list/index.js +3 -0
- package/dist/components/workflow-run-list/index.js.map +1 -0
- package/dist/components/workflow-run-list/run-display.d.ts +5 -0
- package/dist/components/workflow-run-list/run-display.d.ts.map +1 -0
- package/dist/components/workflow-run-list/run-display.js +19 -0
- package/dist/components/workflow-run-list/run-display.js.map +1 -0
- package/dist/components/workflow-run-list/types.d.ts +21 -0
- package/dist/components/workflow-run-list/types.d.ts.map +1 -0
- package/dist/components/workflow-run-list/types.js +3 -0
- package/dist/components/workflow-run-list/types.js.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-content.d.ts +14 -0
- package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-content.js +37 -0
- package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-header.d.ts +10 -0
- package/dist/components/workflow-run-list/workflow-run-list-header.d.ts.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-header.js +55 -0
- package/dist/components/workflow-run-list/workflow-run-list-header.js.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-states.d.ts +15 -0
- package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-states.js +103 -0
- package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-view.d.ts +3 -0
- package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list-view.js +46 -0
- package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list.d.ts +3 -0
- package/dist/components/workflow-run-list/workflow-run-list.d.ts.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-list.js +20 -0
- package/dist/components/workflow-run-list/workflow-run-list.js.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-row.d.ts +14 -0
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -0
- package/dist/components/workflow-run-list/workflow-run-row.js +172 -0
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -0
- package/dist/components/workflow-run-summary/index.d.ts +2 -0
- package/dist/components/workflow-run-summary/index.d.ts.map +1 -0
- package/dist/components/workflow-run-summary/index.js +3 -0
- package/dist/components/workflow-run-summary/index.js.map +1 -0
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts +9 -0
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts.map +1 -0
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +138 -0
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -0
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +20 -0
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -0
- package/dist/components/workflow-run-summary/workflow-run-summary.js +252 -0
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -0
- package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts +7 -0
- package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +1 -0
- package/dist/components/workflow-run-view/agent-config-failure-callout.js +84 -0
- package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +1 -0
- package/dist/components/workflow-run-view/index.d.ts +3 -0
- package/dist/components/workflow-run-view/index.d.ts.map +1 -0
- package/dist/components/workflow-run-view/index.js +4 -0
- package/dist/components/workflow-run-view/index.js.map +1 -0
- package/dist/components/workflow-run-view/job-card.d.ts +17 -0
- package/dist/components/workflow-run-view/job-card.d.ts.map +1 -0
- package/dist/components/workflow-run-view/job-card.js +476 -0
- package/dist/components/workflow-run-view/job-card.js.map +1 -0
- package/dist/components/workflow-run-view/job-execution-switcher.d.ts +10 -0
- package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +1 -0
- package/dist/components/workflow-run-view/job-execution-switcher.js +171 -0
- package/dist/components/workflow-run-view/job-execution-switcher.js.map +1 -0
- package/dist/components/workflow-run-view/job-execution-time-text.d.ts +6 -0
- package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +1 -0
- package/dist/components/workflow-run-view/job-execution-time-text.js +24 -0
- package/dist/components/workflow-run-view/job-execution-time-text.js.map +1 -0
- package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +9 -0
- package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +1 -0
- package/dist/components/workflow-run-view/step-attempt-log-panel.js +152 -0
- package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-selection.d.ts +14 -0
- package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-selection.js +56 -0
- package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-states.d.ts +12 -0
- package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-states.js +91 -0
- package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-view.d.ts +15 -0
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-view.js +285 -0
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -0
- package/dist/components/workflow-source-panel/index.d.ts +2 -0
- package/dist/components/workflow-source-panel/index.d.ts.map +1 -0
- package/dist/components/workflow-source-panel/index.js +3 -0
- package/dist/components/workflow-source-panel/index.js.map +1 -0
- package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +13 -0
- package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -0
- package/dist/components/workflow-source-panel/workflow-source-panel.js +91 -0
- package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -0
- package/dist/components/workflow-status/status-visuals.d.ts +12 -0
- package/dist/components/workflow-status/status-visuals.d.ts.map +1 -0
- package/dist/components/workflow-status/status-visuals.js +54 -0
- package/dist/components/workflow-status/status-visuals.js.map +1 -0
- package/dist/components/workflow-status/workflow-status-icon.d.ts +22 -0
- package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -0
- package/dist/components/workflow-status/workflow-status-icon.js +100 -0
- package/dist/components/workflow-status/workflow-status-icon.js.map +1 -0
- package/dist/core/entities/job-execution.d.ts +69 -0
- package/dist/core/entities/job-execution.d.ts.map +1 -0
- package/dist/core/entities/job-execution.js +83 -0
- package/dist/core/entities/job-execution.js.map +1 -0
- package/dist/core/entities/job.d.ts +56 -0
- package/dist/core/entities/job.d.ts.map +1 -0
- package/dist/core/entities/job.js +71 -0
- package/dist/core/entities/job.js.map +1 -0
- package/dist/core/entities/step-attempt.d.ts +49 -0
- package/dist/core/entities/step-attempt.d.ts.map +1 -0
- package/dist/core/entities/step-attempt.js +48 -0
- package/dist/core/entities/step-attempt.js.map +1 -0
- package/dist/core/entities/step.d.ts +41 -0
- package/dist/core/entities/step.d.ts.map +1 -0
- package/dist/core/entities/step.js +51 -0
- package/dist/core/entities/step.js.map +1 -0
- package/dist/core/entities/workflow-run-attempt.d.ts +44 -0
- package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -0
- package/dist/core/entities/workflow-run-attempt.js +51 -0
- package/dist/core/entities/workflow-run-attempt.js.map +1 -0
- package/dist/core/entities/workflow-run.d.ts +61 -0
- package/dist/core/entities/workflow-run.d.ts.map +1 -0
- package/dist/core/entities/workflow-run.js +104 -0
- package/dist/core/entities/workflow-run.js.map +1 -0
- package/dist/core/workflow-run-url-state.d.ts +13 -0
- package/dist/core/workflow-run-url-state.d.ts.map +1 -0
- package/dist/core/workflow-run-url-state.js +43 -0
- package/dist/core/workflow-run-url-state.js.map +1 -0
- package/dist/core/workflow-run.d.ts +13 -0
- package/dist/core/workflow-run.d.ts.map +1 -0
- package/dist/core/workflow-run.js +8 -0
- package/dist/core/workflow-run.js.map +1 -0
- package/dist/env.d.js +2 -0
- package/dist/env.d.js.map +1 -0
- package/dist/feature.d.ts +20 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +27 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/workflow-runs.d.ts +109 -0
- package/dist/hooks/api/workflow-runs.d.ts.map +1 -0
- package/dist/hooks/api/workflow-runs.js +394 -0
- package/dist/hooks/api/workflow-runs.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/workflow-run-first-time-use.d.ts +6 -0
- package/dist/pages/workflow-run-first-time-use.d.ts.map +1 -0
- package/dist/pages/workflow-run-first-time-use.js +17 -0
- package/dist/pages/workflow-run-first-time-use.js.map +1 -0
- package/dist/pages/workflow-run-page.d.ts +8 -0
- package/dist/pages/workflow-run-page.d.ts.map +1 -0
- package/dist/pages/workflow-run-page.js +83 -0
- package/dist/pages/workflow-run-page.js.map +1 -0
- package/dist/routes/run-detail.d.ts +8 -0
- package/dist/routes/run-detail.d.ts.map +1 -0
- package/dist/routes/run-detail.js +21 -0
- package/dist/routes/run-detail.js.map +1 -0
- package/dist/routes/runs.d.ts +8 -0
- package/dist/routes/runs.d.ts.map +1 -0
- package/dist/routes/runs.js +20 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +114 -0
- package/src/components/identifier/identifier.tsx +178 -0
- package/src/components/identifier/index.ts +1 -0
- package/src/components/job-graph/graph-model.test.ts +189 -0
- package/src/components/job-graph/graph-model.ts +178 -0
- package/src/components/job-graph/index.ts +2 -0
- package/src/components/job-graph/job-duration-format.ts +54 -0
- package/src/components/job-graph/job-duration-label.tsx +29 -0
- package/src/components/job-graph/job-graph-content.tsx +225 -0
- package/src/components/job-graph/job-graph-view.test.tsx +298 -0
- package/src/components/job-graph/job-graph-view.tsx +45 -0
- package/src/components/job-graph/job-graph.stories.tsx +182 -0
- package/src/components/job-graph/job-graph.tsx +24 -0
- package/src/components/job-graph/job-node.stories.tsx +364 -0
- package/src/components/job-graph/job-node.test.tsx +347 -0
- package/src/components/job-graph/job-node.tsx +202 -0
- package/src/components/job-graph/types.ts +9 -0
- package/src/components/step-list/index.ts +6 -0
- package/src/components/step-list/step-list-model.test.ts +369 -0
- package/src/components/step-list/step-list-model.ts +205 -0
- package/src/components/step-list/step-list.stories.tsx +419 -0
- package/src/components/step-list/step-list.test.tsx +566 -0
- package/src/components/step-list/step-list.tsx +494 -0
- package/src/components/workflow-run-duration-label.tsx +118 -0
- package/src/components/workflow-run-list/index.ts +1 -0
- package/src/components/workflow-run-list/run-display.test.ts +53 -0
- package/src/components/workflow-run-list/run-display.ts +22 -0
- package/src/components/workflow-run-list/types.ts +22 -0
- package/src/components/workflow-run-list/workflow-run-list-content.tsx +58 -0
- package/src/components/workflow-run-list/workflow-run-list-header.tsx +53 -0
- package/src/components/workflow-run-list/workflow-run-list-states.tsx +86 -0
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +178 -0
- package/src/components/workflow-run-list/workflow-run-list-view.tsx +57 -0
- package/src/components/workflow-run-list/workflow-run-list.stories.tsx +111 -0
- package/src/components/workflow-run-list/workflow-run-list.tsx +29 -0
- package/src/components/workflow-run-list/workflow-run-row.tsx +176 -0
- package/src/components/workflow-run-summary/index.ts +1 -0
- package/src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx +230 -0
- package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +146 -0
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +414 -0
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +354 -0
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +295 -0
- package/src/components/workflow-run-view/agent-config-failure-callout.stories.tsx +140 -0
- package/src/components/workflow-run-view/agent-config-failure-callout.tsx +93 -0
- package/src/components/workflow-run-view/index.ts +2 -0
- package/src/components/workflow-run-view/job-card.stories.tsx +788 -0
- package/src/components/workflow-run-view/job-card.tsx +566 -0
- package/src/components/workflow-run-view/job-execution-switcher.tsx +170 -0
- package/src/components/workflow-run-view/job-execution-time-text.tsx +34 -0
- package/src/components/workflow-run-view/step-attempt-log-panel.test.tsx +290 -0
- package/src/components/workflow-run-view/step-attempt-log-panel.tsx +152 -0
- package/src/components/workflow-run-view/workflow-run-selection.test.ts +280 -0
- package/src/components/workflow-run-view/workflow-run-selection.ts +89 -0
- package/src/components/workflow-run-view/workflow-run-states.tsx +70 -0
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +1214 -0
- package/src/components/workflow-run-view/workflow-run-view.tsx +341 -0
- package/src/components/workflow-source-panel/index.ts +1 -0
- package/src/components/workflow-source-panel/workflow-source-panel.stories.tsx +110 -0
- package/src/components/workflow-source-panel/workflow-source-panel.test.tsx +96 -0
- package/src/components/workflow-source-panel/workflow-source-panel.tsx +136 -0
- package/src/components/workflow-status/status-visuals.test.ts +38 -0
- package/src/components/workflow-status/status-visuals.ts +36 -0
- package/src/components/workflow-status/workflow-status-icon.stories.tsx +90 -0
- package/src/components/workflow-status/workflow-status-icon.tsx +135 -0
- package/src/core/entities/job-execution.ts +139 -0
- package/src/core/entities/job.ts +149 -0
- package/src/core/entities/step-attempt.ts +89 -0
- package/src/core/entities/step.ts +105 -0
- package/src/core/entities/workflow-run-attempt.ts +87 -0
- package/src/core/entities/workflow-run.ts +183 -0
- package/src/core/workflow-run-url-state.ts +61 -0
- package/src/core/workflow-run.test.ts +640 -0
- package/src/core/workflow-run.ts +66 -0
- package/src/env.d.ts +8 -0
- package/src/feature.ts +26 -0
- package/src/hooks/api/workflow-runs.test.tsx +410 -0
- package/src/hooks/api/workflow-runs.ts +485 -0
- package/src/index.ts +45 -0
- package/src/page-harness-budget.test.ts +33 -0
- package/src/pages/workflow-run-first-time-use.tsx +17 -0
- package/src/pages/workflow-run-page.test.tsx +517 -0
- package/src/pages/workflow-run-page.tsx +89 -0
- package/src/routes/run-detail.tsx +15 -0
- package/src/routes/runs.tsx +11 -0
- package/test/fixtures/logs.ts +13 -0
- package/test/fixtures/workflow-run.ts +311 -0
- package/test/pages.tsx +79 -0
- package/test/render.tsx +40 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +70 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {humanDuration} from '@shipfox/react-ui/utils';
|
|
2
|
+
import type {JobDisplayDuration, JobExecutionTime} from '#core/workflow-run.js';
|
|
3
|
+
|
|
4
|
+
export function formatJobExecutionTimeLabel(time: JobExecutionTime): string {
|
|
5
|
+
switch (time.state) {
|
|
6
|
+
case 'live':
|
|
7
|
+
return humanDuration(time.fromIso);
|
|
8
|
+
case 'fixed':
|
|
9
|
+
return formatFixedDurationLabel(time.elapsed);
|
|
10
|
+
default: {
|
|
11
|
+
const exhaustive: never = time;
|
|
12
|
+
return exhaustive;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatJobDurationAccessibleLabel(
|
|
18
|
+
duration: JobDisplayDuration | null,
|
|
19
|
+
): string | undefined {
|
|
20
|
+
if (duration === null) return undefined;
|
|
21
|
+
|
|
22
|
+
const label = formatJobExecutionTimeLabel(duration);
|
|
23
|
+
switch (duration.kind) {
|
|
24
|
+
case 'queue':
|
|
25
|
+
return duration.state === 'live' ? `queueing ${label}` : `queued ${label}`;
|
|
26
|
+
case 'run':
|
|
27
|
+
return duration.state === 'live' ? `running ${label}` : `ran ${label}`;
|
|
28
|
+
default: {
|
|
29
|
+
const exhaustive: never = duration;
|
|
30
|
+
return exhaustive;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatFixedDurationLabel({
|
|
36
|
+
years = 0,
|
|
37
|
+
months = 0,
|
|
38
|
+
weeks = 0,
|
|
39
|
+
days = 0,
|
|
40
|
+
hours = 0,
|
|
41
|
+
minutes = 0,
|
|
42
|
+
seconds = 0,
|
|
43
|
+
}: Extract<JobExecutionTime, {state: 'fixed'}>['elapsed']): string {
|
|
44
|
+
const totalDays = years * 365 + months * 30 + weeks * 7 + days;
|
|
45
|
+
const totalHours = totalDays * 24 + hours;
|
|
46
|
+
|
|
47
|
+
if (totalHours > 0) return `${totalHours}h ${pad2(minutes)}m`;
|
|
48
|
+
if (minutes > 0) return `${minutes}m ${pad2(seconds)}s`;
|
|
49
|
+
return `${seconds}s`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pad2(value: number): string {
|
|
53
|
+
return value.toString().padStart(2, '0');
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {useTimeTick} from '@shipfox/react-ui/time-ticker';
|
|
2
|
+
import {Code} from '@shipfox/react-ui/typography';
|
|
3
|
+
import {humanDuration} from '@shipfox/react-ui/utils';
|
|
4
|
+
import type {JobDisplayDuration} from '#core/workflow-run.js';
|
|
5
|
+
import {formatJobExecutionTimeLabel} from './job-duration-format.js';
|
|
6
|
+
|
|
7
|
+
export function JobDurationLabel({duration}: {duration: JobDisplayDuration | null}) {
|
|
8
|
+
if (duration === null) return null;
|
|
9
|
+
|
|
10
|
+
if (duration.state === 'live') {
|
|
11
|
+
return <LiveDurationText fromIso={duration.fromIso} />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return <DurationText>{formatJobExecutionTimeLabel(duration)}</DurationText>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function LiveDurationText({fromIso}: {fromIso: string}) {
|
|
18
|
+
useTimeTick();
|
|
19
|
+
return <DurationText>{humanDuration(fromIso)}</DurationText>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DurationText({children}: {children: string}) {
|
|
23
|
+
// The status icon names the state; the node aria-label carries the verb.
|
|
24
|
+
return (
|
|
25
|
+
<Code as="span" variant="label" className="shrink-0 tabular-nums text-foreground-neutral-muted">
|
|
26
|
+
{children}
|
|
27
|
+
</Code>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
2
|
+
import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
|
|
3
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
4
|
+
import type {KeyboardEvent} from 'react';
|
|
5
|
+
import {useRef, useState} from 'react';
|
|
6
|
+
import type {WorkflowRunDetail} from '#core/workflow-run.js';
|
|
7
|
+
import type {JobGraphModel, JobGraphNavigationKey} from './graph-model.js';
|
|
8
|
+
import {nextJobGraphNodeId} from './graph-model.js';
|
|
9
|
+
import {JobNode, TriggerNode} from './job-node.js';
|
|
10
|
+
|
|
11
|
+
const NODE_WIDTH = 208;
|
|
12
|
+
const NODE_HEIGHT = 48;
|
|
13
|
+
const COLUMN_GAP = 64;
|
|
14
|
+
const ROW_GAP = 20;
|
|
15
|
+
const TRIGGER_WIDTH = 36;
|
|
16
|
+
const PADDING = 16;
|
|
17
|
+
|
|
18
|
+
export function JobGraphContent({
|
|
19
|
+
model,
|
|
20
|
+
trigger,
|
|
21
|
+
selectedJobId,
|
|
22
|
+
onSelectJob,
|
|
23
|
+
}: {
|
|
24
|
+
model: JobGraphModel;
|
|
25
|
+
trigger: Pick<
|
|
26
|
+
WorkflowRunDetail,
|
|
27
|
+
'triggerDisplayLabel' | 'triggerLabel' | 'triggerProvider' | 'triggerSource'
|
|
28
|
+
>;
|
|
29
|
+
selectedJobId?: string | undefined;
|
|
30
|
+
onSelectJob: (jobId: string | undefined) => void;
|
|
31
|
+
}) {
|
|
32
|
+
const nodeRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
33
|
+
const [hoveredJobId, setHoveredJobId] = useState<string | undefined>();
|
|
34
|
+
|
|
35
|
+
if (model.nodes.length === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<EmptyState
|
|
38
|
+
className="min-h-160 px-16 py-24"
|
|
39
|
+
icon="componentLine"
|
|
40
|
+
title="No jobs yet"
|
|
41
|
+
description="This run has not materialized jobs."
|
|
42
|
+
variant="compact"
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const maxRows = Math.max(1, ...model.columns.map((column) => column.length));
|
|
48
|
+
const contentWidth =
|
|
49
|
+
PADDING * 2 +
|
|
50
|
+
TRIGGER_WIDTH +
|
|
51
|
+
COLUMN_GAP +
|
|
52
|
+
model.columns.length * NODE_WIDTH +
|
|
53
|
+
Math.max(0, model.columns.length - 1) * COLUMN_GAP;
|
|
54
|
+
const contentHeight = PADDING * 2 + maxRows * NODE_HEIGHT + Math.max(0, maxRows - 1) * ROW_GAP;
|
|
55
|
+
|
|
56
|
+
function setNodeRef(jobId: string) {
|
|
57
|
+
return (element: HTMLButtonElement | null) => {
|
|
58
|
+
if (element) {
|
|
59
|
+
nodeRefs.current.set(jobId, element);
|
|
60
|
+
} else {
|
|
61
|
+
nodeRefs.current.delete(jobId);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
|
67
|
+
const key = navigationKey(event.key);
|
|
68
|
+
if (!key) return;
|
|
69
|
+
|
|
70
|
+
const currentNodeId = event.currentTarget.dataset.jobId;
|
|
71
|
+
if (!currentNodeId) return;
|
|
72
|
+
|
|
73
|
+
const nextNodeId = nextJobGraphNodeId({model, currentNodeId, key});
|
|
74
|
+
if (!nextNodeId) return;
|
|
75
|
+
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
onSelectJob(nextNodeId);
|
|
78
|
+
nodeRefs.current.get(nextNodeId)?.focus();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
83
|
+
<div className="min-h-0 overflow-auto bg-background-neutral-base">
|
|
84
|
+
<div className="relative" style={{width: contentWidth, minHeight: contentHeight}}>
|
|
85
|
+
<GraphEdges model={model} hoveredJobId={hoveredJobId} />
|
|
86
|
+
<div
|
|
87
|
+
className="absolute"
|
|
88
|
+
style={{left: PADDING, top: PADDING + (NODE_HEIGHT - TRIGGER_WIDTH) / 2}}
|
|
89
|
+
>
|
|
90
|
+
<TriggerNode trigger={trigger} />
|
|
91
|
+
</div>
|
|
92
|
+
{model.columns.map((column, columnIndex) => (
|
|
93
|
+
<div
|
|
94
|
+
key={column.map((node) => node.id).join(':')}
|
|
95
|
+
className="absolute flex flex-col gap-20"
|
|
96
|
+
style={{left: jobLeft(columnIndex), top: PADDING}}
|
|
97
|
+
>
|
|
98
|
+
{column.map((node) => (
|
|
99
|
+
<JobNode
|
|
100
|
+
key={node.id}
|
|
101
|
+
node={node}
|
|
102
|
+
selected={node.id === selectedJobId}
|
|
103
|
+
ref={setNodeRef(node.id)}
|
|
104
|
+
onSelect={() => onSelectJob(node.id === selectedJobId ? undefined : node.id)}
|
|
105
|
+
onKeyDown={handleKeyDown}
|
|
106
|
+
onHoverStart={() => setHoveredJobId(node.id)}
|
|
107
|
+
onHoverEnd={() =>
|
|
108
|
+
setHoveredJobId((current) => (current === node.id ? undefined : current))
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</TimeTickerProvider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function navigationKey(key: string): JobGraphNavigationKey | undefined {
|
|
121
|
+
if (
|
|
122
|
+
key === 'ArrowRight' ||
|
|
123
|
+
key === 'ArrowLeft' ||
|
|
124
|
+
key === 'ArrowDown' ||
|
|
125
|
+
key === 'ArrowUp' ||
|
|
126
|
+
key === 'j' ||
|
|
127
|
+
key === 'k'
|
|
128
|
+
) {
|
|
129
|
+
return key;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function GraphEdges({
|
|
135
|
+
model,
|
|
136
|
+
hoveredJobId,
|
|
137
|
+
}: {
|
|
138
|
+
model: JobGraphModel;
|
|
139
|
+
hoveredJobId?: string | undefined;
|
|
140
|
+
}) {
|
|
141
|
+
const byId = new Map(model.nodes.map((node) => [node.id, node]));
|
|
142
|
+
const edgeViews = model.edges
|
|
143
|
+
.map((edge) => {
|
|
144
|
+
const fromNode = byId.get(edge.from);
|
|
145
|
+
const toNode = byId.get(edge.to);
|
|
146
|
+
const from = edge.from === 'trigger' ? triggerPoint() : jobRightPoint(fromNode);
|
|
147
|
+
const to = jobLeftPoint(toNode);
|
|
148
|
+
if (!from || !to) return undefined;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
id: edge.id,
|
|
152
|
+
highlighted: edge.from === hoveredJobId || edge.to === hoveredJobId,
|
|
153
|
+
path: edgePath({from, fromNode, to, toNode}),
|
|
154
|
+
};
|
|
155
|
+
})
|
|
156
|
+
.filter((edgeView): edgeView is NonNullable<typeof edgeView> => edgeView !== undefined)
|
|
157
|
+
.sort((left, right) => Number(left.highlighted) - Number(right.highlighted));
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<svg className="pointer-events-none absolute inset-0 size-full" aria-hidden="true">
|
|
161
|
+
{edgeViews.map((edge) => {
|
|
162
|
+
return (
|
|
163
|
+
<g
|
|
164
|
+
key={edge.id}
|
|
165
|
+
className={cn(
|
|
166
|
+
'text-foreground-neutral-muted transition-colors',
|
|
167
|
+
edge.highlighted && 'text-foreground-neutral-base',
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
<path
|
|
171
|
+
data-edge-id={edge.id}
|
|
172
|
+
d={edge.path}
|
|
173
|
+
fill="none"
|
|
174
|
+
stroke="currentColor"
|
|
175
|
+
strokeWidth={edge.highlighted ? 1.5 : 1}
|
|
176
|
+
/>
|
|
177
|
+
</g>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</svg>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function edgePath({
|
|
185
|
+
from,
|
|
186
|
+
fromNode,
|
|
187
|
+
to,
|
|
188
|
+
toNode,
|
|
189
|
+
}: {
|
|
190
|
+
from: {x: number; y: number};
|
|
191
|
+
fromNode: {column: number} | undefined;
|
|
192
|
+
to: {x: number; y: number};
|
|
193
|
+
toNode: {column: number} | undefined;
|
|
194
|
+
}) {
|
|
195
|
+
if (fromNode && toNode && toNode.column - fromNode.column > 1 && from.y !== to.y) {
|
|
196
|
+
const targetLaneX = to.x - COLUMN_GAP / 2;
|
|
197
|
+
return `M ${from.x} ${from.y} H ${targetLaneX} V ${to.y} H ${to.x}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const midX = from.x + (to.x - from.x) / 2;
|
|
201
|
+
return `M ${from.x} ${from.y} H ${midX} V ${to.y} H ${to.x}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function triggerPoint() {
|
|
205
|
+
return {x: PADDING + TRIGGER_WIDTH, y: PADDING + NODE_HEIGHT / 2};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function jobLeft(column: number): number {
|
|
209
|
+
// Dependency depths are contiguous, so a node's depth matches its rendered column index.
|
|
210
|
+
return PADDING + TRIGGER_WIDTH + COLUMN_GAP + column * (NODE_WIDTH + COLUMN_GAP);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function jobTop(row: number): number {
|
|
214
|
+
return PADDING + row * (NODE_HEIGHT + ROW_GAP);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function jobLeftPoint(node: {column: number; row: number} | undefined) {
|
|
218
|
+
if (!node) return undefined;
|
|
219
|
+
return {x: jobLeft(node.column), y: jobTop(node.row) + NODE_HEIGHT / 2};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function jobRightPoint(node: {column: number; row: number} | undefined) {
|
|
223
|
+
if (!node) return undefined;
|
|
224
|
+
return {x: jobLeft(node.column) + NODE_WIDTH, y: jobTop(node.row) + NODE_HEIGHT / 2};
|
|
225
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type {WorkflowRunJobDetailDto} from '@shipfox/api-workflows-dto';
|
|
2
|
+
import {fireEvent, render, screen, within} from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import type {Job, WorkflowRunDetail} from '#core/workflow-run.js';
|
|
5
|
+
import {
|
|
6
|
+
workflowJob,
|
|
7
|
+
workflowJobExecutionDto,
|
|
8
|
+
workflowRunDetail,
|
|
9
|
+
} from '#test/fixtures/workflow-run.js';
|
|
10
|
+
import {JobGraph} from './job-graph.js';
|
|
11
|
+
|
|
12
|
+
describe('JobGraph', () => {
|
|
13
|
+
test('renders a graph region and trigger', () => {
|
|
14
|
+
render(<JobGraph run={makeRun({jobs: [makeJob({name: 'build'})]})} />);
|
|
15
|
+
|
|
16
|
+
expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByRole('button', {name: 'fire'})).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByRole('button', {name: 'build, Pending'})).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('selects and unselects a job locally', async () => {
|
|
22
|
+
const user = userEvent.setup();
|
|
23
|
+
render(<JobGraph run={makeRun({jobs: [makeJob({name: 'build'})]})} />);
|
|
24
|
+
const build = screen.getByRole('button', {name: 'build, Pending'});
|
|
25
|
+
|
|
26
|
+
await user.click(build);
|
|
27
|
+
|
|
28
|
+
expect(build).toHaveAttribute('aria-pressed', 'true');
|
|
29
|
+
|
|
30
|
+
await user.click(build);
|
|
31
|
+
|
|
32
|
+
expect(build).toHaveAttribute('aria-pressed', 'false');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('supports an initially selected job', () => {
|
|
36
|
+
const buildJob = makeJob({name: 'build'});
|
|
37
|
+
render(<JobGraph run={makeRun({jobs: [buildJob]})} defaultSelectedJobId={buildJob.id} />);
|
|
38
|
+
|
|
39
|
+
expect(screen.getByRole('button', {name: 'build, Pending'})).toHaveAttribute(
|
|
40
|
+
'aria-pressed',
|
|
41
|
+
'true',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('renders two running jobs at the same time', () => {
|
|
46
|
+
render(
|
|
47
|
+
<JobGraph
|
|
48
|
+
run={makeRun({
|
|
49
|
+
jobs: [
|
|
50
|
+
makeJob({name: 'linux', status: 'running'}),
|
|
51
|
+
makeJob({name: 'macos', status: 'running', position: 1}),
|
|
52
|
+
],
|
|
53
|
+
})}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByRole('button', {name: 'linux, Running'})).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByRole('button', {name: 'macos, Running'})).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('renders a downstream job skipped after an upstream failure', () => {
|
|
62
|
+
render(
|
|
63
|
+
<JobGraph
|
|
64
|
+
run={makeRun({
|
|
65
|
+
jobs: [
|
|
66
|
+
makeJob({name: 'build', status: 'failed'}),
|
|
67
|
+
makeJob({
|
|
68
|
+
name: 'deploy',
|
|
69
|
+
status: 'skipped',
|
|
70
|
+
status_reason: 'dependency_not_completed',
|
|
71
|
+
position: 1,
|
|
72
|
+
dependencies: ['build'],
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
})}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(screen.getByRole('button', {name: 'build, Failed'})).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByRole('button', {name: 'deploy, Skipped'})).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('renders an empty state', () => {
|
|
84
|
+
render(<JobGraph run={makeRun({jobs: []})} />);
|
|
85
|
+
|
|
86
|
+
expect(screen.getByText('No jobs yet')).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('keeps long names accessible while truncating visually', () => {
|
|
90
|
+
const name = 'release-production-multi-region-with-canary-and-smoke-tests';
|
|
91
|
+
render(<JobGraph run={makeRun({jobs: [makeJob({name})]})} />);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByRole('button', {name: `${name}, Pending`})).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('does not show dependency counts in the job node label', () => {
|
|
97
|
+
render(
|
|
98
|
+
<JobGraph
|
|
99
|
+
run={makeRun({
|
|
100
|
+
jobs: [
|
|
101
|
+
makeJob({name: 'build'}),
|
|
102
|
+
makeJob({name: 'deploy', position: 1, dependencies: ['build'], status: 'running'}),
|
|
103
|
+
],
|
|
104
|
+
})}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(screen.getByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
|
|
109
|
+
expect(screen.queryByText('1')).not.toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('does not show multiple pending dependency counts', () => {
|
|
113
|
+
render(
|
|
114
|
+
<JobGraph
|
|
115
|
+
run={makeRun({
|
|
116
|
+
jobs: [
|
|
117
|
+
makeJob({name: 'build'}),
|
|
118
|
+
makeJob({name: 'lint', position: 1}),
|
|
119
|
+
makeJob({name: 'deploy', position: 2, dependencies: ['build', 'lint']}),
|
|
120
|
+
],
|
|
121
|
+
})}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
|
|
126
|
+
expect(deploy).toBeInTheDocument();
|
|
127
|
+
expect(within(deploy).queryByText('2')).not.toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('hides the dependency pill when upstream jobs are resolved', () => {
|
|
131
|
+
render(
|
|
132
|
+
<JobGraph
|
|
133
|
+
run={makeRun({
|
|
134
|
+
jobs: [
|
|
135
|
+
makeJob({name: 'build', status: 'succeeded'}),
|
|
136
|
+
makeJob({name: 'deploy', position: 1, dependencies: ['build']}),
|
|
137
|
+
],
|
|
138
|
+
})}
|
|
139
|
+
/>,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
|
|
143
|
+
expect(within(deploy).queryByText('1')).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('marks jobs with multiple executions without showing dependency counts', () => {
|
|
147
|
+
render(
|
|
148
|
+
<JobGraph
|
|
149
|
+
run={makeRun({
|
|
150
|
+
jobs: [
|
|
151
|
+
makeJob({
|
|
152
|
+
name: 'build',
|
|
153
|
+
job_executions: [
|
|
154
|
+
workflowJobExecutionDto({sequence: 1, status: 'failed'}),
|
|
155
|
+
workflowJobExecutionDto({sequence: 2, status: 'succeeded'}),
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
makeJob({name: 'deploy', position: 1, dependencies: ['build']}),
|
|
159
|
+
],
|
|
160
|
+
})}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const build = screen.getByRole('button', {name: 'build, Pending, 2 executions'});
|
|
165
|
+
const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
|
|
166
|
+
expect(within(build).getByText('2')).toBeInTheDocument();
|
|
167
|
+
expect(within(build).queryByText('2 exec')).not.toBeInTheDocument();
|
|
168
|
+
expect(within(deploy).queryByText('1')).not.toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('moves selection and focus with graph keyboard navigation', async () => {
|
|
172
|
+
const user = userEvent.setup();
|
|
173
|
+
render(
|
|
174
|
+
<JobGraph
|
|
175
|
+
run={makeRun({
|
|
176
|
+
jobs: [
|
|
177
|
+
makeJob({name: 'build'}),
|
|
178
|
+
makeJob({name: 'deploy', position: 1, dependencies: ['build']}),
|
|
179
|
+
],
|
|
180
|
+
})}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const build = screen.getByRole('button', {name: 'build, Pending'});
|
|
185
|
+
const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
|
|
186
|
+
build.focus();
|
|
187
|
+
|
|
188
|
+
await user.keyboard('{ArrowRight}');
|
|
189
|
+
|
|
190
|
+
expect(deploy).toHaveFocus();
|
|
191
|
+
expect(deploy).toHaveAttribute('aria-pressed', 'true');
|
|
192
|
+
expect(build).toHaveAttribute('aria-pressed', 'false');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('highlights adjacent edges only while hovering a job', async () => {
|
|
196
|
+
const user = userEvent.setup();
|
|
197
|
+
const build = makeJob({name: 'build'});
|
|
198
|
+
const deploy = makeJob({name: 'deploy', position: 1, dependencies: ['build']});
|
|
199
|
+
const {container} = render(<JobGraph run={makeRun({jobs: [build, deploy]})} />);
|
|
200
|
+
const triggerEdge = container.querySelector(`[data-edge-id="trigger:${build.id}"]`);
|
|
201
|
+
const deployEdge = container.querySelector(`[data-edge-id="${build.id}:${deploy.id}"]`);
|
|
202
|
+
const deployNode = screen.getByRole('button', {name: 'deploy, Pending'});
|
|
203
|
+
|
|
204
|
+
fireEvent.click(deployNode);
|
|
205
|
+
|
|
206
|
+
expect(deployEdge).toHaveAttribute('stroke-width', '1');
|
|
207
|
+
|
|
208
|
+
await user.hover(deployNode);
|
|
209
|
+
|
|
210
|
+
expect(deployEdge).toHaveAttribute('stroke-width', '1.5');
|
|
211
|
+
expect(triggerEdge).toHaveAttribute('stroke-width', '1');
|
|
212
|
+
|
|
213
|
+
await user.unhover(deployNode);
|
|
214
|
+
|
|
215
|
+
expect(deployEdge).toHaveAttribute('stroke-width', '1');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('draws highlighted edges above overlapping normal edges', async () => {
|
|
219
|
+
const user = userEvent.setup();
|
|
220
|
+
const build = makeJob({name: 'build', status: 'succeeded'});
|
|
221
|
+
const lint = makeJob({
|
|
222
|
+
name: 'lint',
|
|
223
|
+
position: 1,
|
|
224
|
+
dependencies: ['build'],
|
|
225
|
+
status: 'succeeded',
|
|
226
|
+
});
|
|
227
|
+
const testJob = makeJob({
|
|
228
|
+
name: 'test',
|
|
229
|
+
position: 2,
|
|
230
|
+
dependencies: ['build'],
|
|
231
|
+
status: 'running',
|
|
232
|
+
});
|
|
233
|
+
const deploy = makeJob({
|
|
234
|
+
name: 'deploy',
|
|
235
|
+
position: 3,
|
|
236
|
+
dependencies: ['lint', 'test'],
|
|
237
|
+
});
|
|
238
|
+
const {container} = render(<JobGraph run={makeRun({jobs: [build, lint, testJob, deploy]})} />);
|
|
239
|
+
const buildToLintEdge = container.querySelector(`[data-edge-id="${build.id}:${lint.id}"]`);
|
|
240
|
+
const buildToTestEdge = container.querySelector(`[data-edge-id="${build.id}:${testJob.id}"]`);
|
|
241
|
+
const testNode = screen.getByRole('button', {
|
|
242
|
+
name: 'test, Running',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await user.hover(testNode);
|
|
246
|
+
|
|
247
|
+
expect(buildToTestEdge).toHaveAttribute('stroke-width', '1.5');
|
|
248
|
+
expect(buildToLintEdge).toHaveAttribute('stroke-width', '1');
|
|
249
|
+
if (!buildToLintEdge || !buildToTestEdge) {
|
|
250
|
+
throw new Error('Expected branch edges to render');
|
|
251
|
+
}
|
|
252
|
+
expect(buildToLintEdge.compareDocumentPosition(buildToTestEdge)).toBe(
|
|
253
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('routes skipped-column join edges away from intermediate nodes', () => {
|
|
258
|
+
const packageJob = makeJob({name: 'package', position: 0, status: 'succeeded'});
|
|
259
|
+
const securityScan = makeJob({name: 'security-scan', position: 1, status: 'succeeded'});
|
|
260
|
+
const smokeTests = makeJob({
|
|
261
|
+
name: 'smoke-tests',
|
|
262
|
+
position: 2,
|
|
263
|
+
dependencies: ['package'],
|
|
264
|
+
status: 'running',
|
|
265
|
+
});
|
|
266
|
+
const deploy = makeJob({
|
|
267
|
+
name: 'deploy',
|
|
268
|
+
position: 3,
|
|
269
|
+
dependencies: ['smoke-tests', 'security-scan'],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const {container} = render(
|
|
273
|
+
<JobGraph run={makeRun({jobs: [packageJob, securityScan, smokeTests, deploy]})} />,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const skippedJoinEdge = container.querySelector(
|
|
277
|
+
`[data-edge-id="${securityScan.id}:${deploy.id}"]`,
|
|
278
|
+
);
|
|
279
|
+
expect(skippedJoinEdge).toHaveAttribute('d', 'M 324 108 H 628 V 40 H 660');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
function makeRun(overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail {
|
|
284
|
+
return {
|
|
285
|
+
...workflowRunDetail({
|
|
286
|
+
name: 'Deploy',
|
|
287
|
+
trigger_source: 'manual',
|
|
288
|
+
trigger_event: 'fire',
|
|
289
|
+
started_at: '2026-06-21T12:00:10.000Z',
|
|
290
|
+
jobs: [],
|
|
291
|
+
}),
|
|
292
|
+
...overrides,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function makeJob(overrides: Partial<WorkflowRunJobDetailDto> & {name: string}): Job {
|
|
297
|
+
return workflowJob(overrides);
|
|
298
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
2
|
+
import {useState} from 'react';
|
|
3
|
+
import type {WorkflowRunDetail} from '#core/workflow-run.js';
|
|
4
|
+
import type {JobGraphModel} from './graph-model.js';
|
|
5
|
+
import {JobGraphContent} from './job-graph-content.js';
|
|
6
|
+
|
|
7
|
+
export function JobGraphView({
|
|
8
|
+
model,
|
|
9
|
+
trigger,
|
|
10
|
+
selectedJobId,
|
|
11
|
+
defaultSelectedJobId,
|
|
12
|
+
onSelectedJobChange,
|
|
13
|
+
className,
|
|
14
|
+
}: {
|
|
15
|
+
model: JobGraphModel;
|
|
16
|
+
trigger: Pick<
|
|
17
|
+
WorkflowRunDetail,
|
|
18
|
+
'triggerDisplayLabel' | 'triggerLabel' | 'triggerProvider' | 'triggerSource'
|
|
19
|
+
>;
|
|
20
|
+
selectedJobId?: string | undefined;
|
|
21
|
+
defaultSelectedJobId?: string | undefined;
|
|
22
|
+
onSelectedJobChange?: ((jobId: string | undefined) => void) | undefined;
|
|
23
|
+
className?: string | undefined;
|
|
24
|
+
}) {
|
|
25
|
+
const [localSelectedJobId, setLocalSelectedJobId] = useState<string | undefined>(
|
|
26
|
+
defaultSelectedJobId,
|
|
27
|
+
);
|
|
28
|
+
const selected = selectedJobId ?? localSelectedJobId;
|
|
29
|
+
|
|
30
|
+
function selectJob(jobId: string | undefined) {
|
|
31
|
+
setLocalSelectedJobId(jobId);
|
|
32
|
+
onSelectedJobChange?.(jobId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section aria-label="Workflow jobs" className={cn('min-h-0', className)}>
|
|
37
|
+
<JobGraphContent
|
|
38
|
+
model={model}
|
|
39
|
+
trigger={trigger}
|
|
40
|
+
selectedJobId={selected}
|
|
41
|
+
onSelectJob={selectJob}
|
|
42
|
+
/>
|
|
43
|
+
</section>
|
|
44
|
+
);
|
|
45
|
+
}
|