@shipfox/client-workflows 20.0.0 → 21.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 +29 -0
- package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-view.js +231 -42
- package/dist/components/job-detail/job-detail-view.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/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 +24 -7
- 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-states.js +2 -2
- 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 +13 -10
- 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 +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.js +1 -1
- 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 +43 -36
- 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 +3 -2
- 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 +7 -7
- package/src/components/job-detail/job-detail-view.tsx +263 -47
- 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.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 +28 -9
- 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-states.tsx +2 -2
- 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 +17 -13
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +2 -2
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +2 -1
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -4
- package/src/components/workflow-run-view/run-workspace-nav.tsx +1 -1
- package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
- package/src/components/workflow-run-view/workflow-run-view.stories.tsx +140 -0
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +38 -7
- package/src/components/workflow-run-view/workflow-run-view.tsx +45 -37
- package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
- package/src/pages/project-workflows-page.tsx +3 -2
- 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
|
@@ -11,7 +11,7 @@ const meta = {
|
|
|
11
11
|
parameters: {layout: 'centered'},
|
|
12
12
|
decorators: [
|
|
13
13
|
(Story) => (
|
|
14
|
-
<div className="h-520 w-900 overflow-auto bg-background-
|
|
14
|
+
<div className="h-520 w-900 overflow-auto bg-background-subtle-base p-16">
|
|
15
15
|
<Story />
|
|
16
16
|
</div>
|
|
17
17
|
),
|
|
@@ -132,7 +132,7 @@ function toStepModel(step: Step, index: number): StepModel {
|
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
function stepLabel(step: Step, index: number): string {
|
|
135
|
+
export function stepLabel(step: Step, index: number): string {
|
|
136
136
|
const name = step.name.trim();
|
|
137
137
|
if (name) return name;
|
|
138
138
|
|
|
@@ -145,6 +145,34 @@ describe('StepList', () => {
|
|
|
145
145
|
expect(screen.getByText(`logs for ${deployAttempt.id}`)).toBeInTheDocument();
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
test('reports the expanded attempt ids as rows change', async () => {
|
|
149
|
+
const user = userEvent.setup();
|
|
150
|
+
const onExpandedAttemptIdsChange = vi.fn();
|
|
151
|
+
const buildAttempt = makeAttempt({status: 'succeeded'});
|
|
152
|
+
const deployAttempt = makeAttempt({status: 'running'});
|
|
153
|
+
const build = makeStep({name: 'build', attempts: [buildAttempt]});
|
|
154
|
+
const deploy = makeStep({name: 'deploy', position: 1, attempts: [deployAttempt]});
|
|
155
|
+
render(
|
|
156
|
+
<StepList
|
|
157
|
+
job={makeJob({steps: [build, deploy]})}
|
|
158
|
+
onExpandedAttemptIdsChange={onExpandedAttemptIdsChange}
|
|
159
|
+
renderExpandedStep={({attemptId}) => <Text size="sm">logs for {attemptId}</Text>}
|
|
160
|
+
/>,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await user.click(screen.getByRole('button', {name: 'build, Succeeded, attempt 1'}));
|
|
164
|
+
expect(onExpandedAttemptIdsChange).toHaveBeenLastCalledWith([buildAttempt.id]);
|
|
165
|
+
|
|
166
|
+
await user.click(screen.getByRole('button', {name: 'deploy, Running, attempt 1'}));
|
|
167
|
+
expect(onExpandedAttemptIdsChange).toHaveBeenLastCalledWith([
|
|
168
|
+
buildAttempt.id,
|
|
169
|
+
deployAttempt.id,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
await user.click(screen.getByRole('button', {name: 'build, Succeeded, attempt 1'}));
|
|
173
|
+
expect(onExpandedAttemptIdsChange).toHaveBeenLastCalledWith([deployAttempt.id]);
|
|
174
|
+
});
|
|
175
|
+
|
|
148
176
|
test('keeps multiple expanded rows open when external attempt selection is singular', async () => {
|
|
149
177
|
const user = userEvent.setup();
|
|
150
178
|
const buildAttempt = makeAttempt({status: 'succeeded'});
|
|
@@ -210,7 +238,7 @@ describe('StepList', () => {
|
|
|
210
238
|
await user.click(deployRow);
|
|
211
239
|
|
|
212
240
|
expect(buildRow.parentElement?.parentElement).not.toHaveClass('bg-background-components-hover');
|
|
213
|
-
expect(deployRow.parentElement?.parentElement).toHaveClass('bg-background-
|
|
241
|
+
expect(deployRow.parentElement?.parentElement).toHaveClass('bg-background-neutral-hover');
|
|
214
242
|
});
|
|
215
243
|
|
|
216
244
|
test('opens a default selected attempt', () => {
|
|
@@ -58,6 +58,7 @@ export interface StepListProps {
|
|
|
58
58
|
selectedAttemptId?: string | null | undefined;
|
|
59
59
|
defaultSelectedAttemptId?: string | undefined;
|
|
60
60
|
onSelectedAttemptChange?: ((attemptId: string | undefined) => void) | undefined;
|
|
61
|
+
onExpandedAttemptIdsChange?: ((attemptIds: readonly string[]) => void) | undefined;
|
|
61
62
|
inspectorOpenAttemptId?: string | null | undefined;
|
|
62
63
|
onInspectorOpenChange?: ((attemptId: string | null) => void) | undefined;
|
|
63
64
|
autoSelectActiveAttempt?: boolean | undefined;
|
|
@@ -74,6 +75,7 @@ export function StepList({
|
|
|
74
75
|
selectedAttemptId,
|
|
75
76
|
defaultSelectedAttemptId,
|
|
76
77
|
onSelectedAttemptChange,
|
|
78
|
+
onExpandedAttemptIdsChange,
|
|
77
79
|
inspectorOpenAttemptId = null,
|
|
78
80
|
onInspectorOpenChange,
|
|
79
81
|
autoSelectActiveAttempt = false,
|
|
@@ -96,6 +98,7 @@ export function StepList({
|
|
|
96
98
|
selectedAttemptId={selectedAttemptId}
|
|
97
99
|
defaultSelectedAttemptId={defaultSelectedAttemptId}
|
|
98
100
|
onSelectedAttemptChange={onSelectedAttemptChange}
|
|
101
|
+
onExpandedAttemptIdsChange={onExpandedAttemptIdsChange}
|
|
99
102
|
inspectorOpenAttemptId={inspectorOpenAttemptId}
|
|
100
103
|
onInspectorOpenChange={onInspectorOpenChange}
|
|
101
104
|
autoSelectActiveAttempt={autoSelectActiveAttempt}
|
|
@@ -113,6 +116,7 @@ function StepListContent({
|
|
|
113
116
|
selectedAttemptId,
|
|
114
117
|
defaultSelectedAttemptId,
|
|
115
118
|
onSelectedAttemptChange,
|
|
119
|
+
onExpandedAttemptIdsChange,
|
|
116
120
|
inspectorOpenAttemptId = null,
|
|
117
121
|
onInspectorOpenChange,
|
|
118
122
|
autoSelectActiveAttempt,
|
|
@@ -134,20 +138,35 @@ function StepListContent({
|
|
|
134
138
|
const lastNotifiedSelectedAttemptId = useRef<string | null>(null);
|
|
135
139
|
const autoSelectedAttemptIdRef = useRef<string | undefined>(undefined);
|
|
136
140
|
const previousSelectedAttemptIdRef = useRef<string | null | undefined>(selectedAttemptId);
|
|
141
|
+
const lastReportedExpandedAttemptIdsKeyRef = useRef<string | null>(null);
|
|
137
142
|
const shouldUseControlledCollapsedState =
|
|
138
143
|
selectedAttemptId === null && lastNotifiedSelectedAttemptId.current === null;
|
|
139
144
|
const autoSelectedAttemptId =
|
|
140
145
|
selectedAttemptId === undefined && autoSelectActiveAttempt && !userSelectedAttempt
|
|
141
146
|
? (autoSelectedAttemptIdRef.current ?? model.activeEntryId)
|
|
142
147
|
: undefined;
|
|
143
|
-
const autoSelectedAttemptIds =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
const autoSelectedAttemptIds = useMemo(
|
|
149
|
+
() => (autoSelectedAttemptId ? [autoSelectedAttemptId] : []),
|
|
150
|
+
[autoSelectedAttemptId],
|
|
151
|
+
);
|
|
152
|
+
const selectedAttemptIds = useMemo(
|
|
153
|
+
() =>
|
|
154
|
+
shouldUseControlledCollapsedState
|
|
155
|
+
? []
|
|
156
|
+
: localSelectedAttemptIds.length > 0
|
|
157
|
+
? localSelectedAttemptIds
|
|
158
|
+
: autoSelectedAttemptIds,
|
|
159
|
+
[autoSelectedAttemptIds, localSelectedAttemptIds, shouldUseControlledCollapsedState],
|
|
160
|
+
);
|
|
149
161
|
const hasExpandedContent = renderExpandedStep !== undefined;
|
|
150
162
|
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const key = selectedAttemptIds.join('|');
|
|
165
|
+
if (lastReportedExpandedAttemptIdsKeyRef.current === key) return;
|
|
166
|
+
lastReportedExpandedAttemptIdsKeyRef.current = key;
|
|
167
|
+
onExpandedAttemptIdsChange?.(selectedAttemptIds);
|
|
168
|
+
}, [onExpandedAttemptIdsChange, selectedAttemptIds]);
|
|
169
|
+
|
|
151
170
|
useEffect(() => {
|
|
152
171
|
if (selectedAttemptId !== undefined) {
|
|
153
172
|
previousSelectedAttemptIdRef.current = selectedAttemptId;
|
|
@@ -223,7 +242,7 @@ function StepListContent({
|
|
|
223
242
|
<section
|
|
224
243
|
aria-labelledby={showHeader ? titleId : undefined}
|
|
225
244
|
className={cn(
|
|
226
|
-
'flex min-h-0 flex-col rounded-8 border border-border-neutral-base bg-background-
|
|
245
|
+
'flex min-h-0 flex-col rounded-8 border border-border-neutral-base bg-background-neutral-base',
|
|
227
246
|
className,
|
|
228
247
|
)}
|
|
229
248
|
>
|
|
@@ -425,8 +444,8 @@ function StepRow({
|
|
|
425
444
|
<>
|
|
426
445
|
<div
|
|
427
446
|
className={cn(
|
|
428
|
-
'group flex min-w-0 items-center gap-inline pr-[8px] transition-colors hover:bg-background-
|
|
429
|
-
selected && 'bg-background-
|
|
447
|
+
'group flex min-w-0 items-center gap-inline bg-background-neutral-base pr-[8px] transition-colors hover:bg-background-neutral-hover active:bg-background-neutral-pressed',
|
|
448
|
+
selected && 'bg-background-neutral-hover',
|
|
430
449
|
!hasExpandedContent && ['border-b border-border-neutral-base', isLast && 'border-b-0'],
|
|
431
450
|
)}
|
|
432
451
|
>
|
|
@@ -1,21 +1,17 @@
|
|
|
1
|
+
import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
|
|
1
2
|
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
2
3
|
import {Code, Text} from '@shipfox/react-ui/typography';
|
|
3
4
|
import {cn} from '@shipfox/react-ui/utils';
|
|
5
|
+
import {memo} from 'react';
|
|
4
6
|
import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
|
|
5
7
|
import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
|
|
6
8
|
import {deriveJobDisplayStatus, type JobDisplayStatus} from '#core/entities/job.js';
|
|
7
9
|
import type {WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
|
|
8
10
|
|
|
9
|
-
/**
|
|
10
|
-
* How many glyphs fit the strip's column before it starts costing the run name width.
|
|
11
|
-
*
|
|
12
|
-
* Sized so the widest case still fits the row's 160px strip column: seven 12px glyphs with
|
|
13
|
-
* 3px gaps is 102px, and the overflow indicator adds a glyph plus a count of at most five
|
|
14
|
-
* monospace characters, about 56px. Overshooting does not wrap, it paints over the duration
|
|
15
|
-
* column. This sits under the API's preview bound, so resizing the strip is a change here.
|
|
16
|
-
*/
|
|
17
|
-
const MAX_VISIBLE_JOBS = 7;
|
|
18
11
|
const GLYPH_SIZE = 12;
|
|
12
|
+
const GLYPH_GAP = 3;
|
|
13
|
+
const OVERFLOW_GAP = 2;
|
|
14
|
+
const OVERFLOW_LABEL_WIDTH = 40;
|
|
19
15
|
const MAX_TOOLTIP_JOB_NAMES = 6;
|
|
20
16
|
|
|
21
17
|
/**
|
|
@@ -32,6 +28,20 @@ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
|
|
|
32
28
|
maximumFractionDigits: 1,
|
|
33
29
|
});
|
|
34
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Reserves one full API preview and its overflow marker in a run row.
|
|
33
|
+
*
|
|
34
|
+
* Keep this derived from the API preview bound so the row reservation grows with the payload
|
|
35
|
+
* contract instead of silently drifting into the duration and timestamp columns.
|
|
36
|
+
*/
|
|
37
|
+
export function jobStatusStripWidthForPreview(previewLimit: number): number {
|
|
38
|
+
const previewWidth = previewLimit * GLYPH_SIZE + Math.max(previewLimit - 1, 0) * GLYPH_GAP;
|
|
39
|
+
const overflowWidth = GLYPH_GAP + GLYPH_SIZE + OVERFLOW_GAP + OVERFLOW_LABEL_WIDTH;
|
|
40
|
+
return previewWidth + overflowWidth;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const JOB_STATUS_STRIP_WIDTH = jobStatusStripWidthForPreview(WORKFLOW_RUN_JOB_PREVIEW_LIMIT);
|
|
44
|
+
|
|
35
45
|
// Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
|
|
36
46
|
// A strip that hides a failure behind eight green discs would be worse than no strip at all.
|
|
37
47
|
const STATUS_SEVERITY: Record<JobDisplayStatus, number> = {
|
|
@@ -62,10 +72,13 @@ export interface JobStatusStripProps {
|
|
|
62
72
|
* job including those the server never sent. That split is what lets the overflow glyph
|
|
63
73
|
* report a failure sitting past the preview instead of quietly dropping it.
|
|
64
74
|
*/
|
|
65
|
-
export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
|
|
75
|
+
export const JobStatusStrip = memo(function JobStatusStrip({jobs, className}: JobStatusStripProps) {
|
|
66
76
|
if (jobs.total === 0) return null;
|
|
67
77
|
|
|
68
|
-
|
|
78
|
+
// The data frame gives the row enough room for the whole API preview. Only jobs beyond that
|
|
79
|
+
// server-side preview need an overflow marker; the old seven-glyph cap belonged to the
|
|
80
|
+
// previous 1120px page width.
|
|
81
|
+
const visible = jobs.preview;
|
|
69
82
|
const hiddenCount = jobs.total - visible.length;
|
|
70
83
|
const overflowStatus = worstHiddenStatus(jobs, visible);
|
|
71
84
|
const summary = jobStatusSummary(jobs);
|
|
@@ -115,7 +128,7 @@ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
|
|
|
115
128
|
</TooltipContent>
|
|
116
129
|
</Tooltip>
|
|
117
130
|
);
|
|
118
|
-
}
|
|
131
|
+
});
|
|
119
132
|
|
|
120
133
|
function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary: string}) {
|
|
121
134
|
// Naming the jobs that need attention is the whole point of hovering; succeeded jobs are
|
|
@@ -53,7 +53,7 @@ export function WorkflowRunFilters({
|
|
|
53
53
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
54
54
|
|
|
55
55
|
return (
|
|
56
|
-
<div className="flex flex-wrap items-center gap-inline">
|
|
56
|
+
<div className="flex w-full flex-wrap items-center gap-inline">
|
|
57
57
|
<Input
|
|
58
58
|
value={search.search ?? ''}
|
|
59
59
|
onChange={(event) => onChange({search: event.target.value})}
|
|
@@ -16,7 +16,7 @@ const SKELETON_ROW_COUNT = 8;
|
|
|
16
16
|
export function WorkflowRunListSkeleton() {
|
|
17
17
|
return (
|
|
18
18
|
<div
|
|
19
|
-
className="@container divide-y divide-border-neutral-base
|
|
19
|
+
className="@container divide-y divide-border-neutral-base"
|
|
20
20
|
role="status"
|
|
21
21
|
aria-label="Loading runs"
|
|
22
22
|
>
|
|
@@ -28,7 +28,7 @@ export function WorkflowRunListSkeleton() {
|
|
|
28
28
|
>
|
|
29
29
|
<Skeleton className="size-14 shrink-0 rounded-full" />
|
|
30
30
|
<Skeleton className="h-12 w-[220px] max-w-[40%]" />
|
|
31
|
-
<Skeleton className="ml-auto hidden h-12 w-96 @min-[
|
|
31
|
+
<Skeleton className="ml-auto hidden h-12 w-96 @min-[1200px]:block" />
|
|
32
32
|
<Skeleton className="h-12 w-48" />
|
|
33
33
|
<Skeleton className="h-12 w-48" />
|
|
34
34
|
</div>
|
|
@@ -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
|
}
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
workflowRunCommitLabel,
|
|
23
23
|
} from '#core/workflow-run.js';
|
|
24
24
|
import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
|
|
25
|
-
import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
|
|
25
|
+
import {JOB_STATUS_STRIP_WIDTH, JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
|
|
26
26
|
|
|
27
27
|
export function WorkflowRunRowList({
|
|
28
28
|
runs,
|
|
@@ -34,10 +34,10 @@ export function WorkflowRunRowList({
|
|
|
34
34
|
projectSlug?: string | undefined;
|
|
35
35
|
}) {
|
|
36
36
|
return (
|
|
37
|
-
// A container, not the viewport, drives the row's breakpoints.
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
<ul className="@container divide-y divide-border-neutral-base
|
|
37
|
+
// A container, not the viewport, drives the row's breakpoints. What a row can afford is its
|
|
38
|
+
// own width; keying off the viewport would keep the job strip hidden on a wide screen or
|
|
39
|
+
// crush it on a narrow one.
|
|
40
|
+
<ul className="@container divide-y divide-border-neutral-base">
|
|
41
41
|
{runs.map((run) => (
|
|
42
42
|
<li key={run.id}>
|
|
43
43
|
<WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
|
|
@@ -55,9 +55,9 @@ export function WorkflowRunRowList({
|
|
|
55
55
|
* drops beneath. The numeric columns are fixed-width so duration and time form real columns
|
|
56
56
|
* down the list rather than tracking each row's name length.
|
|
57
57
|
*
|
|
58
|
-
* The
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* The 976px threshold keeps identity and provenance on one line; below it, provenance drops
|
|
59
|
+
* beneath the identity. The 1200px threshold keeps the full job preview and numeric columns
|
|
60
|
+
* readable together; below it, the strip is hidden so the numeric columns remain aligned.
|
|
61
61
|
*/
|
|
62
62
|
export function WorkflowRunRow({
|
|
63
63
|
run,
|
|
@@ -99,10 +99,14 @@ export function WorkflowRunRow({
|
|
|
99
99
|
</div>
|
|
100
100
|
|
|
101
101
|
<span className="flex shrink-0 items-center gap-cluster">
|
|
102
|
-
{/*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
{/* Reserve the API preview's full width so the overflow count cannot paint over the
|
|
103
|
+
duration, and keep it even when a run has no jobs planned yet so the numerics stay in
|
|
104
|
+
line down the list instead of stepping in and out. The max-content width expands if
|
|
105
|
+
the payload ever exceeds the current API preview bound. */}
|
|
106
|
+
<span
|
|
107
|
+
className="hidden w-max shrink-0 @min-[1200px]:flex"
|
|
108
|
+
style={{minWidth: JOB_STATUS_STRIP_WIDTH}}
|
|
109
|
+
>
|
|
106
110
|
<JobStatusStrip jobs={run.jobs} />
|
|
107
111
|
</span>
|
|
108
112
|
<span className="flex w-64 justify-end">
|
|
@@ -121,7 +125,7 @@ export function WorkflowRunRow({
|
|
|
121
125
|
// Rows run edge to edge inside the list's scroll container, which would clip the standard
|
|
122
126
|
// outset focus ring, so this one is inset per the design system's focus-ring rule.
|
|
123
127
|
const rowClassName =
|
|
124
|
-
'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-
|
|
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-[inset_0_0_0_2px_var(--color-primary-500)] focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
|
|
125
129
|
|
|
126
130
|
// Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
|
|
127
131
|
// 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-
|
|
59
|
+
<div className="min-h-screen bg-background-subtle-base">
|
|
60
60
|
<div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col overflow-hidden border-x border-border-neutral-base bg-background-subtle-base">
|
|
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);
|
|
@@ -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">
|
|
@@ -53,7 +53,7 @@ export function RunWorkspaceNav({
|
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
55
|
<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
|
|
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 min-[768px]:border-r">
|
|
57
57
|
<Collapsible
|
|
58
58
|
open={mobileOpen}
|
|
59
59
|
onOpenChange={setMobileOpen}
|
|
@@ -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" />
|