@shipfox/client-workflows 15.0.0 → 16.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 (91) hide show
  1. package/.storybook/preview.css +4 -0
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/dist/components/job-detail/job-detail-header.d.ts +8 -1
  5. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -1
  6. package/dist/components/job-detail/job-detail-header.js +25 -12
  7. package/dist/components/job-detail/job-detail-header.js.map +1 -1
  8. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  9. package/dist/components/job-detail/job-detail-view.js +17 -2
  10. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  11. package/dist/components/workflow-run-summary/workflow-run-summary.js +1 -0
  12. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  13. package/dist/components/workflow-run-tabs/index.d.ts +3 -0
  14. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  15. package/dist/components/workflow-run-tabs/index.js +3 -0
  16. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  17. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts +16 -0
  18. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts.map +1 -0
  19. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +50 -0
  20. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -0
  21. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts +20 -0
  22. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts.map +1 -0
  23. package/dist/components/workflow-run-tabs/run-annotation-item.js +102 -0
  24. package/dist/components/workflow-run-tabs/run-annotation-item.js.map +1 -0
  25. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +32 -0
  26. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -0
  27. package/dist/components/workflow-run-tabs/run-annotation-list.js +202 -0
  28. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -0
  29. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +1 -1
  30. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  31. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +87 -22
  32. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  33. package/dist/components/workflow-run-tabs/severity-visuals.d.ts +20 -0
  34. package/dist/components/workflow-run-tabs/severity-visuals.d.ts.map +1 -0
  35. package/dist/components/workflow-run-tabs/severity-visuals.js +31 -0
  36. package/dist/components/workflow-run-tabs/severity-visuals.js.map +1 -0
  37. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +1 -1
  38. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  39. package/dist/components/workflow-run-view/run-workspace-nav.js +9 -3
  40. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  41. package/dist/components/workflow-run-view/workflow-run-view.d.ts +1 -3
  42. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  43. package/dist/components/workflow-run-view/workflow-run-view.js +150 -52
  44. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  45. package/dist/core/run-annotation.d.ts +73 -0
  46. package/dist/core/run-annotation.d.ts.map +1 -0
  47. package/dist/core/run-annotation.js +125 -0
  48. package/dist/core/run-annotation.js.map +1 -0
  49. package/dist/hooks/api/run-annotation-mapper.d.ts +10 -0
  50. package/dist/hooks/api/run-annotation-mapper.d.ts.map +1 -0
  51. package/dist/hooks/api/run-annotation-mapper.js +22 -0
  52. package/dist/hooks/api/run-annotation-mapper.js.map +1 -0
  53. package/dist/hooks/api/run-annotations.d.ts +25 -0
  54. package/dist/hooks/api/run-annotations.d.ts.map +1 -0
  55. package/dist/hooks/api/run-annotations.js +78 -0
  56. package/dist/hooks/api/run-annotations.js.map +1 -0
  57. package/dist/routes/inputs.d.ts +7 -2
  58. package/dist/routes/inputs.d.ts.map +1 -1
  59. package/dist/routes/inputs.js +5 -6
  60. package/dist/routes/inputs.js.map +1 -1
  61. package/dist/tsconfig.test.tsbuildinfo +1 -1
  62. package/package.json +10 -9
  63. package/src/components/job-detail/job-detail-header.test.tsx +3 -0
  64. package/src/components/job-detail/job-detail-header.tsx +30 -5
  65. package/src/components/job-detail/job-detail-view.stories.tsx +5 -0
  66. package/src/components/job-detail/job-detail-view.tsx +18 -1
  67. package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -1
  68. package/src/components/workflow-run-tabs/index.ts +3 -0
  69. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +63 -0
  70. package/src/components/workflow-run-tabs/run-annotation-item.tsx +123 -0
  71. package/src/components/workflow-run-tabs/run-annotation-list.tsx +258 -0
  72. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +77 -3
  73. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +92 -17
  74. package/src/components/workflow-run-tabs/run-annotations.stories.tsx +293 -0
  75. package/src/components/workflow-run-tabs/severity-visuals.ts +37 -0
  76. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +44 -0
  77. package/src/components/workflow-run-view/run-workspace-nav.tsx +15 -2
  78. package/src/components/workflow-run-view/workflow-run-view.test.tsx +246 -20
  79. package/src/components/workflow-run-view/workflow-run-view.tsx +148 -30
  80. package/src/core/run-annotation.test.ts +315 -0
  81. package/src/core/run-annotation.ts +238 -0
  82. package/src/hooks/api/run-annotation-mapper.ts +30 -0
  83. package/src/hooks/api/run-annotations.ts +137 -0
  84. package/src/pages/workflow-job-detail-page.test.tsx +66 -0
  85. package/src/routes/inputs.ts +7 -2
  86. package/tsconfig.build.tsbuildinfo +1 -1
  87. package/dist/core/workflow-run-tabs.d.ts +0 -9
  88. package/dist/core/workflow-run-tabs.d.ts.map +0 -1
  89. package/dist/core/workflow-run-tabs.js +0 -3
  90. package/dist/core/workflow-run-tabs.js.map +0 -1
  91. package/src/core/workflow-run-tabs.ts +0 -8
@@ -7,7 +7,7 @@ import {
7
7
  RouterProvider,
8
8
  } from '@tanstack/react-router';
9
9
  import {act, render, screen} from '@testing-library/react';
10
- import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
10
+ import type {RunAnnotationSummary} from '#core/run-annotation.js';
11
11
  import type {WorkflowRunsSearch} from '#routes/inputs.js';
12
12
  import {RunAnnotationSummaryLine} from './run-annotation-summary-line.js';
13
13
 
@@ -17,8 +17,12 @@ const ANNOTATION_SUMMARY: RunAnnotationSummary = {
17
17
  warning: 1,
18
18
  info: 2,
19
19
  success: 0,
20
+ truncated: false,
20
21
  };
21
22
 
23
+ const INFO_OR_SUCCESS_PATTERN = /info|success/;
24
+ const SHOW_ALL_PATTERN = /Show all/;
25
+
22
26
  describe('RunAnnotationSummaryLine', () => {
23
27
  test('links the severity breakdown into the Annotations panel', async () => {
24
28
  await renderSummaryLine();
@@ -30,6 +34,66 @@ describe('RunAnnotationSummaryLine', () => {
30
34
  );
31
35
  });
32
36
 
37
+ test('renders every count at one type scale', async () => {
38
+ // The severity counts previously bypassed `Text` and inherited the ambient 14px, so they
39
+ // rendered a step larger and heavier than the total they sit beside.
40
+ await renderSummaryLine();
41
+
42
+ const total = screen.getByText('5 annotations');
43
+ const errors = screen.getByText('2 errors');
44
+
45
+ expect(total).toHaveClass('text-xs', 'font-display');
46
+ expect(errors).toHaveClass('text-xs', 'font-display');
47
+ expect(errors).not.toHaveClass('text-foreground-highlight-interactive');
48
+ });
49
+
50
+ test('distinguishes severities by glyph rather than by label colour', async () => {
51
+ const {container} = await renderSummaryLine();
52
+
53
+ const tones = [...container.querySelectorAll('svg')].map((glyph) =>
54
+ glyph.getAttribute('class'),
55
+ );
56
+
57
+ expect(tones.some((tone) => tone?.includes('text-tag-error-icon'))).toBe(true);
58
+ expect(tones.some((tone) => tone?.includes('text-tag-warning-icon'))).toBe(true);
59
+ });
60
+
61
+ test('names only the severities that need attention', async () => {
62
+ await renderSummaryLine();
63
+
64
+ expect(screen.getByRole('link', {name: '2 errors'})).toBeInTheDocument();
65
+ expect(screen.getByRole('link', {name: '1 warning'})).toBeInTheDocument();
66
+ // The 2 info annotations are inside the total; naming them again would give a clean
67
+ // result the same weight and the same accent as a failure.
68
+ expect(screen.queryByRole('link', {name: '2 infos'})).not.toBeInTheDocument();
69
+ expect(screen.queryByText(INFO_OR_SUCCESS_PATTERN)).not.toBeInTheDocument();
70
+ });
71
+
72
+ test('turns the total into the way out of an active severity filter', async () => {
73
+ await renderSummaryLine({severity: 'error'});
74
+
75
+ expect(screen.getByRole('link', {name: 'Show all 5 annotations'})).toHaveAttribute(
76
+ 'href',
77
+ '/w/acme/p/project/runs/run-1?tab=annotations',
78
+ );
79
+ });
80
+
81
+ test('leaves the total as plain text when no severity filter is active', async () => {
82
+ await renderSummaryLine();
83
+
84
+ expect(screen.queryByRole('link', {name: SHOW_ALL_PATTERN})).not.toBeInTheDocument();
85
+ });
86
+
87
+ test('falls back to the bare total when nothing needs attention', async () => {
88
+ await renderSummaryLine(
89
+ {},
90
+ {total: 3, error: 0, warning: 0, info: 3, success: 0, truncated: false},
91
+ );
92
+
93
+ expect(screen.getByText('3 annotations')).toBeInTheDocument();
94
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
95
+ });
96
+
33
97
  test('clears a stale annotation when linking to a severity', async () => {
34
98
  await renderSummaryLine({annotation: 'annotation-old'});
35
99
 
@@ -38,16 +102,26 @@ describe('RunAnnotationSummaryLine', () => {
38
102
  '/w/acme/p/project/runs/run-1?tab=annotations&severity=error',
39
103
  );
40
104
  });
105
+
106
+ test('marks a truncated read as a lower bound', async () => {
107
+ await renderSummaryLine({}, {...ANNOTATION_SUMMARY, truncated: true});
108
+
109
+ expect(screen.getByText('5+ annotations')).toBeInTheDocument();
110
+ expect(screen.getByRole('link', {name: '2+ errors'})).toBeInTheDocument();
111
+ });
41
112
  });
42
113
 
43
- async function renderSummaryLine(search: WorkflowRunsSearch = {}) {
114
+ async function renderSummaryLine(
115
+ search: WorkflowRunsSearch = {},
116
+ summary: RunAnnotationSummary = ANNOTATION_SUMMARY,
117
+ ) {
44
118
  const rootRoute = createRootRoute({component: Outlet});
45
119
  const runRoute = createRoute({
46
120
  getParentRoute: () => rootRoute,
47
121
  path: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
48
122
  component: () => (
49
123
  <RunAnnotationSummaryLine
50
- summary={ANNOTATION_SUMMARY}
124
+ summary={summary}
51
125
  workspaceSlug="acme"
52
126
  projectSlug="project"
53
127
  workflowRunId="run-1"
@@ -1,13 +1,28 @@
1
+ import {Icon} from '@shipfox/react-ui/icon';
1
2
  import {Text} from '@shipfox/react-ui/typography';
3
+ import {cn} from '@shipfox/react-ui/utils';
2
4
  import {Link} from '@tanstack/react-router';
3
- import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
5
+ import type {RunAnnotationSummary} from '#core/run-annotation.js';
4
6
  import {
5
- WORKFLOW_RUN_ANNOTATION_SEVERITIES,
6
7
  type WorkflowRunAnnotationSeverity,
7
8
  type WorkflowRunsSearch,
8
9
  workflowRunSearchParams,
9
10
  } from '#routes/inputs.js';
10
11
  import {MetadataSeparator} from '../workflow-run-summary/workflow-run-summary.js';
12
+ import {SEVERITY_ICON, SEVERITY_ICON_TONE} from './severity-visuals.js';
13
+
14
+ /**
15
+ * The severities this line names.
16
+ *
17
+ * The line exists so a failing run cannot look clean while the explanation sits behind an
18
+ * inactive section, which makes its subject "what needs attention", not "what happened".
19
+ * `info` and `success` are already inside the total, and repeating them gives "nothing is
20
+ * wrong" the same weight and the same accent as "something failed".
21
+ */
22
+ const ATTENTION_SEVERITIES = [
23
+ 'error',
24
+ 'warning',
25
+ ] as const satisfies readonly WorkflowRunAnnotationSeverity[];
11
26
 
12
27
  export interface RunAnnotationSummaryLineProps {
13
28
  summary?: RunAnnotationSummary | undefined;
@@ -24,22 +39,49 @@ export function RunAnnotationSummaryLine({
24
39
  workflowRunId,
25
40
  search = {},
26
41
  }: RunAnnotationSummaryLineProps) {
27
- if (!summary) return null;
42
+ // A run with no annotations says so in its empty state; a "0 annotations" line beside it is
43
+ // the same fact twice.
44
+ if (!summary || summary.total === 0) return null;
28
45
 
29
- const visibleSeverities = WORKFLOW_RUN_ANNOTATION_SEVERITIES.filter(
30
- (severity) => summary[severity] > 0,
31
- );
46
+ const visibleSeverities = ATTENTION_SEVERITIES.filter((severity) => summary[severity] > 0);
47
+
48
+ const totalLabel = countLabel(summary.total, 'annotation', summary.truncated);
32
49
 
33
50
  return (
34
51
  <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>
52
+ {search.severity && workspaceSlug && projectSlug && workflowRunId ? (
53
+ // The severity filter has no control of its own, so while one is active the total is
54
+ // the way back out. Unfiltered it stays plain text: a link to where you already are
55
+ // is noise.
56
+ <Link
57
+ to="/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId"
58
+ params={{workspaceSlug, projectSlug, workflowRunId}}
59
+ search={
60
+ workflowRunSearchParams(
61
+ {...withoutSeverity(search), tab: 'annotations'},
62
+ search,
63
+ ) as never
64
+ }
65
+ aria-label={`Show all ${totalLabel}`}
66
+ className="shrink-0 rounded-4 outline-none focus-visible:shadow-border-interactive-with-active"
67
+ >
68
+ {/* Underlined rather than accented: the escape hatch has to read as a link without
69
+ becoming the loudest thing on a line whose job is to be scannable. */}
70
+ <Text as="span" size="xs" className="text-foreground-neutral-subtle underline">
71
+ {totalLabel}
72
+ </Text>
73
+ </Link>
74
+ ) : (
75
+ <Text as="span" size="xs" className="shrink-0 text-foreground-neutral-subtle">
76
+ {totalLabel}
77
+ </Text>
78
+ )}
38
79
  {visibleSeverities.map((severity) => (
39
80
  <span key={severity} className="inline-flex shrink-0 items-center gap-8">
40
81
  <MetadataSeparator />
41
82
  <SeverityLink
42
83
  count={summary[severity]}
84
+ truncated={summary.truncated}
43
85
  severity={severity}
44
86
  workspaceSlug={workspaceSlug}
45
87
  projectSlug={projectSlug}
@@ -54,6 +96,7 @@ export function RunAnnotationSummaryLine({
54
96
 
55
97
  function SeverityLink({
56
98
  count,
99
+ truncated,
57
100
  severity,
58
101
  workspaceSlug,
59
102
  projectSlug,
@@ -61,19 +104,33 @@ function SeverityLink({
61
104
  search,
62
105
  }: {
63
106
  count: number;
107
+ truncated: boolean;
64
108
  severity: WorkflowRunAnnotationSeverity;
65
109
  workspaceSlug?: string | undefined;
66
110
  projectSlug?: string | undefined;
67
111
  workflowRunId?: string | undefined;
68
112
  search: WorkflowRunsSearch;
69
113
  }) {
70
- const label = `${count} ${pluralize(severity, count)}`;
114
+ const label = countLabel(count, severity, truncated);
115
+ const content = (
116
+ <>
117
+ <Icon
118
+ name={SEVERITY_ICON[severity]}
119
+ size={12}
120
+ aria-hidden="true"
121
+ className={cn('shrink-0', SEVERITY_ICON_TONE[severity])}
122
+ />
123
+ <Text as="span" size="xs" className="text-foreground-neutral-subtle">
124
+ {label}
125
+ </Text>
126
+ </>
127
+ );
128
+
71
129
  if (!workspaceSlug || !projectSlug || !workflowRunId) {
72
- return <span className="text-foreground-highlight-interactive">{label}</span>;
130
+ return <span className="inline-flex items-center gap-4">{content}</span>;
73
131
  }
74
132
 
75
- const searchWithoutAnnotation = {...search};
76
- delete searchWithoutAnnotation.annotation;
133
+ const searchWithoutAnnotation = withoutAnnotation(search);
77
134
 
78
135
  return (
79
136
  <Link
@@ -85,13 +142,31 @@ function SeverityLink({
85
142
  search,
86
143
  ) as never
87
144
  }
88
- className="text-foreground-highlight-interactive outline-none hover:underline focus-visible:shadow-border-interactive-with-active"
145
+ className="inline-flex items-center gap-4 rounded-4 outline-none hover:underline focus-visible:shadow-border-interactive-with-active"
89
146
  >
90
- {label}
147
+ {content}
91
148
  </Link>
92
149
  );
93
150
  }
94
151
 
95
- function pluralize(word: string, count: number): string {
96
- return count === 1 ? word : `${word}s`;
152
+ /** A selected annotation is stale the moment the list it was chosen from is refiltered. */
153
+ function withoutAnnotation(search: WorkflowRunsSearch): WorkflowRunsSearch {
154
+ const next = {...search};
155
+ delete next.annotation;
156
+ return next;
157
+ }
158
+
159
+ function withoutSeverity(search: WorkflowRunsSearch): WorkflowRunsSearch {
160
+ const next = withoutAnnotation(search);
161
+ delete next.severity;
162
+ return next;
163
+ }
164
+
165
+ /**
166
+ * A truncated read renders `500+ annotations`: the counts are a lower bound once the page budget
167
+ * is hit, and presenting them as exact would understate a run the reader is trying to trust.
168
+ */
169
+ function countLabel(count: number, word: string, truncated: boolean): string {
170
+ const plural = count === 1 && !truncated ? word : `${word}s`;
171
+ return `${count}${truncated ? '+' : ''} ${plural}`;
97
172
  }
@@ -0,0 +1,293 @@
1
+ import type {Meta, StoryObj} from '@storybook/react';
2
+ import type {ReactNode} from 'react';
3
+ import {
4
+ buildRunAnnotationList,
5
+ type RunAnnotationRecord,
6
+ type RunAnnotationStyle,
7
+ summarizeRunAnnotations,
8
+ } from '#core/run-annotation.js';
9
+ import type {Job} from '#core/workflow-run.js';
10
+ import {
11
+ workflowJob,
12
+ workflowJobExecutionDto,
13
+ workflowStepAttemptDto,
14
+ workflowStepDto,
15
+ } from '#test/fixtures/workflow-run.js';
16
+ import {RunAnnotationCountChip} from './run-annotation-count-chip.js';
17
+ import {RunAnnotationList, type RunAnnotationListQuery} from './run-annotation-list.js';
18
+ import {RunAnnotationSummaryLine} from './run-annotation-summary-line.js';
19
+
20
+ const WORKSPACE_SLUG = 'acme';
21
+ const PROJECT_SLUG = 'platform';
22
+ const RUN_ID = '11111111-1111-4111-8111-111111111111';
23
+ const BUILD_JOB_ID = '44444444-4444-4444-8444-00000000000b';
24
+ const TEST_JOB_ID = '44444444-4444-4444-8444-00000000000c';
25
+ const BUILD_EXECUTION_ID = '77777777-7777-4777-8777-00000000000b';
26
+ const TEST_EXECUTION_ID = '77777777-7777-4777-8777-00000000000c';
27
+ const BUILD_STEP_ID = '55555555-5555-4555-8555-00000000000b';
28
+ const TEST_STEP_ID = '55555555-5555-4555-8555-00000000000c';
29
+ const BUILD_ATTEMPT_ID = '66666666-6666-4666-8666-00000000000b';
30
+ const TEST_ATTEMPT_ID = '66666666-6666-4666-8666-00000000000c';
31
+
32
+ const LONG_BODY = [
33
+ '### Failing specs',
34
+ '',
35
+ ...Array.from(
36
+ {length: 40},
37
+ (_unused, index) => `- \`packages/web/src/route-${index}.test.ts\` expected 200, received 503`,
38
+ ),
39
+ ].join('\n');
40
+
41
+ const meta = {
42
+ title: 'Workflows/RunAnnotations',
43
+ parameters: {
44
+ layout: 'fullscreen',
45
+ },
46
+ } satisfies Meta;
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof meta>;
50
+
51
+ export const Playground: Story = {
52
+ render: () => {
53
+ const annotations = [
54
+ annotation({
55
+ id: 'a1',
56
+ context: 'smoke check',
57
+ style: 'error',
58
+ sequence: 3,
59
+ body: 'Task nine failed the smoke check against `https://preview.example.com`.\n\n```sh\ncurl -sSf https://preview.example.com/healthz\n```',
60
+ }),
61
+ annotation({
62
+ id: 'a2',
63
+ context: 'flaky tests',
64
+ style: 'warning',
65
+ sequence: 1,
66
+ jobId: TEST_JOB_ID,
67
+ jobExecutionId: TEST_EXECUTION_ID,
68
+ originStepId: TEST_STEP_ID,
69
+ body: '2 specs retried before passing.\n\n| Spec | Retries |\n| --- | --- |\n| `checkout.spec.ts` | 1 |\n| `billing.spec.ts` | 2 |',
70
+ }),
71
+ annotation({
72
+ id: 'a3',
73
+ context: 'deploy',
74
+ style: 'success',
75
+ sequence: 2,
76
+ body: 'Deployed **v42** to staging: [preview](https://preview.example.com)',
77
+ }),
78
+ annotation({
79
+ id: 'a4',
80
+ context: 'coverage',
81
+ style: 'info',
82
+ sequence: 4,
83
+ body: 'Line coverage held at 87.4%.',
84
+ }),
85
+ ];
86
+
87
+ return <AnnotationsFrame annotations={annotations} />;
88
+ },
89
+ };
90
+
91
+ export const BoundedBody: Story = {
92
+ // The only story in this package that earns a second Argos mode. The clamp's fade is the one
93
+ // gradient in the client, and it blends a surface token that flips with the theme, so a
94
+ // dark-only capture would never catch it breaking on a light card.
95
+ parameters: {
96
+ argos: {modes: {dark: {theme: 'dark'}, light: {theme: 'light'}}},
97
+ },
98
+ render: () => (
99
+ <AnnotationsFrame
100
+ annotations={[
101
+ annotation({id: 'long', context: 'test results', style: 'error', body: LONG_BODY}),
102
+ ]}
103
+ />
104
+ ),
105
+ };
106
+
107
+ export const DataStates: Story = {
108
+ render: () => {
109
+ const loaded = [annotation({id: 'a1', context: 'deploy', style: 'success'})];
110
+ return (
111
+ <div className="grid max-w-[1120px] gap-16 p-16 md:grid-cols-2">
112
+ <StateExample label="Loading">
113
+ <AnnotationsList annotations={[]} query={storyQuery({isPending: true})} />
114
+ </StateExample>
115
+ <StateExample label="Empty">
116
+ <AnnotationsList annotations={[]} />
117
+ </StateExample>
118
+ <StateExample label="Error">
119
+ <AnnotationsList
120
+ annotations={[]}
121
+ query={storyQuery({isError: true, data: undefined, error: new Error('Storybook')})}
122
+ />
123
+ </StateExample>
124
+ <StateExample label="Stale">
125
+ <AnnotationsList
126
+ annotations={loaded}
127
+ query={storyQuery({isError: true, error: new Error('Storybook')})}
128
+ />
129
+ </StateExample>
130
+ <StateExample label="Filtered miss">
131
+ <AnnotationsList annotations={[]} filtered filteredJobName="build" />
132
+ </StateExample>
133
+ <StateExample label="Truncated counts">
134
+ <RunAnnotationSummaryLine
135
+ summary={{...summarizeRunAnnotations(loaded), total: 500, error: 12, truncated: true}}
136
+ workspaceSlug={WORKSPACE_SLUG}
137
+ projectSlug={PROJECT_SLUG}
138
+ workflowRunId={RUN_ID}
139
+ />
140
+ </StateExample>
141
+ </div>
142
+ );
143
+ },
144
+ };
145
+
146
+ export const CountChips: Story = {
147
+ render: () => (
148
+ <div className="flex max-w-[1120px] flex-wrap items-center gap-12 p-16">
149
+ {(['error', 'warning', 'info', 'success'] as const).map((style) => (
150
+ <RunAnnotationCountChip
151
+ key={style}
152
+ summary={summarizeRunAnnotations([annotation({id: style, context: style, style})])}
153
+ workspaceSlug={WORKSPACE_SLUG}
154
+ projectSlug={PROJECT_SLUG}
155
+ workflowRunId={RUN_ID}
156
+ jobId={BUILD_JOB_ID}
157
+ />
158
+ ))}
159
+ <RunAnnotationCountChip
160
+ summary={{total: 500, error: 12, warning: 3, info: 0, success: 0, truncated: true}}
161
+ workspaceSlug={WORKSPACE_SLUG}
162
+ projectSlug={PROJECT_SLUG}
163
+ workflowRunId={RUN_ID}
164
+ />
165
+ </div>
166
+ ),
167
+ };
168
+
169
+ function AnnotationsFrame({annotations}: {annotations: RunAnnotationRecord[]}) {
170
+ return (
171
+ <div className="min-h-screen bg-background-neutral-base py-16">
172
+ <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24">
173
+ <RunAnnotationSummaryLine
174
+ summary={summarizeRunAnnotations(annotations)}
175
+ workspaceSlug={WORKSPACE_SLUG}
176
+ projectSlug={PROJECT_SLUG}
177
+ workflowRunId={RUN_ID}
178
+ />
179
+ <AnnotationsList annotations={annotations} />
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ function AnnotationsList({
186
+ annotations,
187
+ query = storyQuery(),
188
+ filtered = false,
189
+ filteredJobName,
190
+ }: {
191
+ annotations: RunAnnotationRecord[];
192
+ query?: RunAnnotationListQuery;
193
+ filtered?: boolean;
194
+ filteredJobName?: string;
195
+ }) {
196
+ return (
197
+ <RunAnnotationList
198
+ query={query}
199
+ entries={query.data === undefined ? undefined : buildRunAnnotationList({annotations, jobs})}
200
+ workspaceSlug={WORKSPACE_SLUG}
201
+ projectSlug={PROJECT_SLUG}
202
+ workflowRunId={RUN_ID}
203
+ runAttempt={1}
204
+ filtered={filtered}
205
+ filteredJobName={filteredJobName}
206
+ onClearFilters={() => undefined}
207
+ />
208
+ );
209
+ }
210
+
211
+ function StateExample({label, children}: {label: string; children: ReactNode}) {
212
+ return (
213
+ <div className="flex flex-col gap-8">
214
+ <span className="text-xs font-medium text-foreground-neutral-muted">{label}</span>
215
+ {children}
216
+ </div>
217
+ );
218
+ }
219
+
220
+ function storyQuery(overrides: Partial<RunAnnotationListQuery> = {}): RunAnnotationListQuery {
221
+ return {
222
+ isPending: false,
223
+ isError: false,
224
+ isFetching: false,
225
+ data: {pages: []},
226
+ error: null,
227
+ refetch: () => undefined,
228
+ hasNextPage: false,
229
+ isFetchingNextPage: false,
230
+ fetchNextPage: () => undefined,
231
+ ...overrides,
232
+ };
233
+ }
234
+
235
+ function annotation(
236
+ overrides: Partial<RunAnnotationRecord> & {id: string; style: RunAnnotationStyle},
237
+ ): RunAnnotationRecord {
238
+ return {
239
+ jobId: BUILD_JOB_ID,
240
+ jobExecutionId: BUILD_EXECUTION_ID,
241
+ originStepId: BUILD_STEP_ID,
242
+ originStepAttempt: 1,
243
+ context: 'default',
244
+ sequence: 1,
245
+ body: 'Body',
246
+ ...overrides,
247
+ };
248
+ }
249
+
250
+ const jobs: Job[] = [
251
+ workflowJob({
252
+ id: BUILD_JOB_ID,
253
+ key: 'build',
254
+ name: 'build',
255
+ position: 0,
256
+ job_executions: [
257
+ workflowJobExecutionDto({
258
+ id: BUILD_EXECUTION_ID,
259
+ job_id: BUILD_JOB_ID,
260
+ steps: [
261
+ workflowStepDto({
262
+ id: BUILD_STEP_ID,
263
+ name: 'run smoke checks',
264
+ attempts: [
265
+ workflowStepAttemptDto({id: BUILD_ATTEMPT_ID, step_id: BUILD_STEP_ID, attempt: 1}),
266
+ ],
267
+ }),
268
+ ],
269
+ }),
270
+ ],
271
+ }),
272
+ workflowJob({
273
+ id: TEST_JOB_ID,
274
+ key: 'test',
275
+ name: 'test',
276
+ position: 1,
277
+ job_executions: [
278
+ workflowJobExecutionDto({
279
+ id: TEST_EXECUTION_ID,
280
+ job_id: TEST_JOB_ID,
281
+ steps: [
282
+ workflowStepDto({
283
+ id: TEST_STEP_ID,
284
+ name: 'vitest',
285
+ attempts: [
286
+ workflowStepAttemptDto({id: TEST_ATTEMPT_ID, step_id: TEST_STEP_ID, attempt: 1}),
287
+ ],
288
+ }),
289
+ ],
290
+ }),
291
+ ],
292
+ }),
293
+ ];
@@ -0,0 +1,37 @@
1
+ import type {IconName} from '@shipfox/react-ui/icon';
2
+ import type {RunAnnotationSeverity} from '#core/run-annotation.js';
3
+
4
+ /**
5
+ * The glyph vocabulary for annotation severity, shared by every count surface.
6
+ *
7
+ * Deliberately the same marks the `Callout` uses on the cards themselves, so the glyph beside
8
+ * `1 error` in the summary is the glyph on the error card below it.
9
+ */
10
+ export const SEVERITY_ICON: Record<RunAnnotationSeverity, IconName> = {
11
+ error: 'closeCircleFill',
12
+ warning: 'errorWarningFill',
13
+ info: 'info',
14
+ success: 'checkboxCircleFill',
15
+ };
16
+
17
+ /**
18
+ * Severity tone lives in the glyph, never in the label.
19
+ *
20
+ * Coloring the text would make severity compete with the brand accent, which this system
21
+ * reserves for links, focus, and "you are here". Shape plus tone in a 12px mark distinguishes
22
+ * an error from a warning without either of them shouting.
23
+ */
24
+ export const SEVERITY_ICON_TONE: Record<RunAnnotationSeverity, string> = {
25
+ error: 'text-tag-error-icon',
26
+ warning: 'text-tag-warning-icon',
27
+ info: 'text-tag-blue-icon',
28
+ success: 'text-tag-success-icon',
29
+ };
30
+
31
+ /** Hairline tone for a bordered count chip, matching the glyph it sits beside. */
32
+ export const SEVERITY_CHIP_TONE: Record<RunAnnotationSeverity, string> = {
33
+ error: 'border-tag-error-border text-tag-error-icon',
34
+ warning: 'border-tag-warning-border text-tag-warning-icon',
35
+ info: 'border-tag-blue-border text-tag-blue-icon',
36
+ success: 'border-tag-success-border text-tag-success-icon',
37
+ };
@@ -27,6 +27,50 @@ describe('RunWorkspaceNav', () => {
27
27
  vi.useRealTimers();
28
28
  });
29
29
 
30
+ test('announces the annotation count with its unit and marks a truncated read', async () => {
31
+ const run = workflowRunDetail({
32
+ id: RUN_ID,
33
+ jobs: [workflowJobDto({id: CURRENT_JOB_ID, name: 'build', position: 0})],
34
+ });
35
+
36
+ renderWithRouter(
37
+ <RunWorkspaceNav
38
+ workspaceSlug="acme"
39
+ projectSlug="project"
40
+ run={run}
41
+ activeSection="summary"
42
+ annotationSummary={{
43
+ total: 500,
44
+ error: 12,
45
+ warning: 3,
46
+ info: 0,
47
+ success: 0,
48
+ truncated: true,
49
+ }}
50
+ />,
51
+ );
52
+
53
+ expect(await screen.findByRole('link', {name: 'Annotations, 500 or more'})).toBeVisible();
54
+ });
55
+
56
+ test('omits the annotation count until the read resolves', async () => {
57
+ const run = workflowRunDetail({
58
+ id: RUN_ID,
59
+ jobs: [workflowJobDto({id: CURRENT_JOB_ID, name: 'build', position: 0})],
60
+ });
61
+
62
+ renderWithRouter(
63
+ <RunWorkspaceNav
64
+ workspaceSlug="acme"
65
+ projectSlug="project"
66
+ run={run}
67
+ activeSection="summary"
68
+ />,
69
+ );
70
+
71
+ expect(await screen.findByRole('link', {name: 'Annotations'})).toBeVisible();
72
+ });
73
+
30
74
  test('shows the complete run hierarchy and marks the current job', async () => {
31
75
  const run = workflowRunDetail({
32
76
  id: RUN_ID,