@shipfox/client-workflows 4.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +68 -0
  3. package/dist/components/workflow-run-list/types.d.ts +12 -0
  4. package/dist/components/workflow-run-list/types.d.ts.map +1 -1
  5. package/dist/components/workflow-run-list/types.js.map +1 -1
  6. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts +1 -1
  7. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  8. package/dist/components/workflow-run-list/workflow-run-list-view.js +32 -11
  9. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  10. package/dist/components/workflow-run-list/workflow-run-list.d.ts +1 -1
  11. package/dist/components/workflow-run-list/workflow-run-list.d.ts.map +1 -1
  12. package/dist/components/workflow-run-list/workflow-run-list.js +7 -2
  13. package/dist/components/workflow-run-list/workflow-run-list.js.map +1 -1
  14. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +2 -3
  15. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  16. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  17. package/dist/components/workflow-run-view/job-card.d.ts.map +1 -1
  18. package/dist/components/workflow-run-view/job-card.js +9 -8
  19. package/dist/components/workflow-run-view/job-card.js.map +1 -1
  20. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  21. package/dist/core/entities/job-execution.d.ts +11 -6
  22. package/dist/core/entities/job-execution.d.ts.map +1 -1
  23. package/dist/core/entities/job-execution.js +0 -19
  24. package/dist/core/entities/job-execution.js.map +1 -1
  25. package/dist/core/entities/job.d.ts +32 -10
  26. package/dist/core/entities/job.d.ts.map +1 -1
  27. package/dist/core/entities/job.js +0 -22
  28. package/dist/core/entities/job.js.map +1 -1
  29. package/dist/core/entities/step-attempt.d.ts +28 -3
  30. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  31. package/dist/core/entities/step-attempt.js +0 -17
  32. package/dist/core/entities/step-attempt.js.map +1 -1
  33. package/dist/core/entities/step.d.ts +6 -6
  34. package/dist/core/entities/step.d.ts.map +1 -1
  35. package/dist/core/entities/step.js +19 -49
  36. package/dist/core/entities/step.js.map +1 -1
  37. package/dist/core/entities/workflow-run-attempt.d.ts +0 -2
  38. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  39. package/dist/core/entities/workflow-run-attempt.js +0 -12
  40. package/dist/core/entities/workflow-run-attempt.js.map +1 -1
  41. package/dist/core/entities/workflow-run.d.ts +6 -7
  42. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  43. package/dist/core/entities/workflow-run.js +1 -67
  44. package/dist/core/entities/workflow-run.js.map +1 -1
  45. package/dist/core/workflow-run.d.ts +9 -9
  46. package/dist/core/workflow-run.d.ts.map +1 -1
  47. package/dist/core/workflow-run.js +6 -6
  48. package/dist/core/workflow-run.js.map +1 -1
  49. package/dist/feature.d.ts +23 -0
  50. package/dist/feature.d.ts.map +1 -1
  51. package/dist/feature.js +22 -9
  52. package/dist/feature.js.map +1 -1
  53. package/dist/hooks/api/workflow-run-mapper.d.ts +12 -0
  54. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -0
  55. package/dist/hooks/api/workflow-run-mapper.js +207 -0
  56. package/dist/hooks/api/workflow-run-mapper.js.map +1 -0
  57. package/dist/hooks/api/workflow-runs.d.ts +25 -55
  58. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  59. package/dist/hooks/api/workflow-runs.js +95 -51
  60. package/dist/hooks/api/workflow-runs.js.map +1 -1
  61. package/dist/index.d.ts +3 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/pages/project-workflows-page.d.ts +4 -0
  66. package/dist/pages/project-workflows-page.d.ts.map +1 -0
  67. package/dist/pages/project-workflows-page.js +479 -0
  68. package/dist/pages/project-workflows-page.js.map +1 -0
  69. package/dist/pages/workflow-run-page.d.ts +3 -1
  70. package/dist/pages/workflow-run-page.d.ts.map +1 -1
  71. package/dist/pages/workflow-run-page.js +21 -14
  72. package/dist/pages/workflow-run-page.js.map +1 -1
  73. package/dist/routes/inputs.d.ts +22 -0
  74. package/dist/routes/inputs.d.ts.map +1 -0
  75. package/dist/routes/inputs.js +76 -0
  76. package/dist/routes/inputs.js.map +1 -0
  77. package/dist/routes/run-detail.d.ts +2 -0
  78. package/dist/routes/run-detail.d.ts.map +1 -1
  79. package/dist/routes/run-detail.js +6 -6
  80. package/dist/routes/run-detail.js.map +1 -1
  81. package/dist/routes/runs.d.ts +2 -0
  82. package/dist/routes/runs.d.ts.map +1 -1
  83. package/dist/routes/runs.js +6 -6
  84. package/dist/routes/runs.js.map +1 -1
  85. package/dist/routes/workflows.d.ts +5 -0
  86. package/dist/routes/workflows.d.ts.map +1 -0
  87. package/dist/routes/workflows.js +14 -0
  88. package/dist/routes/workflows.js.map +1 -0
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/package.json +13 -12
  91. package/src/components/workflow-run-list/types.ts +6 -0
  92. package/src/components/workflow-run-list/workflow-run-list-view.tsx +27 -11
  93. package/src/components/workflow-run-list/workflow-run-list.tsx +6 -0
  94. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx +5 -6
  95. package/src/components/workflow-run-summary/workflow-run-summary.tsx +3 -3
  96. package/src/components/workflow-run-view/job-card.tsx +20 -11
  97. package/src/components/workflow-run-view/workflow-run-view.test.tsx +3 -3
  98. package/src/components/workflow-run-view/workflow-run-view.tsx +2 -2
  99. package/src/core/entities/job-execution.ts +12 -28
  100. package/src/core/entities/job.ts +44 -45
  101. package/src/core/entities/step-attempt.ts +9 -20
  102. package/src/core/entities/step.ts +38 -66
  103. package/src/core/entities/workflow-run-attempt.ts +0 -14
  104. package/src/core/entities/workflow-run.ts +8 -86
  105. package/src/core/workflow-run.test.ts +13 -11
  106. package/src/core/workflow-run.ts +8 -13
  107. package/src/feature.ts +24 -10
  108. package/src/hooks/api/workflow-run-mapper.ts +246 -0
  109. package/src/hooks/api/workflow-runs.test.tsx +57 -59
  110. package/src/hooks/api/workflow-runs.ts +187 -88
  111. package/src/index.ts +5 -0
  112. package/src/page-harness-budget.test.ts +1 -0
  113. package/src/pages/project-workflows-page.test.tsx +247 -0
  114. package/src/pages/project-workflows-page.tsx +448 -0
  115. package/src/pages/workflow-run-page.test.tsx +4 -2
  116. package/src/pages/workflow-run-page.tsx +22 -17
  117. package/src/routes/inputs.test.ts +22 -0
  118. package/src/routes/inputs.ts +58 -0
  119. package/src/routes/run-detail.tsx +12 -8
  120. package/src/routes/runs.tsx +11 -4
  121. package/src/routes/workflows.tsx +10 -0
  122. package/test/fixtures/workflow-run.ts +9 -7
  123. package/test/pages.tsx +48 -6
  124. package/tsconfig.build.tsbuildinfo +1 -1
  125. package/vercel.json +5 -0
@@ -0,0 +1,448 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {
3
+ type Definition,
4
+ type DefinitionSyncSummary,
5
+ SourceStrip,
6
+ useDefinitionsInfiniteQuery,
7
+ useProjectQuery,
8
+ } from '@shipfox/client-projects';
9
+ import {QueryLoadError} from '@shipfox/client-ui';
10
+ import {Button} from '@shipfox/react-ui/button';
11
+ import {Callout} from '@shipfox/react-ui/callout';
12
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
13
+ import {Icon, type IconName} from '@shipfox/react-ui/icon';
14
+ import {LoadErrorState} from '@shipfox/react-ui/load-error-state';
15
+ import {RelativeTime, RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
16
+ import {
17
+ Sheet,
18
+ SheetBody,
19
+ SheetContent,
20
+ SheetDescription,
21
+ SheetHeader,
22
+ SheetTitle,
23
+ } from '@shipfox/react-ui/sheet';
24
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
25
+ import {
26
+ Table,
27
+ TableBody,
28
+ TableCell,
29
+ TableHead,
30
+ TableHeader,
31
+ TableRow,
32
+ } from '@shipfox/react-ui/table';
33
+ import {toast} from '@shipfox/react-ui/toast';
34
+ import {Code, Header, Text} from '@shipfox/react-ui/typography';
35
+ import {useState} from 'react';
36
+ import {useFireManualWorkflowMutation} from '#hooks/api/workflow-runs.js';
37
+
38
+ export function ProjectWorkflowsPage({projectId}: {projectId: string}) {
39
+ return (
40
+ <RelativeTimeProvider>
41
+ <ProjectWorkflowsPageInner projectId={projectId} />
42
+ </RelativeTimeProvider>
43
+ );
44
+ }
45
+
46
+ function ProjectWorkflowsPageInner({projectId}: {projectId: string}) {
47
+ const projectQuery = useProjectQuery(projectId);
48
+ const definitionsQuery = useDefinitionsInfiniteQuery(projectId);
49
+ const fireManual = useFireManualWorkflowMutation();
50
+ const [selectedDefinition, setSelectedDefinition] = useState<Definition | null>(null);
51
+ const [runError, setRunError] = useState<{definitionId: string; message: string} | null>(null);
52
+ const definitions = definitionsQuery.data?.pages.flatMap((page) => page.definitions) ?? [];
53
+ const sync = definitionsQuery.data?.pages[0]?.sync;
54
+
55
+ async function handleRun(definition: Definition) {
56
+ setRunError(null);
57
+ if (!definition.manualTrigger) return;
58
+ try {
59
+ await fireManual.mutateAsync({projectId, definitionId: definition.id});
60
+ toast.success('Run queued');
61
+ } catch (error) {
62
+ const message = errorMessage(error, 'Could not queue run.');
63
+ setRunError({definitionId: definition.id, message});
64
+ toast.error(message);
65
+ }
66
+ }
67
+
68
+ return (
69
+ <div className="flex w-full flex-col gap-24">
70
+ {projectQuery.isPending ? (
71
+ <div className="flex flex-col gap-12">
72
+ <Skeleton className="h-28 w-1/3" />
73
+ <Skeleton className="h-18 w-1/2" />
74
+ </div>
75
+ ) : null}
76
+
77
+ {projectQuery.isError && projectQuery.data === undefined ? (
78
+ projectQuery.error instanceof ApiError && projectQuery.error.status === 404 ? (
79
+ <EmptyState
80
+ icon="errorWarningLine"
81
+ title="Project not found"
82
+ description="This project doesn't exist, or you don't have access to it."
83
+ />
84
+ ) : (
85
+ <QueryLoadError query={projectQuery} subject="project" />
86
+ )
87
+ ) : null}
88
+
89
+ {projectQuery.data ? (
90
+ <>
91
+ <header className="flex flex-col gap-4">
92
+ <Header variant="h2">Workflows</Header>
93
+ <Text size="sm" className="text-foreground-neutral-muted">
94
+ Synced workflow definitions for this project source.
95
+ </Text>
96
+ </header>
97
+
98
+ <SourceStrip
99
+ connectionId={projectQuery.data.source.connectionId}
100
+ externalRepositoryId={projectQuery.data.source.externalRepositoryId}
101
+ sync={sync}
102
+ isPending={definitionsQuery.isPending}
103
+ />
104
+
105
+ <WorkflowSyncAlert sync={sync} />
106
+
107
+ <WorkflowDefinitionsList
108
+ definitions={definitions}
109
+ isPending={definitionsQuery.isPending}
110
+ isError={definitionsQuery.isError}
111
+ sync={sync ?? null}
112
+ runError={runError}
113
+ runningDefinitionId={
114
+ fireManual.isPending && fireManual.variables
115
+ ? fireManual.variables.definitionId
116
+ : null
117
+ }
118
+ hasNextPage={definitionsQuery.hasNextPage}
119
+ isFetchingNextPage={definitionsQuery.isFetchingNextPage}
120
+ isFetchNextPageError={definitionsQuery.isFetchNextPageError}
121
+ onRetry={() => definitionsQuery.refetch()}
122
+ onLoadMore={() => definitionsQuery.fetchNextPage()}
123
+ onOpenDefinition={setSelectedDefinition}
124
+ onRun={(definition) => {
125
+ void handleRun(definition);
126
+ }}
127
+ />
128
+ </>
129
+ ) : null}
130
+
131
+ <DefinitionSheet
132
+ definition={selectedDefinition}
133
+ onOpenChange={(open) => {
134
+ if (!open) setSelectedDefinition(null);
135
+ }}
136
+ />
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function WorkflowDefinitionsList({
142
+ definitions,
143
+ isPending,
144
+ isError,
145
+ sync,
146
+ runError,
147
+ runningDefinitionId,
148
+ hasNextPage,
149
+ isFetchingNextPage,
150
+ isFetchNextPageError,
151
+ onRetry,
152
+ onLoadMore,
153
+ onOpenDefinition,
154
+ onRun,
155
+ }: {
156
+ definitions: Definition[];
157
+ isPending: boolean;
158
+ isError: boolean;
159
+ sync: DefinitionSyncSummary | null;
160
+ runError: {definitionId: string; message: string} | null;
161
+ runningDefinitionId: string | null;
162
+ hasNextPage: boolean;
163
+ isFetchingNextPage: boolean;
164
+ isFetchNextPageError: boolean;
165
+ onRetry: () => void;
166
+ onLoadMore: () => void;
167
+ onOpenDefinition: (definition: Definition) => void;
168
+ onRun: (definition: Definition) => void;
169
+ }) {
170
+ if (isPending) {
171
+ return (
172
+ <div className="flex flex-col gap-8">
173
+ <Skeleton className="h-40 w-full" />
174
+ <Skeleton className="h-40 w-full" />
175
+ <Skeleton className="h-40 w-full" />
176
+ </div>
177
+ );
178
+ }
179
+
180
+ if (isError && definitions.length === 0) {
181
+ return (
182
+ <LoadErrorState
183
+ title="Couldn't load workflows"
184
+ description="Definitions could not be loaded. Source metadata remains visible."
185
+ onRetry={onRetry}
186
+ retryLabel="Retry loading workflows"
187
+ />
188
+ );
189
+ }
190
+
191
+ if (definitions.length === 0) {
192
+ return <WorkflowEmptyState sync={sync} />;
193
+ }
194
+
195
+ return (
196
+ <>
197
+ <div className="hidden rounded-8 border border-border-neutral-base md:block">
198
+ <Table>
199
+ <TableHeader>
200
+ <TableRow>
201
+ <TableHead className="w-40"></TableHead>
202
+ <TableHead>Workflow</TableHead>
203
+ <TableHead className="w-180">Updated</TableHead>
204
+ <TableHead className="w-80 text-right"></TableHead>
205
+ </TableRow>
206
+ </TableHeader>
207
+ <TableBody>
208
+ {definitions.map((definition) => {
209
+ const runErrorMessage =
210
+ runError?.definitionId === definition.id ? runError.message : null;
211
+ const isRunning = runningDefinitionId === definition.id;
212
+
213
+ return (
214
+ // The workflow-name cell holds a `<button>` so the row is
215
+ // keyboard-reachable (Tab focuses, Enter/Space activates
216
+ // via native button semantics). The TableRow itself is no
217
+ // longer clickable — a row-level onClick would be invisible
218
+ // to keyboard users and require custom keydown handling.
219
+ // The `group` class on the row still drives the Run button
220
+ // reveal on hover or focus-within.
221
+ <TableRow key={definition.id} className="group">
222
+ <TableCell>
223
+ <Icon
224
+ name={sourceIcon(definition.source)}
225
+ className="size-16 text-foreground-neutral-muted"
226
+ aria-hidden="true"
227
+ />
228
+ </TableCell>
229
+ <TableCell className="max-w-260">
230
+ <div className="flex min-w-0 flex-col gap-2">
231
+ <button
232
+ type="button"
233
+ onClick={() => onOpenDefinition(definition)}
234
+ className="flex min-w-0 flex-col gap-2 text-left outline-none focus-visible:shadow-border-interactive-with-active rounded-4"
235
+ >
236
+ <Text size="sm" bold className="truncate">
237
+ {definition.name}
238
+ </Text>
239
+ <Code className="truncate text-foreground-neutral-muted">
240
+ {definition.configPath ?? 'Manual definition'}
241
+ </Code>
242
+ </button>
243
+ {runErrorMessage ? (
244
+ <Text size="xs" className="text-tag-error-text">
245
+ {runErrorMessage}
246
+ </Text>
247
+ ) : null}
248
+ </div>
249
+ </TableCell>
250
+ <TableCell className="text-foreground-neutral-muted">
251
+ <RelativeTime value={definition.updatedAt} />
252
+ </TableCell>
253
+ <TableCell>
254
+ {definition.manualTrigger ? (
255
+ <div className="flex justify-end opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
256
+ <Button size="xs" isLoading={isRunning} onClick={() => onRun(definition)}>
257
+ Run
258
+ </Button>
259
+ </div>
260
+ ) : null}
261
+ </TableCell>
262
+ </TableRow>
263
+ );
264
+ })}
265
+ </TableBody>
266
+ </Table>
267
+ </div>
268
+
269
+ <div className="flex flex-col rounded-8 border border-border-neutral-base md:hidden">
270
+ {definitions.map((definition) => {
271
+ const runErrorMessage =
272
+ runError?.definitionId === definition.id ? runError.message : null;
273
+ const isRunning = runningDefinitionId === definition.id;
274
+
275
+ return (
276
+ <div
277
+ key={definition.id}
278
+ className="flex flex-col gap-10 border-b border-border-neutral-base p-12 last:border-b-0"
279
+ >
280
+ <button
281
+ type="button"
282
+ className="flex min-w-0 items-start gap-10 text-left"
283
+ onClick={() => onOpenDefinition(definition)}
284
+ >
285
+ <Icon
286
+ name={sourceIcon(definition.source)}
287
+ className="size-16 shrink-0 text-foreground-neutral-muted"
288
+ aria-hidden="true"
289
+ />
290
+ <div className="flex min-w-0 flex-col gap-4">
291
+ <Text size="sm" bold className="break-words">
292
+ {definition.name}
293
+ </Text>
294
+ <Code className="break-words text-foreground-neutral-muted">
295
+ {definition.configPath ?? 'Manual definition'}
296
+ </Code>
297
+ </div>
298
+ </button>
299
+ <div className="flex items-center justify-between gap-8">
300
+ <Text size="xs" className="text-foreground-neutral-muted">
301
+ Updated <RelativeTime value={definition.updatedAt} />
302
+ </Text>
303
+ {definition.manualTrigger ? (
304
+ <Button size="sm" isLoading={isRunning} onClick={() => onRun(definition)}>
305
+ Run
306
+ </Button>
307
+ ) : null}
308
+ </div>
309
+ {runErrorMessage ? (
310
+ <Text size="xs" className="text-tag-error-text">
311
+ {runErrorMessage}
312
+ </Text>
313
+ ) : null}
314
+ </div>
315
+ );
316
+ })}
317
+ </div>
318
+
319
+ {isFetchNextPageError ? (
320
+ <Callout role="alert" type="error">
321
+ <div className="flex items-center justify-between gap-12">
322
+ <Text size="sm">Could not load more workflows.</Text>
323
+ <Button size="sm" variant="secondary" onClick={onLoadMore}>
324
+ Retry
325
+ </Button>
326
+ </div>
327
+ </Callout>
328
+ ) : null}
329
+
330
+ {hasNextPage ? (
331
+ <div className="flex justify-center">
332
+ <Button size="sm" variant="secondary" isLoading={isFetchingNextPage} onClick={onLoadMore}>
333
+ Load more
334
+ </Button>
335
+ </div>
336
+ ) : null}
337
+ </>
338
+ );
339
+ }
340
+
341
+ function sourceIcon(source: 'manual' | 'vcs'): IconName {
342
+ return source === 'vcs' ? ('gitBranchLine' as IconName) : ('terminalLine' as IconName);
343
+ }
344
+
345
+ function WorkflowEmptyState({sync}: {sync: DefinitionSyncSummary | null}) {
346
+ const message =
347
+ sync?.status === 'failed' && sync.lastErrorCode === 'no-workflow-files'
348
+ ? 'No workflow files found under .shipfox/workflows/.'
349
+ : sync?.status === 'failed'
350
+ ? (sync.lastErrorMessage ?? 'Workflow definitions could not be synced.')
351
+ : sync?.status === 'syncing'
352
+ ? 'Workflow definitions are being discovered.'
353
+ : sync?.status === 'succeeded'
354
+ ? 'No workflow definitions found.'
355
+ : 'Workflow sync has not reported yet.';
356
+
357
+ return <EmptyState icon="flowChart" title="No workflows" description={message} />;
358
+ }
359
+
360
+ function WorkflowSyncAlert({sync}: {sync: DefinitionSyncSummary | null | undefined}) {
361
+ if (sync?.status !== 'failed') return null;
362
+
363
+ return (
364
+ <Callout role="alert" type="error">
365
+ <div className="flex flex-col gap-4">
366
+ <Text size="sm" bold>
367
+ Workflow sync failed
368
+ </Text>
369
+ <Text size="sm">
370
+ {sync.lastErrorMessage ?? 'The latest workflow sync failed before definitions updated.'}
371
+ </Text>
372
+ </div>
373
+ </Callout>
374
+ );
375
+ }
376
+
377
+ function DefinitionSheet({
378
+ definition,
379
+ onOpenChange,
380
+ }: {
381
+ definition: Definition | null;
382
+ onOpenChange: (open: boolean) => void;
383
+ }) {
384
+ const normalizedJson = definition
385
+ ? JSON.stringify(
386
+ {
387
+ workflow_document: definition.workflowDocument,
388
+ workflow_model: definition.workflowModel,
389
+ },
390
+ null,
391
+ 2,
392
+ )
393
+ : '';
394
+
395
+ return (
396
+ <Sheet open={Boolean(definition)} onOpenChange={onOpenChange}>
397
+ <SheetContent className="w-full sm:max-w-[560px]">
398
+ {definition ? (
399
+ <>
400
+ <SheetHeader>
401
+ <SheetTitle>{definition.name}</SheetTitle>
402
+ <SheetDescription>
403
+ {definition.configPath ?? 'Manual workflow definition'}
404
+ </SheetDescription>
405
+ </SheetHeader>
406
+ <SheetBody className="gap-18">
407
+ <div className="grid w-full gap-10">
408
+ <Metadata label="Definition id" value={definition.id} />
409
+ <Metadata label="Source" value={definition.source} />
410
+ <Metadata label="Ref" value={definition.ref ?? 'Not set'} />
411
+ <Metadata label="SHA" value={definition.sha ?? 'Not set'} />
412
+ </div>
413
+ <div className="flex w-full flex-col gap-8">
414
+ <Text size="sm" bold>
415
+ Normalized definition
416
+ </Text>
417
+ <pre className="max-h-[52vh] w-full overflow-auto rounded-8 border border-border-neutral-base bg-background-neutral-subtle p-12 scrollbar">
418
+ <Code as="code" className="whitespace-pre text-foreground-neutral-base">
419
+ {normalizedJson}
420
+ </Code>
421
+ </pre>
422
+ </div>
423
+ </SheetBody>
424
+ </>
425
+ ) : null}
426
+ </SheetContent>
427
+ </Sheet>
428
+ );
429
+ }
430
+
431
+ function Metadata({label, value}: {label: string; value: string}) {
432
+ return (
433
+ <div className="min-w-0 py-12 first:pt-0 last:pb-0">
434
+ <Text size="xs" className="text-foreground-neutral-muted">
435
+ {label}
436
+ </Text>
437
+ <Text size="sm" className="break-words">
438
+ {value}
439
+ </Text>
440
+ </div>
441
+ );
442
+ }
443
+
444
+ function errorMessage(error: unknown, fallback: string) {
445
+ if (error instanceof ApiError && error.message) return error.message;
446
+ if (error instanceof Error && error.message) return error.message;
447
+ return fallback;
448
+ }
@@ -257,11 +257,12 @@ describe('WorkflowRunPage', () => {
257
257
  function renderRunsPath(search = '') {
258
258
  return renderProjectPage(
259
259
  `/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}/runs${search}`,
260
- ({workflowRunId}) => (
260
+ ({workflowRunId, search}) => (
261
261
  <WorkflowRunPage
262
262
  workspaceId={PROJECT_TEST_WID}
263
263
  projectId={PROJECT_ID}
264
264
  workflowRunId={workflowRunId}
265
+ search={search}
265
266
  />
266
267
  ),
267
268
  );
@@ -270,11 +271,12 @@ function renderRunsPath(search = '') {
270
271
  function renderRunPath(search = '') {
271
272
  return renderProjectPage(
272
273
  `/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}/runs/${RUN_ID}${search}`,
273
- ({workflowRunId}) => (
274
+ ({workflowRunId, search}) => (
274
275
  <WorkflowRunPage
275
276
  workspaceId={PROJECT_TEST_WID}
276
277
  projectId={PROJECT_ID}
277
278
  workflowRunId={workflowRunId}
279
+ search={search}
278
280
  />
279
281
  ),
280
282
  );
@@ -1,20 +1,17 @@
1
- import {useNavigate, useSearch} from '@tanstack/react-router';
1
+ import {useNavigate} from '@tanstack/react-router';
2
2
  import {useCallback, useEffect} from 'react';
3
3
  import {WorkflowRunList} from '#components/workflow-run-list/workflow-run-list.js';
4
4
  import {WorkflowRunView} from '#components/workflow-run-view/index.js';
5
- import {
6
- type WorkflowRunSelectionInput,
7
- withoutWorkflowRunSelectionSearch,
8
- withWorkflowRunSelectionSearch,
9
- workflowRunSelectionFromSearch,
10
- } from '#core/workflow-run-url-state.js';
5
+ import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
11
6
  import {useWorkflowRunsInfiniteQuery} from '#hooks/api/workflow-runs.js';
7
+ import {type WorkflowRunsSearch, workflowRunSearchParams} from '#routes/inputs.js';
12
8
  import {WorkflowRunFirstTimeUse} from './workflow-run-first-time-use.js';
13
9
 
14
10
  interface WorkflowRunPageProps {
15
11
  workspaceId: string;
16
12
  projectId: string;
17
13
  workflowRunId?: string | undefined;
14
+ search?: WorkflowRunsSearch;
18
15
  }
19
16
 
20
17
  /**
@@ -28,6 +25,7 @@ function useWorkflowRunPageTarget(
28
25
  workspaceId: string,
29
26
  projectId: string,
30
27
  workflowRunId: string | undefined,
28
+ search: WorkflowRunsSearch,
31
29
  ) {
32
30
  const navigate = useNavigate();
33
31
  const {data, isPending} = useWorkflowRunsInfiniteQuery(projectId, {});
@@ -42,28 +40,30 @@ function useWorkflowRunPageTarget(
42
40
  navigate({
43
41
  to: '/workspaces/$wid/projects/$pid/runs/$workflowRunId',
44
42
  params: {wid: workspaceId, pid: projectId, workflowRunId: firstWorkflowRunId},
45
- search: ((previous: Record<string, unknown>) =>
46
- withoutWorkflowRunSelectionSearch(previous)) as never,
43
+ search: workflowRunSearchParams(search, {}),
47
44
  replace: true,
48
45
  });
49
- }, [navigate, workspaceId, projectId, workflowRunId, isLoaded, firstWorkflowRunId]);
46
+ }, [navigate, workspaceId, projectId, workflowRunId, isLoaded, firstWorkflowRunId, search]);
50
47
 
51
48
  return {hasNoRuns: isLoaded && firstWorkflowRunId === undefined};
52
49
  }
53
50
 
54
- export function WorkflowRunPage({workspaceId, projectId, workflowRunId}: WorkflowRunPageProps) {
55
- const {hasNoRuns} = useWorkflowRunPageTarget(workspaceId, projectId, workflowRunId);
51
+ export function WorkflowRunPage({
52
+ workspaceId,
53
+ projectId,
54
+ workflowRunId,
55
+ search = {},
56
+ }: WorkflowRunPageProps) {
57
+ const {hasNoRuns} = useWorkflowRunPageTarget(workspaceId, projectId, workflowRunId, search);
56
58
  const navigate = useNavigate();
57
- const search = useSearch({strict: false}) as Record<string, unknown>;
58
- const selection = workflowRunSelectionFromSearch(search);
59
+ const selection: WorkflowRunSelectionInput = search;
59
60
  const onSelectionChange = useCallback(
60
61
  (nextSelection: WorkflowRunSelectionInput) => {
61
62
  navigate({
62
- search: ((previous: Record<string, unknown>) =>
63
- withWorkflowRunSelectionSearch(previous, nextSelection)) as never,
63
+ search: workflowRunSearchParams(search, nextSelection) as never,
64
64
  });
65
65
  },
66
- [navigate],
66
+ [navigate, search],
67
67
  );
68
68
 
69
69
  if (!workflowRunId && hasNoRuns) {
@@ -76,6 +76,11 @@ export function WorkflowRunPage({workspaceId, projectId, workflowRunId}: Workflo
76
76
  workspaceId={workspaceId}
77
77
  projectId={projectId}
78
78
  selectedWorkflowRunId={workflowRunId}
79
+ search={search.search ?? ''}
80
+ statusFilter={search.status ?? 'all'}
81
+ onFiltersChange={(filters) =>
82
+ navigate({search: workflowRunSearchParams({...search, ...filters}) as never})
83
+ }
79
84
  />
80
85
  <WorkflowRunView
81
86
  workspaceId={workspaceId}
@@ -0,0 +1,22 @@
1
+ import {validateWorkflowRunsSearch, workflowRouteParams} from './inputs.js';
2
+
3
+ describe('workflow route inputs', () => {
4
+ it('normalizes malformed search values to safe defaults', () => {
5
+ expect(validateWorkflowRunsSearch({search: ['unexpected'], status: 'unknown'})).toEqual({});
6
+ expect(validateWorkflowRunsSearch({status: 'failed', runAttempt: '2'})).toEqual({
7
+ status: 'failed',
8
+ runAttempt: 2,
9
+ });
10
+ });
11
+
12
+ it('requires workspace and project path parameters', () => {
13
+ expect(workflowRouteParams({wid: 'workspace-1', pid: 'project-1'})).toEqual({
14
+ wid: 'workspace-1',
15
+ pid: 'project-1',
16
+ });
17
+ expect(() => workflowRouteParams({wid: 'workspace-1'})).toThrow(
18
+ 'Workflow route is missing required path parameters.',
19
+ );
20
+ expect(() => workflowRouteParams({wid: 'workspace-1', pid: null})).toThrow();
21
+ });
22
+ });
@@ -0,0 +1,58 @@
1
+ import type {WorkflowRunListStatusFilter} from '#components/workflow-run-list/types.js';
2
+ import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
3
+
4
+ export interface WorkflowRunsSearch extends WorkflowRunSelectionInput {
5
+ search?: string;
6
+ status?: WorkflowRunListStatusFilter;
7
+ }
8
+
9
+ export function validateWorkflowRunsSearch(input: Record<string, unknown>): WorkflowRunsSearch {
10
+ const search = string(input.search);
11
+ const status = input.status === 'failed' || input.status === 'running' ? input.status : 'all';
12
+ const runAttempt = positiveInteger(input.runAttempt);
13
+ return {
14
+ ...(search ? {search} : {}),
15
+ ...(status === 'all' ? {} : {status}),
16
+ ...(string(input.job) ? {jobId: string(input.job)} : {}),
17
+ ...(string(input.jobExecution) ? {jobExecutionId: string(input.jobExecution)} : {}),
18
+ ...(string(input.step) ? {stepId: string(input.step)} : {}),
19
+ ...(string(input.stepAttempt) ? {stepAttemptId: string(input.stepAttempt)} : {}),
20
+ ...(runAttempt ? {runAttempt} : {}),
21
+ };
22
+ }
23
+
24
+ export function workflowRunSearchParams(
25
+ search: WorkflowRunsSearch,
26
+ selection: WorkflowRunSelectionInput = search,
27
+ ) {
28
+ return {
29
+ ...(search.search ? {search: search.search} : {}),
30
+ ...(search.status && search.status !== 'all' ? {status: search.status} : {}),
31
+ ...(selection.jobId ? {job: selection.jobId} : {}),
32
+ ...(selection.jobExecutionId ? {jobExecution: selection.jobExecutionId} : {}),
33
+ ...(selection.stepId ? {step: selection.stepId} : {}),
34
+ ...(selection.stepAttemptId ? {stepAttempt: selection.stepAttemptId} : {}),
35
+ ...(selection.runAttempt ? {runAttempt: String(selection.runAttempt)} : {}),
36
+ };
37
+ }
38
+
39
+ export function workflowRouteParams(input: Record<string, unknown>): {
40
+ wid: string;
41
+ pid: string;
42
+ workflowRunId?: string;
43
+ } {
44
+ const wid = string(input.wid);
45
+ const pid = string(input.pid);
46
+ if (!wid || !pid) throw new Error('Workflow route is missing required path parameters.');
47
+ const workflowRunId = string(input.workflowRunId);
48
+ return workflowRunId ? {wid, pid, workflowRunId} : {wid, pid};
49
+ }
50
+
51
+ function string(value: unknown): string | undefined {
52
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
53
+ }
54
+
55
+ function positiveInteger(value: unknown): number | undefined {
56
+ const number = typeof value === 'string' ? Number(value) : value;
57
+ return typeof number === 'number' && Number.isInteger(number) && number > 0 ? number : undefined;
58
+ }
@@ -1,15 +1,19 @@
1
- import {defineRoute} from '@shipfox/client-shell/runtime';
2
- import {useParams} from '@tanstack/react-router';
1
+ import {defineRoute, useRouteParams, useRouteSearch} from '@shipfox/client-shell/runtime';
3
2
  import {WorkflowRunPage} from '#pages/workflow-run-page.js';
3
+ import {validateWorkflowRunsSearch, workflowRouteParams} from './inputs.js';
4
4
 
5
5
  export default defineRoute({
6
6
  staticData: {layout: 'full-bleed'},
7
+ validateSearch: validateWorkflowRunsSearch,
7
8
  component: () => {
8
- const {wid, pid, workflowRunId} = useParams({strict: false}) as {
9
- wid: string;
10
- pid: string;
11
- workflowRunId: string;
12
- };
13
- return <WorkflowRunPage workspaceId={wid} projectId={pid} workflowRunId={workflowRunId} />;
9
+ const {wid, pid, workflowRunId} = useRouteParams(workflowRouteParams);
10
+ return (
11
+ <WorkflowRunPage
12
+ workspaceId={wid}
13
+ projectId={pid}
14
+ workflowRunId={workflowRunId}
15
+ search={useRouteSearch(validateWorkflowRunsSearch)}
16
+ />
17
+ );
14
18
  },
15
19
  });