@shipfox/client-workflows 20.0.0 → 22.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +51 -0
- package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-view.js +232 -44
- 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/step-attempt-log-panel.d.ts +7 -1
- package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
- package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
- package/dist/components/job-detail/step-attempt-log-panel.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-model.d.ts +1 -0
- package/dist/components/step-list/step-list-model.d.ts.map +1 -1
- package/dist/components/step-list/step-list-model.js +1 -1
- package/dist/components/step-list/step-list-model.js.map +1 -1
- package/dist/components/step-list/step-list.d.ts +2 -1
- package/dist/components/step-list/step-list.d.ts.map +1 -1
- package/dist/components/step-list/step-list.js +25 -9
- package/dist/components/step-list/step-list.js.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.d.ts +9 -1
- package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.js +22 -11
- package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-filters.js +1 -1
- package/dist/components/workflow-run-list/workflow-run-filters.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 +43 -38
- package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
- package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +25 -18
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +4 -4
- 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-states.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.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 +103 -78
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
- package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
- package/dist/pages/project-workflows-page.d.ts.map +1 -1
- package/dist/pages/project-workflows-page.js +171 -158
- package/dist/pages/project-workflows-page.js.map +1 -1
- package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
- package/dist/pages/workflow-job-detail-page.js +3 -2
- package/dist/pages/workflow-job-detail-page.js.map +1 -1
- package/dist/pages/workflow-run-detail-page.js +2 -1
- package/dist/pages/workflow-run-detail-page.js.map +1 -1
- package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
- package/dist/pages/workflow-run-list-page.js +9 -11
- package/dist/pages/workflow-run-list-page.js.map +1 -1
- package/dist/routes/job-detail.d.ts +1 -1
- package/dist/routes/job-detail.js +1 -1
- package/dist/routes/job-detail.js.map +1 -1
- package/dist/routes/run-detail.d.ts +1 -1
- package/dist/routes/run-detail.js +1 -1
- package/dist/routes/run-detail.js.map +1 -1
- package/dist/routes/runs.d.ts +1 -1
- package/dist/routes/runs.js +1 -1
- package/dist/routes/runs.js.map +1 -1
- package/dist/routes/workflows.d.ts +3 -0
- package/dist/routes/workflows.js +3 -0
- package/dist/routes/workflows.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 +264 -49
- package/src/components/job-detail/job-empty-states.tsx +2 -2
- package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
- package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
- package/src/components/job-graph/job-graph-content.tsx +2 -2
- package/src/components/job-graph/job-graph.stories.tsx +1 -1
- package/src/components/job-graph/job-node.stories.tsx +1 -1
- package/src/components/step-list/step-list-model.ts +1 -1
- package/src/components/step-list/step-list.test.tsx +29 -1
- package/src/components/step-list/step-list.tsx +29 -11
- package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
- package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
- package/src/components/workflow-run-list/workflow-run-list-content.tsx +7 -2
- package/src/components/workflow-run-list/workflow-run-list-states.tsx +42 -41
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +53 -4
- package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
- package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
- package/src/components/workflow-run-list/workflow-run-row.tsx +26 -19
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +3 -3
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +11 -2
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +4 -7
- 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-states.tsx +1 -4
- package/src/components/workflow-run-view/workflow-run-view.stories.tsx +142 -0
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +42 -8
- package/src/components/workflow-run-view/workflow-run-view.tsx +112 -96
- package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
- package/src/pages/project-workflows-page.tsx +135 -125
- package/src/pages/workflow-job-detail-page.test.tsx +32 -0
- package/src/pages/workflow-job-detail-page.tsx +5 -1
- package/src/pages/workflow-run-detail-page.tsx +1 -1
- package/src/pages/workflow-run-list-page.tsx +9 -11
- package/src/pages/workflow-run-pages.test.tsx +26 -0
- package/src/routes/job-detail.tsx +1 -1
- package/src/routes/run-detail.tsx +1 -1
- package/src/routes/runs.tsx +1 -1
- package/src/routes/workflows.tsx +1 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {JobStatusDto} from '@shipfox/api-workflows-dto';
|
|
2
|
+
import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
|
|
2
3
|
import {screen, within} from '@testing-library/react';
|
|
3
4
|
import userEvent from '@testing-library/user-event';
|
|
4
5
|
import type {WorkflowRunListItem, WorkflowRunStatus} from '#core/workflow-run.js';
|
|
@@ -8,12 +9,14 @@ import {
|
|
|
8
9
|
workflowRunListItem,
|
|
9
10
|
} from '#test/fixtures/workflow-run.js';
|
|
10
11
|
import {renderWithRouter} from '#test/render.js';
|
|
12
|
+
import {JOB_STATUS_STRIP_WIDTH, jobStatusStripWidthForPreview} from './job-status-strip.js';
|
|
11
13
|
import type {WorkflowRunListQuery, WorkflowRunListViewProps} from './types.js';
|
|
12
14
|
import {WorkflowRunListView} from './workflow-run-list-view.js';
|
|
13
15
|
|
|
14
16
|
const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
|
|
15
17
|
const JOB_STRIP_LABEL_RE = /jobs:/u;
|
|
16
18
|
const JOB_SUMMARY_NAME_RE = /queued-build/u;
|
|
19
|
+
const OVERFLOW_COUNT_RE = /^\+/u;
|
|
17
20
|
const WORKSPACE_SLUG = 'acme';
|
|
18
21
|
const PROJECT_SLUG = 'checkout-api';
|
|
19
22
|
|
|
@@ -35,6 +38,38 @@ describe('WorkflowRunListView', () => {
|
|
|
35
38
|
vi.restoreAllMocks();
|
|
36
39
|
});
|
|
37
40
|
|
|
41
|
+
describe('panel composition', () => {
|
|
42
|
+
test('keeps the controls in the panel header and states in its body', async () => {
|
|
43
|
+
renderListView([run('succeeded', 'build-image')]);
|
|
44
|
+
await screen.findByText('build-image');
|
|
45
|
+
|
|
46
|
+
const panel = document.querySelector<HTMLElement>('[data-slot="panel"]');
|
|
47
|
+
const header = panel?.querySelector<HTMLElement>('[data-slot="panel-header"]');
|
|
48
|
+
const body = panel?.querySelector<HTMLElement>('[data-slot="panel-body"]');
|
|
49
|
+
|
|
50
|
+
expect(panel).not.toBeNull();
|
|
51
|
+
expect(header).not.toBeNull();
|
|
52
|
+
expect(body).not.toBeNull();
|
|
53
|
+
expect(within(header as HTMLElement).getByLabelText('Search runs')).toBeInTheDocument();
|
|
54
|
+
expect(
|
|
55
|
+
within(header as HTMLElement).getByRole('button', {name: filterTrigger('Status')}),
|
|
56
|
+
).toBeInTheDocument();
|
|
57
|
+
expect(
|
|
58
|
+
within(header as HTMLElement).getByRole('button', {name: filterTrigger('Branch')}),
|
|
59
|
+
).toBeInTheDocument();
|
|
60
|
+
expect(
|
|
61
|
+
within(header as HTMLElement).getByRole('button', {name: filterTrigger('Actor')}),
|
|
62
|
+
).toBeInTheDocument();
|
|
63
|
+
expect(
|
|
64
|
+
within(header as HTMLElement).getByRole('button', {name: filterTrigger('Event')}),
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
expect(
|
|
67
|
+
within(header as HTMLElement).getByLabelText('Filter runs by creation date'),
|
|
68
|
+
).toBeInTheDocument();
|
|
69
|
+
expect(within(body as HTMLElement).getByText('build-image')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
38
73
|
describe('filtering', () => {
|
|
39
74
|
test('narrows the list to the selected status', async () => {
|
|
40
75
|
const user = userEvent.setup();
|
|
@@ -380,10 +415,24 @@ describe('WorkflowRunListView', () => {
|
|
|
380
415
|
test('counts the jobs it had to hide beyond the strip threshold', async () => {
|
|
381
416
|
renderListView([run('running', 'deploy-web', 'run-1', {...workflowRunJobsOfStatus(40)})]);
|
|
382
417
|
|
|
383
|
-
expect(await screen.findByText('+
|
|
418
|
+
expect(await screen.findByText('+24')).toBeInTheDocument();
|
|
384
419
|
expect(screen.getByRole('img', {name: '40 jobs: 40 succeeded'})).toBeInTheDocument();
|
|
385
420
|
});
|
|
386
421
|
|
|
422
|
+
test('renders every job in the API preview without a client-side cap', async () => {
|
|
423
|
+
renderListView([run('running', 'deploy-web', 'run-1', {...workflowRunJobsOfStatus(16)})]);
|
|
424
|
+
|
|
425
|
+
const strip = await screen.findByRole('img', {name: '16 jobs: 16 succeeded'});
|
|
426
|
+
|
|
427
|
+
expect(within(strip).getAllByLabelText('Succeeded')).toHaveLength(16);
|
|
428
|
+
expect(within(strip).queryByText(OVERFLOW_COUNT_RE)).not.toBeInTheDocument();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('reserves the full API preview and overflow marker width', () => {
|
|
432
|
+
expect(jobStatusStripWidthForPreview(WORKFLOW_RUN_JOB_PREVIEW_LIMIT)).toBe(294);
|
|
433
|
+
expect(JOB_STATUS_STRIP_WIDTH).toBe(294);
|
|
434
|
+
});
|
|
435
|
+
|
|
387
436
|
// The API sends a bounded preview, so a run whose only failure sits past it has no failed
|
|
388
437
|
// job in the payload at all. The counts still carry it, and the overflow glyph has to say
|
|
389
438
|
// so rather than showing an unbroken row of green.
|
|
@@ -398,7 +447,7 @@ describe('WorkflowRunListView', () => {
|
|
|
398
447
|
name: '21 jobs: 1 failed, 20 succeeded',
|
|
399
448
|
});
|
|
400
449
|
expect(strip).toBeInTheDocument();
|
|
401
|
-
expect(screen.getByText('+
|
|
450
|
+
expect(screen.getByText('+5')).toBeInTheDocument();
|
|
402
451
|
expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
|
|
403
452
|
});
|
|
404
453
|
|
|
@@ -420,8 +469,8 @@ describe('WorkflowRunListView', () => {
|
|
|
420
469
|
]);
|
|
421
470
|
|
|
422
471
|
const strip = await screen.findByRole('img', {name: '20 jobs: 20 listening'});
|
|
423
|
-
expect(screen.getByText('+
|
|
424
|
-
expect(within(strip).getAllByLabelText('Listening')).toHaveLength(
|
|
472
|
+
expect(screen.getByText('+4')).toBeInTheDocument();
|
|
473
|
+
expect(within(strip).getAllByLabelText('Listening')).toHaveLength(17);
|
|
425
474
|
|
|
426
475
|
const user = userEvent.setup();
|
|
427
476
|
await user.hover(strip);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {Panel, PanelBody, PanelHeader} from '@shipfox/react-ui/panel';
|
|
1
2
|
import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
|
|
2
3
|
import {Header} from '@shipfox/react-ui/typography';
|
|
3
4
|
import {cn} from '@shipfox/react-ui/utils';
|
|
@@ -58,7 +59,7 @@ export function WorkflowRunListView({
|
|
|
58
59
|
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
59
60
|
<section
|
|
60
61
|
aria-labelledby={headingId}
|
|
61
|
-
className={cn('flex min-h-0 min-w-0 flex-1 flex-col
|
|
62
|
+
className={cn('flex min-h-0 min-w-0 flex-1 flex-col', className)}
|
|
62
63
|
>
|
|
63
64
|
{/* The tab strip above already reads "Runs", so the page heading is for structure and
|
|
64
65
|
assistive tech rather than a second visible title competing with it. */}
|
|
@@ -66,27 +67,32 @@ export function WorkflowRunListView({
|
|
|
66
67
|
Workflow runs
|
|
67
68
|
</Header>
|
|
68
69
|
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
70
|
+
<Panel className="min-h-0 flex-1">
|
|
71
|
+
<PanelHeader className="flex-wrap">
|
|
72
|
+
<WorkflowRunFilters
|
|
73
|
+
search={currentSearch}
|
|
74
|
+
facets={facets}
|
|
75
|
+
onChange={handleFiltersChange}
|
|
76
|
+
onClear={handleClearFilters}
|
|
77
|
+
hasActiveFilters={hasActiveFilters}
|
|
78
|
+
/>
|
|
79
|
+
</PanelHeader>
|
|
80
|
+
<PanelBody className="min-h-0 flex-1">
|
|
81
|
+
<WorkflowRunListContent
|
|
82
|
+
query={query}
|
|
83
|
+
totalRuns={runs.length}
|
|
84
|
+
runs={filteredRuns}
|
|
85
|
+
workspaceSlug={workspaceSlug}
|
|
86
|
+
projectSlug={projectSlug}
|
|
87
|
+
hasActiveFilters={hasActiveFilters}
|
|
88
|
+
onClearFilters={handleClearFilters}
|
|
89
|
+
hasNextPage={hasNextPage}
|
|
90
|
+
isFetchingNextPage={isFetchingNextPage}
|
|
91
|
+
isFetchNextPageError={isFetchNextPageError}
|
|
92
|
+
{...(onLoadMore ? {onLoadMore} : {})}
|
|
93
|
+
/>
|
|
94
|
+
</PanelBody>
|
|
95
|
+
</Panel>
|
|
90
96
|
</section>
|
|
91
97
|
</TimeTickerProvider>
|
|
92
98
|
);
|
|
@@ -75,15 +75,15 @@ const SAMPLE_RUNS: WorkflowRunListItem[] = [
|
|
|
75
75
|
|
|
76
76
|
// One story on the full-width page. The data states (loading / empty / errors / runs) are
|
|
77
77
|
// driven by args; search, the filter menus, "clear filters" and "no matches" are live in the
|
|
78
|
-
// rendered toolbar since the view owns that state. The decorator gives the list
|
|
79
|
-
//
|
|
78
|
+
// rendered toolbar since the view owns that state. The decorator gives the list a full-width
|
|
79
|
+
// subtle canvas and a real height, so `flex-1` scrolls the way it does in the app shell.
|
|
80
80
|
const meta = {
|
|
81
81
|
title: 'Workflows/WorkflowRunList',
|
|
82
82
|
component: WorkflowRunListView,
|
|
83
83
|
parameters: {layout: 'centered'},
|
|
84
84
|
decorators: [
|
|
85
85
|
(Story) => (
|
|
86
|
-
<div className="flex h-600 w-
|
|
86
|
+
<div className="flex h-600 w-full bg-background-subtle-base p-24">
|
|
87
87
|
<Story />
|
|
88
88
|
</div>
|
|
89
89
|
),
|
|
@@ -137,27 +137,18 @@ export const NarrowLayout: Story = {
|
|
|
137
137
|
};
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* Row density against the three strip sizes the
|
|
141
|
-
* it,
|
|
140
|
+
* Row density against the three strip sizes the preview bound has to survive: comfortably under
|
|
141
|
+
* it, just over it, and far enough over that the overflow count carries the failure.
|
|
142
142
|
*/
|
|
143
143
|
export const JobStripDensity: Story = {
|
|
144
144
|
args: {
|
|
145
145
|
runs: [
|
|
146
146
|
makeRun('succeeded', 'two-jobs', 2, {jobs: ['succeeded', 'succeeded']}),
|
|
147
|
-
makeRun('failed', '
|
|
147
|
+
makeRun('failed', 'seventeen-jobs', 6, {
|
|
148
148
|
jobs: [
|
|
149
|
-
'succeeded',
|
|
150
|
-
'succeeded',
|
|
151
|
-
'succeeded',
|
|
149
|
+
...(Array.from({length: 12}, () => 'succeeded') as JobStatusDto[]),
|
|
152
150
|
'failed',
|
|
153
|
-
'
|
|
154
|
-
'succeeded',
|
|
155
|
-
'skipped',
|
|
156
|
-
'skipped',
|
|
157
|
-
'succeeded',
|
|
158
|
-
'succeeded',
|
|
159
|
-
'succeeded',
|
|
160
|
-
'succeeded',
|
|
151
|
+
...(Array.from({length: 4}, () => 'skipped') as JobStatusDto[]),
|
|
161
152
|
],
|
|
162
153
|
}),
|
|
163
154
|
makeRun('running', 'forty-jobs', 9, {
|
|
@@ -229,9 +220,7 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
|
|
|
229
220
|
<Code variant="label" className="text-foreground-neutral-subtle">
|
|
230
221
|
{label}
|
|
231
222
|
</Code>
|
|
232
|
-
<div className="flex h-560
|
|
233
|
-
{children}
|
|
234
|
-
</div>
|
|
223
|
+
<div className="flex h-560 min-w-0 bg-background-subtle-base p-16">{children}</div>
|
|
235
224
|
</div>
|
|
236
225
|
);
|
|
237
226
|
}
|
|
@@ -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';
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
workflowRunCommitLabel,
|
|
23
24
|
} from '#core/workflow-run.js';
|
|
24
25
|
import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
|
|
25
|
-
import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
|
|
26
|
+
import {JOB_STATUS_STRIP_WIDTH, JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
|
|
26
27
|
|
|
27
28
|
export function WorkflowRunRowList({
|
|
28
29
|
runs,
|
|
@@ -34,16 +35,18 @@ export function WorkflowRunRowList({
|
|
|
34
35
|
projectSlug?: string | undefined;
|
|
35
36
|
}) {
|
|
36
37
|
return (
|
|
37
|
-
// A container, not the viewport, drives the row's breakpoints.
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
// A container, not the viewport, drives the row's breakpoints. What a row can afford is its
|
|
39
|
+
// own width; keying off the viewport would keep the job strip hidden on a wide screen or
|
|
40
|
+
// crush it on a narrow one.
|
|
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
|
|
|
@@ -55,9 +58,9 @@ export function WorkflowRunRowList({
|
|
|
55
58
|
* drops beneath. The numeric columns are fixed-width so duration and time form real columns
|
|
56
59
|
* down the list rather than tracking each row's name length.
|
|
57
60
|
*
|
|
58
|
-
* The
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
+
* The 976px threshold keeps identity and provenance on one line; below it, provenance drops
|
|
62
|
+
* beneath the identity. The 1200px threshold keeps the full job preview and numeric columns
|
|
63
|
+
* readable together; below it, the strip is hidden so the numeric columns remain aligned.
|
|
61
64
|
*/
|
|
62
65
|
export function WorkflowRunRow({
|
|
63
66
|
run,
|
|
@@ -99,10 +102,14 @@ export function WorkflowRunRow({
|
|
|
99
102
|
</div>
|
|
100
103
|
|
|
101
104
|
<span className="flex shrink-0 items-center gap-cluster">
|
|
102
|
-
{/*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
{/* Reserve the API preview's full width so the overflow count cannot paint over the
|
|
106
|
+
duration, and keep it even when a run has no jobs planned yet so the numerics stay in
|
|
107
|
+
line down the list instead of stepping in and out. The max-content width expands if
|
|
108
|
+
the payload ever exceeds the current API preview bound. */}
|
|
109
|
+
<span
|
|
110
|
+
className="hidden w-max shrink-0 @min-[1200px]:flex"
|
|
111
|
+
style={{minWidth: JOB_STATUS_STRIP_WIDTH}}
|
|
112
|
+
>
|
|
106
113
|
<JobStatusStrip jobs={run.jobs} />
|
|
107
114
|
</span>
|
|
108
115
|
<span className="flex w-64 justify-end">
|
|
@@ -121,7 +128,7 @@ export function WorkflowRunRow({
|
|
|
121
128
|
// Rows run edge to edge inside the list's scroll container, which would clip the standard
|
|
122
129
|
// outset focus ring, so this one is inset per the design system's focus-ring rule.
|
|
123
130
|
const rowClassName =
|
|
124
|
-
'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-
|
|
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';
|
|
125
132
|
|
|
126
133
|
// Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
|
|
127
134
|
// replaces them on the next poll, so they render non-interactively instead of as a link
|
|
@@ -56,10 +56,10 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
const withFrame: Decorator = (Story) => (
|
|
59
|
-
<div className="min-h-screen bg-background-
|
|
60
|
-
<div className="
|
|
59
|
+
<div className="min-h-screen bg-background-subtle-base">
|
|
60
|
+
<div className="flex min-h-screen w-full flex-col px-frame py-frame">
|
|
61
61
|
<Story />
|
|
62
|
-
<div className="min-h-0 flex-1 bg-background-
|
|
62
|
+
<div className="min-h-0 flex-1 bg-background-subtle-base p-16" />
|
|
63
63
|
</div>
|
|
64
64
|
</div>
|
|
65
65
|
);
|
|
@@ -38,7 +38,8 @@ describe('WorkflowRunSummary', () => {
|
|
|
38
38
|
|
|
39
39
|
const summary = await screen.findByRole('region', {name: 'deploy-web'});
|
|
40
40
|
|
|
41
|
-
expect(summary).toHaveClass('
|
|
41
|
+
expect(summary).toHaveClass('px-row', 'py-row');
|
|
42
|
+
expect(summary).not.toHaveClass('bg-background-subtle-base');
|
|
42
43
|
expect(summary.firstElementChild).not.toHaveClass('max-w-[1120px]');
|
|
43
44
|
expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
|
|
44
45
|
expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
|
|
@@ -298,7 +299,15 @@ describe('WorkflowRunSummary', () => {
|
|
|
298
299
|
const onCancel = vi.fn();
|
|
299
300
|
renderSummary({}, {onCancel});
|
|
300
301
|
|
|
301
|
-
|
|
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);
|
|
302
311
|
|
|
303
312
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
304
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';
|
|
@@ -70,10 +70,7 @@ export function WorkflowRunSummary({
|
|
|
70
70
|
|
|
71
71
|
return (
|
|
72
72
|
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
73
|
-
<section
|
|
74
|
-
aria-labelledby={headingId}
|
|
75
|
-
className="bg-background-neutral-background px-row py-row"
|
|
76
|
-
>
|
|
73
|
+
<section aria-labelledby={headingId} className="px-row py-row">
|
|
77
74
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-cluster gap-y-inline overflow-hidden max-[480px]:grid-cols-1">
|
|
78
75
|
<div className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto">
|
|
79
76
|
<div className="flex min-w-0 items-center gap-inline">
|
|
@@ -228,7 +225,7 @@ function WorkflowRunActionSlot({
|
|
|
228
225
|
type="button"
|
|
229
226
|
variant="secondary"
|
|
230
227
|
size="xs"
|
|
231
|
-
className={
|
|
228
|
+
className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
|
|
232
229
|
isLoading={rerunPending}
|
|
233
230
|
disabled={rerunPending}
|
|
234
231
|
onClick={() => onRerun('all')}
|
|
@@ -247,7 +244,7 @@ function WorkflowRunActionSlot({
|
|
|
247
244
|
type="button"
|
|
248
245
|
variant="secondary"
|
|
249
246
|
size="xs"
|
|
250
|
-
className={
|
|
247
|
+
className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
|
|
251
248
|
iconRight="arrowDownSLine"
|
|
252
249
|
isLoading={rerunPending}
|
|
253
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
|
|
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';
|
|
@@ -7,10 +7,7 @@ import {Text} from '@shipfox/react-ui/typography';
|
|
|
7
7
|
|
|
8
8
|
export function WorkflowRunSkeleton() {
|
|
9
9
|
return (
|
|
10
|
-
<section
|
|
11
|
-
aria-label="Loading workflow run"
|
|
12
|
-
className="bg-background-neutral-background px-row py-row"
|
|
13
|
-
>
|
|
10
|
+
<section aria-label="Loading workflow run" className="px-row py-row">
|
|
14
11
|
<div className="flex min-w-0 flex-wrap items-center gap-x-cluster gap-y-inline">
|
|
15
12
|
<div className="flex min-w-0 items-center gap-inline">
|
|
16
13
|
<Skeleton className="size-8 rounded-full" />
|