@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +8 -0
- package/.storybook/preview.tsx +89 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/dist/components/events-filter-bar.d.ts +13 -0
- package/dist/components/events-filter-bar.d.ts.map +1 -0
- package/dist/components/events-filter-bar.js +198 -0
- package/dist/components/events-filter-bar.js.map +1 -0
- package/dist/components/events-list-states.d.ts +16 -0
- package/dist/components/events-list-states.d.ts.map +1 -0
- package/dist/components/events-list-states.js +105 -0
- package/dist/components/events-list-states.js.map +1 -0
- package/dist/components/events-list.d.ts +3 -0
- package/dist/components/events-list.d.ts.map +1 -0
- package/dist/components/events-list.js +97 -0
- package/dist/components/events-list.js.map +1 -0
- package/dist/components/events-list.stories.d.ts +53 -0
- package/dist/components/events-list.stories.d.ts.map +1 -0
- package/dist/components/events-list.stories.js +180 -0
- package/dist/components/events-list.stories.js.map +1 -0
- package/dist/components/trigger-event-detail.d.ts +13 -0
- package/dist/components/trigger-event-detail.d.ts.map +1 -0
- package/dist/components/trigger-event-detail.js +376 -0
- package/dist/components/trigger-event-detail.js.map +1 -0
- package/dist/components/trigger-event-detail.stories.d.ts +65 -0
- package/dist/components/trigger-event-detail.stories.d.ts.map +1 -0
- package/dist/components/trigger-event-detail.stories.js +181 -0
- package/dist/components/trigger-event-detail.stories.js.map +1 -0
- package/dist/components/trigger-event-result.d.ts +15 -0
- package/dist/components/trigger-event-result.d.ts.map +1 -0
- package/dist/components/trigger-event-result.js +48 -0
- package/dist/components/trigger-event-result.js.map +1 -0
- package/dist/components/trigger-event-row.d.ts +9 -0
- package/dist/components/trigger-event-row.d.ts.map +1 -0
- package/dist/components/trigger-event-row.js +91 -0
- package/dist/components/trigger-event-row.js.map +1 -0
- package/dist/components/trigger-source-icon.d.ts +20 -0
- package/dist/components/trigger-source-icon.d.ts.map +1 -0
- package/dist/components/trigger-source-icon.js +28 -0
- package/dist/components/trigger-source-icon.js.map +1 -0
- package/dist/components/types.d.ts +20 -0
- package/dist/components/types.d.ts.map +1 -0
- package/dist/components/types.js +3 -0
- package/dist/components/types.js.map +1 -0
- package/dist/hooks/api/trigger-events.d.ts +164 -0
- package/dist/hooks/api/trigger-events.d.ts.map +1 -0
- package/dist/hooks/api/trigger-events.js +122 -0
- package/dist/hooks/api/trigger-events.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/events-page.d.ts +12 -0
- package/dist/pages/events-page.d.ts.map +1 -0
- package/dist/pages/events-page.js +82 -0
- package/dist/pages/events-page.js.map +1 -0
- package/dist/search.d.ts +23 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +28 -0
- package/dist/search.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +92 -0
- package/src/components/events-filter-bar.tsx +191 -0
- package/src/components/events-list-states.tsx +89 -0
- package/src/components/events-list.stories.tsx +141 -0
- package/src/components/events-list.test.tsx +173 -0
- package/src/components/events-list.tsx +107 -0
- package/src/components/trigger-event-detail.stories.tsx +169 -0
- package/src/components/trigger-event-detail.test.tsx +201 -0
- package/src/components/trigger-event-detail.tsx +317 -0
- package/src/components/trigger-event-result.test.ts +40 -0
- package/src/components/trigger-event-result.ts +40 -0
- package/src/components/trigger-event-row.tsx +86 -0
- package/src/components/trigger-source-icon.react.test.tsx +34 -0
- package/src/components/trigger-source-icon.test.ts +23 -0
- package/src/components/trigger-source-icon.tsx +36 -0
- package/src/components/types.ts +22 -0
- package/src/hooks/api/trigger-events.test.ts +252 -0
- package/src/hooks/api/trigger-events.ts +143 -0
- package/src/index.ts +4 -0
- package/src/pages/events-page.test.tsx +100 -0
- package/src/pages/events-page.tsx +79 -0
- package/src/search.test.ts +66 -0
- package/src/search.ts +38 -0
- package/test/setup.ts +35 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- 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
|
+
}
|