@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,252 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {
|
|
3
|
+
getTriggerEvent,
|
|
4
|
+
getTriggerEventFacets,
|
|
5
|
+
listTriggerEvents,
|
|
6
|
+
triggerEventsQueryKeys,
|
|
7
|
+
} from './trigger-events.js';
|
|
8
|
+
|
|
9
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
headers: {'content-type': 'application/json'},
|
|
12
|
+
status: 200,
|
|
13
|
+
...init,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requestFrom(fetchImpl: ReturnType<typeof vi.fn>): Request {
|
|
18
|
+
return fetchImpl.mock.calls[0]?.[0] as Request;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('triggerEventsQueryKeys', () => {
|
|
22
|
+
test('list key nests under the workspace lists key', () => {
|
|
23
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
24
|
+
|
|
25
|
+
const key = triggerEventsQueryKeys.list(workspaceId, {});
|
|
26
|
+
|
|
27
|
+
expect(key).toEqual([
|
|
28
|
+
'trigger-events',
|
|
29
|
+
'list',
|
|
30
|
+
workspaceId,
|
|
31
|
+
50,
|
|
32
|
+
{source: null, event: null, outcome: null, from: null, to: null},
|
|
33
|
+
]);
|
|
34
|
+
expect(key.slice(0, 3)).toEqual(triggerEventsQueryKeys.lists(workspaceId));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('keeps distinct page limits on distinct keys', () => {
|
|
38
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
39
|
+
|
|
40
|
+
const defaultLimit = triggerEventsQueryKeys.list(workspaceId, {});
|
|
41
|
+
const compactLimit = triggerEventsQueryKeys.list(workspaceId, {}, 25);
|
|
42
|
+
|
|
43
|
+
expect(defaultLimit).not.toEqual(compactLimit);
|
|
44
|
+
expect(defaultLimit[3]).toBe(50);
|
|
45
|
+
expect(compactLimit[3]).toBe(25);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('normalizes absent filter fields to null so empty variants share a key', () => {
|
|
49
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
50
|
+
|
|
51
|
+
const empty = triggerEventsQueryKeys.list(workspaceId, {});
|
|
52
|
+
const allUndefined = triggerEventsQueryKeys.list(workspaceId, {
|
|
53
|
+
source: undefined,
|
|
54
|
+
event: undefined,
|
|
55
|
+
outcome: undefined,
|
|
56
|
+
from: undefined,
|
|
57
|
+
to: undefined,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(allUndefined).toEqual(empty);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('treats an empty outcome array as no outcome filter', () => {
|
|
64
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
65
|
+
|
|
66
|
+
const key = triggerEventsQueryKeys.list(workspaceId, {outcome: []});
|
|
67
|
+
|
|
68
|
+
expect(key[4]).toMatchObject({outcome: null});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('treats empty source and event arrays as no filter', () => {
|
|
72
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
73
|
+
|
|
74
|
+
const key = triggerEventsQueryKeys.list(workspaceId, {source: [], event: []});
|
|
75
|
+
|
|
76
|
+
expect(key[4]).toMatchObject({source: null, event: null});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('sorts and de-duplicates outcome so filter order does not split the cache', () => {
|
|
80
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
81
|
+
|
|
82
|
+
const a = triggerEventsQueryKeys.list(workspaceId, {outcome: ['routed', 'failed']});
|
|
83
|
+
const b = triggerEventsQueryKeys.list(workspaceId, {outcome: ['failed', 'routed', 'failed']});
|
|
84
|
+
|
|
85
|
+
expect(a).toEqual(b);
|
|
86
|
+
expect(a[4]).toMatchObject({outcome: ['failed', 'routed']});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('sorts and de-duplicates source and event filters so order does not split the cache', () => {
|
|
90
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
91
|
+
|
|
92
|
+
const a = triggerEventsQueryKeys.list(workspaceId, {
|
|
93
|
+
source: ['github', 'gitea'],
|
|
94
|
+
event: ['push', 'pull_request'],
|
|
95
|
+
});
|
|
96
|
+
const b = triggerEventsQueryKeys.list(workspaceId, {
|
|
97
|
+
source: ['gitea', 'github', 'gitea'],
|
|
98
|
+
event: ['pull_request', 'push', 'push'],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(a).toEqual(b);
|
|
102
|
+
expect(a[4]).toMatchObject({
|
|
103
|
+
source: ['gitea', 'github'],
|
|
104
|
+
event: ['pull_request', 'push'],
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('keeps distinct filters on distinct keys', () => {
|
|
109
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
110
|
+
|
|
111
|
+
const push = triggerEventsQueryKeys.list(workspaceId, {event: ['push']});
|
|
112
|
+
const pr = triggerEventsQueryKeys.list(workspaceId, {event: ['pull_request']});
|
|
113
|
+
|
|
114
|
+
expect(push).not.toEqual(pr);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('detail key carries the event id', () => {
|
|
118
|
+
const id = '22222222-2222-4222-8222-222222222222';
|
|
119
|
+
|
|
120
|
+
expect(triggerEventsQueryKeys.detail(id)).toEqual(['trigger-events', 'detail', id]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('facets key carries the workspace id', () => {
|
|
124
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
125
|
+
|
|
126
|
+
expect(triggerEventsQueryKeys.facets(workspaceId)).toEqual([
|
|
127
|
+
'trigger-events',
|
|
128
|
+
'facets',
|
|
129
|
+
workspaceId,
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('listTriggerEvents', () => {
|
|
135
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
136
|
+
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('sends workspace_id and the default limit with no filters', async () => {
|
|
142
|
+
const fetchImpl = vi.fn(async () => jsonResponse({trigger_events: [], next_cursor: null}));
|
|
143
|
+
configureApiClient({fetchImpl});
|
|
144
|
+
|
|
145
|
+
await listTriggerEvents({workspaceId});
|
|
146
|
+
|
|
147
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
148
|
+
expect(url.pathname).toBe('/trigger-events');
|
|
149
|
+
expect(url.searchParams.get('workspace_id')).toBe(workspaceId);
|
|
150
|
+
expect(url.searchParams.get('limit')).toBe('50');
|
|
151
|
+
expect(url.searchParams.has('cursor')).toBe(false);
|
|
152
|
+
expect(url.searchParams.has('outcome')).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('serializes every filter, the cursor, and a custom limit', async () => {
|
|
156
|
+
const fetchImpl = vi.fn(async () => jsonResponse({trigger_events: [], next_cursor: null}));
|
|
157
|
+
configureApiClient({fetchImpl});
|
|
158
|
+
|
|
159
|
+
await listTriggerEvents({
|
|
160
|
+
workspaceId,
|
|
161
|
+
limit: 25,
|
|
162
|
+
cursor: 'cursor-abc',
|
|
163
|
+
filters: {
|
|
164
|
+
source: ['github', 'gitea'],
|
|
165
|
+
event: ['push', 'pull_request'],
|
|
166
|
+
outcome: ['routed', 'failed'],
|
|
167
|
+
from: '2026-06-01T00:00:00.000Z',
|
|
168
|
+
to: '2026-06-22T00:00:00.000Z',
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
173
|
+
expect(url.searchParams.get('limit')).toBe('25');
|
|
174
|
+
expect(url.searchParams.get('cursor')).toBe('cursor-abc');
|
|
175
|
+
expect(url.searchParams.get('source')).toBe('gitea,github');
|
|
176
|
+
expect(url.searchParams.get('event')).toBe('pull_request,push');
|
|
177
|
+
expect(url.searchParams.get('from')).toBe('2026-06-01T00:00:00.000Z');
|
|
178
|
+
expect(url.searchParams.get('to')).toBe('2026-06-22T00:00:00.000Z');
|
|
179
|
+
expect(url.searchParams.get('outcome')).toBe('failed,routed');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('omits list params when the filter arrays are empty', async () => {
|
|
183
|
+
const fetchImpl = vi.fn(async () => jsonResponse({trigger_events: [], next_cursor: null}));
|
|
184
|
+
configureApiClient({fetchImpl});
|
|
185
|
+
|
|
186
|
+
await listTriggerEvents({workspaceId, filters: {source: [], event: [], outcome: []}});
|
|
187
|
+
|
|
188
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
189
|
+
expect(url.searchParams.has('source')).toBe(false);
|
|
190
|
+
expect(url.searchParams.has('event')).toBe(false);
|
|
191
|
+
expect(url.searchParams.has('outcome')).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('sorts and de-duplicates the serialized outcome param', async () => {
|
|
195
|
+
const fetchImpl = vi.fn(async () => jsonResponse({trigger_events: [], next_cursor: null}));
|
|
196
|
+
configureApiClient({fetchImpl});
|
|
197
|
+
|
|
198
|
+
await listTriggerEvents({workspaceId, filters: {outcome: ['routed', 'failed', 'routed']}});
|
|
199
|
+
|
|
200
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
201
|
+
expect(url.searchParams.get('outcome')).toBe('failed,routed');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('returns the parsed list response', async () => {
|
|
205
|
+
const page = {
|
|
206
|
+
trigger_events: [],
|
|
207
|
+
next_cursor: 'next-page',
|
|
208
|
+
};
|
|
209
|
+
configureApiClient({fetchImpl: vi.fn(async () => jsonResponse(page))});
|
|
210
|
+
|
|
211
|
+
const result = await listTriggerEvents({workspaceId});
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual(page);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('getTriggerEvent', () => {
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('requests the event detail by id', async () => {
|
|
223
|
+
const id = '22222222-2222-4222-8222-222222222222';
|
|
224
|
+
const fetchImpl = vi.fn(async () => jsonResponse({id, decisions: []}));
|
|
225
|
+
configureApiClient({fetchImpl});
|
|
226
|
+
|
|
227
|
+
await getTriggerEvent({id});
|
|
228
|
+
|
|
229
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
230
|
+
expect(url.pathname).toBe(`/trigger-events/${id}`);
|
|
231
|
+
expect(requestFrom(fetchImpl).method).toBe('GET');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('getTriggerEventFacets', () => {
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('requests the workspace facets', async () => {
|
|
241
|
+
const workspaceId = '11111111-1111-4111-8111-111111111111';
|
|
242
|
+
const fetchImpl = vi.fn(async () => jsonResponse({sources: [], events: []}));
|
|
243
|
+
configureApiClient({fetchImpl});
|
|
244
|
+
|
|
245
|
+
await getTriggerEventFacets({workspaceId});
|
|
246
|
+
|
|
247
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
248
|
+
expect(url.pathname).toBe('/trigger-events/facets');
|
|
249
|
+
expect(url.searchParams.get('workspace_id')).toBe(workspaceId);
|
|
250
|
+
expect(requestFrom(fetchImpl).method).toBe('GET');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TriggerEventDetailResponseDto,
|
|
3
|
+
TriggerEventFacetsResponseDto,
|
|
4
|
+
TriggerEventListResponseDto,
|
|
5
|
+
TriggerEventOutcomeDto,
|
|
6
|
+
} from '@shipfox/api-triggers-dto';
|
|
7
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
8
|
+
import {keepPreviousData, useInfiniteQuery, useQuery} from '@tanstack/react-query';
|
|
9
|
+
|
|
10
|
+
export interface TriggerEventFilters {
|
|
11
|
+
source?: string[] | undefined;
|
|
12
|
+
event?: string[] | undefined;
|
|
13
|
+
outcome?: TriggerEventOutcomeDto[] | undefined;
|
|
14
|
+
from?: string | undefined;
|
|
15
|
+
to?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeStringFilter(values: readonly string[] | undefined) {
|
|
19
|
+
return values && values.length > 0 ? [...new Set(values)].sort() : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeTriggerEventFiltersForQueryKey(filters: TriggerEventFilters) {
|
|
23
|
+
return {
|
|
24
|
+
source: normalizeStringFilter(filters.source),
|
|
25
|
+
event: normalizeStringFilter(filters.event),
|
|
26
|
+
outcome: normalizeStringFilter(filters.outcome),
|
|
27
|
+
from: filters.from ?? null,
|
|
28
|
+
to: filters.to ?? null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setListParam(
|
|
33
|
+
params: URLSearchParams,
|
|
34
|
+
name: string,
|
|
35
|
+
values: readonly string[] | undefined,
|
|
36
|
+
) {
|
|
37
|
+
const normalized = normalizeStringFilter(values);
|
|
38
|
+
if (normalized) params.set(name, normalized.join(','));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const triggerEventsQueryKeys = {
|
|
42
|
+
all: ['trigger-events'] as const,
|
|
43
|
+
lists: (workspaceId: string) => [...triggerEventsQueryKeys.all, 'list', workspaceId] as const,
|
|
44
|
+
list: (workspaceId: string, filters: TriggerEventFilters, limit = 50) =>
|
|
45
|
+
[
|
|
46
|
+
...triggerEventsQueryKeys.lists(workspaceId),
|
|
47
|
+
limit,
|
|
48
|
+
normalizeTriggerEventFiltersForQueryKey(filters),
|
|
49
|
+
] as const,
|
|
50
|
+
detail: (id: string) => [...triggerEventsQueryKeys.all, 'detail', id] as const,
|
|
51
|
+
facets: (workspaceId: string) => [...triggerEventsQueryKeys.all, 'facets', workspaceId] as const,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export async function listTriggerEvents({
|
|
55
|
+
workspaceId,
|
|
56
|
+
filters = {},
|
|
57
|
+
limit = 50,
|
|
58
|
+
cursor,
|
|
59
|
+
signal,
|
|
60
|
+
}: {
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
filters?: TriggerEventFilters;
|
|
63
|
+
limit?: number;
|
|
64
|
+
cursor?: string | undefined;
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
}) {
|
|
67
|
+
const params = new URLSearchParams({workspace_id: workspaceId, limit: String(limit)});
|
|
68
|
+
if (cursor) params.set('cursor', cursor);
|
|
69
|
+
setListParam(params, 'source', filters.source);
|
|
70
|
+
setListParam(params, 'event', filters.event);
|
|
71
|
+
if (filters.from) params.set('from', filters.from);
|
|
72
|
+
if (filters.to) params.set('to', filters.to);
|
|
73
|
+
setListParam(params, 'outcome', filters.outcome);
|
|
74
|
+
|
|
75
|
+
return await apiRequest<TriggerEventListResponseDto>(`/trigger-events?${params.toString()}`, {
|
|
76
|
+
signal,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getTriggerEvent({id, signal}: {id: string; signal?: AbortSignal}) {
|
|
81
|
+
return await apiRequest<TriggerEventDetailResponseDto>(
|
|
82
|
+
`/trigger-events/${encodeURIComponent(id)}`,
|
|
83
|
+
{signal},
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useTriggerEventsInfiniteQuery(
|
|
88
|
+
workspaceId: string | undefined,
|
|
89
|
+
filters: TriggerEventFilters = {},
|
|
90
|
+
limit = 50,
|
|
91
|
+
) {
|
|
92
|
+
return useInfiniteQuery({
|
|
93
|
+
queryKey: workspaceId
|
|
94
|
+
? triggerEventsQueryKeys.list(workspaceId, filters, limit)
|
|
95
|
+
: [...triggerEventsQueryKeys.all, 'list'],
|
|
96
|
+
enabled: Boolean(workspaceId),
|
|
97
|
+
initialPageParam: undefined as string | undefined,
|
|
98
|
+
queryFn: ({pageParam, signal}) =>
|
|
99
|
+
listTriggerEvents({
|
|
100
|
+
workspaceId: workspaceId ?? '',
|
|
101
|
+
filters,
|
|
102
|
+
limit,
|
|
103
|
+
cursor: pageParam,
|
|
104
|
+
signal,
|
|
105
|
+
}),
|
|
106
|
+
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
|
107
|
+
placeholderData: keepPreviousData,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useTriggerEventQuery(id: string | undefined) {
|
|
112
|
+
return useQuery({
|
|
113
|
+
queryKey: id ? triggerEventsQueryKeys.detail(id) : [...triggerEventsQueryKeys.all, 'detail'],
|
|
114
|
+
enabled: Boolean(id),
|
|
115
|
+
queryFn: ({signal}) => getTriggerEvent({id: id ?? '', signal}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function getTriggerEventFacets({
|
|
120
|
+
workspaceId,
|
|
121
|
+
signal,
|
|
122
|
+
}: {
|
|
123
|
+
workspaceId: string;
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
}) {
|
|
126
|
+
const params = new URLSearchParams({workspace_id: workspaceId});
|
|
127
|
+
return await apiRequest<TriggerEventFacetsResponseDto>(
|
|
128
|
+
`/trigger-events/facets?${params.toString()}`,
|
|
129
|
+
{signal},
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function useTriggerEventFacetsQuery(workspaceId: string | undefined) {
|
|
134
|
+
return useQuery({
|
|
135
|
+
queryKey: workspaceId
|
|
136
|
+
? triggerEventsQueryKeys.facets(workspaceId)
|
|
137
|
+
: [...triggerEventsQueryKeys.all, 'facets'],
|
|
138
|
+
enabled: Boolean(workspaceId),
|
|
139
|
+
queryFn: ({signal}) => getTriggerEventFacets({workspaceId: workspaceId ?? '', signal}),
|
|
140
|
+
// Distinct values change slowly; avoid refetching on every page mount.
|
|
141
|
+
staleTime: 5 * 60 * 1000,
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TriggerEventDetailResponseDto,
|
|
3
|
+
TriggerEventListItemDto,
|
|
4
|
+
TriggerEventListResponseDto,
|
|
5
|
+
} from '@shipfox/api-triggers-dto';
|
|
6
|
+
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
7
|
+
import {
|
|
8
|
+
useTriggerEventFacetsQuery,
|
|
9
|
+
useTriggerEventQuery,
|
|
10
|
+
useTriggerEventsInfiniteQuery,
|
|
11
|
+
} from '#hooks/api/trigger-events.js';
|
|
12
|
+
import {EventsPage} from './events-page.js';
|
|
13
|
+
|
|
14
|
+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
15
|
+
const EVENT_ID = '22222222-2222-4222-8222-222222222222';
|
|
16
|
+
|
|
17
|
+
const useTriggerEventsInfiniteQueryMock = vi.mocked(useTriggerEventsInfiniteQuery);
|
|
18
|
+
const useTriggerEventFacetsQueryMock = vi.mocked(useTriggerEventFacetsQuery);
|
|
19
|
+
const useTriggerEventQueryMock = vi.mocked(useTriggerEventQuery);
|
|
20
|
+
|
|
21
|
+
function makeEvent(overrides: Partial<TriggerEventListItemDto> = {}): TriggerEventListItemDto {
|
|
22
|
+
return {
|
|
23
|
+
id: EVENT_ID,
|
|
24
|
+
event_ref: 'github:delivery-179:push',
|
|
25
|
+
origin: 'integration',
|
|
26
|
+
workspace_id: WORKSPACE_ID,
|
|
27
|
+
provider: 'github',
|
|
28
|
+
source: 'github',
|
|
29
|
+
event: 'push',
|
|
30
|
+
delivery_id: 'delivery-179',
|
|
31
|
+
connection_id: '33333333-3333-4333-8333-333333333333',
|
|
32
|
+
outcome: 'routed',
|
|
33
|
+
matched_count: 1,
|
|
34
|
+
received_at: '2026-06-25T19:30:00.000Z',
|
|
35
|
+
processed_at: '2026-06-25T19:30:02.000Z',
|
|
36
|
+
created_at: '2026-06-25T19:30:00.000Z',
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeDetail(overrides: Partial<TriggerEventDetailResponseDto> = {}) {
|
|
42
|
+
return {
|
|
43
|
+
...makeEvent(),
|
|
44
|
+
connection_name: 'ShipfoxHQ Production',
|
|
45
|
+
payload: {ref: 'refs/heads/main'},
|
|
46
|
+
decisions: [],
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeListQuery(triggerEvents: TriggerEventListItemDto[]) {
|
|
52
|
+
const page: TriggerEventListResponseDto = {
|
|
53
|
+
trigger_events: triggerEvents,
|
|
54
|
+
next_cursor: null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
data: {pages: [page], pageParams: [undefined]},
|
|
59
|
+
isPending: false,
|
|
60
|
+
isError: false,
|
|
61
|
+
isFetching: false,
|
|
62
|
+
error: null,
|
|
63
|
+
refetch: vi.fn(),
|
|
64
|
+
hasNextPage: false,
|
|
65
|
+
isFetchingNextPage: false,
|
|
66
|
+
fetchNextPage: vi.fn(),
|
|
67
|
+
} as unknown as ReturnType<typeof useTriggerEventsInfiniteQuery>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('EventsPage', () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
useTriggerEventFacetsQueryMock.mockReturnValue({
|
|
73
|
+
data: {sources: [{value: 'github', count: 1}], events: [{value: 'push', count: 1}]},
|
|
74
|
+
} as ReturnType<typeof useTriggerEventFacetsQuery>);
|
|
75
|
+
useTriggerEventQueryMock.mockImplementation(
|
|
76
|
+
(eventId) =>
|
|
77
|
+
({
|
|
78
|
+
data: eventId ? makeDetail({id: eventId}) : undefined,
|
|
79
|
+
isError: false,
|
|
80
|
+
refetch: vi.fn(),
|
|
81
|
+
}) as unknown as ReturnType<typeof useTriggerEventQuery>,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('clears the selected detail when a settled refresh no longer includes the event', async () => {
|
|
86
|
+
useTriggerEventsInfiniteQueryMock.mockReturnValue(makeListQuery([makeEvent()]));
|
|
87
|
+
const {rerender} = render(
|
|
88
|
+
<EventsPage workspaceId={WORKSPACE_ID} filters={{}} onFiltersChange={vi.fn()} />,
|
|
89
|
+
);
|
|
90
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Open details for github · push'}));
|
|
91
|
+
expect(screen.getByRole('button', {name: 'Back to events'})).toBeInTheDocument();
|
|
92
|
+
|
|
93
|
+
useTriggerEventsInfiniteQueryMock.mockReturnValue(makeListQuery([]));
|
|
94
|
+
rerender(<EventsPage workspaceId={WORKSPACE_ID} filters={{}} onFiltersChange={vi.fn()} />);
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(screen.queryByRole('button', {name: 'Back to events'})).not.toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {TriggerEventListItemDto} from '@shipfox/api-triggers-dto';
|
|
2
|
+
import {RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
|
|
3
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
4
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
5
|
+
import {useEffect, useMemo, useState} from 'react';
|
|
6
|
+
import {EventsList} from '#components/events-list.js';
|
|
7
|
+
import {TriggerEventDetail} from '#components/trigger-event-detail.js';
|
|
8
|
+
import {
|
|
9
|
+
type TriggerEventFilters,
|
|
10
|
+
useTriggerEventFacetsQuery,
|
|
11
|
+
useTriggerEventsInfiniteQuery,
|
|
12
|
+
} from '#hooks/api/trigger-events.js';
|
|
13
|
+
|
|
14
|
+
export interface EventsPageProps {
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
filters: TriggerEventFilters;
|
|
17
|
+
onFiltersChange: (patch: Partial<TriggerEventFilters>) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Workspace-scoped Events list. Router-agnostic: filters and their setter come in as props
|
|
22
|
+
* (the settings wrapper binds them to the URL), so a story can drive it with local state.
|
|
23
|
+
*/
|
|
24
|
+
export function EventsPage({workspaceId, filters, onFiltersChange}: EventsPageProps) {
|
|
25
|
+
const query = useTriggerEventsInfiniteQuery(workspaceId, filters);
|
|
26
|
+
const facetsQuery = useTriggerEventFacetsQuery(workspaceId);
|
|
27
|
+
const [selectedEventId, setSelectedEventId] = useState<string | undefined>();
|
|
28
|
+
|
|
29
|
+
const events = useMemo<TriggerEventListItemDto[]>(
|
|
30
|
+
() => query.data?.pages.flatMap((page) => page.trigger_events) ?? [],
|
|
31
|
+
[query.data],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!selectedEventId || query.isPending || query.isFetching || query.isError) return;
|
|
36
|
+
if (!events.some((event) => event.id === selectedEventId)) setSelectedEventId(undefined);
|
|
37
|
+
}, [events, query.isError, query.isFetching, query.isPending, selectedEventId]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<RelativeTimeProvider>
|
|
41
|
+
<section className="flex min-w-0 flex-col gap-16" aria-labelledby="trigger-events-heading">
|
|
42
|
+
<div className="flex flex-col gap-4">
|
|
43
|
+
<Header id="trigger-events-heading" variant="h3">
|
|
44
|
+
Events
|
|
45
|
+
</Header>
|
|
46
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
47
|
+
A workspace-wide audit log of trigger events received from integrations, schedules, and
|
|
48
|
+
manual trigger calls.
|
|
49
|
+
</Text>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div className="grid min-h-0 gap-16 min-[900px]:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]">
|
|
53
|
+
<div className={cn('min-w-0', selectedEventId && 'max-[899px]:hidden')}>
|
|
54
|
+
<EventsList
|
|
55
|
+
events={events}
|
|
56
|
+
query={query}
|
|
57
|
+
facets={facetsQuery.data}
|
|
58
|
+
filters={filters}
|
|
59
|
+
onFiltersChange={onFiltersChange}
|
|
60
|
+
workspaceId={workspaceId}
|
|
61
|
+
hasNextPage={query.hasNextPage}
|
|
62
|
+
isFetchingNextPage={query.isFetchingNextPage}
|
|
63
|
+
onLoadMore={() => {
|
|
64
|
+
void query.fetchNextPage();
|
|
65
|
+
}}
|
|
66
|
+
selectedEventId={selectedEventId}
|
|
67
|
+
onSelectEvent={setSelectedEventId}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
<TriggerEventDetail
|
|
71
|
+
workspaceId={workspaceId}
|
|
72
|
+
eventId={selectedEventId}
|
|
73
|
+
onBack={() => setSelectedEventId(undefined)}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
</RelativeTimeProvider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {validateTriggerEventsSearch} from './search.js';
|
|
2
|
+
|
|
3
|
+
describe('validateTriggerEventsSearch', () => {
|
|
4
|
+
test('passes through a full valid search', () => {
|
|
5
|
+
const search = validateTriggerEventsSearch({
|
|
6
|
+
source: ['github', 'gitea'],
|
|
7
|
+
event: ['push', 'pull_request'],
|
|
8
|
+
outcome: ['routed', 'failed'],
|
|
9
|
+
from: '2026-06-01T00:00:00.000Z',
|
|
10
|
+
to: '2026-06-22T00:00:00.000Z',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(search).toEqual({
|
|
14
|
+
source: ['github', 'gitea'],
|
|
15
|
+
event: ['push', 'pull_request'],
|
|
16
|
+
outcome: ['routed', 'failed'],
|
|
17
|
+
from: '2026-06-01T00:00:00.000Z',
|
|
18
|
+
to: '2026-06-22T00:00:00.000Z',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns an empty search for empty input', () => {
|
|
23
|
+
expect(validateTriggerEventsSearch({})).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('keeps a valid single-outcome filter', () => {
|
|
27
|
+
expect(validateTriggerEventsSearch({outcome: ['discarded']}).outcome).toEqual(['discarded']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('normalizes scalar filter params from existing URLs', () => {
|
|
31
|
+
const search = validateTriggerEventsSearch({
|
|
32
|
+
source: 'github',
|
|
33
|
+
event: 'push',
|
|
34
|
+
outcome: 'discarded',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(search.source).toEqual(['github']);
|
|
38
|
+
expect(search.event).toEqual(['push']);
|
|
39
|
+
expect(search.outcome).toEqual(['discarded']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('drops an outcome array containing an unknown value', () => {
|
|
43
|
+
expect(validateTriggerEventsSearch({outcome: ['routed', 'bogus']}).outcome).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('drops a non-string source rather than throwing', () => {
|
|
47
|
+
expect(validateTriggerEventsSearch({source: 123}).source).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('drops a non-ISO date so it never reaches the read API', () => {
|
|
51
|
+
const search = validateTriggerEventsSearch({from: '2026-06-01', to: 'yesterday'});
|
|
52
|
+
|
|
53
|
+
expect(search.from).toBeUndefined();
|
|
54
|
+
expect(search.to).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('keeps a valid ISO datetime window', () => {
|
|
58
|
+
const search = validateTriggerEventsSearch({
|
|
59
|
+
from: '2026-06-01T00:00:00.000Z',
|
|
60
|
+
to: '2026-06-22T23:59:59.999Z',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(search.from).toBe('2026-06-01T00:00:00.000Z');
|
|
64
|
+
expect(search.to).toBe('2026-06-22T23:59:59.999Z');
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {triggerEventOutcomeSchema} from '@shipfox/api-triggers-dto';
|
|
2
|
+
import {z} from 'zod';
|
|
3
|
+
|
|
4
|
+
const stringListSearchSchema = z
|
|
5
|
+
.preprocess((value) => (typeof value === 'string' ? [value] : value), z.array(z.string()))
|
|
6
|
+
.optional()
|
|
7
|
+
.catch(undefined);
|
|
8
|
+
|
|
9
|
+
const outcomeListSearchSchema = z
|
|
10
|
+
.preprocess(
|
|
11
|
+
(value) => (typeof value === 'string' ? [value] : value),
|
|
12
|
+
z.array(triggerEventOutcomeSchema),
|
|
13
|
+
)
|
|
14
|
+
.optional()
|
|
15
|
+
.catch(undefined);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* URL search params for the Events page. Reuses the DTO outcome enum so the page and the
|
|
19
|
+
* read API share one source of truth, and matches the `TriggerEventFilters` shape the data
|
|
20
|
+
* hook consumes. Every field uses `.catch(undefined)` so a hand-edited or stale URL drops
|
|
21
|
+
* the bad param rather than throwing inside the router's `validateSearch`.
|
|
22
|
+
*/
|
|
23
|
+
export const triggerEventsSearchSchema = z.object({
|
|
24
|
+
source: stringListSearchSchema,
|
|
25
|
+
event: stringListSearchSchema,
|
|
26
|
+
outcome: outcomeListSearchSchema,
|
|
27
|
+
// The read API requires ISO datetimes (`z.string().datetime()`); validate the same here so
|
|
28
|
+
// a hand-edited or stale URL with a non-ISO date drops the param rather than forwarding it
|
|
29
|
+
// and turning the first fetch into a full-page load error.
|
|
30
|
+
from: z.string().datetime().optional().catch(undefined),
|
|
31
|
+
to: z.string().datetime().optional().catch(undefined),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type TriggerEventsSearch = z.infer<typeof triggerEventsSearchSchema>;
|
|
35
|
+
|
|
36
|
+
export function validateTriggerEventsSearch(search: Record<string, unknown>): TriggerEventsSearch {
|
|
37
|
+
return triggerEventsSearchSchema.parse(search);
|
|
38
|
+
}
|