@shipfox/client-workflows 21.0.0 → 22.0.1
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +33 -0
- package/dist/components/job-detail/job-detail-view.js +1 -2
- package/dist/components/job-detail/job-detail-view.js.map +1 -1
- package/dist/components/job-detail/job-empty-states.js +2 -2
- package/dist/components/job-detail/job-empty-states.js.map +1 -1
- package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -1
- package/dist/components/job-detail/job-execution-time-text.js +2 -15
- package/dist/components/job-detail/job-execution-time-text.js.map +1 -1
- package/dist/components/job-graph/job-duration-format.js +1 -1
- package/dist/components/job-graph/job-duration-format.js.map +1 -1
- package/dist/components/job-graph/job-graph-content.js +2 -2
- package/dist/components/job-graph/job-graph-content.js.map +1 -1
- package/dist/components/step-list/step-list.js +1 -2
- package/dist/components/step-list/step-list.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-content.js +13 -7
- package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-states.js +42 -37
- package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +14 -10
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +3 -3
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.js +20 -7
- package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.js +68 -50
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/pages/project-workflows-page.js +181 -172
- package/dist/pages/project-workflows-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/components/job-detail/job-detail-view.tsx +1 -2
- package/src/components/job-detail/job-empty-states.tsx +2 -2
- package/src/components/job-detail/job-execution-time-text.test.ts +46 -0
- package/src/components/job-detail/job-execution-time-text.tsx +2 -23
- package/src/components/job-graph/job-duration-format.ts +1 -1
- package/src/components/job-graph/job-graph-content.tsx +2 -2
- package/src/components/step-list/step-list.tsx +1 -2
- package/src/components/workflow-run-list/workflow-run-list-content.tsx +7 -2
- package/src/components/workflow-run-list/workflow-run-list-states.tsx +41 -40
- package/src/components/workflow-run-list/workflow-run-row.tsx +11 -8
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +1 -1
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +9 -1
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +3 -3
- package/src/components/workflow-run-view/run-workspace-nav.test.tsx +40 -2
- package/src/components/workflow-run-view/run-workspace-nav.tsx +28 -9
- package/src/components/workflow-run-view/workflow-run-view.stories.tsx +3 -1
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +11 -8
- package/src/components/workflow-run-view/workflow-run-view.tsx +78 -70
- package/src/pages/project-workflows-page.test.tsx +14 -4
- package/src/pages/project-workflows-page.tsx +138 -132
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-workflows",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "22.0.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -29,14 +29,14 @@
|
|
|
29
29
|
"date-fns": "^4.1.0",
|
|
30
30
|
"@shipfox/annotations-dto": "12.3.0",
|
|
31
31
|
"@shipfox/api-definitions-dto": "12.3.0",
|
|
32
|
-
"@shipfox/api-workflows-dto": "
|
|
32
|
+
"@shipfox/api-workflows-dto": "13.0.0",
|
|
33
33
|
"@shipfox/client-api": "6.0.1",
|
|
34
|
-
"@shipfox/client-logs": "
|
|
35
|
-
"@shipfox/client-projects": "
|
|
36
|
-
"@shipfox/client-shell": "
|
|
37
|
-
"@shipfox/client-triggers": "
|
|
38
|
-
"@shipfox/
|
|
39
|
-
"@shipfox/
|
|
34
|
+
"@shipfox/client-logs": "22.0.0",
|
|
35
|
+
"@shipfox/client-projects": "22.0.1",
|
|
36
|
+
"@shipfox/client-shell": "22.0.1",
|
|
37
|
+
"@shipfox/client-triggers": "22.0.1",
|
|
38
|
+
"@shipfox/react-ui": "2.1.0",
|
|
39
|
+
"@shipfox/client-ui": "22.0.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -558,11 +558,10 @@ function EmptyStateForMissingExecution({job}: {job: Job}) {
|
|
|
558
558
|
const emptyState = emptyStateForMissingExecution(job);
|
|
559
559
|
return (
|
|
560
560
|
<EmptyState
|
|
561
|
-
className="min-h-120 p-panel"
|
|
562
561
|
icon="componentLine"
|
|
563
562
|
title={emptyState.title}
|
|
564
563
|
description={emptyState.description}
|
|
565
|
-
variant="
|
|
564
|
+
variant="panel"
|
|
566
565
|
/>
|
|
567
566
|
);
|
|
568
567
|
}
|
|
@@ -256,11 +256,11 @@ function humanizeFailureReason(reason: string): string {
|
|
|
256
256
|
export function CarriedOverStepPanel() {
|
|
257
257
|
return (
|
|
258
258
|
<EmptyState
|
|
259
|
-
className="
|
|
259
|
+
className="rounded-8 border border-border-neutral-base bg-background-components-base"
|
|
260
260
|
icon="componentLine"
|
|
261
261
|
title="Carried over from a previous attempt"
|
|
262
262
|
description="Not executed in this run."
|
|
263
|
-
variant="
|
|
263
|
+
variant="panel"
|
|
264
264
|
/>
|
|
265
265
|
);
|
|
266
266
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {formatJobExecutionTime} from './job-execution-time-text.js';
|
|
2
|
+
|
|
3
|
+
const FROM_ISO = '2026-08-15T08:51:00.000Z';
|
|
4
|
+
const FROM_MS = Date.parse(FROM_ISO);
|
|
5
|
+
|
|
6
|
+
describe('formatJobExecutionTime', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test.each([
|
|
12
|
+
[999, '0s'],
|
|
13
|
+
[37_999, '37s'],
|
|
14
|
+
[60_000, '1m 00s'],
|
|
15
|
+
[124_999, '2m 04s'],
|
|
16
|
+
[3_780_999, '1h 03m'],
|
|
17
|
+
])('formats a live %i ms timer as %s', (elapsedMs, expected) => {
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
vi.setSystemTime(FROM_MS + elapsedMs);
|
|
20
|
+
|
|
21
|
+
const result = formatJobExecutionTime({
|
|
22
|
+
state: 'live',
|
|
23
|
+
fromIso: FROM_ISO,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(result).toBe(expected);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('falls back to zero seconds for an invalid live timestamp', () => {
|
|
30
|
+
const result = formatJobExecutionTime({state: 'live', fromIso: 'not-a-date'});
|
|
31
|
+
|
|
32
|
+
expect(result).toBe('0s');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test.each([
|
|
36
|
+
[{seconds: 37}, '37s'],
|
|
37
|
+
[{minutes: 1}, '1m 00s'],
|
|
38
|
+
[{minutes: 2, seconds: 4}, '2m 04s'],
|
|
39
|
+
[{hours: 1}, '1h 00m'],
|
|
40
|
+
[{hours: 1, minutes: 3}, '1h 03m'],
|
|
41
|
+
])('formats a fixed timer consistently as %s', (elapsed, expected) => {
|
|
42
|
+
const result = formatJobExecutionTime({state: 'fixed', elapsed});
|
|
43
|
+
|
|
44
|
+
expect(result).toBe(expected);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {useTimeTick} from '@shipfox/react-ui/time-ticker';
|
|
2
|
-
import {formatDuration} from '@shipfox/react-ui/utils';
|
|
3
2
|
import type {JobExecutionTime} from '#core/workflow-run.js';
|
|
3
|
+
import {formatJobExecutionTimeLabel} from '../job-graph/job-duration-format.js';
|
|
4
4
|
|
|
5
5
|
export function JobExecutionTimeText({time}: {time: JobExecutionTime}) {
|
|
6
6
|
useTimeTick();
|
|
@@ -9,26 +9,5 @@ export function JobExecutionTimeText({time}: {time: JobExecutionTime}) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function formatJobExecutionTime(time: JobExecutionTime): string {
|
|
12
|
-
|
|
13
|
-
return formatDuration(Date.now() - Date.parse(time.fromIso));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return formatElapsedDuration(time.elapsed);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function formatElapsedDuration(duration: {
|
|
20
|
-
days?: number | undefined;
|
|
21
|
-
hours?: number | undefined;
|
|
22
|
-
minutes?: number | undefined;
|
|
23
|
-
seconds?: number | undefined;
|
|
24
|
-
}): string {
|
|
25
|
-
const days = duration.days ?? 0;
|
|
26
|
-
const hours = duration.hours ?? 0;
|
|
27
|
-
const minutes = duration.minutes ?? 0;
|
|
28
|
-
const seconds = duration.seconds ?? 0;
|
|
29
|
-
|
|
30
|
-
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
31
|
-
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
32
|
-
if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
33
|
-
return `${seconds}s`;
|
|
12
|
+
return formatJobExecutionTimeLabel(time);
|
|
34
13
|
}
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
export function formatJobExecutionTimeLabel(time: JobExecutionTime): string {
|
|
9
9
|
switch (time.state) {
|
|
10
10
|
case 'live':
|
|
11
|
-
return humanDuration(time.fromIso);
|
|
11
|
+
return humanDuration(time.fromIso) || '0s';
|
|
12
12
|
case 'fixed':
|
|
13
13
|
return formatFixedDurationLabel(time.elapsed);
|
|
14
14
|
default: {
|
|
@@ -37,11 +37,11 @@ export function JobGraphContent({
|
|
|
37
37
|
if (model.nodes.length === 0) {
|
|
38
38
|
return (
|
|
39
39
|
<EmptyState
|
|
40
|
-
className="min-h-160
|
|
40
|
+
className="min-h-160"
|
|
41
41
|
icon="componentLine"
|
|
42
42
|
title="No jobs yet"
|
|
43
43
|
description="This run has not materialized jobs."
|
|
44
|
-
variant="
|
|
44
|
+
variant="panel"
|
|
45
45
|
/>
|
|
46
46
|
);
|
|
47
47
|
}
|
|
@@ -326,11 +326,10 @@ function StepListEmptyStateView({
|
|
|
326
326
|
if (!emptyState.status) {
|
|
327
327
|
return (
|
|
328
328
|
<EmptyState
|
|
329
|
-
className="min-h-120 p-panel"
|
|
330
329
|
icon="componentLine"
|
|
331
330
|
title={emptyState.title}
|
|
332
331
|
description={emptyState.description}
|
|
333
|
-
variant="
|
|
332
|
+
variant="panel"
|
|
334
333
|
/>
|
|
335
334
|
);
|
|
336
335
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {QueryLoadError} from '@shipfox/client-ui';
|
|
2
|
+
import {Panel} from '@shipfox/react-ui/panel';
|
|
2
3
|
import type {WorkflowRunListItem} from '#core/workflow-run.js';
|
|
3
4
|
import type {WorkflowRunListQuery} from './types.js';
|
|
4
5
|
import {
|
|
@@ -55,11 +56,15 @@ export function WorkflowRunListContent({
|
|
|
55
56
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
56
57
|
{isPending ? <WorkflowRunListSkeleton /> : null}
|
|
57
58
|
{!isPending ? (
|
|
58
|
-
<
|
|
59
|
+
<Panel>
|
|
60
|
+
<QueryLoadError query={query} subject="workflow runs" icon="pulseLine" variant="panel" />
|
|
61
|
+
</Panel>
|
|
59
62
|
) : null}
|
|
60
63
|
{!isPending && refreshFailed ? <WorkflowRunListStaleError query={query} /> : null}
|
|
61
64
|
{showEmptyState ? (
|
|
62
|
-
<
|
|
65
|
+
<Panel>
|
|
66
|
+
<WorkflowRunListEmpty workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
|
|
67
|
+
</Panel>
|
|
63
68
|
) : null}
|
|
64
69
|
{showNoMatches ? (
|
|
65
70
|
<WorkflowRunListNoMatches
|
|
@@ -2,6 +2,7 @@ import type {QueryLoadErrorQuery} from '@shipfox/client-ui';
|
|
|
2
2
|
import {Button} from '@shipfox/react-ui/button';
|
|
3
3
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
4
4
|
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
5
|
+
import {Panel} from '@shipfox/react-ui/panel';
|
|
5
6
|
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
6
7
|
import {Text} from '@shipfox/react-ui/typography';
|
|
7
8
|
import {Link} from '@tanstack/react-router';
|
|
@@ -15,16 +16,12 @@ const SKELETON_ROW_COUNT = 8;
|
|
|
15
16
|
*/
|
|
16
17
|
export function WorkflowRunListSkeleton() {
|
|
17
18
|
return (
|
|
18
|
-
<
|
|
19
|
-
className="@container divide-y divide-border-neutral-base"
|
|
20
|
-
role="status"
|
|
21
|
-
aria-label="Loading runs"
|
|
22
|
-
>
|
|
19
|
+
<Panel role="status" aria-label="Loading runs" className="@container divide-y">
|
|
23
20
|
{Array.from({length: SKELETON_ROW_COUNT}).map((_, index) => (
|
|
24
21
|
<div
|
|
25
22
|
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton row, stable position
|
|
26
23
|
key={index}
|
|
27
|
-
className="flex items-center gap-inline px-row py-row @min-[976px]:h-44 @min-[976px]:py-0"
|
|
24
|
+
className="flex min-h-44 items-center gap-inline px-row py-row @min-[976px]:h-44 @min-[976px]:py-0"
|
|
28
25
|
>
|
|
29
26
|
<Skeleton className="size-14 shrink-0 rounded-full" />
|
|
30
27
|
<Skeleton className="h-12 w-[220px] max-w-[40%]" />
|
|
@@ -33,7 +30,7 @@ export function WorkflowRunListSkeleton() {
|
|
|
33
30
|
<Skeleton className="h-12 w-48" />
|
|
34
31
|
</div>
|
|
35
32
|
))}
|
|
36
|
-
</
|
|
33
|
+
</Panel>
|
|
37
34
|
);
|
|
38
35
|
}
|
|
39
36
|
|
|
@@ -89,6 +86,7 @@ export function WorkflowRunListEmpty({
|
|
|
89
86
|
</Button>
|
|
90
87
|
) : null
|
|
91
88
|
}
|
|
89
|
+
variant="panel"
|
|
92
90
|
/>
|
|
93
91
|
);
|
|
94
92
|
}
|
|
@@ -116,40 +114,43 @@ export function WorkflowRunListNoMatches({
|
|
|
116
114
|
onLoadMore?: () => void;
|
|
117
115
|
}) {
|
|
118
116
|
return (
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
117
|
+
<Panel>
|
|
118
|
+
<div className="flex flex-col gap-inline">
|
|
119
|
+
{isFetchNextPageError ? (
|
|
120
|
+
<Callout role="alert" type="error">
|
|
121
|
+
Could not load more workflow runs. Try again to continue searching older runs.
|
|
122
|
+
</Callout>
|
|
123
|
+
) : null}
|
|
124
|
+
<EmptyState
|
|
125
|
+
icon="filterOffLine"
|
|
126
|
+
title={hasNextPage ? 'No matches in loaded history' : 'No matching runs'}
|
|
127
|
+
description={
|
|
128
|
+
hasNextPage
|
|
129
|
+
? 'No loaded run matches these filters. Load more to search further back.'
|
|
130
|
+
: 'No run matches these filters.'
|
|
131
|
+
}
|
|
132
|
+
action={
|
|
133
|
+
<div className="flex flex-wrap justify-center gap-inline">
|
|
134
|
+
{hasNextPage && onLoadMore ? (
|
|
135
|
+
<Button
|
|
136
|
+
type="button"
|
|
137
|
+
size="sm"
|
|
138
|
+
variant="primary"
|
|
139
|
+
isLoading={isFetchingNextPage}
|
|
140
|
+
onClick={onLoadMore}
|
|
141
|
+
>
|
|
142
|
+
Load more runs
|
|
143
|
+
</Button>
|
|
144
|
+
) : null}
|
|
145
|
+
<Button type="button" size="sm" variant="secondary" onClick={onClear}>
|
|
146
|
+
Show all runs
|
|
144
147
|
</Button>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
/>
|
|
152
|
-
</div>
|
|
148
|
+
</div>
|
|
149
|
+
}
|
|
150
|
+
variant="panel"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</Panel>
|
|
153
154
|
);
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {TriggerSourceIcon} from '@shipfox/client-triggers';
|
|
2
2
|
import {Icon, type IconName} from '@shipfox/react-ui/icon';
|
|
3
|
+
import {Panel} from '@shipfox/react-ui/panel';
|
|
3
4
|
import {RelativeTime} from '@shipfox/react-ui/relative-time';
|
|
4
5
|
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
5
6
|
import {Code, Text} from '@shipfox/react-ui/typography';
|
|
@@ -37,13 +38,15 @@ export function WorkflowRunRowList({
|
|
|
37
38
|
// A container, not the viewport, drives the row's breakpoints. What a row can afford is its
|
|
38
39
|
// own width; keying off the viewport would keep the job strip hidden on a wide screen or
|
|
39
40
|
// crush it on a narrow one.
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
<Panel className="@container">
|
|
42
|
+
<ul className="divide-y divide-border-neutral-base">
|
|
43
|
+
{runs.map((run) => (
|
|
44
|
+
<li key={run.id}>
|
|
45
|
+
<WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
|
|
46
|
+
</li>
|
|
47
|
+
))}
|
|
48
|
+
</ul>
|
|
49
|
+
</Panel>
|
|
47
50
|
);
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -125,7 +128,7 @@ export function WorkflowRunRow({
|
|
|
125
128
|
// Rows run edge to edge inside the list's scroll container, which would clip the standard
|
|
126
129
|
// outset focus ring, so this one is inset per the design system's focus-ring rule.
|
|
127
130
|
const rowClassName =
|
|
128
|
-
'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-neutral-hover focus-visible:shadow-
|
|
131
|
+
'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-neutral-hover focus-visible:shadow-focus-inset focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
|
|
129
132
|
|
|
130
133
|
// Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
|
|
131
134
|
// replaces them on the next poll, so they render non-interactively instead of as a link
|
|
@@ -57,7 +57,7 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
|
|
|
57
57
|
|
|
58
58
|
const withFrame: Decorator = (Story) => (
|
|
59
59
|
<div className="min-h-screen bg-background-subtle-base">
|
|
60
|
-
<div className="
|
|
60
|
+
<div className="flex min-h-screen w-full flex-col px-frame py-frame">
|
|
61
61
|
<Story />
|
|
62
62
|
<div className="min-h-0 flex-1 bg-background-subtle-base p-16" />
|
|
63
63
|
</div>
|
|
@@ -299,7 +299,15 @@ describe('WorkflowRunSummary', () => {
|
|
|
299
299
|
const onCancel = vi.fn();
|
|
300
300
|
renderSummary({}, {onCancel});
|
|
301
301
|
|
|
302
|
-
|
|
302
|
+
const button = await screen.findByRole('button', {name: 'Cancel workflow'});
|
|
303
|
+
expect(button).toHaveClass(
|
|
304
|
+
'bg-background-button-danger-default',
|
|
305
|
+
'text-foreground-neutral-on-color',
|
|
306
|
+
'shadow-button-danger',
|
|
307
|
+
);
|
|
308
|
+
expect(button).not.toHaveClass('bg-background-neutral-base');
|
|
309
|
+
|
|
310
|
+
await user.click(button);
|
|
303
311
|
|
|
304
312
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
305
313
|
});
|
|
@@ -29,7 +29,7 @@ import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
|
|
|
29
29
|
const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
|
|
30
30
|
...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
|
|
31
31
|
);
|
|
32
|
-
const
|
|
32
|
+
const NEUTRAL_ACTION_SURFACE_CLASS_NAME =
|
|
33
33
|
'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
|
|
34
34
|
|
|
35
35
|
type WorkflowRunAction = 'cancel' | 'rerun-all' | 'rerun-menu' | 'none';
|
|
@@ -225,7 +225,7 @@ function WorkflowRunActionSlot({
|
|
|
225
225
|
type="button"
|
|
226
226
|
variant="secondary"
|
|
227
227
|
size="xs"
|
|
228
|
-
className={
|
|
228
|
+
className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
|
|
229
229
|
isLoading={rerunPending}
|
|
230
230
|
disabled={rerunPending}
|
|
231
231
|
onClick={() => onRerun('all')}
|
|
@@ -244,7 +244,7 @@ function WorkflowRunActionSlot({
|
|
|
244
244
|
type="button"
|
|
245
245
|
variant="secondary"
|
|
246
246
|
size="xs"
|
|
247
|
-
className={
|
|
247
|
+
className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
|
|
248
248
|
iconRight="arrowDownSLine"
|
|
249
249
|
isLoading={rerunPending}
|
|
250
250
|
disabled={rerunPending}
|
|
@@ -13,6 +13,7 @@ const RUN_ID = '66666666-6666-4666-8666-666666666666';
|
|
|
13
13
|
const CURRENT_JOB_ID = '88888888-8888-4888-8888-888888888888';
|
|
14
14
|
const BUILD_LINK_PATTERN = /build/;
|
|
15
15
|
const DEPLOY_LINK_PATTERN = /deploy/;
|
|
16
|
+
const SETUP_LINK_PATTERN = /setup/;
|
|
16
17
|
const NOW = Date.parse('2026-06-26T12:00:00.000Z');
|
|
17
18
|
|
|
18
19
|
describe('RunWorkspaceNav', () => {
|
|
@@ -92,7 +93,8 @@ describe('RunWorkspaceNav', () => {
|
|
|
92
93
|
/>,
|
|
93
94
|
);
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
const summary = await screen.findByRole('link', {name: 'Summary'});
|
|
97
|
+
expect(summary).toBeInTheDocument();
|
|
96
98
|
expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
|
|
97
99
|
expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
|
|
98
100
|
expect(screen.getByRole('link', {name: 'Annotations'})).toBeInTheDocument();
|
|
@@ -112,6 +114,13 @@ describe('RunWorkspaceNav', () => {
|
|
|
112
114
|
|
|
113
115
|
const current = within(jobs).getByRole('link', {name: BUILD_LINK_PATTERN});
|
|
114
116
|
expect(current).toHaveAttribute('aria-current', 'page');
|
|
117
|
+
expect(current.querySelector('[data-run-workspace-active-bar]')).not.toBeNull();
|
|
118
|
+
expect(
|
|
119
|
+
within(jobs)
|
|
120
|
+
.getByRole('link', {name: SETUP_LINK_PATTERN})
|
|
121
|
+
.querySelector('[data-run-workspace-active-bar]'),
|
|
122
|
+
).toBeNull();
|
|
123
|
+
expect(jobs.querySelectorAll('[data-run-workspace-active-bar]')).toHaveLength(1);
|
|
115
124
|
expect(current).toHaveAttribute(
|
|
116
125
|
'href',
|
|
117
126
|
expect.stringContaining(`/runs/${RUN_ID}/jobs/${CURRENT_JOB_ID}`),
|
|
@@ -137,7 +146,36 @@ describe('RunWorkspaceNav', () => {
|
|
|
137
146
|
|
|
138
147
|
expect(await screen.findByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
|
|
139
148
|
expect(screen.getByRole('link', {name: BUILD_LINK_PATTERN})).toBeInTheDocument();
|
|
140
|
-
|
|
149
|
+
const summary = screen.getByRole('link', {name: 'Summary'});
|
|
150
|
+
expect(summary).toHaveAttribute('aria-current', 'page');
|
|
151
|
+
expect(summary.querySelector('[data-run-workspace-active-bar]')).not.toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('falls back to the first job when the current job id is stale', async () => {
|
|
155
|
+
const run = workflowRunDetail({
|
|
156
|
+
id: RUN_ID,
|
|
157
|
+
jobs: [
|
|
158
|
+
workflowJobDto({id: 'job-deploy', name: 'deploy', position: 1}),
|
|
159
|
+
workflowJobDto({id: 'job-setup', name: 'setup', position: 0}),
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
renderWithRouter(
|
|
164
|
+
<RunWorkspaceNav
|
|
165
|
+
workspaceSlug="acme"
|
|
166
|
+
projectSlug="project"
|
|
167
|
+
run={run}
|
|
168
|
+
activeSection="summary"
|
|
169
|
+
currentJobId="stale-job-id"
|
|
170
|
+
/>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const jobs = (await screen.findByRole('heading', {name: 'Jobs'})).closest('section');
|
|
174
|
+
if (!jobs) throw new Error('Missing jobs section');
|
|
175
|
+
|
|
176
|
+
const fallback = within(jobs).getByRole('link', {name: SETUP_LINK_PATTERN});
|
|
177
|
+
expect(fallback).toHaveAttribute('aria-current', 'page');
|
|
178
|
+
expect(jobs.querySelectorAll('[data-run-workspace-active-bar]')).toHaveLength(1);
|
|
141
179
|
});
|
|
142
180
|
|
|
143
181
|
test('scrolls the current job into view when the mobile rail opens', async () => {
|
|
@@ -23,6 +23,9 @@ import {JobExecutionTimeText} from '../job-detail/job-execution-time-text.js';
|
|
|
23
23
|
|
|
24
24
|
type RunWorkspaceSection = Exclude<WorkflowRunTab, 'jobs'>;
|
|
25
25
|
|
|
26
|
+
const RUN_WORKSPACE_LINK_CLASS_NAME =
|
|
27
|
+
'relative flex min-h-32 items-center gap-inline rounded-4 px-tight outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44';
|
|
28
|
+
|
|
26
29
|
export interface RunWorkspaceNavProps {
|
|
27
30
|
workspaceSlug: string;
|
|
28
31
|
projectSlug: string;
|
|
@@ -48,12 +51,14 @@ export function RunWorkspaceNav({
|
|
|
48
51
|
}: RunWorkspaceNavProps) {
|
|
49
52
|
const jobs = useMemo(() => [...run.jobs].sort(compareJobs), [run.jobs]);
|
|
50
53
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
51
|
-
const currentJob =
|
|
54
|
+
const currentJob =
|
|
55
|
+
jobs.find((job) => job.id === currentJobId) ?? (currentJobId ? jobs[0] : undefined);
|
|
56
|
+
const resolvedCurrentJobId = currentJob?.id;
|
|
52
57
|
const currentLabel = currentJob?.displayName ?? sectionLabel(activeSection);
|
|
53
58
|
|
|
54
59
|
return (
|
|
55
60
|
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
56
|
-
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base min-[768px]:w-240 min-[768px]:border-b-0
|
|
61
|
+
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base min-[768px]:w-240 min-[768px]:border-b-0">
|
|
57
62
|
<Collapsible
|
|
58
63
|
open={mobileOpen}
|
|
59
64
|
onOpenChange={setMobileOpen}
|
|
@@ -96,7 +101,7 @@ export function RunWorkspaceNav({
|
|
|
96
101
|
run={run}
|
|
97
102
|
jobs={jobs}
|
|
98
103
|
activeSection={activeSection}
|
|
99
|
-
currentJobId={
|
|
104
|
+
currentJobId={resolvedCurrentJobId}
|
|
100
105
|
jobSearch={jobSearch}
|
|
101
106
|
annotationSummary={annotationSummary}
|
|
102
107
|
mobileOpen={mobileOpen}
|
|
@@ -140,8 +145,8 @@ function RunWorkspaceNavContent({
|
|
|
140
145
|
}, [currentJobId, mobileOpen]);
|
|
141
146
|
|
|
142
147
|
return (
|
|
143
|
-
<nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col
|
|
144
|
-
<ul>
|
|
148
|
+
<nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
149
|
+
<ul className="border-b border-border-neutral-base pb-row">
|
|
145
150
|
<li>
|
|
146
151
|
<RunSectionLink
|
|
147
152
|
workspaceSlug={workspaceSlug}
|
|
@@ -157,7 +162,7 @@ function RunWorkspaceNavContent({
|
|
|
157
162
|
|
|
158
163
|
<section
|
|
159
164
|
aria-labelledby="run-workspace-jobs-heading"
|
|
160
|
-
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
|
165
|
+
className="flex min-h-0 min-w-0 flex-1 flex-col border-b border-border-neutral-base py-row"
|
|
161
166
|
>
|
|
162
167
|
<div className="flex items-center justify-between gap-inline px-tight pb-[6px]">
|
|
163
168
|
<Text
|
|
@@ -189,10 +194,12 @@ function RunWorkspaceNavContent({
|
|
|
189
194
|
onClick={onNavigate}
|
|
190
195
|
aria-current={current ? 'page' : undefined}
|
|
191
196
|
className={cn(
|
|
192
|
-
|
|
197
|
+
RUN_WORKSPACE_LINK_CLASS_NAME,
|
|
198
|
+
'min-w-0 text-left',
|
|
193
199
|
current && 'bg-background-neutral-hover',
|
|
194
200
|
)}
|
|
195
201
|
>
|
|
202
|
+
{current ? <RunWorkspaceActiveBar /> : null}
|
|
196
203
|
<WorkflowStatusIcon
|
|
197
204
|
status={deriveJobDisplayStatus(job)}
|
|
198
205
|
size={12}
|
|
@@ -211,7 +218,7 @@ function RunWorkspaceNavContent({
|
|
|
211
218
|
</ol>
|
|
212
219
|
</section>
|
|
213
220
|
|
|
214
|
-
<section aria-labelledby="run-workspace-details-heading">
|
|
221
|
+
<section aria-labelledby="run-workspace-details-heading" className="pt-row">
|
|
215
222
|
<Text
|
|
216
223
|
as="h2"
|
|
217
224
|
id="run-workspace-details-heading"
|
|
@@ -292,10 +299,12 @@ function RunSectionLink({
|
|
|
292
299
|
: undefined
|
|
293
300
|
}
|
|
294
301
|
className={cn(
|
|
295
|
-
|
|
302
|
+
RUN_WORKSPACE_LINK_CLASS_NAME,
|
|
303
|
+
'text-xs font-medium text-foreground-neutral-base',
|
|
296
304
|
current && 'bg-background-neutral-hover',
|
|
297
305
|
)}
|
|
298
306
|
>
|
|
307
|
+
{current ? <RunWorkspaceActiveBar /> : null}
|
|
299
308
|
{sectionLabel(section)}
|
|
300
309
|
{count ? (
|
|
301
310
|
<span
|
|
@@ -310,6 +319,16 @@ function RunSectionLink({
|
|
|
310
319
|
);
|
|
311
320
|
}
|
|
312
321
|
|
|
322
|
+
function RunWorkspaceActiveBar() {
|
|
323
|
+
return (
|
|
324
|
+
<span
|
|
325
|
+
aria-hidden="true"
|
|
326
|
+
data-run-workspace-active-bar
|
|
327
|
+
className="absolute inset-y-0 left-0 w-2 rounded-l-4 bg-border-highlights-interactive"
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
313
332
|
function sectionLabel(section: RunWorkspaceSection): string {
|
|
314
333
|
if (section === 'annotations') return 'Annotations';
|
|
315
334
|
if (section === 'source') return 'Source';
|
|
@@ -133,7 +133,9 @@ function RunWorkspaceStoryProviders({children}: {children: ReactNode}) {
|
|
|
133
133
|
return (
|
|
134
134
|
<QueryClientProvider client={queryClient}>
|
|
135
135
|
<div className="flex h-[720px] min-w-[1440px] w-[1440px] bg-background-subtle-base">
|
|
136
|
-
|
|
136
|
+
<div className="flex min-h-0 min-w-0 w-full flex-1 flex-col px-frame py-frame">
|
|
137
|
+
{children}
|
|
138
|
+
</div>
|
|
137
139
|
</div>
|
|
138
140
|
</QueryClientProvider>
|
|
139
141
|
);
|
|
@@ -54,16 +54,18 @@ describe('WorkflowRunView', () => {
|
|
|
54
54
|
.closest('[data-run-workspace-layout]');
|
|
55
55
|
expect(workspaceLayout).toHaveClass('border-t', 'border-border-neutral-base');
|
|
56
56
|
expect(workspaceLayout).not.toHaveClass('max-w-[1360px]');
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
'max-w-[calc(240px_+_1120px)]',
|
|
61
|
-
'min-[768px]:flex-row',
|
|
62
|
-
);
|
|
57
|
+
const workspaceFrame = workspaceLayout?.querySelector('[data-run-workspace-frame]');
|
|
58
|
+
expect(workspaceFrame).toHaveClass('w-full', 'min-[768px]:flex-row');
|
|
59
|
+
expect(workspaceFrame).not.toHaveClass('mx-auto', 'max-w-[calc(240px_+_1120px)]');
|
|
63
60
|
expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
|
|
64
61
|
expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
|
|
65
62
|
expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
|
|
66
|
-
|
|
63
|
+
const allJobsSummary = screen.getByRole('region', {name: 'All jobs summary'});
|
|
64
|
+
expect(allJobsSummary).toBeInTheDocument();
|
|
65
|
+
expect(allJobsSummary.querySelector('[data-slot="panel"]')).not.toBeNull();
|
|
66
|
+
expect(allJobsSummary.querySelector('[data-slot="panel-body"]')).toHaveClass(
|
|
67
|
+
'bg-background-components-base',
|
|
68
|
+
);
|
|
67
69
|
expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
|
|
68
70
|
expect(container.querySelector('[data-run-workspace-content]')).toHaveClass('flex-1');
|
|
69
71
|
expect(container.querySelector('[data-run-workspace-content]')).not.toHaveClass(
|
|
@@ -82,7 +84,8 @@ describe('WorkflowRunView', () => {
|
|
|
82
84
|
renderView({tab});
|
|
83
85
|
|
|
84
86
|
const section = await screen.findByRole('region', {name: region});
|
|
85
|
-
expect(section.firstElementChild).not.toHaveClass('max-w-[1120px]');
|
|
87
|
+
expect(section.firstElementChild).not.toHaveClass('mx-auto', 'px-frame', 'max-w-[1120px]');
|
|
88
|
+
expect(section.querySelector('[data-slot="panel"]')).not.toBeNull();
|
|
86
89
|
});
|
|
87
90
|
|
|
88
91
|
test('keeps dedicated job content on the full-width data surface', async () => {
|