@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,100 @@
1
+ import {Button} from '@shipfox/react-ui/button';
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuCheckboxItem,
5
+ DropdownMenuContent,
6
+ DropdownMenuTrigger,
7
+ } from '@shipfox/react-ui/dropdown-menu';
8
+ import {Text} from '@shipfox/react-ui/typography';
9
+ import {cn} from '@shipfox/react-ui/utils';
10
+
11
+ export interface WorkflowRunFilterOption {
12
+ value: string;
13
+ label: string;
14
+ }
15
+
16
+ export interface WorkflowRunFilterMenuProps {
17
+ label: string;
18
+ options: readonly WorkflowRunFilterOption[];
19
+ selected: readonly string[];
20
+ onChange: (next: string[]) => void;
21
+ /** Shown in place of the option list when nothing is loaded to filter by. */
22
+ emptyMessage: string;
23
+ className?: string | undefined;
24
+ }
25
+
26
+ /**
27
+ * One multi-select filter.
28
+ *
29
+ * The trigger states its own value rather than only a count, because a toolbar of five
30
+ * buttons that all read "Branch" forces the user to open each one to find out what is on.
31
+ * Checked items keep the menu open: choosing two branches should not cost two round trips.
32
+ */
33
+ export function WorkflowRunFilterMenu({
34
+ label,
35
+ options,
36
+ selected,
37
+ onChange,
38
+ emptyMessage,
39
+ className,
40
+ }: WorkflowRunFilterMenuProps) {
41
+ const active = selected.length > 0;
42
+ const triggerText = triggerLabel(label, options, selected);
43
+
44
+ function toggle(value: string) {
45
+ onChange(
46
+ selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value],
47
+ );
48
+ }
49
+
50
+ return (
51
+ <DropdownMenu>
52
+ <DropdownMenuTrigger asChild>
53
+ <Button
54
+ type="button"
55
+ variant="secondary"
56
+ size="sm"
57
+ iconRight="arrowDownSLine"
58
+ // Mirrors the visible label so the selected value is not sighted-only; an aria-label
59
+ // replaces the button's contents as its accessible name rather than adding to them.
60
+ aria-label={`${triggerText} filter`}
61
+ className={cn('max-w-[200px]', !active && 'text-foreground-neutral-subtle', className)}
62
+ >
63
+ <span className="min-w-0 truncate">{triggerText}</span>
64
+ </Button>
65
+ </DropdownMenuTrigger>
66
+ <DropdownMenuContent align="start" className="max-h-[320px] w-[240px] overflow-y-auto">
67
+ {options.length === 0 ? (
68
+ <Text as="p" size="xs" className="px-8 py-6 text-foreground-neutral-muted">
69
+ {emptyMessage}
70
+ </Text>
71
+ ) : (
72
+ options.map((option) => (
73
+ <DropdownMenuCheckboxItem
74
+ key={option.value}
75
+ checked={selected.includes(option.value)}
76
+ closeOnSelect={false}
77
+ onSelect={() => toggle(option.value)}
78
+ >
79
+ {option.label}
80
+ </DropdownMenuCheckboxItem>
81
+ ))
82
+ )}
83
+ </DropdownMenuContent>
84
+ </DropdownMenu>
85
+ );
86
+ }
87
+
88
+ function triggerLabel(
89
+ label: string,
90
+ options: readonly WorkflowRunFilterOption[],
91
+ selected: readonly string[],
92
+ ): string {
93
+ if (selected.length === 0) return label;
94
+ if (selected.length === 1) {
95
+ const [value] = selected;
96
+ const option = options.find((entry) => entry.value === value);
97
+ return `${label}: ${option?.label ?? value}`;
98
+ }
99
+ return `${label} · ${selected.length}`;
100
+ }
@@ -0,0 +1,243 @@
1
+ import {Button} from '@shipfox/react-ui/button';
2
+ import {DateRangePicker} from '@shipfox/react-ui/date-range-picker';
3
+ import {Icon} from '@shipfox/react-ui/icon';
4
+ import {Input} from '@shipfox/react-ui/input';
5
+ import {Label} from '@shipfox/react-ui/label';
6
+ import {
7
+ Sheet,
8
+ SheetBody,
9
+ SheetContent,
10
+ SheetFooter,
11
+ SheetHeader,
12
+ SheetTitle,
13
+ SheetTrigger,
14
+ } from '@shipfox/react-ui/sheet';
15
+ import {useState} from 'react';
16
+ import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
17
+ import {
18
+ WORKFLOW_RUN_LIST_STATUSES,
19
+ type WorkflowRunFilterPatch,
20
+ type WorkflowRunListStatus,
21
+ type WorkflowRunsSearch,
22
+ } from '#routes/inputs.js';
23
+ import type {WorkflowRunFacets} from './run-display.js';
24
+ import {WorkflowRunFilterMenu, type WorkflowRunFilterOption} from './workflow-run-filter-menu.js';
25
+
26
+ const STATUS_OPTIONS: WorkflowRunFilterOption[] = WORKFLOW_RUN_LIST_STATUSES.map((status) => ({
27
+ value: status,
28
+ label: getWorkflowStatusVisual(status).label,
29
+ }));
30
+
31
+ export interface WorkflowRunFiltersProps {
32
+ search: WorkflowRunsSearch;
33
+ facets: WorkflowRunFacets;
34
+ onChange: (patch: WorkflowRunFilterPatch) => void;
35
+ onClear: () => void;
36
+ hasActiveFilters: boolean;
37
+ }
38
+
39
+ /**
40
+ * The run list filter row.
41
+ *
42
+ * Inline from `md` up, and behind a sheet below it, where a five-control toolbar would eat
43
+ * the viewport the list is supposed to fill. Both layouts render the same controls, so the
44
+ * narrow surface is the full filter set rather than a reduced one.
45
+ */
46
+ export function WorkflowRunFilters({
47
+ search,
48
+ facets,
49
+ onChange,
50
+ onClear,
51
+ hasActiveFilters,
52
+ }: WorkflowRunFiltersProps) {
53
+ const [sheetOpen, setSheetOpen] = useState(false);
54
+
55
+ return (
56
+ <div className="flex flex-wrap items-center gap-8">
57
+ <Input
58
+ value={search.search ?? ''}
59
+ onChange={(event) => onChange({search: event.target.value})}
60
+ placeholder="Search runs by name, number, branch, or commit"
61
+ aria-label="Search runs"
62
+ size="small"
63
+ className="min-w-[240px] flex-1"
64
+ iconLeft={<Icon name="searchLine" className="size-14 text-foreground-neutral-muted" />}
65
+ />
66
+
67
+ <div className="hidden flex-wrap items-center gap-8 md:flex">
68
+ <WorkflowRunFilterControls search={search} facets={facets} onChange={onChange} />
69
+ {hasActiveFilters ? <ClearFiltersButton onClear={onClear} /> : null}
70
+ </div>
71
+
72
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
73
+ <SheetTrigger asChild>
74
+ <Button
75
+ type="button"
76
+ variant="secondary"
77
+ size="sm"
78
+ iconLeft="filterLine"
79
+ className="md:hidden"
80
+ >
81
+ Filters
82
+ </Button>
83
+ </SheetTrigger>
84
+ <SheetContent side="bottom" aria-describedby={undefined}>
85
+ <SheetHeader>
86
+ <SheetTitle>Filter runs</SheetTitle>
87
+ </SheetHeader>
88
+ <SheetBody className="flex flex-col gap-12">
89
+ <WorkflowRunFilterControls
90
+ search={search}
91
+ facets={facets}
92
+ onChange={onChange}
93
+ stacked
94
+ />
95
+ </SheetBody>
96
+ <SheetFooter>
97
+ <Button
98
+ type="button"
99
+ variant="secondary"
100
+ size="sm"
101
+ disabled={!hasActiveFilters}
102
+ onClick={onClear}
103
+ >
104
+ Clear filters
105
+ </Button>
106
+ <Button type="button" variant="primary" size="sm" onClick={() => setSheetOpen(false)}>
107
+ Show runs
108
+ </Button>
109
+ </SheetFooter>
110
+ </SheetContent>
111
+ </Sheet>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function WorkflowRunFilterControls({
117
+ search,
118
+ facets,
119
+ onChange,
120
+ stacked = false,
121
+ }: {
122
+ search: WorkflowRunsSearch;
123
+ facets: WorkflowRunFacets;
124
+ onChange: (patch: WorkflowRunFilterPatch) => void;
125
+ stacked?: boolean;
126
+ }) {
127
+ const controlClassName = stacked ? 'w-full max-w-none' : undefined;
128
+
129
+ return (
130
+ <>
131
+ <WorkflowRunFilterMenu
132
+ label="Status"
133
+ options={STATUS_OPTIONS}
134
+ selected={search.status ?? []}
135
+ onChange={(next) => onChange({status: next as WorkflowRunListStatus[]})}
136
+ emptyMessage="No statuses available."
137
+ className={controlClassName}
138
+ />
139
+ <WorkflowRunFilterMenu
140
+ label="Branch"
141
+ options={toOptions(facets.branch)}
142
+ selected={search.branch ?? []}
143
+ onChange={(branch) => onChange({branch})}
144
+ emptyMessage="No branches in the loaded runs."
145
+ className={controlClassName}
146
+ />
147
+ <WorkflowRunFilterMenu
148
+ label="Actor"
149
+ options={toOptions(facets.actor)}
150
+ selected={search.actor ?? []}
151
+ onChange={(actor) => onChange({actor})}
152
+ emptyMessage="No actors in the loaded runs."
153
+ className={controlClassName}
154
+ />
155
+ <WorkflowRunFilterMenu
156
+ label="Event"
157
+ options={toOptions(facets.event)}
158
+ selected={search.event ?? []}
159
+ onChange={(event) => onChange({event})}
160
+ emptyMessage="No events in the loaded runs."
161
+ className={controlClassName}
162
+ />
163
+ {stacked ? (
164
+ <div className="flex flex-col gap-6">
165
+ <Label>Created between</Label>
166
+ <RunDateRangeFilter search={search} onChange={onChange} />
167
+ </div>
168
+ ) : (
169
+ <RunDateRangeFilter search={search} onChange={onChange} />
170
+ )}
171
+ </>
172
+ );
173
+ }
174
+
175
+ function RunDateRangeFilter({
176
+ search,
177
+ onChange,
178
+ }: {
179
+ search: WorkflowRunsSearch;
180
+ onChange: (patch: WorkflowRunFilterPatch) => void;
181
+ }) {
182
+ return (
183
+ <DateRangePicker
184
+ size="small"
185
+ aria-label="Filter runs by creation date"
186
+ // The shared picker renders a value only when both bounds are set, so a one-sided filter
187
+ // would otherwise read as unset. Naming the active bound keeps it visible without
188
+ // inventing the bound the user did not choose.
189
+ placeholder={partialDateLabel(search) ?? 'Any date'}
190
+ dateRange={{
191
+ ...(search.after ? {start: fromCalendarDate(search.after)} : {}),
192
+ ...(search.before ? {end: fromCalendarDate(search.before)} : {}),
193
+ }}
194
+ onDateRangeSelect={(range) =>
195
+ onChange({
196
+ after: range?.start ? toCalendarDate(range.start) : undefined,
197
+ before: range?.end ? toCalendarDate(range.end) : undefined,
198
+ })
199
+ }
200
+ onClear={() => onChange({after: undefined, before: undefined})}
201
+ />
202
+ );
203
+ }
204
+
205
+ function ClearFiltersButton({onClear}: {onClear: () => void}) {
206
+ return (
207
+ <Button
208
+ type="button"
209
+ variant="transparentMuted"
210
+ size="sm"
211
+ iconLeft="closeLine"
212
+ onClick={onClear}
213
+ >
214
+ Clear filters
215
+ </Button>
216
+ );
217
+ }
218
+
219
+ /** Describes a bound the picker cannot display on its own, or nothing when it can. */
220
+ function partialDateLabel(search: WorkflowRunsSearch): string | undefined {
221
+ if (search.after && search.before) return undefined;
222
+ if (search.after) return `From ${search.after}`;
223
+ if (search.before) return `Until ${search.before}`;
224
+ return undefined;
225
+ }
226
+
227
+ function toOptions(values: readonly string[]): WorkflowRunFilterOption[] {
228
+ return values.map((value) => ({value, label: value}));
229
+ }
230
+
231
+ // The bounds are local calendar dates, matching how each row's date reads, so the picker and
232
+ // the URL agree with what the list shows rather than with UTC.
233
+ function toCalendarDate(date: Date): string {
234
+ const month = String(date.getMonth() + 1).padStart(2, '0');
235
+ const day = String(date.getDate()).padStart(2, '0');
236
+ return `${date.getFullYear()}-${month}-${day}`;
237
+ }
238
+
239
+ function fromCalendarDate(value: string): Date | undefined {
240
+ const [year, month, day] = value.split('-').map(Number) as [number, number, number];
241
+ const date = new Date(year, month - 1, day);
242
+ return Number.isNaN(date.getTime()) ? undefined : date;
243
+ }
@@ -3,6 +3,7 @@ import type {WorkflowRunListItem} from '#core/workflow-run.js';
3
3
  import type {WorkflowRunListQuery} from './types.js';
4
4
  import {
5
5
  WorkflowRunListEmpty,
6
+ WorkflowRunListLoadMore,
6
7
  WorkflowRunListNoMatches,
7
8
  WorkflowRunListSkeleton,
8
9
  WorkflowRunListStaleError,
@@ -13,26 +14,42 @@ interface WorkflowRunListContentProps {
13
14
  query: WorkflowRunListQuery;
14
15
  totalRuns: number;
15
16
  runs: WorkflowRunListItem[];
16
- workspaceId: string;
17
- projectId: string;
18
- selectedWorkflowRunId?: string | undefined;
17
+ workspaceSlug?: string | undefined;
18
+ projectSlug?: string | undefined;
19
+ hasActiveFilters: boolean;
19
20
  onClearFilters: () => void;
21
+ hasNextPage: boolean;
22
+ isFetchingNextPage: boolean;
23
+ isFetchNextPageError: boolean;
24
+ onLoadMore?: () => void;
20
25
  }
21
26
 
22
27
  export function WorkflowRunListContent({
23
28
  query,
24
29
  totalRuns,
25
30
  runs,
26
- workspaceId,
27
- projectId,
28
- selectedWorkflowRunId,
31
+ workspaceSlug,
32
+ projectSlug,
33
+ hasActiveFilters,
29
34
  onClearFilters,
35
+ hasNextPage,
36
+ isFetchingNextPage,
37
+ isFetchNextPageError,
38
+ onLoadMore,
30
39
  }: WorkflowRunListContentProps) {
31
40
  const {isPending, isError} = query;
32
41
  // A refetch that fails after a prior success keeps the rows on screen behind a slim
33
42
  // banner. QueryLoadError owns the inverse case (errored before anything loaded) and
34
- // self-gates to nothing here once data exists.
43
+ // self-gates to nothing here once data exists. A failed fetch never renders as an empty
44
+ // list: "nothing here" and "we could not find out" are different answers.
35
45
  const refreshFailed = isError && query.data !== undefined;
46
+ // Keyed off "we have an answer" rather than "no error", so a stale refresh still reports
47
+ // whether the filters match anything instead of leaving the list blank under the banner.
48
+ const hasLoaded = !isPending && query.data !== undefined;
49
+ // An empty result is only an empty *list* when no filter is on. With filters active it is a
50
+ // no-matches state even at zero loaded runs, so the way out is clearing them.
51
+ const showEmptyState = hasLoaded && totalRuns === 0 && !hasActiveFilters;
52
+ const showNoMatches = hasLoaded && runs.length === 0 && !showEmptyState;
36
53
 
37
54
  return (
38
55
  <div className="min-h-0 flex-1 overflow-y-auto">
@@ -41,18 +58,29 @@ export function WorkflowRunListContent({
41
58
  <QueryLoadError query={query} subject="workflow runs" icon="pulseLine" />
42
59
  ) : null}
43
60
  {!isPending && refreshFailed ? <WorkflowRunListStaleError query={query} /> : null}
44
- {!isPending && !isError && totalRuns === 0 ? <WorkflowRunListEmpty /> : null}
45
- {!isPending && totalRuns > 0 && runs.length === 0 ? (
46
- <WorkflowRunListNoMatches onClear={onClearFilters} />
61
+ {showEmptyState ? (
62
+ <WorkflowRunListEmpty workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
47
63
  ) : null}
48
- {!isPending && runs.length > 0 ? (
49
- <WorkflowRunRowList
50
- runs={runs}
51
- workspaceId={workspaceId}
52
- projectId={projectId}
53
- selectedWorkflowRunId={selectedWorkflowRunId}
64
+ {showNoMatches ? (
65
+ <WorkflowRunListNoMatches
66
+ onClear={onClearFilters}
67
+ hasNextPage={hasNextPage}
68
+ isFetchingNextPage={isFetchingNextPage}
69
+ isFetchNextPageError={isFetchNextPageError}
70
+ {...(onLoadMore ? {onLoadMore} : {})}
54
71
  />
55
72
  ) : null}
73
+ {!isPending && runs.length > 0 ? (
74
+ <>
75
+ <WorkflowRunRowList runs={runs} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
76
+ <WorkflowRunListLoadMore
77
+ hasNextPage={hasNextPage}
78
+ isFetchingNextPage={isFetchingNextPage}
79
+ isFetchNextPageError={isFetchNextPageError}
80
+ {...(onLoadMore ? {onLoadMore} : {})}
81
+ />
82
+ </>
83
+ ) : null}
56
84
  </div>
57
85
  );
58
86
  }
@@ -4,24 +4,33 @@ import {Callout} from '@shipfox/react-ui/callout';
4
4
  import {EmptyState} from '@shipfox/react-ui/empty-state';
5
5
  import {Skeleton} from '@shipfox/react-ui/skeleton';
6
6
  import {Text} from '@shipfox/react-ui/typography';
7
+ import {Link} from '@tanstack/react-router';
7
8
 
9
+ const SKELETON_ROW_COUNT = 8;
10
+
11
+ /**
12
+ * Placeholder rows at the real row height, so the list does not resize under the cursor when
13
+ * the first page lands. Only the initial load renders these: a refetch keeps the rows it has,
14
+ * per the design system's rule against animating frequently-updating data.
15
+ */
8
16
  export function WorkflowRunListSkeleton() {
9
17
  return (
10
- <div className="flex flex-col gap-4 p-8" role="status" aria-label="Loading runs">
11
- {Array.from({length: 6}).map((_, index) => (
18
+ <div
19
+ className="@container divide-y divide-border-neutral-base border-t border-border-neutral-base"
20
+ role="status"
21
+ aria-label="Loading runs"
22
+ >
23
+ {Array.from({length: SKELETON_ROW_COUNT}).map((_, index) => (
12
24
  <div
13
25
  // biome-ignore lint/suspicious/noArrayIndexKey: skeleton row, stable position
14
26
  key={index}
15
- className="flex flex-col gap-3 rounded-8 border border-transparent px-10 py-7"
27
+ className="flex items-center gap-10 px-12 py-8 @min-[976px]:h-44 @min-[976px]:py-0"
16
28
  >
17
- <div className="flex h-20 items-center gap-7">
18
- <Skeleton className="size-6 shrink-0 rounded-full" />
19
- <Skeleton className="h-12 w-1/3" />
20
- </div>
21
- <div className="flex h-20 items-center gap-6 pl-13">
22
- <Skeleton className="h-12 flex-1" />
23
- <Skeleton className="h-12 w-40" />
24
- </div>
29
+ <Skeleton className="size-14 shrink-0 rounded-full" />
30
+ <Skeleton className="h-12 w-[220px] max-w-[40%]" />
31
+ <Skeleton className="ml-auto hidden h-12 w-96 @min-[1040px]:block" />
32
+ <Skeleton className="h-12 w-48" />
33
+ <Skeleton className="h-12 w-48" />
25
34
  </div>
26
35
  ))}
27
36
  </div>
@@ -35,7 +44,7 @@ export function WorkflowRunListSkeleton() {
35
44
  */
36
45
  export function WorkflowRunListStaleError({query}: {query: QueryLoadErrorQuery}) {
37
46
  return (
38
- <div className="border-b border-border-neutral-base p-8">
47
+ <div className="pb-8">
39
48
  <Callout role="alert" type="error">
40
49
  <div className="flex items-center justify-between gap-8">
41
50
  <Text size="xs">Could not refresh workflow runs.</Text>
@@ -56,31 +65,123 @@ export function WorkflowRunListStaleError({query}: {query: QueryLoadErrorQuery})
56
65
  );
57
66
  }
58
67
 
59
- export function WorkflowRunListEmpty() {
68
+ export function WorkflowRunListEmpty({
69
+ workspaceSlug,
70
+ projectSlug,
71
+ }: {
72
+ workspaceSlug?: string | undefined;
73
+ projectSlug?: string | undefined;
74
+ }) {
60
75
  return (
61
- <div className="p-16">
62
- <EmptyState
63
- icon="pulseLine"
64
- title="No runs yet"
65
- description="Runs from this project's workflows will appear here as soon as one is launched."
66
- />
67
- </div>
76
+ <EmptyState
77
+ icon="pulseLine"
78
+ title="No runs yet"
79
+ description="Runs from this project's workflows appear here the moment one is launched."
80
+ action={
81
+ workspaceSlug && projectSlug ? (
82
+ <Button asChild type="button" size="sm" variant="primary">
83
+ <Link
84
+ to="/w/$workspaceSlug/p/$projectSlug/workflows"
85
+ params={{workspaceSlug, projectSlug}}
86
+ >
87
+ View workflows
88
+ </Link>
89
+ </Button>
90
+ ) : null
91
+ }
92
+ />
68
93
  );
69
94
  }
70
95
 
71
- export function WorkflowRunListNoMatches({onClear}: {onClear: () => void}) {
96
+ /**
97
+ * The filtered-empty state, deliberately distinct from the unfiltered one: someone who
98
+ * filtered their way to nothing needs a way back out, not an onboarding call to action.
99
+ *
100
+ * Its reset is worded as its outcome rather than repeating the toolbar's "Clear filters"
101
+ * verbatim, so the two controls on screen read as one action offered twice rather than two
102
+ * different ones. It is also the only reset within reach below `md`, where the toolbar's
103
+ * lives inside the filter sheet.
104
+ */
105
+ export function WorkflowRunListNoMatches({
106
+ onClear,
107
+ hasNextPage,
108
+ isFetchingNextPage,
109
+ isFetchNextPageError,
110
+ onLoadMore,
111
+ }: {
112
+ onClear: () => void;
113
+ hasNextPage: boolean;
114
+ isFetchingNextPage: boolean;
115
+ isFetchNextPageError: boolean;
116
+ onLoadMore?: () => void;
117
+ }) {
72
118
  return (
73
- <div className="p-16">
119
+ <div className="flex flex-col gap-8">
120
+ {isFetchNextPageError ? (
121
+ <Callout role="alert" type="error">
122
+ Could not load more workflow runs. Try again to continue searching older runs.
123
+ </Callout>
124
+ ) : null}
74
125
  <EmptyState
75
126
  icon="filterOffLine"
76
- title="No matching runs"
77
- description="No runs match your current search or status filter."
127
+ title={hasNextPage ? 'No matches in loaded history' : 'No matching runs'}
128
+ description={
129
+ hasNextPage
130
+ ? 'No loaded run matches these filters. Load more to search further back.'
131
+ : 'No run matches these filters.'
132
+ }
78
133
  action={
79
- <Button type="button" size="sm" variant="secondary" onClick={onClear}>
80
- Clear filters
81
- </Button>
134
+ <div className="flex flex-wrap justify-center gap-8">
135
+ {hasNextPage && onLoadMore ? (
136
+ <Button
137
+ type="button"
138
+ size="sm"
139
+ variant="primary"
140
+ isLoading={isFetchingNextPage}
141
+ onClick={onLoadMore}
142
+ >
143
+ Load more runs
144
+ </Button>
145
+ ) : null}
146
+ <Button type="button" size="sm" variant="secondary" onClick={onClear}>
147
+ Show all runs
148
+ </Button>
149
+ </div>
82
150
  }
83
151
  />
84
152
  </div>
85
153
  );
86
154
  }
155
+
156
+ export function WorkflowRunListLoadMore({
157
+ hasNextPage,
158
+ isFetchingNextPage,
159
+ isFetchNextPageError,
160
+ onLoadMore,
161
+ }: {
162
+ hasNextPage: boolean;
163
+ isFetchingNextPage: boolean;
164
+ isFetchNextPageError: boolean;
165
+ onLoadMore?: () => void;
166
+ }) {
167
+ if (!hasNextPage || !onLoadMore) return null;
168
+
169
+ return (
170
+ <div className="flex flex-col items-center gap-8 border-t border-border-neutral-base py-12">
171
+ {isFetchNextPageError ? (
172
+ <Callout role="alert" type="error">
173
+ Could not load more workflow runs. Try again.
174
+ </Callout>
175
+ ) : null}
176
+ <Button
177
+ type="button"
178
+ size="sm"
179
+ variant="secondary"
180
+ isLoading={isFetchingNextPage}
181
+ onClick={onLoadMore}
182
+ >
183
+ Load more runs
184
+ </Button>
185
+ </div>
186
+ );
187
+ }