@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.
Files changed (133) hide show
  1. package/.storybook/main.ts +16 -1
  2. package/.storybook/workspace-source.ts +31 -0
  3. package/.turbo/turbo-build.log +1 -1
  4. package/CHANGELOG.md +23 -0
  5. package/dist/components/job-graph/job-duration-label.js +1 -1
  6. package/dist/components/job-graph/job-duration-label.js.map +1 -1
  7. package/dist/components/job-graph/job-graph-content.d.ts +2 -1
  8. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
  9. package/dist/components/job-graph/job-graph-content.js +3 -3
  10. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  11. package/dist/components/job-graph/job-graph-view.d.ts +2 -1
  12. package/dist/components/job-graph/job-graph-view.d.ts.map +1 -1
  13. package/dist/components/job-graph/job-graph-view.js +2 -2
  14. package/dist/components/job-graph/job-graph-view.js.map +1 -1
  15. package/dist/components/job-graph/job-node.js +5 -5
  16. package/dist/components/job-graph/job-node.js.map +1 -1
  17. package/dist/components/job-graph/types.d.ts +2 -1
  18. package/dist/components/job-graph/types.d.ts.map +1 -1
  19. package/dist/components/job-graph/types.js.map +1 -1
  20. package/dist/components/workflow-run-duration-label.js +1 -1
  21. package/dist/components/workflow-run-duration-label.js.map +1 -1
  22. package/dist/components/workflow-run-number-label.js +1 -1
  23. package/dist/components/workflow-run-number-label.js.map +1 -1
  24. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +2 -2
  25. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  26. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +2 -7
  27. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  28. package/dist/components/workflow-run-summary/workflow-run-summary.js +92 -68
  29. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  30. package/dist/components/workflow-run-tabs/index.d.ts +6 -0
  31. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -0
  32. package/dist/components/workflow-run-tabs/index.js +7 -0
  33. package/dist/components/workflow-run-tabs/index.js.map +1 -0
  34. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +11 -0
  35. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -0
  36. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +71 -0
  37. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -0
  38. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +2 -0
  39. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -0
  40. package/dist/components/workflow-run-tabs/run-annotations-empty.js +11 -0
  41. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -0
  42. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +18 -0
  43. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +1 -0
  44. package/dist/components/workflow-run-tabs/run-jobs-list.js +107 -0
  45. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +1 -0
  46. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +6 -0
  47. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +1 -0
  48. package/dist/components/workflow-run-tabs/run-tab-count.js +27 -0
  49. package/dist/components/workflow-run-tabs/run-tab-count.js.map +1 -0
  50. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +8 -0
  51. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +1 -0
  52. package/dist/components/workflow-run-tabs/run-tab-strip.js +82 -0
  53. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +1 -0
  54. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  55. package/dist/components/workflow-run-view/workflow-run-selection.js +40 -1
  56. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  57. package/dist/components/workflow-run-view/workflow-run-states.d.ts +1 -0
  58. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  59. package/dist/components/workflow-run-view/workflow-run-states.js +10 -1
  60. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  61. package/dist/components/workflow-run-view/workflow-run-view.d.ts +8 -4
  62. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  63. package/dist/components/workflow-run-view/workflow-run-view.js +125 -78
  64. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  65. package/dist/components/workflow-source-panel/index.d.ts +1 -0
  66. package/dist/components/workflow-source-panel/index.d.ts.map +1 -1
  67. package/dist/components/workflow-source-panel/index.js +1 -0
  68. package/dist/components/workflow-source-panel/index.js.map +1 -1
  69. package/dist/components/workflow-source-panel/workflow-source-content.d.ts +12 -0
  70. package/dist/components/workflow-source-panel/workflow-source-content.d.ts.map +1 -0
  71. package/dist/components/workflow-source-panel/workflow-source-content.js +53 -0
  72. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -0
  73. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +1 -1
  74. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -1
  75. package/dist/components/workflow-source-panel/workflow-source-panel.js +7 -54
  76. package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -1
  77. package/dist/core/entities/step.d.ts +1 -1
  78. package/dist/core/entities/step.d.ts.map +1 -1
  79. package/dist/core/entities/step.js +2 -0
  80. package/dist/core/entities/step.js.map +1 -1
  81. package/dist/core/workflow-run-tabs.d.ts +9 -0
  82. package/dist/core/workflow-run-tabs.d.ts.map +1 -0
  83. package/dist/core/workflow-run-tabs.js +3 -0
  84. package/dist/core/workflow-run-tabs.js.map +1 -0
  85. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  86. package/dist/pages/workflow-run-detail-page.js +28 -6
  87. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  88. package/dist/routes/inputs.d.ts +33 -0
  89. package/dist/routes/inputs.d.ts.map +1 -1
  90. package/dist/routes/inputs.js +49 -0
  91. package/dist/routes/inputs.js.map +1 -1
  92. package/dist/tsconfig.test.tsbuildinfo +1 -1
  93. package/package.json +9 -9
  94. package/src/components/job-graph/job-duration-label.tsx +5 -1
  95. package/src/components/job-graph/job-graph-content.tsx +7 -4
  96. package/src/components/job-graph/job-graph-view.tsx +6 -3
  97. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  98. package/src/components/job-graph/job-node.stories.tsx +1 -1
  99. package/src/components/job-graph/job-node.test.tsx +27 -2
  100. package/src/components/job-graph/job-node.tsx +6 -6
  101. package/src/components/job-graph/types.ts +5 -1
  102. package/src/components/workflow-run-duration-label.tsx +1 -1
  103. package/src/components/workflow-run-number-label.tsx +1 -1
  104. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +2 -2
  105. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +23 -30
  106. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +3 -22
  107. package/src/components/workflow-run-summary/workflow-run-summary.tsx +84 -69
  108. package/src/components/workflow-run-tabs/index.ts +5 -0
  109. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +69 -0
  110. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +97 -0
  111. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +11 -0
  112. package/src/components/workflow-run-tabs/run-jobs-list.tsx +151 -0
  113. package/src/components/workflow-run-tabs/run-tab-count.tsx +28 -0
  114. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +52 -0
  115. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +117 -0
  116. package/src/components/workflow-run-tabs/run-tab-strip.tsx +97 -0
  117. package/src/components/workflow-run-view/workflow-run-selection.test.ts +48 -0
  118. package/src/components/workflow-run-view/workflow-run-selection.ts +48 -1
  119. package/src/components/workflow-run-view/workflow-run-states.tsx +12 -1
  120. package/src/components/workflow-run-view/workflow-run-view.test.tsx +64 -64
  121. package/src/components/workflow-run-view/workflow-run-view.tsx +199 -85
  122. package/src/components/workflow-source-panel/index.ts +1 -0
  123. package/src/components/workflow-source-panel/workflow-source-content.tsx +84 -0
  124. package/src/components/workflow-source-panel/workflow-source-panel.tsx +14 -81
  125. package/src/core/entities/step.ts +4 -0
  126. package/src/core/workflow-run-tabs.ts +8 -0
  127. package/src/pages/workflow-run-detail-page.tsx +31 -4
  128. package/src/pages/workflow-run-pages.test.tsx +185 -45
  129. package/src/routes/inputs.test.ts +47 -0
  130. package/src/routes/inputs.ts +45 -0
  131. package/src/storybook-config.test.ts +27 -0
  132. package/tsconfig.build.tsbuildinfo +1 -1
  133. 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,11 @@
1
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
2
+
3
+ export function RunAnnotationsEmpty() {
4
+ return (
5
+ <EmptyState
6
+ icon="fileDamageLine"
7
+ title="No annotations"
8
+ description="This run has no annotations to show."
9
+ />
10
+ );
11
+ }
@@ -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
+ }