@shipfox/client-workflows 13.0.0 → 14.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/.storybook/main.ts +16 -1
- package/.storybook/workspace-source.ts +31 -0
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +23 -0
- package/dist/components/job-graph/job-duration-label.js +1 -1
- package/dist/components/job-graph/job-duration-label.js.map +1 -1
- package/dist/components/job-graph/job-graph-content.d.ts +2 -1
- package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
- package/dist/components/job-graph/job-graph-content.js +3 -3
- package/dist/components/job-graph/job-graph-content.js.map +1 -1
- package/dist/components/job-graph/job-graph-view.d.ts +2 -1
- package/dist/components/job-graph/job-graph-view.d.ts.map +1 -1
- package/dist/components/job-graph/job-graph-view.js +2 -2
- package/dist/components/job-graph/job-graph-view.js.map +1 -1
- package/dist/components/job-graph/job-node.js +5 -5
- package/dist/components/job-graph/job-node.js.map +1 -1
- package/dist/components/job-graph/types.d.ts +2 -1
- package/dist/components/job-graph/types.d.ts.map +1 -1
- package/dist/components/job-graph/types.js.map +1 -1
- package/dist/components/workflow-run-duration-label.js +1 -1
- package/dist/components/workflow-run-duration-label.js.map +1 -1
- package/dist/components/workflow-run-number-label.js +1 -1
- package/dist/components/workflow-run-number-label.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +2 -2
- package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +2 -7
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +92 -68
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-tabs/index.d.ts +6 -0
- package/dist/components/workflow-run-tabs/index.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/index.js +7 -0
- package/dist/components/workflow-run-tabs/index.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +11 -0
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +71 -0
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +2 -0
- package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotations-empty.js +11 -0
- package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +18 -0
- package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-jobs-list.js +107 -0
- package/dist/components/workflow-run-tabs/run-jobs-list.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-tab-count.d.ts +6 -0
- package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-tab-count.js +27 -0
- package/dist/components/workflow-run-tabs/run-tab-count.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +8 -0
- package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-tab-strip.js +82 -0
- package/dist/components/workflow-run-tabs/run-tab-strip.js.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-selection.js +40 -1
- package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.d.ts +1 -0
- package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.js +10 -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 +8 -4
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.js +125 -78
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/components/workflow-source-panel/index.d.ts +1 -0
- package/dist/components/workflow-source-panel/index.d.ts.map +1 -1
- package/dist/components/workflow-source-panel/index.js +1 -0
- package/dist/components/workflow-source-panel/index.js.map +1 -1
- package/dist/components/workflow-source-panel/workflow-source-content.d.ts +12 -0
- package/dist/components/workflow-source-panel/workflow-source-content.d.ts.map +1 -0
- package/dist/components/workflow-source-panel/workflow-source-content.js +53 -0
- package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -0
- package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +1 -1
- package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -1
- package/dist/components/workflow-source-panel/workflow-source-panel.js +7 -54
- package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -1
- package/dist/core/entities/step.d.ts +1 -1
- package/dist/core/entities/step.d.ts.map +1 -1
- package/dist/core/entities/step.js +2 -0
- package/dist/core/entities/step.js.map +1 -1
- package/dist/core/workflow-run-tabs.d.ts +9 -0
- package/dist/core/workflow-run-tabs.d.ts.map +1 -0
- package/dist/core/workflow-run-tabs.js +3 -0
- package/dist/core/workflow-run-tabs.js.map +1 -0
- package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
- package/dist/pages/workflow-run-detail-page.js +28 -6
- package/dist/pages/workflow-run-detail-page.js.map +1 -1
- package/dist/routes/inputs.d.ts +33 -0
- package/dist/routes/inputs.d.ts.map +1 -1
- package/dist/routes/inputs.js +49 -0
- package/dist/routes/inputs.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/src/components/job-graph/job-duration-label.tsx +5 -1
- package/src/components/job-graph/job-graph-content.tsx +7 -4
- package/src/components/job-graph/job-graph-view.tsx +6 -3
- 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/job-graph/job-node.test.tsx +27 -2
- package/src/components/job-graph/job-node.tsx +6 -6
- package/src/components/job-graph/types.ts +5 -1
- package/src/components/workflow-run-duration-label.tsx +1 -1
- package/src/components/workflow-run-number-label.tsx +1 -1
- package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +2 -2
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +23 -30
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +3 -22
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +84 -69
- package/src/components/workflow-run-tabs/index.ts +5 -0
- package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +69 -0
- package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +97 -0
- package/src/components/workflow-run-tabs/run-annotations-empty.tsx +11 -0
- package/src/components/workflow-run-tabs/run-jobs-list.tsx +151 -0
- package/src/components/workflow-run-tabs/run-tab-count.tsx +28 -0
- package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +52 -0
- package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +117 -0
- package/src/components/workflow-run-tabs/run-tab-strip.tsx +97 -0
- package/src/components/workflow-run-view/workflow-run-selection.test.ts +48 -0
- package/src/components/workflow-run-view/workflow-run-selection.ts +48 -1
- package/src/components/workflow-run-view/workflow-run-states.tsx +12 -1
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +64 -64
- package/src/components/workflow-run-view/workflow-run-view.tsx +199 -85
- package/src/components/workflow-source-panel/index.ts +1 -0
- package/src/components/workflow-source-panel/workflow-source-content.tsx +84 -0
- package/src/components/workflow-source-panel/workflow-source-panel.tsx +14 -81
- package/src/core/entities/step.ts +4 -0
- package/src/core/workflow-run-tabs.ts +8 -0
- package/src/pages/workflow-run-detail-page.tsx +31 -4
- package/src/pages/workflow-run-pages.test.tsx +185 -45
- package/src/routes/inputs.test.ts +47 -0
- package/src/routes/inputs.ts +45 -0
- package/src/storybook-config.test.ts +27 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/tsconfig.test.json +7 -1
|
@@ -2,7 +2,13 @@ import {useNavigate} from '@tanstack/react-router';
|
|
|
2
2
|
import {useCallback} from 'react';
|
|
3
3
|
import {WorkflowRunView} from '#components/workflow-run-view/index.js';
|
|
4
4
|
import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
validateWorkflowRunsSearch,
|
|
7
|
+
type WorkflowRunsSearch,
|
|
8
|
+
type WorkflowRunTab,
|
|
9
|
+
workflowRunSearchParams,
|
|
10
|
+
workflowRunTab,
|
|
11
|
+
} from '#routes/inputs.js';
|
|
6
12
|
|
|
7
13
|
interface WorkflowRunDetailPageProps {
|
|
8
14
|
projectId: string;
|
|
@@ -24,14 +30,33 @@ export function WorkflowRunDetailPage({
|
|
|
24
30
|
const onSelectionChange = useCallback(
|
|
25
31
|
(nextSelection: WorkflowRunSelectionInput) => {
|
|
26
32
|
navigate({
|
|
27
|
-
search:
|
|
33
|
+
search: ((previous: Record<string, unknown>) => {
|
|
34
|
+
const current = validateWorkflowRunsSearch(previous);
|
|
35
|
+
return workflowRunSearchParams(current, nextSelection);
|
|
36
|
+
}) as never,
|
|
37
|
+
replace: true,
|
|
28
38
|
});
|
|
29
39
|
},
|
|
30
|
-
[navigate
|
|
40
|
+
[navigate],
|
|
41
|
+
);
|
|
42
|
+
const onTabChange = useCallback(
|
|
43
|
+
(nextTab: WorkflowRunTab) => {
|
|
44
|
+
navigate({
|
|
45
|
+
search: ((previous: Record<string, unknown>) => {
|
|
46
|
+
const current = validateWorkflowRunsSearch(previous);
|
|
47
|
+
const nextSearch = workflowRunSearchParams({...current, tab: nextTab}, current);
|
|
48
|
+
return nextTab === 'summary' &&
|
|
49
|
+
(current.jobId || current.jobExecutionId || current.stepId || current.stepAttemptId)
|
|
50
|
+
? {...nextSearch, tab: nextTab}
|
|
51
|
+
: nextSearch;
|
|
52
|
+
}) as never,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
[navigate],
|
|
31
56
|
);
|
|
32
57
|
|
|
33
58
|
return (
|
|
34
|
-
<div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-
|
|
59
|
+
<div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-background">
|
|
35
60
|
<WorkflowRunView
|
|
36
61
|
projectId={projectId}
|
|
37
62
|
workspaceSlug={workspaceSlug}
|
|
@@ -39,6 +64,8 @@ export function WorkflowRunDetailPage({
|
|
|
39
64
|
workflowRunId={workflowRunId}
|
|
40
65
|
selection={selection}
|
|
41
66
|
onSelectionChange={onSelectionChange}
|
|
67
|
+
tab={workflowRunTab(search)}
|
|
68
|
+
onTabChange={onTabChange}
|
|
42
69
|
/>
|
|
43
70
|
</div>
|
|
44
71
|
);
|
|
@@ -40,6 +40,10 @@ const INTEGRATION_TESTS_RE = /integration-tests/u;
|
|
|
40
40
|
const BUILD_IMAGE_RE = /build-image/u;
|
|
41
41
|
const STATUS_FILTER_RE = /^Status\b.*filter$/u;
|
|
42
42
|
const EXECUTION_ONE_MENU_ITEM = /Execution #1: deploy review #1/u;
|
|
43
|
+
const JOBS_TAB_NAME = /^Jobs/u;
|
|
44
|
+
const BUILD_JOB_BUTTON_NAME = 'build, Succeeded';
|
|
45
|
+
const DEPLOY_JOB_BUTTON_NAME = 'deploy, Running';
|
|
46
|
+
const LIVE_DEPLOY_JOB_BUTTON_NAME = /^deploy, Running, running /u;
|
|
43
47
|
const RUN_DETAIL_PATH_RE = /^\/workflows\/runs\/([^/]+)$/u;
|
|
44
48
|
const RUN_OVERRIDES = {
|
|
45
49
|
id: RUN_ID,
|
|
@@ -171,25 +175,191 @@ describe('WorkflowRunPages', () => {
|
|
|
171
175
|
test('renders the detail route without mounting the run list', async () => {
|
|
172
176
|
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
173
177
|
|
|
174
|
-
renderRunPath();
|
|
178
|
+
renderRunPath('?tab=jobs');
|
|
175
179
|
|
|
176
|
-
expect(await screen.findByRole('button', {name:
|
|
180
|
+
expect(await screen.findByRole('button', {name: DEPLOY_JOB_BUTTON_NAME})).toBeInTheDocument();
|
|
181
|
+
expect(screen.getByRole('link', {name: 'Runs'})).toHaveAttribute(
|
|
182
|
+
'href',
|
|
183
|
+
`/w/${PROJECT_TEST_WSLUG}/p/project/runs`,
|
|
184
|
+
);
|
|
177
185
|
expect(screen.queryByLabelText('Workflow runs')).not.toBeInTheDocument();
|
|
178
186
|
});
|
|
179
187
|
|
|
188
|
+
test('back to runs preserves list filters and drops run detail state', async () => {
|
|
189
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
190
|
+
|
|
191
|
+
renderRunPath(`?search=deploy-web&status=running&tab=jobs&job=${DEPLOY_JOB_ID}`);
|
|
192
|
+
|
|
193
|
+
expect(await screen.findByRole('link', {name: 'Runs'})).toHaveAttribute(
|
|
194
|
+
'href',
|
|
195
|
+
`/w/${PROJECT_TEST_WSLUG}/p/project/runs?search=deploy-web&status=running`,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('defaults the detail route to the Summary tab', async () => {
|
|
200
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
201
|
+
|
|
202
|
+
const {router} = renderRunPath();
|
|
203
|
+
|
|
204
|
+
expect(await screen.findByRole('region', {name: 'deploy-web'})).toBeInTheDocument();
|
|
205
|
+
const summaryTab = screen.getByRole('tab', {name: 'Summary'});
|
|
206
|
+
expect(summaryTab).toHaveAttribute('aria-selected', 'true');
|
|
207
|
+
expect(summaryTab).toHaveAttribute('aria-controls', screen.getByRole('tabpanel').id);
|
|
208
|
+
expect(screen.queryByRole('list', {name: 'Run jobs'})).not.toBeInTheDocument();
|
|
209
|
+
expect(currentSearch(router).tab).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('opens legacy selection-only detail URLs on the Jobs tab', async () => {
|
|
213
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
214
|
+
|
|
215
|
+
renderRunPath(`?job=${DEPLOY_JOB_ID}`);
|
|
216
|
+
|
|
217
|
+
expect(await screen.findByRole('list', {name: 'Run jobs'})).toBeInTheDocument();
|
|
218
|
+
expect(screen.getByRole('tab', {name: JOBS_TAB_NAME})).toHaveAttribute('aria-selected', 'true');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('opens a legacy job execution URL with its owning job selected', async () => {
|
|
222
|
+
configureApiClient({
|
|
223
|
+
fetchImpl: createRunDetailFetch({details: {[RUN_ID]: retryRunDetailDto()}}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
renderRunPath(`?jobExecution=${DEPLOY_EXECUTION_TWO_ID}`);
|
|
227
|
+
|
|
228
|
+
expect(await screen.findByRole('region', {name: 'deploy review #2'})).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('uses the derived execution status in a live job duration label', async () => {
|
|
232
|
+
configureApiClient({
|
|
233
|
+
fetchImpl: createRunDetailFetch({
|
|
234
|
+
details: {
|
|
235
|
+
[RUN_ID]: defaultRunDetailDto({
|
|
236
|
+
jobs: [
|
|
237
|
+
workflowJobDto({
|
|
238
|
+
id: DEPLOY_JOB_ID,
|
|
239
|
+
run_attempt_id: RUN_ID,
|
|
240
|
+
name: 'deploy',
|
|
241
|
+
status: 'running',
|
|
242
|
+
job_executions: [
|
|
243
|
+
workflowJobExecutionDto({
|
|
244
|
+
job_id: DEPLOY_JOB_ID,
|
|
245
|
+
status: 'pending',
|
|
246
|
+
started_at: '2026-08-04T00:00:00.000Z',
|
|
247
|
+
steps: [workflowStepDto({status: 'running'})],
|
|
248
|
+
}),
|
|
249
|
+
],
|
|
250
|
+
}),
|
|
251
|
+
],
|
|
252
|
+
}),
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
|
|
258
|
+
|
|
259
|
+
expect(
|
|
260
|
+
await screen.findByRole('button', {name: LIVE_DEPLOY_JOB_BUTTON_NAME}),
|
|
261
|
+
).toBeInTheDocument();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('pushes tab changes and restores them with browser history', async () => {
|
|
265
|
+
const user = userEvent.setup();
|
|
266
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
267
|
+
|
|
268
|
+
const {router} = renderRunPath();
|
|
269
|
+
const initialHistoryLength = router.history.length;
|
|
270
|
+
|
|
271
|
+
await user.click(await screen.findByRole('tab', {name: JOBS_TAB_NAME}));
|
|
272
|
+
|
|
273
|
+
await waitFor(() => expect(currentSearch(router).tab).toBe('jobs'));
|
|
274
|
+
expect(router.history.length).toBe(initialHistoryLength + 1);
|
|
275
|
+
expect(screen.getByRole('tab', {name: JOBS_TAB_NAME})).toHaveAttribute('aria-selected', 'true');
|
|
276
|
+
expect(screen.getByRole('list', {name: 'Run jobs'})).toBeInTheDocument();
|
|
277
|
+
|
|
278
|
+
await user.click(screen.getByRole('tab', {name: 'Summary'}));
|
|
279
|
+
|
|
280
|
+
await waitFor(() => expect(currentSearch(router).tab).toBeUndefined());
|
|
281
|
+
expect(router.history.length).toBe(initialHistoryLength + 2);
|
|
282
|
+
|
|
283
|
+
await act(() => {
|
|
284
|
+
router.history.back();
|
|
285
|
+
});
|
|
286
|
+
await waitFor(() => expect(currentSearch(router).tab).toBe('jobs'));
|
|
287
|
+
expect(screen.getByRole('list', {name: 'Run jobs'})).toBeInTheDocument();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('opens the Jobs tab and selects a job from the Summary graph', async () => {
|
|
291
|
+
const user = userEvent.setup();
|
|
292
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
293
|
+
|
|
294
|
+
const {router} = renderRunPath();
|
|
295
|
+
const initialHistoryLength = router.history.length;
|
|
296
|
+
|
|
297
|
+
await user.click(await screen.findByRole('button', {name: 'deploy, Running'}));
|
|
298
|
+
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(currentSearch(router)).toMatchObject({tab: 'jobs', job: DEPLOY_JOB_ID});
|
|
301
|
+
});
|
|
302
|
+
expect(router.history.length).toBe(initialHistoryLength + 1);
|
|
303
|
+
expect(screen.getByRole('button', {name: 'deploy, Running'})).toHaveAttribute(
|
|
304
|
+
'aria-expanded',
|
|
305
|
+
'true',
|
|
306
|
+
);
|
|
307
|
+
expect(screen.getByRole('button', {name: 'deploy, Running'})).toHaveAttribute(
|
|
308
|
+
'aria-controls',
|
|
309
|
+
`job-card-${DEPLOY_JOB_ID}`,
|
|
310
|
+
);
|
|
311
|
+
expect(screen.getByRole('button', {name: BUILD_JOB_BUTTON_NAME})).not.toHaveAttribute(
|
|
312
|
+
'aria-controls',
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('preserves an explicit Summary tab with a selected job', async () => {
|
|
317
|
+
const user = userEvent.setup();
|
|
318
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
319
|
+
|
|
320
|
+
const {router} = renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
|
|
321
|
+
|
|
322
|
+
await screen.findByRole('button', {name: DEPLOY_JOB_BUTTON_NAME});
|
|
323
|
+
await user.click(screen.getByRole('tab', {name: 'Summary'}));
|
|
324
|
+
|
|
325
|
+
await waitFor(() => {
|
|
326
|
+
expect(currentSearch(router)).toMatchObject({tab: 'summary', job: DEPLOY_JOB_ID});
|
|
327
|
+
});
|
|
328
|
+
expect(screen.getByRole('tab', {name: 'Summary'})).toHaveAttribute('aria-selected', 'true');
|
|
329
|
+
expect(screen.queryByRole('list', {name: 'Run jobs'})).not.toBeInTheDocument();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('keeps Summary active while keyboard navigation moves graph focus', async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
335
|
+
|
|
336
|
+
const {router} = renderRunPath();
|
|
337
|
+
const build = await screen.findByRole('button', {name: BUILD_JOB_BUTTON_NAME});
|
|
338
|
+
const deploy = screen.getByRole('button', {name: DEPLOY_JOB_BUTTON_NAME});
|
|
339
|
+
|
|
340
|
+
build.focus();
|
|
341
|
+
await user.keyboard('{ArrowRight}');
|
|
342
|
+
|
|
343
|
+
expect(deploy).toHaveFocus();
|
|
344
|
+
expect(deploy).toHaveAttribute('aria-pressed', 'true');
|
|
345
|
+
expect(screen.getByRole('tab', {name: 'Summary'})).toHaveAttribute('aria-selected', 'true');
|
|
346
|
+
expect(screen.queryByRole('list', {name: 'Run jobs'})).not.toBeInTheDocument();
|
|
347
|
+
expect(currentSearch(router).job).toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
|
|
180
350
|
test('restores a deep-linked job and exact attempt after data loads', async () => {
|
|
181
351
|
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
182
352
|
|
|
183
353
|
renderRunPath(
|
|
184
|
-
`?job=${BUILD_JOB_ID}&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`,
|
|
354
|
+
`?tab=jobs&job=${BUILD_JOB_ID}&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`,
|
|
185
355
|
);
|
|
186
356
|
|
|
187
|
-
const deployJob = await screen.findByRole('button', {name:
|
|
357
|
+
const deployJob = await screen.findByRole('button', {name: DEPLOY_JOB_BUTTON_NAME});
|
|
188
358
|
const deployAttempt = await screen.findByRole('button', {
|
|
189
359
|
name: 'deploy, Running, attempt 2',
|
|
190
360
|
});
|
|
191
361
|
|
|
192
|
-
expect(deployJob).toHaveAttribute('aria-
|
|
362
|
+
expect(deployJob).toHaveAttribute('aria-expanded', 'true');
|
|
193
363
|
expect(deployAttempt).toHaveAttribute('aria-expanded', 'true');
|
|
194
364
|
expect(await screen.findByText('attempt two log')).toBeInTheDocument();
|
|
195
365
|
});
|
|
@@ -198,14 +368,17 @@ describe('WorkflowRunPages', () => {
|
|
|
198
368
|
const user = userEvent.setup();
|
|
199
369
|
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
200
370
|
const {router} = renderRunPath(
|
|
201
|
-
`?step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}&runAttempt=1`,
|
|
371
|
+
`?tab=jobs&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}&runAttempt=1`,
|
|
202
372
|
);
|
|
373
|
+
const initialHistoryLength = router.history.length;
|
|
203
374
|
|
|
204
|
-
await user.click(await screen.findByRole('button', {name:
|
|
375
|
+
await user.click(await screen.findByRole('button', {name: BUILD_JOB_BUTTON_NAME}));
|
|
205
376
|
|
|
206
377
|
await waitFor(() => {
|
|
207
378
|
expect(currentSearch(router)).toMatchObject({job: BUILD_JOB_ID});
|
|
208
379
|
});
|
|
380
|
+
expect(currentSearch(router).tab).toBe('jobs');
|
|
381
|
+
expect(router.history.length).toBe(initialHistoryLength);
|
|
209
382
|
expect(currentSearch(router).step).toBeUndefined();
|
|
210
383
|
expect(currentSearch(router).stepAttempt).toBeUndefined();
|
|
211
384
|
expect(screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'})).toHaveAttribute(
|
|
@@ -217,7 +390,7 @@ describe('WorkflowRunPages', () => {
|
|
|
217
390
|
test('selecting an attempt writes job, step, and attempt search state', async () => {
|
|
218
391
|
const user = userEvent.setup();
|
|
219
392
|
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
220
|
-
const {router} = renderRunPath(`?job=${DEPLOY_JOB_ID}`);
|
|
393
|
+
const {router} = renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
|
|
221
394
|
|
|
222
395
|
await user.click(await screen.findByRole('button', {name: 'deploy, Running, attempt 2'}));
|
|
223
396
|
|
|
@@ -235,7 +408,7 @@ describe('WorkflowRunPages', () => {
|
|
|
235
408
|
configureApiClient({
|
|
236
409
|
fetchImpl: createRunDetailFetch({details: {[RUN_ID]: retryRunDetailDto()}}),
|
|
237
410
|
});
|
|
238
|
-
const {router} = renderRunPath(`?job=${DEPLOY_JOB_ID}`);
|
|
411
|
+
const {router} = renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
|
|
239
412
|
|
|
240
413
|
await user.click(
|
|
241
414
|
await screen.findByRole('button', {
|
|
@@ -273,7 +446,9 @@ describe('WorkflowRunPages', () => {
|
|
|
273
446
|
test('collapsing an attempt removes step and attempt while preserving job', async () => {
|
|
274
447
|
const user = userEvent.setup();
|
|
275
448
|
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
276
|
-
const {router} = renderRunPath(
|
|
449
|
+
const {router} = renderRunPath(
|
|
450
|
+
`?tab=jobs&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`,
|
|
451
|
+
);
|
|
277
452
|
|
|
278
453
|
const deployAttempt = await screen.findByRole('button', {
|
|
279
454
|
name: 'deploy, Running, attempt 2',
|
|
@@ -288,41 +463,6 @@ describe('WorkflowRunPages', () => {
|
|
|
288
463
|
expect(deployAttempt).toHaveAttribute('aria-expanded', 'false');
|
|
289
464
|
});
|
|
290
465
|
|
|
291
|
-
test('back and forward navigation restores prior selections', async () => {
|
|
292
|
-
const user = userEvent.setup();
|
|
293
|
-
configureApiClient({fetchImpl: createRunDetailFetch()});
|
|
294
|
-
const {router} = renderRunPath(`?job=${DEPLOY_JOB_ID}`);
|
|
295
|
-
|
|
296
|
-
await user.click(await screen.findByRole('button', {name: 'deploy, Running, attempt 2'}));
|
|
297
|
-
await waitFor(() => {
|
|
298
|
-
expect(currentSearch(router).stepAttempt).toBe(DEPLOY_ATTEMPT_TWO_ID);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
await act(() => {
|
|
302
|
-
router.history.back();
|
|
303
|
-
});
|
|
304
|
-
await waitFor(() => {
|
|
305
|
-
expect(currentSearch(router)).toMatchObject({job: DEPLOY_JOB_ID});
|
|
306
|
-
});
|
|
307
|
-
expect(currentSearch(router).step).toBeUndefined();
|
|
308
|
-
expect(currentSearch(router).stepAttempt).toBeUndefined();
|
|
309
|
-
expect(screen.getByRole('button', {name: 'deploy, Running, attempt 2'})).toHaveAttribute(
|
|
310
|
-
'aria-expanded',
|
|
311
|
-
'false',
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
await act(() => {
|
|
315
|
-
router.history.forward();
|
|
316
|
-
});
|
|
317
|
-
await waitFor(() => {
|
|
318
|
-
expect(currentSearch(router).stepAttempt).toBe(DEPLOY_ATTEMPT_TWO_ID);
|
|
319
|
-
});
|
|
320
|
-
expect(screen.getByRole('button', {name: 'deploy, Running, attempt 2'})).toHaveAttribute(
|
|
321
|
-
'aria-expanded',
|
|
322
|
-
'true',
|
|
323
|
-
);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
466
|
test('run list links navigate to detail and back restores list filters', async () => {
|
|
327
467
|
const user = userEvent.setup();
|
|
328
468
|
configureApiClient({
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
validateWorkflowRunsSearch,
|
|
7
7
|
type WorkflowRunsSearch,
|
|
8
8
|
workflowRouteParams,
|
|
9
|
+
workflowRunListSearchParams,
|
|
9
10
|
workflowRunSearchParams,
|
|
11
|
+
workflowRunTab,
|
|
10
12
|
} from './inputs.js';
|
|
11
13
|
|
|
12
14
|
/** Everything a filter change writes, then reads back, exactly as the router would. */
|
|
@@ -21,6 +23,8 @@ describe('validateWorkflowRunsSearch', () => {
|
|
|
21
23
|
validateWorkflowRunsSearch({
|
|
22
24
|
search: ['unexpected'],
|
|
23
25
|
status: 'unknown',
|
|
26
|
+
tab: 'unknown',
|
|
27
|
+
severity: 'critical',
|
|
24
28
|
after: 'yesterday',
|
|
25
29
|
before: '2026-13-01',
|
|
26
30
|
}),
|
|
@@ -65,6 +69,28 @@ describe('validateWorkflowRunsSearch', () => {
|
|
|
65
69
|
jobId: 'job-1',
|
|
66
70
|
});
|
|
67
71
|
});
|
|
72
|
+
|
|
73
|
+
test('accepts known tabs and annotation severities while dropping unknown values', () => {
|
|
74
|
+
expect(
|
|
75
|
+
validateWorkflowRunsSearch({tab: 'annotations', annotation: 'run-1', severity: 'error'}),
|
|
76
|
+
).toEqual({tab: 'annotations', annotation: 'run-1', severity: 'error'});
|
|
77
|
+
expect(validateWorkflowRunsSearch({tab: 'unknown', severity: 'critical'})).toEqual({});
|
|
78
|
+
expect(workflowRunTab({})).toBe('summary');
|
|
79
|
+
expect(workflowRunTab({tab: 'source'})).toBe('source');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test.each([
|
|
83
|
+
['job', {jobId: 'job-1'}],
|
|
84
|
+
['job execution', {jobExecutionId: 'execution-1'}],
|
|
85
|
+
['step', {stepId: 'step-1'}],
|
|
86
|
+
['step attempt', {stepAttemptId: 'attempt-1'}],
|
|
87
|
+
])('defaults a legacy %s selection URL to Jobs', (_selection, search) => {
|
|
88
|
+
expect(workflowRunTab(search)).toBe('jobs');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('keeps an explicit Summary tab ahead of legacy selection inference', () => {
|
|
92
|
+
expect(workflowRunTab({tab: 'summary', jobId: 'job-1'})).toBe('summary');
|
|
93
|
+
});
|
|
68
94
|
});
|
|
69
95
|
|
|
70
96
|
describe('the run list URL contract', () => {
|
|
@@ -88,6 +114,9 @@ describe('the run list URL contract', () => {
|
|
|
88
114
|
event: ['push'],
|
|
89
115
|
after: '2026-05-01',
|
|
90
116
|
before: '2026-05-31',
|
|
117
|
+
tab: 'annotations',
|
|
118
|
+
annotation: 'annotation-1',
|
|
119
|
+
severity: 'warning',
|
|
91
120
|
};
|
|
92
121
|
|
|
93
122
|
expect(roundTrip(search)).toEqual(search);
|
|
@@ -119,6 +148,24 @@ describe('the run list URL contract', () => {
|
|
|
119
148
|
expect(stringifyAppSearch(workflowRunSearchParams({}, {}))).toBe('');
|
|
120
149
|
});
|
|
121
150
|
|
|
151
|
+
test('omits the default Summary tab from the URL', () => {
|
|
152
|
+
expect(stringifyAppSearch(workflowRunSearchParams({tab: 'summary'}, {}))).toBe('');
|
|
153
|
+
expect(stringifyAppSearch(workflowRunSearchParams({tab: 'jobs'}, {}))).toBe('?tab=jobs');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('serializes only list filters for the detail back link', () => {
|
|
157
|
+
expect(
|
|
158
|
+
workflowRunListSearchParams({
|
|
159
|
+
search: 'deploy',
|
|
160
|
+
status: ['running'],
|
|
161
|
+
tab: 'jobs',
|
|
162
|
+
annotation: 'annotation-1',
|
|
163
|
+
severity: 'error',
|
|
164
|
+
jobId: 'job-1',
|
|
165
|
+
}),
|
|
166
|
+
).toEqual({search: 'deploy', status: ['running']});
|
|
167
|
+
});
|
|
168
|
+
|
|
122
169
|
test('has no page or cursor parameter to carry', () => {
|
|
123
170
|
const query = stringifyAppSearch(workflowRunSearchParams({search: 'deploy'}, {}));
|
|
124
171
|
|
package/src/routes/inputs.ts
CHANGED
|
@@ -8,6 +8,14 @@ export const WORKFLOW_RUN_LIST_STATUSES = ['succeeded', 'failed', 'running', 'ca
|
|
|
8
8
|
|
|
9
9
|
export type WorkflowRunListStatus = (typeof WORKFLOW_RUN_LIST_STATUSES)[number];
|
|
10
10
|
|
|
11
|
+
export const WORKFLOW_RUN_TABS = ['summary', 'jobs', 'annotations', 'source'] as const;
|
|
12
|
+
|
|
13
|
+
export type WorkflowRunTab = (typeof WORKFLOW_RUN_TABS)[number];
|
|
14
|
+
|
|
15
|
+
export const WORKFLOW_RUN_ANNOTATION_SEVERITIES = ['error', 'warning', 'info', 'success'] as const;
|
|
16
|
+
|
|
17
|
+
export type WorkflowRunAnnotationSeverity = (typeof WORKFLOW_RUN_ANNOTATION_SEVERITIES)[number];
|
|
18
|
+
|
|
11
19
|
export interface WorkflowRunsSearch extends WorkflowRunSelectionInput {
|
|
12
20
|
search?: string;
|
|
13
21
|
status?: WorkflowRunListStatus[];
|
|
@@ -18,9 +26,14 @@ export interface WorkflowRunsSearch extends WorkflowRunSelectionInput {
|
|
|
18
26
|
after?: string;
|
|
19
27
|
/** Inclusive upper bound on the run's local calendar date, `YYYY-MM-DD`. */
|
|
20
28
|
before?: string;
|
|
29
|
+
tab?: WorkflowRunTab;
|
|
30
|
+
annotation?: string;
|
|
31
|
+
severity?: WorkflowRunAnnotationSeverity;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
const STATUS_VALUES = new Set<string>(WORKFLOW_RUN_LIST_STATUSES);
|
|
35
|
+
const TAB_VALUES = new Set<string>(WORKFLOW_RUN_TABS);
|
|
36
|
+
const ANNOTATION_SEVERITY_VALUES = new Set<string>(WORKFLOW_RUN_ANNOTATION_SEVERITIES);
|
|
24
37
|
const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
|
|
25
38
|
|
|
26
39
|
/**
|
|
@@ -39,6 +52,14 @@ export function validateWorkflowRunsSearch(input: Record<string, unknown>): Work
|
|
|
39
52
|
const after = calendarDate(input.after);
|
|
40
53
|
const before = calendarDate(input.before);
|
|
41
54
|
const runAttempt = positiveInteger(input.runAttempt);
|
|
55
|
+
const tabValue = string(input.tab);
|
|
56
|
+
const tab = tabValue && TAB_VALUES.has(tabValue) ? (tabValue as WorkflowRunTab) : undefined;
|
|
57
|
+
const annotation = string(input.annotation);
|
|
58
|
+
const severityValue = string(input.severity);
|
|
59
|
+
const severity =
|
|
60
|
+
severityValue && ANNOTATION_SEVERITY_VALUES.has(severityValue)
|
|
61
|
+
? (severityValue as WorkflowRunAnnotationSeverity)
|
|
62
|
+
: undefined;
|
|
42
63
|
|
|
43
64
|
return {
|
|
44
65
|
...(search ? {search} : {}),
|
|
@@ -48,6 +69,9 @@ export function validateWorkflowRunsSearch(input: Record<string, unknown>): Work
|
|
|
48
69
|
...(event.length > 0 ? {event} : {}),
|
|
49
70
|
...(after ? {after} : {}),
|
|
50
71
|
...(before ? {before} : {}),
|
|
72
|
+
...(tab ? {tab} : {}),
|
|
73
|
+
...(annotation ? {annotation} : {}),
|
|
74
|
+
...(severity ? {severity} : {}),
|
|
51
75
|
...(string(input.job) ? {jobId: string(input.job)} : {}),
|
|
52
76
|
...(string(input.jobExecution) ? {jobExecutionId: string(input.jobExecution)} : {}),
|
|
53
77
|
...(string(input.step) ? {stepId: string(input.step)} : {}),
|
|
@@ -74,6 +98,9 @@ export function workflowRunSearchParams(
|
|
|
74
98
|
...(search.event?.length ? {event: search.event} : {}),
|
|
75
99
|
...(search.after ? {after: search.after} : {}),
|
|
76
100
|
...(search.before ? {before: search.before} : {}),
|
|
101
|
+
...(search.tab && search.tab !== 'summary' ? {tab: search.tab} : {}),
|
|
102
|
+
...(search.annotation ? {annotation: search.annotation} : {}),
|
|
103
|
+
...(search.severity ? {severity: search.severity} : {}),
|
|
77
104
|
...(selection.jobId ? {job: selection.jobId} : {}),
|
|
78
105
|
...(selection.jobExecutionId ? {jobExecution: selection.jobExecutionId} : {}),
|
|
79
106
|
...(selection.stepId ? {step: selection.stepId} : {}),
|
|
@@ -82,6 +109,24 @@ export function workflowRunSearchParams(
|
|
|
82
109
|
};
|
|
83
110
|
}
|
|
84
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Resolves the detail surface for a URL. Explicit tabs win; legacy selection-only URLs belong
|
|
114
|
+
* to Jobs so their selected detail remains visible instead of silently landing on Summary.
|
|
115
|
+
*/
|
|
116
|
+
export function workflowRunTab(
|
|
117
|
+
search: Pick<WorkflowRunsSearch, 'tab' | 'jobId' | 'jobExecutionId' | 'stepId' | 'stepAttemptId'>,
|
|
118
|
+
): WorkflowRunTab {
|
|
119
|
+
if (search.tab) return search.tab;
|
|
120
|
+
if (search.jobId || search.jobExecutionId || search.stepId || search.stepAttemptId) return 'jobs';
|
|
121
|
+
return 'summary';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Serializes only list filters when leaving a run detail page for the run list. */
|
|
125
|
+
export function workflowRunListSearchParams(search: WorkflowRunsSearch) {
|
|
126
|
+
const {tab: _tab, annotation: _annotation, severity: _severity, ...listSearch} = search;
|
|
127
|
+
return workflowRunSearchParams(listSearch, {});
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
/**
|
|
86
131
|
* A filter change. Each member is explicitly optional-or-undefined so that "present and
|
|
87
132
|
* undefined" (clear this filter) stays distinguishable from "absent" (leave it alone) under
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {describe, expect, it} from '@shipfox/vitest/vi';
|
|
2
|
+
import {withWorkspaceSource} from '../.storybook/workspace-source';
|
|
3
|
+
|
|
4
|
+
describe('client-workflows Storybook Vite configuration', () => {
|
|
5
|
+
it('keeps workspace-source active for client and SSR package resolution', () => {
|
|
6
|
+
const config = withWorkspaceSource({
|
|
7
|
+
resolve: {conditions: ['browser']},
|
|
8
|
+
ssr: {resolve: {conditions: ['node']}},
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(config.resolve?.conditions).toEqual(['browser', 'workspace-source']);
|
|
12
|
+
expect(config.ssr?.resolve?.conditions).toEqual(['node', 'workspace-source']);
|
|
13
|
+
expect(config.plugins?.map((plugin) => plugin && 'name' in plugin && plugin.name)).toContain(
|
|
14
|
+
'shipfox:workspace-source-resolver',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('does not duplicate an existing workspace-source condition', () => {
|
|
19
|
+
const config = withWorkspaceSource({
|
|
20
|
+
resolve: {conditions: ['browser', 'workspace-source']},
|
|
21
|
+
ssr: {resolve: {conditions: ['node', 'workspace-source']}},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(config.resolve?.conditions).toEqual(['browser', 'workspace-source']);
|
|
25
|
+
expect(config.ssr?.resolve?.conditions).toEqual(['node', 'workspace-source']);
|
|
26
|
+
});
|
|
27
|
+
});
|