@shipfox/client-workflows 12.0.1 → 13.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 (232) hide show
  1. package/.storybook/preview.tsx +3 -3
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +65 -0
  4. package/dist/components/job-graph/job-duration-format.d.ts +2 -2
  5. package/dist/components/job-graph/job-duration-format.d.ts.map +1 -1
  6. package/dist/components/job-graph/job-duration-format.js +4 -2
  7. package/dist/components/job-graph/job-duration-format.js.map +1 -1
  8. package/dist/components/job-graph/job-node.d.ts +1 -1
  9. package/dist/components/job-graph/job-node.d.ts.map +1 -1
  10. package/dist/components/job-graph/job-node.js +5 -4
  11. package/dist/components/job-graph/job-node.js.map +1 -1
  12. package/dist/components/step-list/step-list.d.ts +2 -2
  13. package/dist/components/step-list/step-list.d.ts.map +1 -1
  14. package/dist/components/step-list/step-list.js.map +1 -1
  15. package/dist/components/workflow-run-list/job-status-strip.d.ts +21 -0
  16. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -0
  17. package/dist/components/workflow-run-list/job-status-strip.js +185 -0
  18. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -0
  19. package/dist/components/workflow-run-list/run-display.d.ts +27 -3
  20. package/dist/components/workflow-run-list/run-display.d.ts.map +1 -1
  21. package/dist/components/workflow-run-list/run-display.js +78 -7
  22. package/dist/components/workflow-run-list/run-display.js.map +1 -1
  23. package/dist/components/workflow-run-list/types.d.ts +23 -24
  24. package/dist/components/workflow-run-list/types.d.ts.map +1 -1
  25. package/dist/components/workflow-run-list/types.js.map +1 -1
  26. package/dist/components/workflow-run-list/workflow-run-filter-menu.d.ts +22 -0
  27. package/dist/components/workflow-run-list/workflow-run-filter-menu.d.ts.map +1 -0
  28. package/dist/components/workflow-run-list/workflow-run-filter-menu.js +68 -0
  29. package/dist/components/workflow-run-list/workflow-run-filter-menu.js.map +1 -0
  30. package/dist/components/workflow-run-list/workflow-run-filters.d.ts +18 -0
  31. package/dist/components/workflow-run-list/workflow-run-filters.d.ts.map +1 -0
  32. package/dist/components/workflow-run-list/workflow-run-filters.js +236 -0
  33. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -0
  34. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts +8 -4
  35. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -1
  36. package/dist/components/workflow-run-list/workflow-run-list-content.js +40 -12
  37. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
  38. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts +29 -2
  39. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
  40. package/dist/components/workflow-run-list/workflow-run-list-states.js +103 -44
  41. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  42. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts +1 -1
  43. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-list-view.js +60 -43
  45. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  46. package/dist/components/workflow-run-list/workflow-run-list.d.ts +1 -1
  47. package/dist/components/workflow-run-list/workflow-run-list.d.ts.map +1 -1
  48. package/dist/components/workflow-run-list/workflow-run-list.js +20 -8
  49. package/dist/components/workflow-run-list/workflow-run-list.js.map +1 -1
  50. package/dist/components/workflow-run-list/workflow-run-row.d.ts +19 -9
  51. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  52. package/dist/components/workflow-run-list/workflow-run-row.js +179 -112
  53. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  54. package/dist/components/workflow-run-number-label.d.ts +15 -0
  55. package/dist/components/workflow-run-number-label.d.ts.map +1 -0
  56. package/dist/components/workflow-run-number-label.js +41 -0
  57. package/dist/components/workflow-run-number-label.js.map +1 -0
  58. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts +3 -3
  59. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts.map +1 -1
  60. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +8 -7
  61. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  62. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +3 -3
  63. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  64. package/dist/components/workflow-run-summary/workflow-run-summary.js +16 -7
  65. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  66. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts +2 -2
  67. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +1 -1
  68. package/dist/components/workflow-run-view/agent-config-failure-callout.js +4 -4
  69. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +1 -1
  70. package/dist/components/workflow-run-view/job-card.d.ts +2 -2
  71. package/dist/components/workflow-run-view/job-card.d.ts.map +1 -1
  72. package/dist/components/workflow-run-view/job-card.js +51 -32
  73. package/dist/components/workflow-run-view/job-card.js.map +1 -1
  74. package/dist/components/workflow-run-view/job-execution-switcher.d.ts +1 -1
  75. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +1 -1
  76. package/dist/components/workflow-run-view/job-execution-switcher.js +20 -8
  77. package/dist/components/workflow-run-view/job-execution-switcher.js.map +1 -1
  78. package/dist/components/workflow-run-view/workflow-run-view.d.ts +3 -2
  79. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  80. package/dist/components/workflow-run-view/workflow-run-view.js +15 -11
  81. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  82. package/dist/components/workflow-status/status-visuals.d.ts +4 -4
  83. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  84. package/dist/components/workflow-status/status-visuals.js +7 -0
  85. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  86. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -4
  87. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  88. package/dist/components/workflow-status/workflow-status-icon.js +6 -4
  89. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  90. package/dist/core/entities/job-execution.d.ts +10 -0
  91. package/dist/core/entities/job-execution.d.ts.map +1 -1
  92. package/dist/core/entities/job-execution.js +11 -0
  93. package/dist/core/entities/job-execution.js.map +1 -1
  94. package/dist/core/entities/job.d.ts +4 -2
  95. package/dist/core/entities/job.d.ts.map +1 -1
  96. package/dist/core/entities/job.js +8 -1
  97. package/dist/core/entities/job.js.map +1 -1
  98. package/dist/core/entities/workflow-run.d.ts +52 -4
  99. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  100. package/dist/core/entities/workflow-run.js +22 -3
  101. package/dist/core/entities/workflow-run.js.map +1 -1
  102. package/dist/core/workflow-run.d.ts +6 -6
  103. package/dist/core/workflow-run.d.ts.map +1 -1
  104. package/dist/core/workflow-run.js +3 -3
  105. package/dist/core/workflow-run.js.map +1 -1
  106. package/dist/feature.d.ts +7 -7
  107. package/dist/feature.js +5 -5
  108. package/dist/feature.js.map +1 -1
  109. package/dist/hooks/api/workflow-run-mapper.d.ts +4 -3
  110. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  111. package/dist/hooks/api/workflow-run-mapper.js +31 -3
  112. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  113. package/dist/hooks/api/workflow-runs.d.ts +4 -4
  114. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  115. package/dist/hooks/api/workflow-runs.js +12 -4
  116. package/dist/hooks/api/workflow-runs.js.map +1 -1
  117. package/dist/index.d.ts +4 -3
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +3 -2
  120. package/dist/index.js.map +1 -1
  121. package/dist/pages/project-workflows-page.js +46 -1
  122. package/dist/pages/project-workflows-page.js.map +1 -1
  123. package/dist/pages/workflow-run-detail-page.d.ts +11 -0
  124. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -0
  125. package/dist/pages/workflow-run-detail-page.js +30 -0
  126. package/dist/pages/workflow-run-detail-page.js.map +1 -0
  127. package/dist/pages/workflow-run-list-page.d.ts +10 -0
  128. package/dist/pages/workflow-run-list-page.d.ts.map +1 -0
  129. package/dist/pages/workflow-run-list-page.js +43 -0
  130. package/dist/pages/workflow-run-list-page.js.map +1 -0
  131. package/dist/routes/inputs.d.ts +61 -5
  132. package/dist/routes/inputs.d.ts.map +1 -1
  133. package/dist/routes/inputs.js +150 -13
  134. package/dist/routes/inputs.js.map +1 -1
  135. package/dist/routes/project-route.d.ts +6 -0
  136. package/dist/routes/project-route.d.ts.map +1 -0
  137. package/dist/routes/project-route.js +25 -0
  138. package/dist/routes/project-route.js.map +1 -0
  139. package/dist/routes/run-detail.d.ts.map +1 -1
  140. package/dist/routes/run-detail.js +12 -7
  141. package/dist/routes/run-detail.js.map +1 -1
  142. package/dist/routes/runs.d.ts.map +1 -1
  143. package/dist/routes/runs.js +11 -6
  144. package/dist/routes/runs.js.map +1 -1
  145. package/dist/routes/workflows.d.ts.map +1 -1
  146. package/dist/routes/workflows.js +6 -5
  147. package/dist/routes/workflows.js.map +1 -1
  148. package/dist/tsconfig.test.tsbuildinfo +1 -1
  149. package/package.json +9 -9
  150. package/src/components/job-graph/job-duration-format.ts +11 -2
  151. package/src/components/job-graph/job-graph-view.test.tsx +19 -8
  152. package/src/components/job-graph/job-graph.stories.tsx +23 -19
  153. package/src/components/job-graph/job-node.stories.tsx +10 -5
  154. package/src/components/job-graph/job-node.test.tsx +55 -10
  155. package/src/components/job-graph/job-node.tsx +16 -9
  156. package/src/components/step-list/step-list.stories.tsx +2 -3
  157. package/src/components/step-list/step-list.tsx +3 -2
  158. package/src/components/workflow-run-list/job-status-strip.tsx +189 -0
  159. package/src/components/workflow-run-list/run-display.test.ts +169 -30
  160. package/src/components/workflow-run-list/run-display.ts +116 -9
  161. package/src/components/workflow-run-list/types.ts +21 -17
  162. package/src/components/workflow-run-list/workflow-run-filter-menu.tsx +100 -0
  163. package/src/components/workflow-run-list/workflow-run-filters.tsx +243 -0
  164. package/src/components/workflow-run-list/workflow-run-list-content.tsx +44 -16
  165. package/src/components/workflow-run-list/workflow-run-list-states.tsx +128 -27
  166. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +441 -104
  167. package/src/components/workflow-run-list/workflow-run-list-view.tsx +73 -43
  168. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +140 -20
  169. package/src/components/workflow-run-list/workflow-run-list.tsx +20 -10
  170. package/src/components/workflow-run-list/workflow-run-row.tsx +173 -106
  171. package/src/components/workflow-run-number-label.test.tsx +45 -0
  172. package/src/components/workflow-run-number-label.tsx +47 -0
  173. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx +7 -8
  174. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +13 -12
  175. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +6 -8
  176. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +27 -2
  177. package/src/components/workflow-run-summary/workflow-run-summary.tsx +19 -9
  178. package/src/components/workflow-run-view/agent-config-failure-callout.stories.tsx +2 -3
  179. package/src/components/workflow-run-view/agent-config-failure-callout.tsx +4 -4
  180. package/src/components/workflow-run-view/job-card.stories.tsx +11 -10
  181. package/src/components/workflow-run-view/job-card.test.tsx +115 -0
  182. package/src/components/workflow-run-view/job-card.tsx +74 -35
  183. package/src/components/workflow-run-view/job-execution-switcher.tsx +31 -9
  184. package/src/components/workflow-run-view/workflow-run-view.test.tsx +88 -30
  185. package/src/components/workflow-run-view/workflow-run-view.tsx +24 -14
  186. package/src/components/workflow-status/status-visuals.test.ts +9 -0
  187. package/src/components/workflow-status/status-visuals.ts +6 -4
  188. package/src/components/workflow-status/workflow-status-icon.stories.tsx +10 -2
  189. package/src/components/workflow-status/workflow-status-icon.test.tsx +18 -0
  190. package/src/components/workflow-status/workflow-status-icon.tsx +17 -24
  191. package/src/core/entities/job-execution.ts +22 -0
  192. package/src/core/entities/job-status.test.ts +104 -0
  193. package/src/core/entities/job.ts +18 -3
  194. package/src/core/entities/workflow-run.ts +76 -7
  195. package/src/core/workflow-run.test.ts +24 -9
  196. package/src/core/workflow-run.ts +17 -2
  197. package/src/feature.ts +5 -5
  198. package/src/hooks/api/workflow-run-mapper.ts +30 -3
  199. package/src/hooks/api/workflow-runs.test.tsx +2 -0
  200. package/src/hooks/api/workflow-runs.ts +12 -7
  201. package/src/index.ts +7 -2
  202. package/src/page-harness-budget.test.ts +1 -1
  203. package/src/pages/project-workflows-page.test.tsx +59 -5
  204. package/src/pages/project-workflows-page.tsx +34 -1
  205. package/src/pages/workflow-run-detail-page.tsx +45 -0
  206. package/src/pages/workflow-run-list-page.tsx +65 -0
  207. package/src/pages/{workflow-run-page.test.tsx → workflow-run-pages.test.tsx} +211 -47
  208. package/src/routes/inputs.test.ts +193 -12
  209. package/src/routes/inputs.ts +170 -11
  210. package/src/routes/project-route.tsx +25 -0
  211. package/src/routes/run-detail.tsx +15 -8
  212. package/src/routes/runs.tsx +14 -7
  213. package/src/routes/workflows.tsx +5 -4
  214. package/test/fixtures/workflow-run.ts +76 -7
  215. package/test/pages.tsx +18 -8
  216. package/test/render.tsx +8 -2
  217. package/tsconfig.build.tsbuildinfo +1 -1
  218. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts +0 -10
  219. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts.map +0 -1
  220. package/dist/components/workflow-run-list/workflow-run-list-header.js +0 -55
  221. package/dist/components/workflow-run-list/workflow-run-list-header.js.map +0 -1
  222. package/dist/pages/workflow-run-first-time-use.d.ts +0 -6
  223. package/dist/pages/workflow-run-first-time-use.d.ts.map +0 -1
  224. package/dist/pages/workflow-run-first-time-use.js +0 -17
  225. package/dist/pages/workflow-run-first-time-use.js.map +0 -1
  226. package/dist/pages/workflow-run-page.d.ts +0 -10
  227. package/dist/pages/workflow-run-page.d.ts.map +0 -1
  228. package/dist/pages/workflow-run-page.js +0 -90
  229. package/dist/pages/workflow-run-page.js.map +0 -1
  230. package/src/components/workflow-run-list/workflow-run-list-header.tsx +0 -53
  231. package/src/pages/workflow-run-first-time-use.tsx +0 -17
  232. package/src/pages/workflow-run-page.tsx +0 -94
@@ -0,0 +1,189 @@
1
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
2
+ import {Code, Text} from '@shipfox/react-ui/typography';
3
+ import {cn} from '@shipfox/react-ui/utils';
4
+ import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
5
+ import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
6
+ import type {JobStatus, WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
7
+
8
+ /**
9
+ * How many glyphs fit the strip's column before it starts costing the run name width.
10
+ *
11
+ * Sized so the widest case still fits the row's 160px strip column: seven 12px glyphs with
12
+ * 3px gaps is 102px, and the overflow indicator adds a glyph plus a count of at most five
13
+ * monospace characters, about 56px. Overshooting does not wrap, it paints over the duration
14
+ * column. This sits under the API's preview bound, so resizing the strip is a change here.
15
+ */
16
+ const MAX_VISIBLE_JOBS = 7;
17
+ const GLYPH_SIZE = 12;
18
+ const MAX_TOOLTIP_JOB_NAMES = 6;
19
+
20
+ /**
21
+ * Above this the overflow count is abbreviated.
22
+ *
23
+ * Nothing caps a workflow's job count, so an exact count is unbounded in width and would
24
+ * eventually push the strip over its column. Abbreviating keeps the label at most five
25
+ * characters for any magnitude, and the exact figure stays in the tooltip and the strip's
26
+ * accessible name, which is where a precise number is actually read.
27
+ */
28
+ const MAX_EXACT_OVERFLOW_COUNT = 999;
29
+ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
30
+ notation: 'compact',
31
+ maximumFractionDigits: 1,
32
+ });
33
+
34
+ // Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
35
+ // A strip that hides a failure behind eight green discs would be worse than no strip at all.
36
+ const STATUS_SEVERITY: Record<JobStatus, number> = {
37
+ failed: 5,
38
+ running: 4,
39
+ pending: 3,
40
+ cancelled: 2,
41
+ skipped: 1,
42
+ succeeded: 0,
43
+ };
44
+
45
+ const NOTABLE_STATUSES: readonly JobStatus[] = ['failed', 'running'];
46
+
47
+ export interface JobStatusStripProps {
48
+ jobs: WorkflowRunJobs;
49
+ className?: string | undefined;
50
+ }
51
+
52
+ /**
53
+ * A run's jobs as a row of status glyphs, in graph order.
54
+ *
55
+ * This is the reason the list is worth its width: it answers "where did this run fail"
56
+ * without opening the run. Glyphs are silent to assistive tech and carry no tooltip of their
57
+ * own; the strip speaks once, as a summary, so a 40-job run does not read out 40 labels.
58
+ *
59
+ * What it draws comes from the preview; what it says comes from the counts, which cover every
60
+ * job including those the server never sent. That split is what lets the overflow glyph
61
+ * report a failure sitting past the preview instead of quietly dropping it.
62
+ */
63
+ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
64
+ if (jobs.total === 0) return null;
65
+
66
+ const visible = jobs.preview.slice(0, MAX_VISIBLE_JOBS);
67
+ const hiddenCount = jobs.total - visible.length;
68
+ const overflowStatus = worstHiddenStatus(jobs, visible);
69
+ const summary = jobStatusSummary(jobs);
70
+
71
+ return (
72
+ <Tooltip>
73
+ <TooltipTrigger asChild>
74
+ <span
75
+ role="img"
76
+ aria-label={summary}
77
+ className={cn('inline-flex items-center gap-3', className)}
78
+ >
79
+ <span aria-hidden="true" className="inline-flex items-center gap-3">
80
+ {visible.map((job) => (
81
+ <WorkflowStatusIcon
82
+ key={job.id}
83
+ status={job.status}
84
+ size={GLYPH_SIZE}
85
+ // One ripple per running job, on every row, would turn a calm list into a
86
+ // field of pulses. The run's own glyph already carries the live edge.
87
+ ripple={false}
88
+ tooltip={false}
89
+ />
90
+ ))}
91
+ {hiddenCount > 0 && overflowStatus ? (
92
+ <span className="inline-flex items-center gap-2">
93
+ <WorkflowStatusIcon
94
+ status={overflowStatus}
95
+ size={GLYPH_SIZE}
96
+ ripple={false}
97
+ tooltip={false}
98
+ />
99
+ <Code
100
+ as="span"
101
+ variant="label"
102
+ className="tabular-nums text-foreground-neutral-muted"
103
+ >
104
+ +{overflowCountLabel(hiddenCount)}
105
+ </Code>
106
+ </span>
107
+ ) : null}
108
+ </span>
109
+ </span>
110
+ </TooltipTrigger>
111
+ <TooltipContent>
112
+ <JobStatusStripTooltip jobs={jobs} summary={summary} />
113
+ </TooltipContent>
114
+ </Tooltip>
115
+ );
116
+ }
117
+
118
+ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary: string}) {
119
+ // Naming the jobs that need attention is the whole point of hovering; succeeded jobs are
120
+ // left to the counts so the tooltip stays a glance rather than a list. Only previewed jobs
121
+ // can be named, but the count of those left over is read off the totals, so a failure past
122
+ // the preview is still accounted for rather than silently missing.
123
+ const named = jobs.preview
124
+ .filter((job) => NOTABLE_STATUSES.includes(job.status))
125
+ .slice(0, MAX_TOOLTIP_JOB_NAMES);
126
+ const notableTotal = NOTABLE_STATUSES.reduce((total, status) => total + countOf(jobs, status), 0);
127
+ const remaining = notableTotal - named.length;
128
+
129
+ return (
130
+ <span className="block max-w-[280px]">
131
+ <Text as="span" size="xs" className="block">
132
+ {summary}
133
+ </Text>
134
+ {named.map((job) => (
135
+ <Code as="span" variant="label" key={job.id} className="mt-2 block truncate">
136
+ {getWorkflowStatusVisual(job.status).label.toLowerCase()} · {job.name ?? job.key}
137
+ </Code>
138
+ ))}
139
+ {remaining > 0 ? (
140
+ <Text as="span" size="xs" className="mt-2 block text-foreground-neutral-muted">
141
+ and {remaining} more
142
+ </Text>
143
+ ) : null}
144
+ </span>
145
+ );
146
+ }
147
+
148
+ /** "12 jobs: 9 succeeded, 2 failed, 1 running" — a count is not an accessible name on its own. */
149
+ export function jobStatusSummary(jobs: WorkflowRunJobs): string {
150
+ const breakdown = [...jobs.statusCounts]
151
+ .sort((left, right) => STATUS_SEVERITY[right.status] - STATUS_SEVERITY[left.status])
152
+ .map(({status, count}) => `${count} ${getWorkflowStatusVisual(status).label.toLowerCase()}`)
153
+ .join(', ');
154
+
155
+ return `${jobs.total} ${jobs.total === 1 ? 'job' : 'jobs'}: ${breakdown}`;
156
+ }
157
+
158
+ /**
159
+ * The worst status among the jobs the strip is not drawing, found by subtracting what it
160
+ * draws from the run's totals rather than by inspecting jobs it was never sent.
161
+ */
162
+ function worstHiddenStatus(
163
+ jobs: WorkflowRunJobs,
164
+ visible: readonly WorkflowRunJobSummary[],
165
+ ): JobStatus | null {
166
+ const hidden = new Map(jobs.statusCounts.map(({status, count}) => [status, count]));
167
+ for (const job of visible) {
168
+ const remaining = (hidden.get(job.status) ?? 0) - 1;
169
+ if (remaining > 0) hidden.set(job.status, remaining);
170
+ else hidden.delete(job.status);
171
+ }
172
+
173
+ let worst: JobStatus | null = null;
174
+ for (const [status, count] of hidden) {
175
+ if (count <= 0) continue;
176
+ if (worst === null || STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) worst = status;
177
+ }
178
+ return worst;
179
+ }
180
+
181
+ export function overflowCountLabel(hiddenCount: number): string {
182
+ return hiddenCount <= MAX_EXACT_OVERFLOW_COUNT
183
+ ? String(hiddenCount)
184
+ : COMPACT_COUNT_FORMAT.format(hiddenCount);
185
+ }
186
+
187
+ function countOf(jobs: WorkflowRunJobs, status: JobStatus): number {
188
+ return jobs.statusCounts.find((entry) => entry.status === status)?.count ?? 0;
189
+ }
@@ -1,53 +1,192 @@
1
1
  import {workflowRunListItem} from '#test/fixtures/workflow-run.js';
2
- import {runMatchesSearch, runMatchesStatusFilter} from './run-display.js';
2
+ import {
3
+ runCalendarDate,
4
+ runMatchesFilters,
5
+ runMatchesSearch,
6
+ runMatchesStatusFilter,
7
+ workflowRunFacets,
8
+ } from './run-display.js';
3
9
 
4
10
  describe('runMatchesSearch', () => {
5
11
  test('matches every run on a blank query', () => {
6
- const matches = runMatchesSearch(workflowRunListItem(), ' ');
12
+ expect(runMatchesSearch(workflowRunListItem(), ' ')).toBe(true);
13
+ });
14
+
15
+ test('matches the run name case-insensitively', () => {
16
+ expect(runMatchesSearch(workflowRunListItem({name: 'Deploy Web'}), 'deploy')).toBe(true);
17
+ });
18
+
19
+ test('matches the run number, which the search box promises', () => {
20
+ expect(runMatchesSearch(workflowRunListItem({number: 5184}), '5184')).toBe(true);
21
+ });
22
+
23
+ test('matches the run id, so a pasted identifier finds its row', () => {
24
+ expect(runMatchesSearch(workflowRunListItem({id: 'ABCD1234-X'}), 'abcd1234-x')).toBe(true);
25
+ });
26
+
27
+ test('matches the workflow name even when the run has a different display name', () => {
28
+ const run = workflowRunListItem({name: 'Deploy to production', workflow_name: 'CI'});
29
+
30
+ expect(runMatchesSearch(run, 'CI')).toBe(true);
31
+ });
32
+
33
+ test('matches the trigger label', () => {
34
+ const run = workflowRunListItem({trigger_source: 'github_acme', trigger_event: 'push'});
7
35
 
8
- expect(matches).toBe(true);
36
+ expect(runMatchesSearch(run, 'github_acme · push')).toBe(true);
9
37
  });
10
38
 
11
- test('matches case-insensitively across id, name, status, and trigger', () => {
39
+ test('matches the branch, so a ref is findable without opening its filter', () => {
12
40
  const run = workflowRunListItem({
13
- id: 'ABCD1234-X',
14
- name: 'Deploy Production',
15
- status: 'running',
16
- trigger_provider: 'github',
17
- trigger_source: 'github_acme',
18
- trigger_event: 'push',
41
+ trigger_reference: {
42
+ repository: 'acme/api',
43
+ ref: 'refs/heads/release/v2',
44
+ commit: 'abcdef1234567890',
45
+ actor: null,
46
+ },
19
47
  });
20
48
 
21
- expect(runMatchesSearch(run, 'abcd1234-x')).toBe(true);
22
- expect(runMatchesSearch(run, 'deploy production')).toBe(true);
23
- expect(runMatchesSearch(run, 'RUNNING')).toBe(true);
24
- expect(runMatchesSearch(run, 'github_acme · push')).toBe(true);
49
+ expect(runMatchesSearch(run, 'release/v2')).toBe(true);
25
50
  });
26
51
 
27
- test('returns false when nothing in the run contains the query', () => {
28
- const matches = runMatchesSearch(workflowRunListItem(), 'no-such-run');
52
+ test('matches the short commit, which is the form the row shows', () => {
53
+ const run = workflowRunListItem({
54
+ trigger_reference: {
55
+ repository: 'acme/api',
56
+ ref: 'refs/heads/main',
57
+ commit: 'abcdef1234567890',
58
+ actor: null,
59
+ },
60
+ });
61
+
62
+ expect(runMatchesSearch(run, 'abcdef1')).toBe(true);
63
+ });
29
64
 
30
- expect(matches).toBe(false);
65
+ test('reports no match for an unrelated query', () => {
66
+ expect(runMatchesSearch(workflowRunListItem(), 'no-such-run')).toBe(false);
31
67
  });
32
68
  });
33
69
 
34
70
  describe('runMatchesStatusFilter', () => {
35
- test('matches every status when the filter is "all"', () => {
36
- expect(runMatchesStatusFilter('succeeded', 'all')).toBe(true);
37
- expect(runMatchesStatusFilter('cancelled', 'all')).toBe(true);
71
+ test('accepts every status when nothing is selected', () => {
72
+ expect(runMatchesStatusFilter('succeeded', undefined)).toBe(true);
73
+ expect(runMatchesStatusFilter('succeeded', [])).toBe(true);
38
74
  });
39
75
 
40
- test('"failed" matches only failed runs', () => {
41
- expect(runMatchesStatusFilter('failed', 'failed')).toBe(true);
42
- expect(runMatchesStatusFilter('running', 'failed')).toBe(false);
43
- expect(runMatchesStatusFilter('succeeded', 'failed')).toBe(false);
76
+ test('accepts a run matching any one of several selected statuses', () => {
77
+ expect(runMatchesStatusFilter('failed', ['succeeded', 'failed'])).toBe(true);
78
+ expect(runMatchesStatusFilter('cancelled', ['succeeded', 'failed'])).toBe(false);
44
79
  });
45
80
 
46
- test('"running" reads as in-progress and also covers pending runs', () => {
47
- expect(runMatchesStatusFilter('running', 'running')).toBe(true);
48
- expect(runMatchesStatusFilter('pending', 'running')).toBe(true);
49
- expect(runMatchesStatusFilter('succeeded', 'running')).toBe(false);
50
- expect(runMatchesStatusFilter('failed', 'running')).toBe(false);
51
- expect(runMatchesStatusFilter('cancelled', 'running')).toBe(false);
81
+ test('treats running as in-progress so a freshly queued run is not hidden', () => {
82
+ expect(runMatchesStatusFilter('pending', ['running'])).toBe(true);
83
+ expect(runMatchesStatusFilter('running', ['running'])).toBe(true);
84
+ expect(runMatchesStatusFilter('succeeded', ['running'])).toBe(false);
52
85
  });
53
86
  });
87
+
88
+ describe('runMatchesFilters', () => {
89
+ const run = workflowRunListItem({
90
+ name: 'deploy-web',
91
+ status: 'failed',
92
+ trigger_event: 'push',
93
+ created_at: '2026-05-07T12:00:00.000Z',
94
+ trigger_reference: {
95
+ repository: 'acme/api',
96
+ ref: 'refs/heads/main',
97
+ commit: 'abcdef1234567890',
98
+ actor: 'octocat',
99
+ },
100
+ });
101
+
102
+ test('accepts a run matching every active dimension at once', () => {
103
+ expect(
104
+ runMatchesFilters(run, {
105
+ search: 'deploy',
106
+ status: ['failed'],
107
+ branch: ['main'],
108
+ actor: ['octocat'],
109
+ event: ['push'],
110
+ }),
111
+ ).toBe(true);
112
+ });
113
+
114
+ test.each([
115
+ ['status', {status: ['succeeded' as const]}],
116
+ ['branch', {branch: ['release']}],
117
+ ['actor', {actor: ['someone-else']}],
118
+ ['event', {event: ['pull_request']}],
119
+ ['search', {search: 'unrelated'}],
120
+ ])('rejects a run failing the %s dimension alone', (_dimension, criteria) => {
121
+ expect(runMatchesFilters(run, criteria)).toBe(false);
122
+ });
123
+
124
+ test('rejects a run that has no value at all for an active dimension', () => {
125
+ const withoutReference = workflowRunListItem({trigger_reference: null});
126
+
127
+ expect(runMatchesFilters(withoutReference, {branch: ['main']})).toBe(false);
128
+ expect(runMatchesFilters(withoutReference, {actor: ['octocat']})).toBe(false);
129
+ });
130
+
131
+ test('treats both date bounds as inclusive', () => {
132
+ const date = runCalendarDate(run) as string;
133
+
134
+ expect(runMatchesFilters(run, {after: date, before: date})).toBe(true);
135
+ });
136
+
137
+ test('excludes a run outside either bound', () => {
138
+ const date = runCalendarDate(run) as string;
139
+
140
+ expect(runMatchesFilters(run, {after: shiftDay(date, 1)})).toBe(false);
141
+ expect(runMatchesFilters(run, {before: shiftDay(date, -1)})).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe('workflowRunFacets', () => {
146
+ test('collects sorted, deduplicated options from the loaded runs', () => {
147
+ const runs = [
148
+ workflowRunListItem({
149
+ id: '11111111-1111-4111-8111-000000000001',
150
+ trigger_event: 'push',
151
+ trigger_reference: {
152
+ repository: 'acme/api',
153
+ ref: 'refs/heads/main',
154
+ commit: 'aaaaaaa',
155
+ actor: 'octocat',
156
+ },
157
+ }),
158
+ workflowRunListItem({
159
+ id: '11111111-1111-4111-8111-000000000002',
160
+ trigger_event: 'pull_request',
161
+ trigger_reference: {
162
+ repository: 'acme/api',
163
+ ref: 'refs/heads/main',
164
+ commit: 'bbbbbbb',
165
+ actor: 'hubot',
166
+ },
167
+ }),
168
+ ];
169
+
170
+ expect(workflowRunFacets(runs)).toEqual({
171
+ branch: ['main'],
172
+ actor: ['hubot', 'octocat'],
173
+ event: ['pull_request', 'push'],
174
+ });
175
+ });
176
+
177
+ test('keeps a selected value no loaded run carries, so a shared link keeps its chip', () => {
178
+ const facets = workflowRunFacets([workflowRunListItem({trigger_reference: null})], {
179
+ branch: ['release/v2'],
180
+ });
181
+
182
+ expect(facets.branch).toEqual(['release/v2']);
183
+ });
184
+ });
185
+
186
+ function shiftDay(date: string, days: number): string {
187
+ const [year, month, day] = date.split('-').map(Number) as [number, number, number];
188
+ const shifted = new Date(year, month - 1, day + days);
189
+ const shiftedMonth = String(shifted.getMonth() + 1).padStart(2, '0');
190
+ const shiftedDay = String(shifted.getDate()).padStart(2, '0');
191
+ return `${shifted.getFullYear()}-${shiftedMonth}-${shiftedDay}`;
192
+ }
@@ -1,10 +1,39 @@
1
- import type {WorkflowRunListItem, WorkflowRunStatus} from '#core/workflow-run.js';
2
- import type {WorkflowRunListStatusFilter} from './types.js';
1
+ import {
2
+ type WorkflowRunListItem,
3
+ type WorkflowRunStatus,
4
+ workflowRunActor,
5
+ workflowRunBranchLabel,
6
+ workflowRunCommitLabel,
7
+ } from '#core/workflow-run.js';
8
+ import type {WorkflowRunListStatus, WorkflowRunsSearch} from '#routes/inputs.js';
9
+
10
+ export type WorkflowRunFilterCriteria = Pick<
11
+ WorkflowRunsSearch,
12
+ 'search' | 'status' | 'branch' | 'actor' | 'event' | 'after' | 'before'
13
+ >;
14
+
15
+ /** The dimensions whose options are read off the loaded runs rather than a fixed enum. */
16
+ export type WorkflowRunFacetName = 'branch' | 'actor' | 'event';
17
+
18
+ export type WorkflowRunFacets = Record<WorkflowRunFacetName, string[]>;
3
19
 
4
20
  export function runMatchesSearch(run: WorkflowRunListItem, query: string): boolean {
5
21
  const needle = query.trim().toLowerCase();
6
22
  if (needle === '') return true;
7
- const haystack = `${run.id} ${run.name} ${run.status} ${run.triggerLabel}`.toLowerCase();
23
+ const haystack = [
24
+ run.id,
25
+ run.name,
26
+ run.workflowName,
27
+ run.status,
28
+ run.triggerLabel,
29
+ run.number?.toString(),
30
+ workflowRunBranchLabel(run),
31
+ workflowRunCommitLabel(run),
32
+ workflowRunActor(run),
33
+ ]
34
+ .filter(Boolean)
35
+ .join(' ')
36
+ .toLowerCase();
8
37
  return haystack.includes(needle);
9
38
  }
10
39
 
@@ -12,11 +41,89 @@ const IN_PROGRESS_STATUSES: ReadonlySet<WorkflowRunStatus> = new Set(['pending',
12
41
 
13
42
  export function runMatchesStatusFilter(
14
43
  status: WorkflowRunStatus,
15
- filter: WorkflowRunListStatusFilter,
44
+ selected: readonly WorkflowRunListStatus[] | undefined,
45
+ ): boolean {
46
+ if (!selected || selected.length === 0) return true;
47
+ return selected.some((filter) => {
48
+ // "Running" reads as in-progress: it covers freshly-queued `pending` runs (including the
49
+ // optimistic manual run inserted on fire) so they are not hidden the moment it is on.
50
+ if (filter === 'running') return IN_PROGRESS_STATUSES.has(status);
51
+ return status === filter;
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Applies every active filter to one run.
57
+ *
58
+ * Filtering runs over the pages already fetched, which is what the rail did before this page
59
+ * existed. Making the API honor the same parameters across full history is ENG-512's job; the
60
+ * predicates here are written against the same dimension names so that swap stays local.
61
+ */
62
+ export function runMatchesFilters(
63
+ run: WorkflowRunListItem,
64
+ criteria: WorkflowRunFilterCriteria,
16
65
  ): boolean {
17
- if (filter === 'all') return true;
18
- // "Running" reads as in-progress: it covers freshly-queued `pending` runs (including the
19
- // optimistic manual run inserted on fire) so they are not hidden the moment the filter is on.
20
- if (filter === 'running') return IN_PROGRESS_STATUSES.has(status);
21
- return status === filter;
66
+ if (!runMatchesStatusFilter(run.status, criteria.status)) return false;
67
+ if (!matchesFacet(criteria.branch, workflowRunBranchLabel(run))) return false;
68
+ if (!matchesFacet(criteria.actor, workflowRunActor(run))) return false;
69
+ if (!matchesFacet(criteria.event, run.triggerEvent)) return false;
70
+ if (!matchesDateBounds(run, criteria)) return false;
71
+ return runMatchesSearch(run, criteria.search ?? '');
72
+ }
73
+
74
+ /**
75
+ * Filter options read off the loaded runs.
76
+ *
77
+ * Values the user already selected are folded in even when no loaded run carries them, so a
78
+ * shared link never renders as though its own filter were unset.
79
+ */
80
+ export function workflowRunFacets(
81
+ runs: readonly WorkflowRunListItem[],
82
+ criteria: WorkflowRunFilterCriteria = {},
83
+ ): WorkflowRunFacets {
84
+ return {
85
+ branch: facetValues(runs.map(workflowRunBranchLabel), criteria.branch),
86
+ actor: facetValues(runs.map(workflowRunActor), criteria.actor),
87
+ event: facetValues(
88
+ runs.map((run) => run.triggerEvent),
89
+ criteria.event,
90
+ ),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The run's local calendar date, which is both what the date filters bound and what the row's
96
+ * relative time is derived from, so a filtered result never contradicts what a row shows.
97
+ */
98
+ export function runCalendarDate(run: Pick<WorkflowRunListItem, 'createdAt'>): string | null {
99
+ const date = new Date(run.createdAt);
100
+ if (Number.isNaN(date.getTime())) return null;
101
+ const month = String(date.getMonth() + 1).padStart(2, '0');
102
+ const day = String(date.getDate()).padStart(2, '0');
103
+ return `${date.getFullYear()}-${month}-${day}`;
104
+ }
105
+
106
+ function matchesFacet(selected: readonly string[] | undefined, value: string | null): boolean {
107
+ if (!selected || selected.length === 0) return true;
108
+ return value !== null && selected.includes(value);
109
+ }
110
+
111
+ function matchesDateBounds(run: WorkflowRunListItem, criteria: WorkflowRunFilterCriteria): boolean {
112
+ if (!criteria.after && !criteria.before) return true;
113
+ const date = runCalendarDate(run);
114
+ if (date === null) return false;
115
+ // Both bounds are inclusive, and `YYYY-MM-DD` sorts correctly as text.
116
+ if (criteria.after && date < criteria.after) return false;
117
+ if (criteria.before && date > criteria.before) return false;
118
+ return true;
119
+ }
120
+
121
+ function facetValues(
122
+ values: readonly (string | null)[],
123
+ selected: readonly string[] | undefined,
124
+ ): string[] {
125
+ const present = values.filter((value): value is string => Boolean(value));
126
+ return [...new Set([...present, ...(selected ?? [])])].sort((left, right) =>
127
+ left.localeCompare(right),
128
+ );
22
129
  }
@@ -1,28 +1,32 @@
1
1
  import type {QueryLoadErrorQuery} from '@shipfox/client-ui';
2
2
  import type {WorkflowRunListItem} from '#core/workflow-run.js';
3
+ import type {WorkflowRunFilterPatch, WorkflowRunsSearch} from '#routes/inputs.js';
3
4
 
4
- export type WorkflowRunListStatusFilter = 'all' | 'failed' | 'running';
5
+ export type WorkflowRunListQuery = QueryLoadErrorQuery & {isPending: boolean};
5
6
 
6
- export interface WorkflowRunListProps {
7
- workspaceId: string;
8
- projectId: string;
9
- selectedWorkflowRunId?: string | undefined;
7
+ interface WorkflowRunListCommonProps {
8
+ workspaceSlug?: string | undefined;
9
+ projectSlug?: string | undefined;
10
10
  className?: string | undefined;
11
- search?: string;
12
- statusFilter?: WorkflowRunListStatusFilter;
13
- onFiltersChange?: (filters: {search?: string; status?: WorkflowRunListStatusFilter}) => void;
11
+ /**
12
+ * The parsed URL search. Pair with `onFiltersChange` to keep it in the URL; omit the
13
+ * handler and the view holds filter state itself, which is what stories and isolated tests
14
+ * mount against.
15
+ */
16
+ search?: WorkflowRunsSearch;
17
+ onFiltersChange?: (patch: WorkflowRunFilterPatch) => void;
18
+ onClearFilters?: () => void;
19
+ hasNextPage?: boolean;
20
+ isFetchingNextPage?: boolean;
21
+ isFetchNextPageError?: boolean;
22
+ onLoadMore?: () => void;
14
23
  }
15
24
 
16
- export type WorkflowRunListQuery = QueryLoadErrorQuery & {isPending: boolean};
25
+ export interface WorkflowRunListProps extends WorkflowRunListCommonProps {
26
+ projectId: string;
27
+ }
17
28
 
18
- export interface WorkflowRunListViewProps {
29
+ export interface WorkflowRunListViewProps extends WorkflowRunListCommonProps {
19
30
  runs: WorkflowRunListItem[];
20
31
  query: WorkflowRunListQuery;
21
- workspaceId: string;
22
- projectId: string;
23
- selectedWorkflowRunId?: string | undefined;
24
- className?: string | undefined;
25
- search?: string;
26
- statusFilter?: WorkflowRunListStatusFilter;
27
- onFiltersChange?: (filters: {search?: string; status?: WorkflowRunListStatusFilter}) => void;
28
32
  }