@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
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@shipfox/client-triggers",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/triggers"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ }
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "development": {
24
+ "types": "./src/index.ts",
25
+ "default": "./src/index.ts"
26
+ },
27
+ "default": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ }
32
+ },
33
+ "dependencies": {
34
+ "@swc/helpers": "^0.5.17",
35
+ "@shipfox/api-triggers-dto": "2.0.0",
36
+ "@shipfox/client-api": "0.2.0",
37
+ "@shipfox/client-integrations": "0.2.0",
38
+ "@shipfox/client-ui": "0.2.0",
39
+ "@shipfox/react-ui": "0.3.1"
40
+ },
41
+ "peerDependencies": {
42
+ "@tanstack/react-query": "^5.101.0",
43
+ "@tanstack/react-router": "^1.170.16",
44
+ "react": "^19.0.0",
45
+ "react-dom": "^19.0.0",
46
+ "zod": "^4.4.3"
47
+ },
48
+ "devDependencies": {
49
+ "@argos-ci/cli": "^5.0.0",
50
+ "@argos-ci/storybook": "^6.0.0",
51
+ "@storybook/addon-vitest": "^10.3.6",
52
+ "@storybook/react": "^10.0.0",
53
+ "@storybook/react-vite": "^10.0.0",
54
+ "@tailwindcss/vite": "^4.1.13",
55
+ "@tanstack/react-query": "^5.101.0",
56
+ "@tanstack/react-router": "^1.170.16",
57
+ "@testing-library/jest-dom": "^6.6.3",
58
+ "@testing-library/react": "^16.3.2",
59
+ "@testing-library/user-event": "^14.6.1",
60
+ "@types/react": "^19.1.11",
61
+ "@types/react-dom": "^19.1.7",
62
+ "@vitejs/plugin-react": "^6.0.0",
63
+ "@vitest/browser-playwright": "^4.1.5",
64
+ "happy-dom": "^20.8.3",
65
+ "jsdom": "^29.0.0",
66
+ "react": "^19.1.1",
67
+ "react-dom": "^19.1.1",
68
+ "storybook": "^10.0.0",
69
+ "storybook-addon-pseudo-states": "^10.0.0",
70
+ "tailwindcss": "^4.1.13",
71
+ "vite": "^8.0.3",
72
+ "vitest": "^4.1.5",
73
+ "zod": "^4.4.3",
74
+ "@shipfox/biome": "1.8.1",
75
+ "@shipfox/client-test-setup": "0.0.2",
76
+ "@shipfox/swc": "1.2.5",
77
+ "@shipfox/ts-config": "1.3.8",
78
+ "@shipfox/vitest": "1.2.2",
79
+ "@shipfox/typescript": "1.1.6"
80
+ },
81
+ "scripts": {
82
+ "build": "shipfox-swc",
83
+ "check": "shipfox-biome-check",
84
+ "check:fix": "shipfox-biome-check --write",
85
+ "storybook": "storybook dev -p 6008",
86
+ "storybook:build": "storybook build -o storybook-static",
87
+ "test": "shipfox-vitest-run",
88
+ "test:watch": "shipfox-vitest-watch",
89
+ "type": "shipfox-tsc-check",
90
+ "type:emit": "shipfox-tsc-emit"
91
+ }
92
+ }
@@ -0,0 +1,191 @@
1
+ import type {TriggerEventFacetItemDto, TriggerEventOutcomeDto} from '@shipfox/api-triggers-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@shipfox/react-ui/collapsible';
4
+ import {Combobox, type ComboboxOption} from '@shipfox/react-ui/combobox';
5
+ import {type DateRange, DateRangePicker} from '@shipfox/react-ui/date-range-picker';
6
+ import {Text} from '@shipfox/react-ui/typography';
7
+ import {cn} from '@shipfox/react-ui/utils';
8
+ import {type ReactNode, useState} from 'react';
9
+ import type {TriggerEventFilters} from '#hooks/api/trigger-events.js';
10
+
11
+ // Filters expose the same plain-language results shown in the table. `failed`
12
+ // includes `errored` so the Failed filter does not hide rows rendered as failed.
13
+ const RESULT_FILTERS: {
14
+ value: TriggerEventOutcomeDto;
15
+ selects: TriggerEventOutcomeDto[];
16
+ label: string;
17
+ }[] = [
18
+ {value: 'routed', selects: ['routed'], label: 'Triggered'},
19
+ {value: 'discarded', selects: ['discarded'], label: 'No match'},
20
+ {value: 'failed', selects: ['failed', 'errored'], label: 'Failed'},
21
+ {value: 'received', selects: ['received'], label: 'Evaluating'},
22
+ ];
23
+
24
+ const RESULT_OPTIONS: ComboboxOption[] = RESULT_FILTERS.map(({value, label}) => ({value, label}));
25
+
26
+ function toOptions(facets: TriggerEventFacetItemDto[] | undefined): ComboboxOption[] {
27
+ return (facets ?? []).map((facet) => ({
28
+ value: facet.value,
29
+ label: `${facet.value} (${facet.count})`,
30
+ }));
31
+ }
32
+
33
+ interface EventsFilterBarProps {
34
+ filters: TriggerEventFilters;
35
+ onFiltersChange: (patch: Partial<TriggerEventFilters>) => void;
36
+ sources: TriggerEventFacetItemDto[] | undefined;
37
+ events: TriggerEventFacetItemDto[] | undefined;
38
+ hasActiveFilters: boolean;
39
+ onClear: () => void;
40
+ }
41
+
42
+ export function EventsFilterBar({
43
+ filters,
44
+ onFiltersChange,
45
+ sources,
46
+ events,
47
+ hasActiveFilters,
48
+ onClear,
49
+ }: EventsFilterBarProps) {
50
+ const [open, setOpen] = useState(false);
51
+
52
+ const dateRange: DateRange | undefined =
53
+ filters.from || filters.to
54
+ ? {
55
+ start: filters.from ? new Date(filters.from) : undefined,
56
+ end: filters.to ? new Date(filters.to) : undefined,
57
+ }
58
+ : undefined;
59
+
60
+ const selectedOutcomes = new Set(filters.outcome ?? []);
61
+ const resultValue = RESULT_FILTERS.filter(({selects}) =>
62
+ selects.some((outcome) => selectedOutcomes.has(outcome)),
63
+ ).map(({value}) => value);
64
+
65
+ // Count active dimensions, not selected options, so the badge stays stable as chips grow.
66
+ const activeCount =
67
+ (dateRange ? 1 : 0) +
68
+ (filters.source?.length ? 1 : 0) +
69
+ (filters.event?.length ? 1 : 0) +
70
+ (resultValue.length > 0 ? 1 : 0);
71
+
72
+ function handleResultValue(values: string[]) {
73
+ const next = RESULT_FILTERS.filter(({value}) => values.includes(value)).flatMap(
74
+ ({selects}) => selects,
75
+ );
76
+ onFiltersChange({outcome: next.length > 0 ? next : undefined});
77
+ }
78
+
79
+ function handleDateRange(range: DateRange | undefined) {
80
+ const from = range?.start ? new Date(range.start).toISOString() : undefined;
81
+ let to: string | undefined;
82
+ if (range?.end) {
83
+ // The backend filters received_at <= to; extend to end-of-day so the picked end
84
+ // date is inclusive rather than cutting off at its midnight.
85
+ const end = new Date(range.end);
86
+ end.setHours(23, 59, 59, 999);
87
+ to = end.toISOString();
88
+ }
89
+ onFiltersChange({from, to});
90
+ }
91
+
92
+ return (
93
+ <Collapsible open={open} onOpenChange={setOpen}>
94
+ <div className="flex items-center justify-between gap-8 border-b border-border-neutral-base px-12 py-8">
95
+ <CollapsibleTrigger asChild>
96
+ <Button
97
+ type="button"
98
+ variant="secondary"
99
+ size="sm"
100
+ iconLeft="filter3Line"
101
+ iconRight={open ? 'arrowUpSLine' : 'arrowDownSLine'}
102
+ >
103
+ {activeCount > 0 ? `Filters (${activeCount})` : 'Filters'}
104
+ </Button>
105
+ </CollapsibleTrigger>
106
+ {hasActiveFilters ? (
107
+ <Button type="button" size="2xs" variant="transparentMuted" onClick={onClear}>
108
+ Clear filters
109
+ </Button>
110
+ ) : null}
111
+ </div>
112
+ <CollapsibleContent>
113
+ <div className="grid grid-cols-1 gap-x-12 gap-y-10 border-b border-border-neutral-base px-12 py-12 min-[520px]:grid-cols-2">
114
+ <FilterField label="Date" className="col-span-full">
115
+ <DateRangePicker
116
+ size="small"
117
+ className="w-full"
118
+ {...(dateRange ? {dateRange} : {})}
119
+ onDateRangeSelect={handleDateRange}
120
+ onClear={() => onFiltersChange({from: undefined, to: undefined})}
121
+ placeholder="Any date"
122
+ />
123
+ </FilterField>
124
+ <FilterField label="Result">
125
+ <Combobox
126
+ multiple
127
+ size="small"
128
+ aria-label="Filter by result"
129
+ options={RESULT_OPTIONS}
130
+ value={resultValue}
131
+ onValueChange={handleResultValue}
132
+ placeholder="All results"
133
+ emptyState="No results"
134
+ className="w-full"
135
+ maxVisibleChips={2}
136
+ />
137
+ </FilterField>
138
+ <FilterField label="Source">
139
+ <Combobox
140
+ multiple
141
+ size="small"
142
+ options={toOptions(sources)}
143
+ value={filters.source ?? []}
144
+ onValueChange={(value) =>
145
+ onFiltersChange({source: value.length > 0 ? value : undefined})
146
+ }
147
+ placeholder="All sources"
148
+ emptyState="No sources yet"
149
+ className="w-full"
150
+ maxVisibleChips={2}
151
+ />
152
+ </FilterField>
153
+ <FilterField label="Event">
154
+ <Combobox
155
+ multiple
156
+ size="small"
157
+ options={toOptions(events)}
158
+ value={filters.event ?? []}
159
+ onValueChange={(value) =>
160
+ onFiltersChange({event: value.length > 0 ? value : undefined})
161
+ }
162
+ placeholder="All events"
163
+ emptyState="No events yet"
164
+ className="w-full"
165
+ maxVisibleChips={2}
166
+ />
167
+ </FilterField>
168
+ </div>
169
+ </CollapsibleContent>
170
+ </Collapsible>
171
+ );
172
+ }
173
+
174
+ function FilterField({
175
+ label,
176
+ className,
177
+ children,
178
+ }: {
179
+ label: string;
180
+ className?: string;
181
+ children: ReactNode;
182
+ }) {
183
+ return (
184
+ <div className={cn('flex min-w-0 flex-col gap-4', className)}>
185
+ <Text size="xs" className="font-medium text-foreground-neutral-muted">
186
+ {label}
187
+ </Text>
188
+ {children}
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,89 @@
1
+ import type {QueryLoadErrorQuery} from '@shipfox/client-ui';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
5
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
6
+ import {Text} from '@shipfox/react-ui/typography';
7
+ import {Link} from '@tanstack/react-router';
8
+
9
+ export function EventsListSkeleton() {
10
+ return (
11
+ <div className="flex flex-col gap-8 p-8" role="status" aria-label="Loading events">
12
+ {Array.from({length: 8}).map((_, index) => (
13
+ <div
14
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton row, stable position
15
+ key={index}
16
+ className="flex h-20 items-center gap-12"
17
+ >
18
+ <Skeleton className="size-6 shrink-0 rounded-full" />
19
+ <Skeleton className="h-12 w-1/4" />
20
+ <Skeleton className="h-12 w-20" />
21
+ <Skeleton className="ml-auto h-12 w-40" />
22
+ </div>
23
+ ))}
24
+ </div>
25
+ );
26
+ }
27
+
28
+ export function EventsListEmpty({workspaceId}: {workspaceId: string}) {
29
+ return (
30
+ <div className="p-16">
31
+ <EmptyState
32
+ icon="pulseLine"
33
+ title="No events yet"
34
+ description="Events appear here once a connected integration delivers one or you fire a trigger."
35
+ action={
36
+ <Button asChild size="sm" variant="secondary">
37
+ <Link to="/workspaces/$wid/settings/integrations" params={{wid: workspaceId}}>
38
+ Configure integrations
39
+ </Link>
40
+ </Button>
41
+ }
42
+ />
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export function EventsListNoMatches({onClear}: {onClear: () => void}) {
48
+ return (
49
+ <div className="p-16">
50
+ <EmptyState
51
+ icon="filterOffLine"
52
+ title="No matching events"
53
+ description="No events match your current filters."
54
+ action={
55
+ <Button type="button" size="sm" variant="secondary" onClick={onClear}>
56
+ Clear filters
57
+ </Button>
58
+ }
59
+ />
60
+ </div>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Slim banner shown when a background refetch fails after a prior success, so the loaded
66
+ * rows stay on screen with the retry inline rather than being wiped by a full placeholder.
67
+ */
68
+ export function EventsListStaleError({query}: {query: QueryLoadErrorQuery}) {
69
+ return (
70
+ <div className="p-8">
71
+ <Callout role="alert" type="error">
72
+ <div className="flex items-center justify-between gap-8">
73
+ <Text size="xs">Could not refresh events.</Text>
74
+ <Button
75
+ type="button"
76
+ size="2xs"
77
+ variant="secondary"
78
+ isLoading={query.isFetching}
79
+ onClick={() => {
80
+ void query.refetch();
81
+ }}
82
+ >
83
+ Retry
84
+ </Button>
85
+ </div>
86
+ </Callout>
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,141 @@
1
+ import type {
2
+ TriggerEventFacetsResponseDto,
3
+ TriggerEventListItemDto,
4
+ TriggerEventOutcomeDto,
5
+ } from '@shipfox/api-triggers-dto';
6
+ import {RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
7
+ import {Code} from '@shipfox/react-ui/typography';
8
+ import type {Decorator, Meta, StoryObj} from '@storybook/react';
9
+ import type {ReactNode} from 'react';
10
+ import {EventsList} from './events-list.js';
11
+ import type {TriggerEventsListQuery} from './types.js';
12
+
13
+ const STORY_NOW = '2026-06-25T20:00:00.000Z';
14
+ const STORY_NOW_MS = Date.parse(STORY_NOW);
15
+
16
+ // Typed so meta's inferred type stays nameable (TS2883) under the package's tsconfig.
17
+ const withWidth: Decorator = (Story) => (
18
+ <RelativeTimeProvider now={STORY_NOW}>
19
+ <div className="w-[900px]">
20
+ <Story />
21
+ </div>
22
+ </RelativeTimeProvider>
23
+ );
24
+
25
+ function makeQuery(overrides: Partial<TriggerEventsListQuery> = {}): TriggerEventsListQuery {
26
+ return {
27
+ isPending: false,
28
+ isError: false,
29
+ isFetching: false,
30
+ data: {pages: [], pageParams: []},
31
+ error: null,
32
+ refetch: () => undefined,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ let seq = 0;
38
+ function makeEvent(
39
+ outcome: TriggerEventOutcomeDto,
40
+ provider: string | null,
41
+ source: string,
42
+ event: string,
43
+ matchedCount: number,
44
+ minutesAgo: number,
45
+ ): TriggerEventListItemDto {
46
+ seq += 1;
47
+ return {
48
+ id: `evt-${String(seq).padStart(8, '0')}`,
49
+ event_ref: `ref-${seq}`,
50
+ origin: 'integration',
51
+ workspace_id: 'ws-demo',
52
+ provider,
53
+ source,
54
+ event,
55
+ delivery_id: `delivery-${String(seq).padStart(6, '0')}`,
56
+ connection_id: 'conn-demo',
57
+ outcome,
58
+ matched_count: matchedCount,
59
+ received_at: new Date(STORY_NOW_MS - minutesAgo * 60_000).toISOString(),
60
+ processed_at: new Date(STORY_NOW_MS - minutesAgo * 60_000).toISOString(),
61
+ created_at: new Date(STORY_NOW_MS - minutesAgo * 60_000).toISOString(),
62
+ };
63
+ }
64
+
65
+ const SAMPLE_EVENTS: TriggerEventListItemDto[] = [
66
+ makeEvent('received', 'github', 'github_acme', 'push', 0, 0),
67
+ makeEvent('routed', 'github', 'github_acme', 'push', 2, 3),
68
+ makeEvent('routed', 'gitea', 'gitea_acme', 'pull_request', 1, 12),
69
+ makeEvent('discarded', 'github', 'github_acme', 'issue_comment', 0, 38),
70
+ makeEvent('failed', 'gitlab', 'gitlab_acme', 'push', 1, 95),
71
+ ];
72
+
73
+ const SAMPLE_FACETS: TriggerEventFacetsResponseDto = {
74
+ sources: [
75
+ {value: 'github_acme', count: 3},
76
+ {value: 'gitea_acme', count: 1},
77
+ {value: 'gitlab_acme', count: 1},
78
+ ],
79
+ events: [
80
+ {value: 'push', count: 3},
81
+ {value: 'pull_request', count: 1},
82
+ {value: 'issue_comment', count: 1},
83
+ ],
84
+ };
85
+
86
+ const meta = {
87
+ title: 'Triggers/EventsList',
88
+ component: EventsList,
89
+ parameters: {layout: 'padded'},
90
+ decorators: [withWidth],
91
+ args: {
92
+ events: SAMPLE_EVENTS,
93
+ query: makeQuery(),
94
+ facets: SAMPLE_FACETS,
95
+ filters: {},
96
+ onFiltersChange: () => undefined,
97
+ workspaceId: 'ws-demo',
98
+ hasNextPage: false,
99
+ isFetchingNextPage: false,
100
+ onLoadMore: () => undefined,
101
+ selectedEventId: 'evt-00000002',
102
+ onSelectEvent: () => undefined,
103
+ },
104
+ } satisfies Meta<typeof EventsList>;
105
+
106
+ export default meta;
107
+ type Story = StoryObj<typeof meta>;
108
+
109
+ export const Playground: Story = {};
110
+
111
+ export const DataStates: Story = {
112
+ render: (args) => (
113
+ <div className="flex flex-col gap-32">
114
+ <StateExample label="Loading">
115
+ <EventsList {...args} events={[]} query={makeQuery({isPending: true, data: undefined})} />
116
+ </StateExample>
117
+ <StateExample label="Empty">
118
+ <EventsList {...args} events={[]} facets={{sources: [], events: []}} />
119
+ </StateExample>
120
+ <StateExample label="No matches">
121
+ <EventsList {...args} events={[]} filters={{outcome: ['failed']}} />
122
+ </StateExample>
123
+ <StateExample label="Load error">
124
+ <EventsList {...args} events={[]} query={makeQuery({isError: true, data: undefined})} />
125
+ </StateExample>
126
+ </div>
127
+ ),
128
+ };
129
+
130
+ function StateExample({label, children}: {label: string; children: ReactNode}) {
131
+ return (
132
+ <div className="flex flex-col gap-8">
133
+ <Code variant="label" className="text-foreground-neutral-subtle">
134
+ {label}
135
+ </Code>
136
+ <div className="min-h-280 rounded-8 border border-border-neutral-base bg-background-neutral-base p-12">
137
+ {children}
138
+ </div>
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,173 @@
1
+ import type {TriggerEventListItemDto} from '@shipfox/api-triggers-dto';
2
+ import {RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
3
+ import {render, screen} from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import {EventsList} from './events-list.js';
6
+ import type {EventsListProps} from './types.js';
7
+
8
+ function makeEvent(overrides: Partial<TriggerEventListItemDto> = {}): TriggerEventListItemDto {
9
+ return {
10
+ id: 'evt-1',
11
+ event_ref: 'ref-1',
12
+ origin: 'integration',
13
+ workspace_id: 'ws-1',
14
+ provider: 'github',
15
+ source: 'github_acme',
16
+ event: 'push',
17
+ delivery_id: 'delivery-1',
18
+ connection_id: 'conn-1',
19
+ outcome: 'routed',
20
+ matched_count: 2,
21
+ received_at: '2026-06-01T00:00:00.000Z',
22
+ processed_at: '2026-06-01T00:00:00.000Z',
23
+ created_at: '2026-06-01T00:00:00.000Z',
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeProps(overrides: Partial<EventsListProps> = {}): EventsListProps {
29
+ return {
30
+ events: [makeEvent()],
31
+ query: {
32
+ isPending: false,
33
+ isError: false,
34
+ isFetching: false,
35
+ data: {pages: [], pageParams: []},
36
+ error: null,
37
+ refetch: () => undefined,
38
+ },
39
+ facets: {sources: [{value: 'github_acme', count: 1}], events: [{value: 'push', count: 1}]},
40
+ filters: {},
41
+ onFiltersChange: vi.fn(),
42
+ workspaceId: 'ws-1',
43
+ hasNextPage: false,
44
+ isFetchingNextPage: false,
45
+ onLoadMore: vi.fn(),
46
+ selectedEventId: undefined,
47
+ onSelectEvent: vi.fn(),
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function renderList(props: EventsListProps) {
53
+ return render(
54
+ <RelativeTimeProvider>
55
+ <EventsList {...props} />
56
+ </RelativeTimeProvider>,
57
+ );
58
+ }
59
+
60
+ const FILTERS_TOGGLE = /Filters/u;
61
+
62
+ async function openFilters() {
63
+ await userEvent.click(screen.getByRole('button', {name: FILTERS_TOGGLE}));
64
+ }
65
+
66
+ async function selectResult(label: string) {
67
+ const user = userEvent.setup();
68
+
69
+ await openFilters();
70
+ await user.click(screen.getByRole('combobox', {name: 'Filter by result'}));
71
+ await user.click(await screen.findByRole('option', {name: label}));
72
+ }
73
+
74
+ describe('EventsList', () => {
75
+ test('renders a row per event with its match summary', () => {
76
+ renderList(makeProps());
77
+
78
+ expect(screen.getByText('Triggered 2 workflows')).toBeInTheDocument();
79
+ });
80
+
81
+ test('still renders rows when the facets query failed', () => {
82
+ renderList(makeProps({facets: undefined}));
83
+
84
+ expect(screen.getByText('Triggered 2 workflows')).toBeInTheDocument();
85
+ });
86
+
87
+ test('shows the no-matches state and clears active filters', async () => {
88
+ const onFiltersChange = vi.fn();
89
+ renderList(makeProps({events: [], filters: {outcome: ['failed']}, onFiltersChange}));
90
+
91
+ expect(screen.getByText('No matching events')).toBeInTheDocument();
92
+
93
+ // Both the filter bar and the empty state expose a "Clear filters" button; either clears.
94
+ const [clearButton] = screen.getAllByRole('button', {name: 'Clear filters'});
95
+ if (!clearButton) throw new Error('expected a Clear filters button');
96
+ await userEvent.click(clearButton);
97
+
98
+ expect(onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({outcome: undefined}));
99
+ });
100
+
101
+ test('empty list filters do not count as active filters', () => {
102
+ renderList(makeProps({filters: {source: [], event: [], outcome: []}}));
103
+
104
+ expect(screen.getByText('Triggered 2 workflows')).toBeInTheDocument();
105
+ expect(screen.queryByRole('button', {name: 'Clear filters'})).not.toBeInTheDocument();
106
+ });
107
+
108
+ test('selecting a result reports the filter patch', async () => {
109
+ const onFiltersChange = vi.fn();
110
+ renderList(makeProps({onFiltersChange}));
111
+
112
+ await selectResult('Triggered');
113
+
114
+ expect(onFiltersChange).toHaveBeenCalledWith({outcome: ['routed']});
115
+ });
116
+
117
+ test('removing the last active result clears the filter to undefined', async () => {
118
+ const onFiltersChange = vi.fn();
119
+ renderList(makeProps({filters: {outcome: ['routed']}, onFiltersChange}));
120
+
121
+ await openFilters();
122
+ await userEvent.click(screen.getByLabelText('Remove Triggered'));
123
+
124
+ expect(onFiltersChange).toHaveBeenCalledWith({outcome: undefined});
125
+ });
126
+
127
+ test('the Failed option folds errored in with failed', async () => {
128
+ const onFiltersChange = vi.fn();
129
+ renderList(makeProps({onFiltersChange}));
130
+
131
+ await selectResult('Failed');
132
+
133
+ expect(onFiltersChange).toHaveBeenCalledWith({outcome: ['failed', 'errored']});
134
+ });
135
+
136
+ test('an errored-only filter shows Failed as selected', async () => {
137
+ renderList(makeProps({filters: {outcome: ['errored']}}));
138
+
139
+ await openFilters();
140
+
141
+ expect(screen.getByLabelText('Remove Failed')).toBeInTheDocument();
142
+ });
143
+
144
+ test('selects an event from the event cell button', async () => {
145
+ const onSelectEvent = vi.fn();
146
+ renderList(makeProps({onSelectEvent}));
147
+
148
+ await userEvent.click(
149
+ screen.getByRole('button', {name: 'Open details for github_acme · push'}),
150
+ );
151
+
152
+ expect(onSelectEvent).toHaveBeenCalledWith('evt-1');
153
+ });
154
+
155
+ test('selects an event when the row is clicked', async () => {
156
+ const onSelectEvent = vi.fn();
157
+ renderList(makeProps({onSelectEvent}));
158
+ const row = screen.getByText('Triggered 2 workflows').closest('tr');
159
+ if (!row) throw new Error('expected an event row');
160
+
161
+ await userEvent.click(row);
162
+
163
+ expect(onSelectEvent).toHaveBeenCalledWith('evt-1');
164
+ });
165
+
166
+ test('marks the selected event row', () => {
167
+ renderList(makeProps({selectedEventId: 'evt-1'}));
168
+
169
+ expect(
170
+ screen.getByRole('button', {name: 'Open details for github_acme · push'}).closest('tr'),
171
+ ).toHaveAttribute('data-selected', 'true');
172
+ });
173
+ });