@shipfox/client-workflows 13.0.0 → 14.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +16 -1
- package/.storybook/workspace-source.ts +31 -0
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +37 -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
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export {RunAnnotationSummaryLine} from './run-annotation-summary-line.js';
|
|
2
|
+
export {RunAnnotationsEmpty} from './run-annotations-empty.js';
|
|
3
|
+
export {RunJobsList} from './run-jobs-list.js';
|
|
4
|
+
export {RunTabCount} from './run-tab-count.js';
|
|
5
|
+
export {RunTabStrip} from './run-tab-strip.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMemoryHistory,
|
|
3
|
+
createRootRoute,
|
|
4
|
+
createRoute,
|
|
5
|
+
createRouter,
|
|
6
|
+
Outlet,
|
|
7
|
+
RouterProvider,
|
|
8
|
+
} from '@tanstack/react-router';
|
|
9
|
+
import {act, render, screen} from '@testing-library/react';
|
|
10
|
+
import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
|
|
11
|
+
import type {WorkflowRunsSearch} from '#routes/inputs.js';
|
|
12
|
+
import {RunAnnotationSummaryLine} from './run-annotation-summary-line.js';
|
|
13
|
+
|
|
14
|
+
const ANNOTATION_SUMMARY: RunAnnotationSummary = {
|
|
15
|
+
total: 5,
|
|
16
|
+
error: 2,
|
|
17
|
+
warning: 1,
|
|
18
|
+
info: 2,
|
|
19
|
+
success: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('RunAnnotationSummaryLine', () => {
|
|
23
|
+
test('links the severity breakdown into the Annotations panel', async () => {
|
|
24
|
+
await renderSummaryLine();
|
|
25
|
+
|
|
26
|
+
expect(screen.getByText('5 annotations')).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByRole('link', {name: '2 errors'})).toHaveAttribute(
|
|
28
|
+
'href',
|
|
29
|
+
'/w/acme/p/project/runs/run-1?tab=annotations&severity=error',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('clears a stale annotation when linking to a severity', async () => {
|
|
34
|
+
await renderSummaryLine({annotation: 'annotation-old'});
|
|
35
|
+
|
|
36
|
+
expect(screen.getByRole('link', {name: '2 errors'})).toHaveAttribute(
|
|
37
|
+
'href',
|
|
38
|
+
'/w/acme/p/project/runs/run-1?tab=annotations&severity=error',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function renderSummaryLine(search: WorkflowRunsSearch = {}) {
|
|
44
|
+
const rootRoute = createRootRoute({component: Outlet});
|
|
45
|
+
const runRoute = createRoute({
|
|
46
|
+
getParentRoute: () => rootRoute,
|
|
47
|
+
path: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
|
|
48
|
+
component: () => (
|
|
49
|
+
<RunAnnotationSummaryLine
|
|
50
|
+
summary={ANNOTATION_SUMMARY}
|
|
51
|
+
workspaceSlug="acme"
|
|
52
|
+
projectSlug="project"
|
|
53
|
+
workflowRunId="run-1"
|
|
54
|
+
search={search}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
const router = createRouter({
|
|
59
|
+
history: createMemoryHistory({initialEntries: ['/w/acme/p/project/runs/run-1']}),
|
|
60
|
+
routeTree: rootRoute.addChildren([runRoute]),
|
|
61
|
+
});
|
|
62
|
+
await router.load();
|
|
63
|
+
let result: ReturnType<typeof render> | undefined;
|
|
64
|
+
await act(() => {
|
|
65
|
+
result = render(<RouterProvider router={router} />);
|
|
66
|
+
});
|
|
67
|
+
if (!result) throw new Error('Run annotation summary did not render.');
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
2
|
+
import {Link} from '@tanstack/react-router';
|
|
3
|
+
import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
|
|
4
|
+
import {
|
|
5
|
+
WORKFLOW_RUN_ANNOTATION_SEVERITIES,
|
|
6
|
+
type WorkflowRunAnnotationSeverity,
|
|
7
|
+
type WorkflowRunsSearch,
|
|
8
|
+
workflowRunSearchParams,
|
|
9
|
+
} from '#routes/inputs.js';
|
|
10
|
+
import {MetadataSeparator} from '../workflow-run-summary/workflow-run-summary.js';
|
|
11
|
+
|
|
12
|
+
export interface RunAnnotationSummaryLineProps {
|
|
13
|
+
summary?: RunAnnotationSummary | undefined;
|
|
14
|
+
workspaceSlug?: string | undefined;
|
|
15
|
+
projectSlug?: string | undefined;
|
|
16
|
+
workflowRunId?: string | undefined;
|
|
17
|
+
search?: WorkflowRunsSearch | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RunAnnotationSummaryLine({
|
|
21
|
+
summary,
|
|
22
|
+
workspaceSlug,
|
|
23
|
+
projectSlug,
|
|
24
|
+
workflowRunId,
|
|
25
|
+
search = {},
|
|
26
|
+
}: RunAnnotationSummaryLineProps) {
|
|
27
|
+
if (!summary) return null;
|
|
28
|
+
|
|
29
|
+
const visibleSeverities = WORKFLOW_RUN_ANNOTATION_SEVERITIES.filter(
|
|
30
|
+
(severity) => summary[severity] > 0,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 text-foreground-neutral-subtle">
|
|
35
|
+
<Text as="span" size="xs" className="shrink-0 text-foreground-neutral-subtle">
|
|
36
|
+
{summary.total} {pluralize('annotation', summary.total)}
|
|
37
|
+
</Text>
|
|
38
|
+
{visibleSeverities.map((severity) => (
|
|
39
|
+
<span key={severity} className="inline-flex shrink-0 items-center gap-8">
|
|
40
|
+
<MetadataSeparator />
|
|
41
|
+
<SeverityLink
|
|
42
|
+
count={summary[severity]}
|
|
43
|
+
severity={severity}
|
|
44
|
+
workspaceSlug={workspaceSlug}
|
|
45
|
+
projectSlug={projectSlug}
|
|
46
|
+
workflowRunId={workflowRunId}
|
|
47
|
+
search={search}
|
|
48
|
+
/>
|
|
49
|
+
</span>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function SeverityLink({
|
|
56
|
+
count,
|
|
57
|
+
severity,
|
|
58
|
+
workspaceSlug,
|
|
59
|
+
projectSlug,
|
|
60
|
+
workflowRunId,
|
|
61
|
+
search,
|
|
62
|
+
}: {
|
|
63
|
+
count: number;
|
|
64
|
+
severity: WorkflowRunAnnotationSeverity;
|
|
65
|
+
workspaceSlug?: string | undefined;
|
|
66
|
+
projectSlug?: string | undefined;
|
|
67
|
+
workflowRunId?: string | undefined;
|
|
68
|
+
search: WorkflowRunsSearch;
|
|
69
|
+
}) {
|
|
70
|
+
const label = `${count} ${pluralize(severity, count)}`;
|
|
71
|
+
if (!workspaceSlug || !projectSlug || !workflowRunId) {
|
|
72
|
+
return <span className="text-foreground-highlight-interactive">{label}</span>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const searchWithoutAnnotation = {...search};
|
|
76
|
+
delete searchWithoutAnnotation.annotation;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Link
|
|
80
|
+
to="/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId"
|
|
81
|
+
params={{workspaceSlug, projectSlug, workflowRunId}}
|
|
82
|
+
search={
|
|
83
|
+
workflowRunSearchParams(
|
|
84
|
+
{...searchWithoutAnnotation, tab: 'annotations', severity},
|
|
85
|
+
search,
|
|
86
|
+
) as never
|
|
87
|
+
}
|
|
88
|
+
className="text-foreground-highlight-interactive outline-none hover:underline focus-visible:shadow-border-interactive-with-active"
|
|
89
|
+
>
|
|
90
|
+
{label}
|
|
91
|
+
</Link>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pluralize(word: string, count: number): string {
|
|
96
|
+
return count === 1 ? word : `${word}s`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
2
|
+
import {useTimeTick} from '@shipfox/react-ui/time-ticker';
|
|
3
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
4
|
+
import {
|
|
5
|
+
defaultJobExecution,
|
|
6
|
+
deriveJobDisplayStatus,
|
|
7
|
+
deriveJobExecutionDisplayStatus,
|
|
8
|
+
type Job,
|
|
9
|
+
} from '#core/workflow-run.js';
|
|
10
|
+
import {formatJobDurationAccessibleLabel} from '../job-graph/job-duration-format.js';
|
|
11
|
+
import {JobDurationLabel} from '../job-graph/job-duration-label.js';
|
|
12
|
+
import {JobCard} from '../workflow-run-view/job-card.js';
|
|
13
|
+
import {WorkflowStatusIcon} from '../workflow-status/workflow-status-icon.js';
|
|
14
|
+
|
|
15
|
+
export interface RunJobsListProps {
|
|
16
|
+
jobs: Job[];
|
|
17
|
+
selectedJobId?: string | undefined;
|
|
18
|
+
onSelectedJobChange: (jobId: string | undefined) => void;
|
|
19
|
+
workspaceSlug?: string | undefined;
|
|
20
|
+
selectedJobExecution: ReturnType<typeof defaultJobExecution>;
|
|
21
|
+
selectedAttemptId: string | null | undefined;
|
|
22
|
+
onSelectedJobExecutionChange: ((jobExecutionId: string | undefined) => void) | undefined;
|
|
23
|
+
onSelectedAttemptChange: ((attemptId: string | undefined) => void) | undefined;
|
|
24
|
+
sourcePanelId?: string | undefined;
|
|
25
|
+
sourceAvailable?: boolean | undefined;
|
|
26
|
+
focusedSourceStepId?: string | null | undefined;
|
|
27
|
+
onOpenStepSource?: Parameters<typeof JobCard>[0]['onOpenStepSource'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function RunJobsList({
|
|
31
|
+
jobs,
|
|
32
|
+
selectedJobId,
|
|
33
|
+
onSelectedJobChange,
|
|
34
|
+
workspaceSlug,
|
|
35
|
+
selectedJobExecution,
|
|
36
|
+
selectedAttemptId,
|
|
37
|
+
onSelectedJobExecutionChange,
|
|
38
|
+
onSelectedAttemptChange,
|
|
39
|
+
sourcePanelId,
|
|
40
|
+
sourceAvailable,
|
|
41
|
+
focusedSourceStepId,
|
|
42
|
+
onOpenStepSource,
|
|
43
|
+
}: RunJobsListProps) {
|
|
44
|
+
useTimeTick();
|
|
45
|
+
|
|
46
|
+
if (jobs.length === 0) {
|
|
47
|
+
return (
|
|
48
|
+
<section
|
|
49
|
+
aria-label="Workflow jobs"
|
|
50
|
+
className="rounded-8 border border-border-neutral-base bg-background-components-base"
|
|
51
|
+
>
|
|
52
|
+
<div className="min-h-160">
|
|
53
|
+
<RunJobsEmptyState />
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const orderedJobs = [...jobs].sort(compareJobs);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<section aria-label="Workflow jobs" className="flex min-w-0 flex-col gap-8">
|
|
63
|
+
<ul aria-label="Run jobs" className="m-0 flex min-w-0 list-none flex-col gap-8 p-0">
|
|
64
|
+
{orderedJobs.map((job) => {
|
|
65
|
+
const selected = job.id === selectedJobId;
|
|
66
|
+
const status = deriveJobDisplayStatus(job);
|
|
67
|
+
const execution = defaultJobExecution(job);
|
|
68
|
+
const durationLabel = formatJobDurationAccessibleLabel(
|
|
69
|
+
job.displayDuration,
|
|
70
|
+
execution ? deriveJobExecutionDisplayStatus(execution) : undefined,
|
|
71
|
+
);
|
|
72
|
+
const rowLabel = [
|
|
73
|
+
job.displayName,
|
|
74
|
+
statusLabel(status),
|
|
75
|
+
durationLabel,
|
|
76
|
+
job.carriedOver ? 'reused' : undefined,
|
|
77
|
+
]
|
|
78
|
+
.filter((part): part is string => Boolean(part))
|
|
79
|
+
.join(', ');
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<li key={job.id} className="min-w-0">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
aria-expanded={selected}
|
|
86
|
+
aria-controls={selected ? `job-card-${job.id}` : undefined}
|
|
87
|
+
aria-label={rowLabel}
|
|
88
|
+
onClick={() => onSelectedJobChange(selected ? undefined : job.id)}
|
|
89
|
+
className={cn(
|
|
90
|
+
'relative flex min-h-44 w-full min-w-0 items-center gap-8 rounded-8 bg-background-neutral-base px-12 text-left shadow-button-neutral outline-none transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus',
|
|
91
|
+
selected &&
|
|
92
|
+
'bg-background-components-hover hover:bg-background-components-pressed',
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{selected ? (
|
|
96
|
+
<span
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className="absolute inset-y-6 left-0 w-px rounded-full bg-border-highlights-interactive"
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
<WorkflowStatusIcon status={status} size={14} tooltip={false} />
|
|
102
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground-neutral-base">
|
|
103
|
+
{job.displayName}
|
|
104
|
+
</span>
|
|
105
|
+
<JobDurationLabel duration={job.displayDuration} />
|
|
106
|
+
</button>
|
|
107
|
+
{selected ? (
|
|
108
|
+
<div id={`job-card-${job.id}`} className="mt-8">
|
|
109
|
+
<JobCard
|
|
110
|
+
workspaceSlug={workspaceSlug}
|
|
111
|
+
job={job}
|
|
112
|
+
selectedJobExecution={selectedJobExecution}
|
|
113
|
+
selectedAttemptId={job.carriedOver ? undefined : selectedAttemptId}
|
|
114
|
+
onSelectedJobExecutionChange={onSelectedJobExecutionChange}
|
|
115
|
+
onSelectedAttemptChange={onSelectedAttemptChange}
|
|
116
|
+
sourcePanelId={sourcePanelId}
|
|
117
|
+
sourceAvailable={sourceAvailable}
|
|
118
|
+
focusedSourceStepId={focusedSourceStepId}
|
|
119
|
+
onOpenStepSource={onOpenStepSource}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
) : null}
|
|
123
|
+
</li>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</ul>
|
|
127
|
+
</section>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function RunJobsEmptyState() {
|
|
132
|
+
return (
|
|
133
|
+
<EmptyState
|
|
134
|
+
icon="componentLine"
|
|
135
|
+
title="No jobs yet"
|
|
136
|
+
description="This run has not materialized jobs."
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function compareJobs(left: Job, right: Job): number {
|
|
142
|
+
return (
|
|
143
|
+
left.position - right.position ||
|
|
144
|
+
left.displayName.localeCompare(right.displayName) ||
|
|
145
|
+
left.id.localeCompare(right.id)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statusLabel(status: ReturnType<typeof deriveJobDisplayStatus>): string {
|
|
150
|
+
return status === 'listening' ? 'Listening' : status.charAt(0).toUpperCase() + status.slice(1);
|
|
151
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {Badge} from '@shipfox/react-ui/badge';
|
|
2
|
+
|
|
3
|
+
export function RunTabCount({
|
|
4
|
+
count,
|
|
5
|
+
alertCount = 0,
|
|
6
|
+
alertLabel,
|
|
7
|
+
}: {
|
|
8
|
+
count: number | undefined;
|
|
9
|
+
alertCount?: number | undefined;
|
|
10
|
+
alertLabel: string;
|
|
11
|
+
}) {
|
|
12
|
+
if (count === undefined && alertCount <= 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
{count === undefined ? null : (
|
|
17
|
+
<Badge size="2xs" variant="neutral" aria-hidden="true">
|
|
18
|
+
{count}
|
|
19
|
+
</Badge>
|
|
20
|
+
)}
|
|
21
|
+
{alertCount > 0 ? (
|
|
22
|
+
<Badge size="2xs" variant="error" aria-hidden="true">
|
|
23
|
+
{alertCount} {alertLabel}
|
|
24
|
+
</Badge>
|
|
25
|
+
) : null}
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {Tabs} from '@shipfox/react-ui/tabs';
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react';
|
|
3
|
+
import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
|
|
4
|
+
import {RunTabStrip} from './run-tab-strip.js';
|
|
5
|
+
|
|
6
|
+
const ANNOTATION_SUMMARY: RunAnnotationSummary = {
|
|
7
|
+
total: 8,
|
|
8
|
+
error: 2,
|
|
9
|
+
warning: 3,
|
|
10
|
+
info: 2,
|
|
11
|
+
success: 1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Workflows/RunTabStrip',
|
|
16
|
+
component: RunTabStrip,
|
|
17
|
+
parameters: {layout: 'fullscreen'},
|
|
18
|
+
render: (args) => (
|
|
19
|
+
<div className="bg-background-neutral-background">
|
|
20
|
+
<Tabs defaultValue="summary">
|
|
21
|
+
<RunTabStrip {...args} />
|
|
22
|
+
</Tabs>
|
|
23
|
+
</div>
|
|
24
|
+
),
|
|
25
|
+
args: {
|
|
26
|
+
jobCount: 6,
|
|
27
|
+
jobsFailed: 1,
|
|
28
|
+
annotationSummary: ANNOTATION_SUMMARY,
|
|
29
|
+
},
|
|
30
|
+
} satisfies Meta<typeof RunTabStrip>;
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof meta>;
|
|
34
|
+
|
|
35
|
+
export const Playground: Story = {};
|
|
36
|
+
|
|
37
|
+
export const NoCounts: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
jobCount: undefined,
|
|
40
|
+
annotationSummary: undefined,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const NarrowLayout: Story = {
|
|
45
|
+
decorators: [
|
|
46
|
+
(Story) => (
|
|
47
|
+
<div className="w-[360px] max-w-full overflow-hidden border">
|
|
48
|
+
<Story />
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
],
|
|
52
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {Tabs} from '@shipfox/react-ui/tabs';
|
|
2
|
+
import {
|
|
3
|
+
createMemoryHistory,
|
|
4
|
+
createRootRoute,
|
|
5
|
+
createRoute,
|
|
6
|
+
createRouter,
|
|
7
|
+
Outlet,
|
|
8
|
+
RouterProvider,
|
|
9
|
+
} from '@tanstack/react-router';
|
|
10
|
+
import {act, render, screen, within} from '@testing-library/react';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
|
|
13
|
+
import {RunTabStrip} from './run-tab-strip.js';
|
|
14
|
+
|
|
15
|
+
const ANNOTATION_SUMMARY: RunAnnotationSummary = {
|
|
16
|
+
total: 5,
|
|
17
|
+
error: 2,
|
|
18
|
+
warning: 1,
|
|
19
|
+
info: 2,
|
|
20
|
+
success: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('RunTabStrip', () => {
|
|
24
|
+
test('exposes tab counts and keyboard navigation', async () => {
|
|
25
|
+
const user = userEvent.setup();
|
|
26
|
+
|
|
27
|
+
await renderStrip({annotationSummary: ANNOTATION_SUMMARY, jobCount: 3, jobsFailed: 1});
|
|
28
|
+
|
|
29
|
+
const jobsTab = screen.getByRole('tab', {name: 'Jobs, 3 jobs, 1 failed'});
|
|
30
|
+
const annotationsTab = screen.getByRole('tab', {
|
|
31
|
+
name: 'Annotations, 5 annotations, 2 errors',
|
|
32
|
+
});
|
|
33
|
+
expect(within(jobsTab).getByText('3')).toHaveClass('bg-tag-neutral-bg');
|
|
34
|
+
expect(within(jobsTab).getByText('1 failed')).toHaveClass('bg-tag-error-bg');
|
|
35
|
+
expect(within(annotationsTab).getByText('5')).toHaveClass('bg-tag-neutral-bg');
|
|
36
|
+
expect(within(annotationsTab).getByText('2 errors')).toHaveClass('bg-tag-error-bg');
|
|
37
|
+
expect(screen.queryByRole('link', {name: '2 errors'})).not.toBeInTheDocument();
|
|
38
|
+
expect(screen.getByRole('tab', {name: 'Source'})).toHaveClass(
|
|
39
|
+
'data-[state=inactive]:text-foreground-neutral-subtle',
|
|
40
|
+
'data-[state=inactive]:hover:text-foreground-neutral-base',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const summary = screen.getByRole('tab', {name: 'Summary'});
|
|
44
|
+
expect(screen.getByRole('tablist', {name: 'Run sections'})).toHaveClass('min-w-full', 'px-16');
|
|
45
|
+
expect(summary.parentElement?.parentElement).toHaveClass('bg-background-neutral-background');
|
|
46
|
+
summary.focus();
|
|
47
|
+
await user.keyboard('{ArrowRight}');
|
|
48
|
+
|
|
49
|
+
expect(screen.getByRole('tab', {name: 'Jobs, 3 jobs, 1 failed'})).toHaveAttribute(
|
|
50
|
+
'aria-selected',
|
|
51
|
+
'true',
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByRole('tab', {name: 'Summary'})).toHaveAttribute('tabindex', '-1');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('does not reserve count space when data is absent', async () => {
|
|
57
|
+
await renderStrip({annotationSummary: undefined, jobCount: undefined});
|
|
58
|
+
|
|
59
|
+
const jobsTab = screen.getByRole('tab', {name: 'Jobs'});
|
|
60
|
+
const annotationsTab = screen.getByRole('tab', {name: 'Annotations'});
|
|
61
|
+
|
|
62
|
+
expect(jobsTab).toBeInTheDocument();
|
|
63
|
+
expect(annotationsTab).toBeInTheDocument();
|
|
64
|
+
expect(jobsTab.querySelector('[data-slot="badge"]')).toBeNull();
|
|
65
|
+
expect(annotationsTab.querySelector('[data-slot="badge"]')).toBeNull();
|
|
66
|
+
expect(screen.queryByText('5 annotations')).not.toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('singularizes accessible count labels', async () => {
|
|
70
|
+
await renderStrip({
|
|
71
|
+
annotationSummary: {total: 1, error: 1, warning: 0, info: 0, success: 0},
|
|
72
|
+
jobCount: 1,
|
|
73
|
+
jobsFailed: 1,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(screen.getByRole('tab', {name: 'Jobs, 1 job, 1 failed'})).toBeInTheDocument();
|
|
77
|
+
expect(
|
|
78
|
+
screen.getByRole('tab', {name: 'Annotations, 1 annotation, 1 error'}),
|
|
79
|
+
).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
async function renderStrip({
|
|
84
|
+
annotationSummary,
|
|
85
|
+
jobCount,
|
|
86
|
+
jobsFailed = 0,
|
|
87
|
+
}: {
|
|
88
|
+
annotationSummary?: RunAnnotationSummary | undefined;
|
|
89
|
+
jobCount?: number | undefined;
|
|
90
|
+
jobsFailed?: number | undefined;
|
|
91
|
+
} = {}) {
|
|
92
|
+
const rootRoute = createRootRoute({component: Outlet});
|
|
93
|
+
const runRoute = createRoute({
|
|
94
|
+
getParentRoute: () => rootRoute,
|
|
95
|
+
path: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
|
|
96
|
+
component: () => (
|
|
97
|
+
<Tabs defaultValue="summary">
|
|
98
|
+
<RunTabStrip
|
|
99
|
+
jobCount={jobCount}
|
|
100
|
+
jobsFailed={jobsFailed}
|
|
101
|
+
annotationSummary={annotationSummary}
|
|
102
|
+
/>
|
|
103
|
+
</Tabs>
|
|
104
|
+
),
|
|
105
|
+
});
|
|
106
|
+
const router = createRouter({
|
|
107
|
+
history: createMemoryHistory({initialEntries: ['/w/acme/p/project/runs/run-1']}),
|
|
108
|
+
routeTree: rootRoute.addChildren([runRoute]),
|
|
109
|
+
});
|
|
110
|
+
await router.load();
|
|
111
|
+
let result: ReturnType<typeof render> | undefined;
|
|
112
|
+
await act(() => {
|
|
113
|
+
result = render(<RouterProvider router={router} />);
|
|
114
|
+
});
|
|
115
|
+
if (!result) throw new Error('Run tab strip did not render.');
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {TabsList, TabsTrigger} from '@shipfox/react-ui/tabs';
|
|
2
|
+
import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
|
|
3
|
+
import {RunTabCount} from './run-tab-count.js';
|
|
4
|
+
|
|
5
|
+
export interface RunTabStripProps {
|
|
6
|
+
jobCount?: number | undefined;
|
|
7
|
+
jobsFailed?: number | undefined;
|
|
8
|
+
annotationSummary?: RunAnnotationSummary | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function RunTabStrip({jobCount, jobsFailed = 0, annotationSummary}: RunTabStripProps) {
|
|
12
|
+
const annotationCount = annotationSummary?.total;
|
|
13
|
+
const annotationErrors = annotationSummary?.error ?? 0;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="min-w-0 overflow-x-auto border-b border-border-neutral-base bg-background-neutral-background [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
17
|
+
<TabsList
|
|
18
|
+
aria-label="Run sections"
|
|
19
|
+
className="h-40 min-h-40 w-max min-w-full gap-16 px-16 [@media(pointer:coarse)]:h-44 [@media(pointer:coarse)]:min-h-44"
|
|
20
|
+
>
|
|
21
|
+
<TabsTrigger
|
|
22
|
+
value="summary"
|
|
23
|
+
className="h-40 min-h-40 py-0 text-xs data-[state=inactive]:text-foreground-neutral-subtle data-[state=inactive]:hover:text-foreground-neutral-base [@media(pointer:coarse)]:h-44 [@media(pointer:coarse)]:min-h-44"
|
|
24
|
+
>
|
|
25
|
+
Summary
|
|
26
|
+
</TabsTrigger>
|
|
27
|
+
<TabsTrigger
|
|
28
|
+
value="jobs"
|
|
29
|
+
aria-label={runTabAccessibleName({
|
|
30
|
+
label: 'Jobs',
|
|
31
|
+
total: jobCount,
|
|
32
|
+
totalSingular: 'job',
|
|
33
|
+
totalPlural: 'jobs',
|
|
34
|
+
alertCount: jobsFailed,
|
|
35
|
+
alertSingular: 'failed',
|
|
36
|
+
alertPlural: 'failed',
|
|
37
|
+
})}
|
|
38
|
+
className="h-40 min-h-40 gap-6 py-0 text-xs data-[state=inactive]:text-foreground-neutral-subtle data-[state=inactive]:hover:text-foreground-neutral-base [@media(pointer:coarse)]:h-44 [@media(pointer:coarse)]:min-h-44"
|
|
39
|
+
>
|
|
40
|
+
<span>Jobs</span>
|
|
41
|
+
<RunTabCount count={jobCount} alertCount={jobsFailed} alertLabel="failed" />
|
|
42
|
+
</TabsTrigger>
|
|
43
|
+
<TabsTrigger
|
|
44
|
+
value="annotations"
|
|
45
|
+
aria-label={runTabAccessibleName({
|
|
46
|
+
label: 'Annotations',
|
|
47
|
+
total: annotationCount,
|
|
48
|
+
totalSingular: 'annotation',
|
|
49
|
+
totalPlural: 'annotations',
|
|
50
|
+
alertCount: annotationErrors,
|
|
51
|
+
alertSingular: 'error',
|
|
52
|
+
alertPlural: 'errors',
|
|
53
|
+
})}
|
|
54
|
+
className="h-40 min-h-40 gap-6 py-0 text-xs data-[state=inactive]:text-foreground-neutral-subtle data-[state=inactive]:hover:text-foreground-neutral-base [@media(pointer:coarse)]:h-44 [@media(pointer:coarse)]:min-h-44"
|
|
55
|
+
>
|
|
56
|
+
<span>Annotations</span>
|
|
57
|
+
<RunTabCount
|
|
58
|
+
count={annotationCount}
|
|
59
|
+
alertCount={annotationErrors}
|
|
60
|
+
alertLabel={annotationErrors === 1 ? 'error' : 'errors'}
|
|
61
|
+
/>
|
|
62
|
+
</TabsTrigger>
|
|
63
|
+
<TabsTrigger
|
|
64
|
+
value="source"
|
|
65
|
+
className="h-40 min-h-40 py-0 text-xs data-[state=inactive]:text-foreground-neutral-subtle data-[state=inactive]:hover:text-foreground-neutral-base [@media(pointer:coarse)]:h-44 [@media(pointer:coarse)]:min-h-44"
|
|
66
|
+
>
|
|
67
|
+
Source
|
|
68
|
+
</TabsTrigger>
|
|
69
|
+
</TabsList>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runTabAccessibleName({
|
|
75
|
+
label,
|
|
76
|
+
total,
|
|
77
|
+
totalSingular,
|
|
78
|
+
totalPlural,
|
|
79
|
+
alertCount,
|
|
80
|
+
alertSingular,
|
|
81
|
+
alertPlural,
|
|
82
|
+
}: {
|
|
83
|
+
label: string;
|
|
84
|
+
total: number | undefined;
|
|
85
|
+
totalSingular: string;
|
|
86
|
+
totalPlural: string;
|
|
87
|
+
alertCount: number;
|
|
88
|
+
alertSingular: string;
|
|
89
|
+
alertPlural: string;
|
|
90
|
+
}): string {
|
|
91
|
+
const parts = [label];
|
|
92
|
+
|
|
93
|
+
if (total !== undefined) parts.push(`${total} ${total === 1 ? totalSingular : totalPlural}`);
|
|
94
|
+
if (alertCount > 0) parts.push(`${alertCount} ${alertCount === 1 ? alertSingular : alertPlural}`);
|
|
95
|
+
|
|
96
|
+
return parts.join(', ');
|
|
97
|
+
}
|