@shipfox/client-triggers 0.2.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 (95) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +8 -0
  3. package/.storybook/preview.tsx +89 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +120 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/events-filter-bar.d.ts +13 -0
  10. package/dist/components/events-filter-bar.d.ts.map +1 -0
  11. package/dist/components/events-filter-bar.js +198 -0
  12. package/dist/components/events-filter-bar.js.map +1 -0
  13. package/dist/components/events-list-states.d.ts +16 -0
  14. package/dist/components/events-list-states.d.ts.map +1 -0
  15. package/dist/components/events-list-states.js +105 -0
  16. package/dist/components/events-list-states.js.map +1 -0
  17. package/dist/components/events-list.d.ts +3 -0
  18. package/dist/components/events-list.d.ts.map +1 -0
  19. package/dist/components/events-list.js +97 -0
  20. package/dist/components/events-list.js.map +1 -0
  21. package/dist/components/events-list.stories.d.ts +53 -0
  22. package/dist/components/events-list.stories.d.ts.map +1 -0
  23. package/dist/components/events-list.stories.js +180 -0
  24. package/dist/components/events-list.stories.js.map +1 -0
  25. package/dist/components/trigger-event-detail.d.ts +13 -0
  26. package/dist/components/trigger-event-detail.d.ts.map +1 -0
  27. package/dist/components/trigger-event-detail.js +376 -0
  28. package/dist/components/trigger-event-detail.js.map +1 -0
  29. package/dist/components/trigger-event-detail.stories.d.ts +65 -0
  30. package/dist/components/trigger-event-detail.stories.d.ts.map +1 -0
  31. package/dist/components/trigger-event-detail.stories.js +181 -0
  32. package/dist/components/trigger-event-detail.stories.js.map +1 -0
  33. package/dist/components/trigger-event-result.d.ts +15 -0
  34. package/dist/components/trigger-event-result.d.ts.map +1 -0
  35. package/dist/components/trigger-event-result.js +48 -0
  36. package/dist/components/trigger-event-result.js.map +1 -0
  37. package/dist/components/trigger-event-row.d.ts +9 -0
  38. package/dist/components/trigger-event-row.d.ts.map +1 -0
  39. package/dist/components/trigger-event-row.js +91 -0
  40. package/dist/components/trigger-event-row.js.map +1 -0
  41. package/dist/components/trigger-source-icon.d.ts +20 -0
  42. package/dist/components/trigger-source-icon.d.ts.map +1 -0
  43. package/dist/components/trigger-source-icon.js +28 -0
  44. package/dist/components/trigger-source-icon.js.map +1 -0
  45. package/dist/components/types.d.ts +20 -0
  46. package/dist/components/types.d.ts.map +1 -0
  47. package/dist/components/types.js +3 -0
  48. package/dist/components/types.js.map +1 -0
  49. package/dist/hooks/api/trigger-events.d.ts +164 -0
  50. package/dist/hooks/api/trigger-events.d.ts.map +1 -0
  51. package/dist/hooks/api/trigger-events.js +122 -0
  52. package/dist/hooks/api/trigger-events.js.map +1 -0
  53. package/dist/index.d.ts +5 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +6 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/pages/events-page.d.ts +12 -0
  58. package/dist/pages/events-page.d.ts.map +1 -0
  59. package/dist/pages/events-page.js +82 -0
  60. package/dist/pages/events-page.js.map +1 -0
  61. package/dist/search.d.ts +23 -0
  62. package/dist/search.d.ts.map +1 -0
  63. package/dist/search.js +28 -0
  64. package/dist/search.js.map +1 -0
  65. package/dist/tsconfig.test.tsbuildinfo +1 -0
  66. package/package.json +92 -0
  67. package/src/components/events-filter-bar.tsx +191 -0
  68. package/src/components/events-list-states.tsx +89 -0
  69. package/src/components/events-list.stories.tsx +141 -0
  70. package/src/components/events-list.test.tsx +173 -0
  71. package/src/components/events-list.tsx +107 -0
  72. package/src/components/trigger-event-detail.stories.tsx +169 -0
  73. package/src/components/trigger-event-detail.test.tsx +201 -0
  74. package/src/components/trigger-event-detail.tsx +317 -0
  75. package/src/components/trigger-event-result.test.ts +40 -0
  76. package/src/components/trigger-event-result.ts +40 -0
  77. package/src/components/trigger-event-row.tsx +86 -0
  78. package/src/components/trigger-source-icon.react.test.tsx +34 -0
  79. package/src/components/trigger-source-icon.test.ts +23 -0
  80. package/src/components/trigger-source-icon.tsx +36 -0
  81. package/src/components/types.ts +22 -0
  82. package/src/hooks/api/trigger-events.test.ts +252 -0
  83. package/src/hooks/api/trigger-events.ts +143 -0
  84. package/src/index.ts +4 -0
  85. package/src/pages/events-page.test.tsx +100 -0
  86. package/src/pages/events-page.tsx +79 -0
  87. package/src/search.test.ts +66 -0
  88. package/src/search.ts +38 -0
  89. package/test/setup.ts +35 -0
  90. package/tsconfig.build.json +9 -0
  91. package/tsconfig.build.tsbuildinfo +1 -0
  92. package/tsconfig.json +3 -0
  93. package/tsconfig.test.json +8 -0
  94. package/vercel.json +8 -0
  95. package/vitest.config.ts +68 -0
@@ -0,0 +1,317 @@
1
+ import type {TriggerDecisionDto, TriggerEventDetailResponseDto} from '@shipfox/api-triggers-dto';
2
+ import {Badge} from '@shipfox/react-ui/badge';
3
+ import {Button} from '@shipfox/react-ui/button';
4
+ import {Callout} from '@shipfox/react-ui/callout';
5
+ import {
6
+ CodeBlock,
7
+ CodeBlockBody,
8
+ CodeBlockContent,
9
+ CodeBlockCopyButton,
10
+ CodeBlockFilename,
11
+ CodeBlockFiles,
12
+ CodeBlockHeader,
13
+ CodeBlockItem,
14
+ } from '@shipfox/react-ui/code-block';
15
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
16
+ import {Icon} from '@shipfox/react-ui/icon';
17
+ import {RelativeTime} from '@shipfox/react-ui/relative-time';
18
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
19
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
20
+ import {Code, Text} from '@shipfox/react-ui/typography';
21
+ import {cn} from '@shipfox/react-ui/utils';
22
+ import {Link} from '@tanstack/react-router';
23
+ import {useMemo} from 'react';
24
+ import {useTriggerEventQuery} from '#hooks/api/trigger-events.js';
25
+ import {triggerEventResult} from './trigger-event-result.js';
26
+ import {TriggerSourceIcon} from './trigger-source-icon.js';
27
+
28
+ const PANEL_CLASS =
29
+ 'min-h-0 rounded-8 border border-border-neutral-base bg-background-neutral-base';
30
+
31
+ export interface TriggerEventDetailProps {
32
+ workspaceId: string;
33
+ eventId?: string | undefined;
34
+ onBack: () => void;
35
+ }
36
+
37
+ export function TriggerEventDetail({workspaceId, eventId, onBack}: TriggerEventDetailProps) {
38
+ const query = useTriggerEventQuery(eventId);
39
+
40
+ if (!eventId) return <TriggerEventDetailPlaceholder />;
41
+ if (query.data) {
42
+ return <TriggerEventDetailView workspaceId={workspaceId} event={query.data} onBack={onBack} />;
43
+ }
44
+ if (query.isError)
45
+ return <TriggerEventDetailError onBack={onBack} onRetry={() => query.refetch()} />;
46
+ return <TriggerEventDetailLoading onBack={onBack} />;
47
+ }
48
+
49
+ export function TriggerEventDetailView({
50
+ workspaceId,
51
+ event,
52
+ onBack,
53
+ }: {
54
+ workspaceId: string;
55
+ event: TriggerEventDetailResponseDto;
56
+ onBack: () => void;
57
+ }) {
58
+ const result = triggerEventResult(event);
59
+ const eventLabel = triggerEventDisplayLabel(event);
60
+ const fullEventLabel = triggerEventFullLabel(event);
61
+ const formattedPayload = useMemo(
62
+ () => JSON.stringify(event.payload ?? null, null, 2) ?? 'null',
63
+ [event.payload],
64
+ );
65
+
66
+ return (
67
+ <aside
68
+ aria-label="Event details"
69
+ className={cn(PANEL_CLASS, 'flex min-h-0 flex-col overflow-hidden')}
70
+ >
71
+ <div className="flex shrink-0 flex-col gap-12 border-b border-border-neutral-base p-16">
72
+ <Button
73
+ type="button"
74
+ variant="transparentMuted"
75
+ size="sm"
76
+ iconLeft="arrowLeftLine"
77
+ className="self-start min-[900px]:hidden"
78
+ onClick={onBack}
79
+ >
80
+ Back to events
81
+ </Button>
82
+ <div className="flex min-w-0 items-start justify-between gap-12">
83
+ <div className="flex min-w-0 flex-col gap-4">
84
+ <Tooltip>
85
+ <TooltipTrigger asChild>
86
+ <button
87
+ type="button"
88
+ aria-label={fullEventLabel}
89
+ className="flex min-w-0 items-center gap-6 rounded-6 border-0 bg-transparent p-0 text-left outline-none focus-visible:shadow-button-neutral-focus"
90
+ >
91
+ <TriggerSourceIcon
92
+ provider={event.provider}
93
+ source={event.source}
94
+ aria-hidden="true"
95
+ className="size-16 shrink-0 text-foreground-neutral-muted"
96
+ />
97
+ <Code as="span" variant="label" className="truncate text-foreground-neutral-base">
98
+ {eventLabel}
99
+ </Code>
100
+ </button>
101
+ </TooltipTrigger>
102
+ <TooltipContent>
103
+ <Code as="span" variant="label" className="block max-w-[360px] break-words">
104
+ {fullEventLabel}
105
+ </Code>
106
+ </TooltipContent>
107
+ </Tooltip>
108
+ <Text size="xs" className="truncate text-foreground-neutral-muted">
109
+ <RelativeTime value={event.received_at} />
110
+ </Text>
111
+ </div>
112
+ <Badge variant={result.badge} size="xs">
113
+ {result.label}
114
+ </Badge>
115
+ </div>
116
+ </div>
117
+
118
+ <div className="flex min-h-0 flex-1 flex-col gap-20 overflow-y-auto p-16 scrollbar">
119
+ <EventRuns workspaceId={workspaceId} event={event} />
120
+ <EventPayload payload={formattedPayload} />
121
+ </div>
122
+ </aside>
123
+ );
124
+ }
125
+
126
+ function triggerEventDisplayLabel(event: Pick<TriggerEventDetailResponseDto, 'event' | 'source'>) {
127
+ return event.event || event.source;
128
+ }
129
+
130
+ function triggerEventFullLabel(event: Pick<TriggerEventDetailResponseDto, 'event' | 'source'>) {
131
+ return [event.source, event.event].filter(Boolean).join(' · ');
132
+ }
133
+
134
+ function TriggerEventDetailPlaceholder() {
135
+ return (
136
+ <aside
137
+ aria-label="Event details"
138
+ className={cn(
139
+ PANEL_CLASS,
140
+ 'hidden min-h-[240px] items-center justify-center p-24 min-[900px]:flex',
141
+ )}
142
+ >
143
+ <EmptyState icon="pulseLine" variant="compact" title="No event selected" />
144
+ </aside>
145
+ );
146
+ }
147
+
148
+ function TriggerEventDetailLoading({onBack}: {onBack: () => void}) {
149
+ return (
150
+ <aside
151
+ aria-label="Event details"
152
+ className={cn(PANEL_CLASS, 'flex min-h-[320px] flex-col gap-16 p-16')}
153
+ >
154
+ <Button
155
+ type="button"
156
+ variant="transparentMuted"
157
+ size="sm"
158
+ iconLeft="arrowLeftLine"
159
+ className="self-start min-[900px]:hidden"
160
+ onClick={onBack}
161
+ >
162
+ Back to events
163
+ </Button>
164
+ <div className="flex flex-col gap-8">
165
+ <Skeleton className="h-16 w-160" />
166
+ <Skeleton className="h-12 w-120" />
167
+ </div>
168
+ <Skeleton className="h-96" />
169
+ <Skeleton className="h-160" />
170
+ </aside>
171
+ );
172
+ }
173
+
174
+ function TriggerEventDetailError({onBack, onRetry}: {onBack: () => void; onRetry: () => void}) {
175
+ return (
176
+ <aside aria-label="Event details" className={cn(PANEL_CLASS, 'flex flex-col gap-16 p-16')}>
177
+ <Button
178
+ type="button"
179
+ variant="transparentMuted"
180
+ size="sm"
181
+ iconLeft="arrowLeftLine"
182
+ className="self-start min-[900px]:hidden"
183
+ onClick={onBack}
184
+ >
185
+ Back to events
186
+ </Button>
187
+ <Callout role="alert" type="error">
188
+ <div className="flex items-center justify-between gap-12">
189
+ <Text size="sm">Event detail could not be loaded.</Text>
190
+ <Button type="button" variant="secondary" size="xs" onClick={onRetry}>
191
+ Retry
192
+ </Button>
193
+ </div>
194
+ </Callout>
195
+ </aside>
196
+ );
197
+ }
198
+
199
+ function EventRuns({
200
+ workspaceId,
201
+ event,
202
+ }: {
203
+ workspaceId: string;
204
+ event: TriggerEventDetailResponseDto;
205
+ }) {
206
+ if (event.decisions.length === 0) {
207
+ if (event.outcome === 'discarded') {
208
+ return (
209
+ <Text size="sm" className="text-foreground-neutral-muted">
210
+ No workflows are subscribed to this event.
211
+ </Text>
212
+ );
213
+ }
214
+ return null;
215
+ }
216
+
217
+ return (
218
+ <section aria-labelledby="trigger-event-runs-heading" className="flex flex-col gap-6">
219
+ <Text id="trigger-event-runs-heading" size="sm" bold>
220
+ Matched workflows
221
+ </Text>
222
+ <ul className="-mx-8 flex flex-col gap-1">
223
+ {event.decisions.map((decision) => (
224
+ <DecisionRow key={decision.id} workspaceId={workspaceId} decision={decision} />
225
+ ))}
226
+ </ul>
227
+ </section>
228
+ );
229
+ }
230
+
231
+ function DecisionRow({workspaceId, decision}: {workspaceId: string; decision: TriggerDecisionDto}) {
232
+ if (decision.decision !== 'triggered' || !decision.run_id || !decision.run_name) {
233
+ return (
234
+ <li className="flex min-w-0 items-start gap-8 rounded-6 px-8 py-6">
235
+ <Icon
236
+ name="cornerDownRightLine"
237
+ className="mt-2 size-14 shrink-0 text-foreground-neutral-disabled"
238
+ aria-hidden="true"
239
+ />
240
+ <div className="flex min-w-0 flex-col gap-1">
241
+ <Text size="sm" className="min-w-0 truncate text-foreground-neutral-base">
242
+ {decision.subscription_name}
243
+ </Text>
244
+ {decision.reason ? (
245
+ <Text size="xs" className="text-foreground-highlight-error">
246
+ {decision.reason}
247
+ </Text>
248
+ ) : (
249
+ <Text size="xs" className="text-foreground-neutral-disabled">
250
+ No run created
251
+ </Text>
252
+ )}
253
+ </div>
254
+ </li>
255
+ );
256
+ }
257
+
258
+ return (
259
+ <li>
260
+ <Link
261
+ to="/workspaces/$wid/projects/$pid/runs/$workflowRunId"
262
+ params={{wid: workspaceId, pid: decision.project_id, workflowRunId: decision.run_id}}
263
+ className="flex min-w-0 items-start gap-8 rounded-6 px-8 py-6 transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none"
264
+ >
265
+ <Icon
266
+ name="cornerDownRightLine"
267
+ className="mt-3 size-14 shrink-0 text-foreground-neutral-muted"
268
+ aria-hidden="true"
269
+ />
270
+ <span className="flex min-w-0 flex-col gap-1">
271
+ <Text as="span" size="sm" className="min-w-0 truncate text-foreground-neutral-base">
272
+ {decision.subscription_name}
273
+ </Text>
274
+ <Code as="span" variant="label" className="truncate text-foreground-neutral-muted">
275
+ {decision.run_name}
276
+ </Code>
277
+ </span>
278
+ </Link>
279
+ </li>
280
+ );
281
+ }
282
+
283
+ function EventPayload({payload}: {payload: string}) {
284
+ const data = [{language: 'json', filename: 'payload.json', code: payload}];
285
+
286
+ return (
287
+ <section aria-labelledby="trigger-event-payload-heading" className="flex flex-col gap-6">
288
+ <Text id="trigger-event-payload-heading" size="sm" bold>
289
+ Payload
290
+ </Text>
291
+ <CodeBlock
292
+ data={data}
293
+ className="flex h-auto max-h-[min(360px,45vh)] flex-col rounded-8 bg-background-contrast-base shadow-none"
294
+ >
295
+ <CodeBlockHeader className="shrink-0 border-b border-border-contrast-base bg-background-contrast-base px-10 py-6">
296
+ <CodeBlockFiles>
297
+ {(item) => <CodeBlockFilename value={item.filename}>{item.filename}</CodeBlockFilename>}
298
+ </CodeBlockFiles>
299
+ <CodeBlockCopyButton />
300
+ </CodeBlockHeader>
301
+ <CodeBlockBody className="min-h-0 overflow-auto scrollbar">
302
+ {(item) => (
303
+ <CodeBlockItem
304
+ value={item.filename}
305
+ lineNumbers={false}
306
+ className="px-0 pb-0 [&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base [&>div]:dark:bg-background-contrast-base [&_code]:!text-foreground-neutral-on-color"
307
+ >
308
+ <CodeBlockContent language="json" syntaxHighlighting={false}>
309
+ {item.code}
310
+ </CodeBlockContent>
311
+ </CodeBlockItem>
312
+ )}
313
+ </CodeBlockBody>
314
+ </CodeBlock>
315
+ </section>
316
+ );
317
+ }
@@ -0,0 +1,40 @@
1
+ import {triggerEventResult} from './trigger-event-result.js';
2
+
3
+ describe('triggerEventResult', () => {
4
+ test('routed reads as triggered workflows and pluralizes', () => {
5
+ expect(triggerEventResult({outcome: 'routed', matched_count: 2}).label).toBe(
6
+ 'Triggered 2 workflows',
7
+ );
8
+ expect(triggerEventResult({outcome: 'routed', matched_count: 1}).label).toBe(
9
+ 'Triggered 1 workflow',
10
+ );
11
+ });
12
+
13
+ test('routed uses the info badge and is not failed', () => {
14
+ const result = triggerEventResult({outcome: 'routed', matched_count: 1});
15
+
16
+ expect(result.badge).toBe('info');
17
+ expect(result.isFailure).toBe(false);
18
+ });
19
+
20
+ test('discarded reads as no workflows triggered', () => {
21
+ const result = triggerEventResult({outcome: 'discarded', matched_count: 0});
22
+
23
+ expect(result.label).toBe('No workflows triggered');
24
+ expect(result.badge).toBe('neutral');
25
+ });
26
+
27
+ test('failed and errored read as failed with the error badge', () => {
28
+ for (const outcome of ['failed', 'errored'] as const) {
29
+ const result = triggerEventResult({outcome, matched_count: 1});
30
+
31
+ expect(result.label).toBe('Failed');
32
+ expect(result.badge).toBe('error');
33
+ expect(result.isFailure).toBe(true);
34
+ }
35
+ });
36
+
37
+ test('received reads as evaluating', () => {
38
+ expect(triggerEventResult({outcome: 'received', matched_count: 0}).label).toBe('Evaluating…');
39
+ });
40
+ });
@@ -0,0 +1,40 @@
1
+ import type {TriggerEventListItemDto} from '@shipfox/api-triggers-dto';
2
+ import type {BadgeVariant} from '@shipfox/react-ui/badge';
3
+
4
+ export interface TriggerEventResult {
5
+ label: string;
6
+ badge: BadgeVariant;
7
+ isFailure: boolean;
8
+ }
9
+
10
+ /**
11
+ * Collapses the raw event outcome into the single result an operator cares about:
12
+ * how many workflows it triggered (or that it failed). `routed`/`discarded` are not
13
+ * surfaced as distinct states - both are successfully-processed events that differ
14
+ * only by match count.
15
+ */
16
+ export function triggerEventResult(
17
+ event: Pick<TriggerEventListItemDto, 'outcome' | 'matched_count'>,
18
+ ): TriggerEventResult {
19
+ switch (event.outcome) {
20
+ case 'routed': {
21
+ const plural = event.matched_count === 1 ? '' : 's';
22
+ return {
23
+ label: `Triggered ${event.matched_count} workflow${plural}`,
24
+ badge: 'info',
25
+ isFailure: false,
26
+ };
27
+ }
28
+ case 'discarded':
29
+ return {label: 'No workflows triggered', badge: 'neutral', isFailure: false};
30
+ case 'failed':
31
+ case 'errored':
32
+ return {label: 'Failed', badge: 'error', isFailure: true};
33
+ case 'received':
34
+ return {label: 'Evaluating…', badge: 'neutral', isFailure: false};
35
+ default: {
36
+ const _exhaustive: never = event.outcome;
37
+ return {label: 'Unknown', badge: 'warning', isFailure: false};
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,86 @@
1
+ import type {TriggerEventListItemDto} from '@shipfox/api-triggers-dto';
2
+ import {RelativeTime} from '@shipfox/react-ui/relative-time';
3
+ import {TableCell, TableRow} from '@shipfox/react-ui/table';
4
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
5
+ import {Code, Text} from '@shipfox/react-ui/typography';
6
+ import {cn} from '@shipfox/react-ui/utils';
7
+ import type {MouseEvent} from 'react';
8
+ import {triggerEventResult} from './trigger-event-result.js';
9
+ import {TriggerSourceIcon} from './trigger-source-icon.js';
10
+
11
+ interface TriggerEventRowProps {
12
+ event: TriggerEventListItemDto;
13
+ selected: boolean;
14
+ onSelect: (eventId: string) => void;
15
+ }
16
+
17
+ export function TriggerEventRow({event, selected, onSelect}: TriggerEventRowProps) {
18
+ const result = triggerEventResult(event);
19
+ const eventLabel = triggerEventDisplayLabel(event);
20
+ const fullEventLabel = triggerEventFullLabel(event);
21
+
22
+ function handleRowClick(clickEvent: MouseEvent<HTMLTableRowElement>) {
23
+ const target = clickEvent.target;
24
+ if (target instanceof HTMLElement && target.closest('button,a')) return;
25
+ onSelect(event.id);
26
+ }
27
+
28
+ return (
29
+ <TableRow
30
+ data-selected={selected ? 'true' : undefined}
31
+ className="cursor-pointer"
32
+ onClick={handleRowClick}
33
+ >
34
+ <TableCell>
35
+ <Tooltip>
36
+ <TooltipTrigger asChild>
37
+ <button
38
+ type="button"
39
+ className="flex min-w-0 items-center gap-6 rounded-6 text-left outline-none focus-visible:shadow-button-neutral-focus"
40
+ onClick={() => onSelect(event.id)}
41
+ aria-label={`Open details for ${fullEventLabel}`}
42
+ >
43
+ <TriggerSourceIcon
44
+ provider={event.provider}
45
+ source={event.source}
46
+ aria-hidden="true"
47
+ className="size-16 shrink-0 text-foreground-neutral-muted"
48
+ />
49
+ <Code as="span" variant="label" className="truncate text-foreground-neutral-base">
50
+ {eventLabel}
51
+ </Code>
52
+ </button>
53
+ </TooltipTrigger>
54
+ <TooltipContent>
55
+ <Code as="span" variant="label" className="block max-w-[360px] break-words">
56
+ {fullEventLabel}
57
+ </Code>
58
+ </TooltipContent>
59
+ </Tooltip>
60
+ </TableCell>
61
+ <TableCell>
62
+ <Text
63
+ size="sm"
64
+ className={cn(
65
+ result.isFailure ? 'text-foreground-highlight-error' : 'text-foreground-neutral-subtle',
66
+ )}
67
+ >
68
+ {result.label}
69
+ </Text>
70
+ </TableCell>
71
+ <TableCell className="text-right">
72
+ <Code as="span" variant="label" className="text-foreground-neutral-muted">
73
+ <RelativeTime value={event.received_at} />
74
+ </Code>
75
+ </TableCell>
76
+ </TableRow>
77
+ );
78
+ }
79
+
80
+ function triggerEventDisplayLabel(event: Pick<TriggerEventListItemDto, 'event' | 'source'>) {
81
+ return event.event || event.source;
82
+ }
83
+
84
+ function triggerEventFullLabel(event: Pick<TriggerEventListItemDto, 'event' | 'source'>) {
85
+ return [event.source, event.event].filter(Boolean).join(' · ');
86
+ }
@@ -0,0 +1,34 @@
1
+ import {render} from '@testing-library/react';
2
+ import {TriggerSourceIcon} from './trigger-source-icon.js';
3
+
4
+ describe('TriggerSourceIcon', () => {
5
+ it('exposes a labeled icon to assistive tech when it is the sole identifier', () => {
6
+ const {container} = render(
7
+ <TriggerSourceIcon provider={null} source="manual" aria-label="Manual" />,
8
+ );
9
+
10
+ const svg = container.querySelector('svg');
11
+ expect(svg).toHaveAttribute('aria-label', 'Manual');
12
+ expect(svg).not.toHaveAttribute('aria-hidden');
13
+ });
14
+
15
+ it('hides the icon from assistive tech when marked decorative', () => {
16
+ const {container} = render(<TriggerSourceIcon provider={null} source="manual" aria-hidden />);
17
+
18
+ const svg = container.querySelector('svg');
19
+ expect(svg).toHaveAttribute('aria-hidden', 'true');
20
+ });
21
+
22
+ it('forwards class names so callers control sizing and color', () => {
23
+ const {container} = render(
24
+ <TriggerSourceIcon
25
+ provider="github"
26
+ source="github_acme"
27
+ className="size-16 text-foreground-neutral-muted"
28
+ />,
29
+ );
30
+
31
+ const svg = container.querySelector('svg');
32
+ expect(svg).toHaveClass('size-16', 'text-foreground-neutral-muted');
33
+ });
34
+ });
@@ -0,0 +1,23 @@
1
+ import {getTriggerSourceIcon} from './trigger-source-icon.js';
2
+
3
+ describe('getTriggerSourceIcon', () => {
4
+ test('maps the system sources manual and cron to their own icons', () => {
5
+ expect(getTriggerSourceIcon({provider: null, source: 'manual'})).toBe('cursorLine');
6
+ expect(getTriggerSourceIcon({provider: null, source: 'cron'})).toBe('timeLine');
7
+ });
8
+
9
+ test('resolves integration icons from provider while preserving slug sources', () => {
10
+ const icon = getTriggerSourceIcon({provider: 'github', source: 'github_acme'});
11
+
12
+ expect(icon).toBe('github');
13
+ });
14
+
15
+ test('falls back for unknown or empty providers', () => {
16
+ expect(getTriggerSourceIcon({provider: 'gitlab', source: 'gitlab_acme'})).toBe('componentLine');
17
+ expect(getTriggerSourceIcon({provider: '', source: 'github_acme'})).toBe('componentLine');
18
+ expect(getTriggerSourceIcon({provider: null, source: 'github_acme'})).toBe('componentLine');
19
+ expect(getTriggerSourceIcon({provider: undefined, source: 'github_acme'})).toBe(
20
+ 'componentLine',
21
+ );
22
+ });
23
+ });
@@ -0,0 +1,36 @@
1
+ import {getIntegrationIcon} from '@shipfox/client-integrations';
2
+ import {Icon, type IconName} from '@shipfox/react-ui/icon';
3
+ import type {ComponentProps} from 'react';
4
+
5
+ const SYSTEM_TRIGGER_SOURCE_ICONS: Record<string, IconName> = {
6
+ manual: 'cursorLine',
7
+ cron: 'timeLine',
8
+ };
9
+
10
+ /**
11
+ * Gives first-class icons to system trigger sources; integration sources keep
12
+ * using the integration catalog and its fallback.
13
+ */
14
+ export function getTriggerSourceIcon({
15
+ provider,
16
+ source,
17
+ }: {
18
+ provider: string | null | undefined;
19
+ source: string | null | undefined;
20
+ }): IconName {
21
+ const systemIcon = source ? SYSTEM_TRIGGER_SOURCE_ICONS[source] : undefined;
22
+ return systemIcon ?? getIntegrationIcon(provider);
23
+ }
24
+
25
+ export interface TriggerSourceIconProps extends Omit<ComponentProps<typeof Icon>, 'name'> {
26
+ provider: string | null | undefined;
27
+ source: string | null | undefined;
28
+ }
29
+
30
+ /**
31
+ * Preserves the base icon props so callers can decide whether the glyph is
32
+ * decorative or labeled.
33
+ */
34
+ export function TriggerSourceIcon({provider, source, ...props}: TriggerSourceIconProps) {
35
+ return <Icon name={getTriggerSourceIcon({provider, source})} {...props} />;
36
+ }
@@ -0,0 +1,22 @@
1
+ import type {
2
+ TriggerEventFacetsResponseDto,
3
+ TriggerEventListItemDto,
4
+ } from '@shipfox/api-triggers-dto';
5
+ import type {QueryLoadErrorQuery} from '@shipfox/client-ui';
6
+ import type {TriggerEventFilters} from '#hooks/api/trigger-events.js';
7
+
8
+ export type TriggerEventsListQuery = QueryLoadErrorQuery & {isPending: boolean};
9
+
10
+ export interface EventsListProps {
11
+ events: TriggerEventListItemDto[];
12
+ query: TriggerEventsListQuery;
13
+ facets: TriggerEventFacetsResponseDto | undefined;
14
+ filters: TriggerEventFilters;
15
+ onFiltersChange: (patch: Partial<TriggerEventFilters>) => void;
16
+ workspaceId: string;
17
+ hasNextPage: boolean;
18
+ isFetchingNextPage: boolean;
19
+ onLoadMore: () => void;
20
+ selectedEventId?: string | undefined;
21
+ onSelectEvent: (eventId: string) => void;
22
+ }