@startsimpli/ui 0.4.78 → 0.4.80
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/package.json +4 -3
- package/src/components/ActivityTimeline.tsx +302 -105
- package/src/components/activity/ActivityFeedPanel.tsx +208 -0
- package/src/components/activity/ActivityFilterBar.tsx +180 -0
- package/src/components/activity/__tests__/activity-feed.test.tsx +434 -0
- package/src/components/activity/activity-events.ts +179 -0
- package/src/components/activity/activity-feed-panel-default-class-names.ts +31 -0
- package/src/components/activity/activity-filter-bar-default-class-names.ts +34 -0
- package/src/components/activity/index.ts +51 -0
- package/src/components/activity/types.ts +94 -0
- package/src/components/index.ts +10 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.5.1 (RED) — the shared, filterable activity feed.
|
|
3
|
+
*
|
|
4
|
+
* The control plane's Activity tab is an app-local list: no filters, no
|
|
5
|
+
* grouping, no reuse, and an event type the UI has never heard of renders as
|
|
6
|
+
* "State changed". This suite is the spec for moving all of that into
|
|
7
|
+
* packages/ui as a primitive (ActivityTimeline, generalised) PLUS a page-level
|
|
8
|
+
* composer (ActivityFeedPanel) — rule #9.
|
|
9
|
+
*
|
|
10
|
+
* THE DESIGN GUARD, stated once: the UI makes NO visibility decisions. The
|
|
11
|
+
* stream endpoint already filtered by the caller's resolved permission level,
|
|
12
|
+
* so a row that arrives is renderable, and an action string the UI does not
|
|
13
|
+
* know renders with a humanised label. Registering a new event type must need
|
|
14
|
+
* zero UI changes.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs'
|
|
17
|
+
import * as path from 'node:path'
|
|
18
|
+
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
|
19
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
20
|
+
|
|
21
|
+
import { ActivityTimeline } from '../../ActivityTimeline'
|
|
22
|
+
import {
|
|
23
|
+
ActivityFeedPanel,
|
|
24
|
+
ActivityFilterBar,
|
|
25
|
+
activityHead,
|
|
26
|
+
activityLabel,
|
|
27
|
+
activityTone,
|
|
28
|
+
deriveActivityFilterOptions,
|
|
29
|
+
toActivityTimelineItems,
|
|
30
|
+
type ActivityEvent,
|
|
31
|
+
type ActivityFilterValue,
|
|
32
|
+
type ActivityPage,
|
|
33
|
+
} from '../index'
|
|
34
|
+
|
|
35
|
+
// --------------------------------------------------------------------------
|
|
36
|
+
// helpers
|
|
37
|
+
// --------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function event(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
|
|
40
|
+
return {
|
|
41
|
+
action: 'state_changed',
|
|
42
|
+
type: 'state_changed',
|
|
43
|
+
target_type: 'state',
|
|
44
|
+
target_id: '',
|
|
45
|
+
metadata: {},
|
|
46
|
+
actor: null,
|
|
47
|
+
actor_email: null,
|
|
48
|
+
state: null,
|
|
49
|
+
previous_state: null,
|
|
50
|
+
source: 'orchestrator',
|
|
51
|
+
reason: '',
|
|
52
|
+
error: '',
|
|
53
|
+
run_id: '',
|
|
54
|
+
run_kind: '',
|
|
55
|
+
created_at: '2026-07-20T12:00:00Z',
|
|
56
|
+
...over,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function page(results: ActivityEvent[], over: Partial<ActivityPage> = {}): ActivityPage {
|
|
61
|
+
return { count: results.length, next: null, previous: null, results, ...over }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function withQuery(ui: React.ReactElement) {
|
|
65
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
66
|
+
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Scope a query to the rendered rows. The filter bar derives its options from
|
|
70
|
+
* the same rows, so an unscoped `getByText('Member invited')` would match both
|
|
71
|
+
* the row and its <option>. */
|
|
72
|
+
function timeline() {
|
|
73
|
+
return within(screen.getByTestId('activity-timeline'))
|
|
74
|
+
}
|
|
75
|
+
async function findInTimeline(text: string) {
|
|
76
|
+
await waitFor(() => expect(timeline().getByText(text)).toBeInTheDocument())
|
|
77
|
+
return timeline().getByText(text)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Capture the IntersectionObserver callback so a test can "scroll" the
|
|
81
|
+
* sentinel into view without a layout engine. */
|
|
82
|
+
function stubIntersectionObserver() {
|
|
83
|
+
const calls: Array<() => void> = []
|
|
84
|
+
class IO {
|
|
85
|
+
constructor(private cb: IntersectionObserverCallback) {
|
|
86
|
+
calls.push(() => this.cb(
|
|
87
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
88
|
+
this as unknown as IntersectionObserver,
|
|
89
|
+
))
|
|
90
|
+
}
|
|
91
|
+
observe() {}
|
|
92
|
+
unobserve() {}
|
|
93
|
+
disconnect() {}
|
|
94
|
+
takeRecords() { return [] }
|
|
95
|
+
root = null
|
|
96
|
+
rootMargin = ''
|
|
97
|
+
thresholds = []
|
|
98
|
+
}
|
|
99
|
+
;(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = IO
|
|
100
|
+
return { intersect: () => calls.forEach((f) => f()) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --------------------------------------------------------------------------
|
|
104
|
+
// 2. ActivityTimeline serves BOTH shapes (generalised, not forked)
|
|
105
|
+
// --------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
describe('ActivityTimeline (generalised)', () => {
|
|
108
|
+
it('renders an event-stream item that has none of the CRM fields', () => {
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<ActivityTimeline
|
|
111
|
+
activities={[
|
|
112
|
+
{
|
|
113
|
+
id: 'e1',
|
|
114
|
+
type: 'member_invited',
|
|
115
|
+
title: 'Member invited',
|
|
116
|
+
occurredAt: '2026-07-20T12:00:00Z',
|
|
117
|
+
actor: { id: 'u1', name: 'Ada Lovelace', email: 'ada@x.com' },
|
|
118
|
+
tone: 'ok',
|
|
119
|
+
detail: 'nordby@ogmc.ai',
|
|
120
|
+
},
|
|
121
|
+
]}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
expect(screen.getByText('Member invited')).toBeInTheDocument()
|
|
125
|
+
// Actor identity, not a bare email string.
|
|
126
|
+
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
|
|
127
|
+
expect(screen.getByText('AL')).toBeInTheDocument()
|
|
128
|
+
expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
|
|
129
|
+
// The tone is addressable so a host can style/assert it.
|
|
130
|
+
expect(container.querySelector('[data-tone="ok"]')).not.toBeNull()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('still renders the CRM shape apps/market passes today', () => {
|
|
134
|
+
render(
|
|
135
|
+
<ActivityTimeline
|
|
136
|
+
loading={false}
|
|
137
|
+
activities={[
|
|
138
|
+
{
|
|
139
|
+
id: 'a1',
|
|
140
|
+
type: 'call',
|
|
141
|
+
title: 'Discovery call',
|
|
142
|
+
description: 'Talked pricing',
|
|
143
|
+
occurredAt: '2026-07-20T12:00:00Z',
|
|
144
|
+
createdBy: { name: 'Rep One' },
|
|
145
|
+
durationMinutes: 30,
|
|
146
|
+
outcome: 'connected',
|
|
147
|
+
participants: [{ name: 'Buyer' }],
|
|
148
|
+
},
|
|
149
|
+
]}
|
|
150
|
+
/>,
|
|
151
|
+
)
|
|
152
|
+
expect(screen.getByText('Discovery call')).toBeInTheDocument()
|
|
153
|
+
expect(screen.getByText('Rep One')).toBeInTheDocument()
|
|
154
|
+
expect(screen.getByText('30m')).toBeInTheDocument()
|
|
155
|
+
expect(screen.getByText('connected')).toBeInTheDocument()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('groups entries under day headings when asked', () => {
|
|
159
|
+
render(
|
|
160
|
+
<ActivityTimeline
|
|
161
|
+
groupByDay
|
|
162
|
+
activities={[
|
|
163
|
+
{ id: 'e1', type: 'x', title: 'Newer', occurredAt: '2026-07-21T12:00:00Z' },
|
|
164
|
+
{ id: 'e2', type: 'x', title: 'Older', occurredAt: '2026-07-20T12:00:00Z' },
|
|
165
|
+
]}
|
|
166
|
+
/>,
|
|
167
|
+
)
|
|
168
|
+
const headings = screen.getAllByTestId('activity-day-heading').map((n) => n.textContent)
|
|
169
|
+
expect(headings).toHaveLength(2)
|
|
170
|
+
expect(headings[0]).toMatch(/Jul 21/)
|
|
171
|
+
expect(headings[1]).toMatch(/Jul 20/)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('calls onLoadMore when the scroll sentinel comes into view', () => {
|
|
175
|
+
const io = stubIntersectionObserver()
|
|
176
|
+
const onLoadMore = jest.fn()
|
|
177
|
+
render(
|
|
178
|
+
<ActivityTimeline
|
|
179
|
+
autoLoadMore
|
|
180
|
+
hasMore
|
|
181
|
+
onLoadMore={onLoadMore}
|
|
182
|
+
activities={[{ id: 'e1', type: 'x', title: 'One', occurredAt: '2026-07-20T12:00:00Z' }]}
|
|
183
|
+
/>,
|
|
184
|
+
)
|
|
185
|
+
io.intersect()
|
|
186
|
+
expect(onLoadMore).toHaveBeenCalled()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// --------------------------------------------------------------------------
|
|
191
|
+
// 5. DESIGN GUARD — an UNKNOWN action renders
|
|
192
|
+
// --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
describe('activityLabel / activityTone (widened)', () => {
|
|
195
|
+
it('humanises an action the UI has never heard of instead of saying "State changed"', () => {
|
|
196
|
+
const e = event({ id: 'e1', action: 'pipeline_source_paused', type: 'pipeline_source_paused' })
|
|
197
|
+
expect(activityLabel(e)).toBe('Pipeline source paused')
|
|
198
|
+
expect(activityLabel(e)).not.toBe('State changed')
|
|
199
|
+
// Neutral, not an error, not a lie.
|
|
200
|
+
expect(activityTone(e)).toBe('muted')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('keeps the richer lifecycle rendering', () => {
|
|
204
|
+
expect(activityLabel(event({ id: '1', state: 'live', previous_state: 'provisioning' }))).toBe('Live')
|
|
205
|
+
expect(activityLabel(event({ id: '2', state: 'draft' }))).toBe('Created')
|
|
206
|
+
expect(
|
|
207
|
+
activityLabel(event({ id: '3', action: 'lifecycle_failed', type: 'lifecycle_failed', run_kind: 'provision' })),
|
|
208
|
+
).toBe('Provision failed')
|
|
209
|
+
expect(activityTone(event({ id: '4', state: 'live' }))).toBe('ok')
|
|
210
|
+
expect(activityTone(event({ id: '5', state: 'paused' }))).toBe('warn')
|
|
211
|
+
expect(activityTone(event({ id: '6', action: 'lifecycle_failed', type: 'lifecycle_failed' }))).toBe('error')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('activityHead is the newest state transition, skipping every other class', () => {
|
|
215
|
+
const head = activityHead([
|
|
216
|
+
event({ id: 'e1', action: 'member_invited', type: 'member_invited' }),
|
|
217
|
+
event({ id: 'e2', state: 'live' }),
|
|
218
|
+
event({ id: 'e3', state: 'draft' }),
|
|
219
|
+
])
|
|
220
|
+
expect(head?.id).toBe('e2')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('maps any event to a timeline item without knowing its type', () => {
|
|
224
|
+
const [item] = toActivityTimelineItems([
|
|
225
|
+
event({
|
|
226
|
+
id: 'e9',
|
|
227
|
+
action: 'brand_new_never_seen',
|
|
228
|
+
type: 'brand_new_never_seen',
|
|
229
|
+
actor: { id: 'u1', email: 'ada@x.com', name: 'Ada Lovelace' },
|
|
230
|
+
}),
|
|
231
|
+
])
|
|
232
|
+
expect(item.title).toBe('Brand new never seen')
|
|
233
|
+
expect(item.actor?.name).toBe('Ada Lovelace')
|
|
234
|
+
expect(item.tone).toBe('muted')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// --------------------------------------------------------------------------
|
|
239
|
+
// 3. ActivityFilterBar
|
|
240
|
+
// --------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
describe('ActivityFilterBar', () => {
|
|
243
|
+
const options = deriveActivityFilterOptions([
|
|
244
|
+
event({
|
|
245
|
+
id: 'e1',
|
|
246
|
+
action: 'member_invited',
|
|
247
|
+
type: 'member_invited',
|
|
248
|
+
actor: { id: 'u1', email: 'ada@x.com', name: 'Ada Lovelace' },
|
|
249
|
+
}),
|
|
250
|
+
event({ id: 'e2', action: 'state_changed', type: 'state_changed', state: 'live' }),
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
it('derives its options from the rows the user actually received', () => {
|
|
254
|
+
expect(options.actions.map((o) => o.value).sort()).toEqual(['member_invited', 'state_changed'])
|
|
255
|
+
expect(options.actions.find((o) => o.value === 'member_invited')?.label).toBe('Member invited')
|
|
256
|
+
expect(options.actors).toEqual([{ value: 'u1', label: 'Ada Lovelace' }])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('emits the whole next value on every control', () => {
|
|
260
|
+
const onChange = jest.fn()
|
|
261
|
+
render(<ActivityFilterBar value={{}} onChange={onChange} {...options} />)
|
|
262
|
+
|
|
263
|
+
fireEvent.change(screen.getByLabelText('Filter by person'), { target: { value: 'u1' } })
|
|
264
|
+
expect(onChange).toHaveBeenLastCalledWith({ actor: 'u1' })
|
|
265
|
+
|
|
266
|
+
fireEvent.change(screen.getByLabelText('Filter by event'), { target: { value: 'member_invited' } })
|
|
267
|
+
expect(onChange).toHaveBeenLastCalledWith({ action: 'member_invited' })
|
|
268
|
+
|
|
269
|
+
fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-07-20' } })
|
|
270
|
+
expect(onChange).toHaveBeenLastCalledWith({ since: '2026-07-20T00:00:00.000Z' })
|
|
271
|
+
|
|
272
|
+
fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-07-21' } })
|
|
273
|
+
expect(onChange).toHaveBeenLastCalledWith({ until: '2026-07-21T23:59:59.999Z' })
|
|
274
|
+
|
|
275
|
+
fireEvent.change(screen.getByLabelText('Search activity'), { target: { value: 'invite' } })
|
|
276
|
+
expect(onChange).toHaveBeenLastCalledWith({ q: 'invite' })
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('renders a removable chip per active filter, plus clear-all', () => {
|
|
280
|
+
const value: ActivityFilterValue = {
|
|
281
|
+
actor: 'u1',
|
|
282
|
+
action: 'member_invited',
|
|
283
|
+
since: '2026-07-20T00:00:00.000Z',
|
|
284
|
+
}
|
|
285
|
+
const onChange = jest.fn()
|
|
286
|
+
render(<ActivityFilterBar value={value} onChange={onChange} {...options} />)
|
|
287
|
+
|
|
288
|
+
const chips = within(screen.getByTestId('activity-active-filters'))
|
|
289
|
+
expect(chips.getByText(/Person: Ada Lovelace/)).toBeInTheDocument()
|
|
290
|
+
expect(chips.getByText(/Event: Member invited/)).toBeInTheDocument()
|
|
291
|
+
expect(chips.getByText(/From Jul 20, 2026/)).toBeInTheDocument()
|
|
292
|
+
|
|
293
|
+
fireEvent.click(screen.getByLabelText('Remove filter: person'))
|
|
294
|
+
expect(onChange).toHaveBeenLastCalledWith({ action: 'member_invited', since: '2026-07-20T00:00:00.000Z' })
|
|
295
|
+
|
|
296
|
+
fireEvent.click(screen.getByRole('button', { name: /clear all/i }))
|
|
297
|
+
expect(onChange).toHaveBeenLastCalledWith({})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('shows no chips and no clear-all when nothing is filtered', () => {
|
|
301
|
+
render(<ActivityFilterBar value={{}} onChange={jest.fn()} {...options} />)
|
|
302
|
+
expect(screen.queryByRole('button', { name: /clear all/i })).toBeNull()
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// --------------------------------------------------------------------------
|
|
307
|
+
// 4. ActivityFeedPanel — THE page composer
|
|
308
|
+
// --------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe('ActivityFeedPanel', () => {
|
|
311
|
+
const rows = [
|
|
312
|
+
event({
|
|
313
|
+
id: 'e1',
|
|
314
|
+
action: 'member_invited',
|
|
315
|
+
type: 'member_invited',
|
|
316
|
+
actor: { id: 'u1', email: 'ada@x.com', name: 'Ada Lovelace' },
|
|
317
|
+
created_at: '2026-07-21T12:00:00Z',
|
|
318
|
+
}),
|
|
319
|
+
event({ id: 'e2', state: 'live', previous_state: 'provisioning', created_at: '2026-07-20T12:00:00Z' }),
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
it('calls the injected client and renders the rows under day headings', async () => {
|
|
323
|
+
const list = jest.fn().mockResolvedValue(page(rows))
|
|
324
|
+
withQuery(<ActivityFeedPanel client={{ list }} />)
|
|
325
|
+
|
|
326
|
+
await findInTimeline('Member invited')
|
|
327
|
+
expect(timeline().getByText('Live')).toBeInTheDocument()
|
|
328
|
+
expect(screen.getAllByTestId('activity-day-heading')).toHaveLength(2)
|
|
329
|
+
expect(list).toHaveBeenCalledWith(expect.objectContaining({ page: 1 }))
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('re-queries the server when a filter changes (never client-filters)', async () => {
|
|
333
|
+
const list = jest.fn().mockResolvedValue(page(rows))
|
|
334
|
+
withQuery(<ActivityFeedPanel client={{ list }} />)
|
|
335
|
+
await findInTimeline('Member invited')
|
|
336
|
+
|
|
337
|
+
fireEvent.change(screen.getByLabelText('Filter by event'), { target: { value: 'member_invited' } })
|
|
338
|
+
|
|
339
|
+
await waitFor(() =>
|
|
340
|
+
expect(list).toHaveBeenCalledWith(expect.objectContaining({ action: 'member_invited', page: 1 })),
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('pages the real paginated endpoint through onLoadMore', async () => {
|
|
345
|
+
const io = stubIntersectionObserver()
|
|
346
|
+
const list = jest
|
|
347
|
+
.fn()
|
|
348
|
+
.mockResolvedValueOnce(page([rows[0]], { count: 2, next: 'http://x/?page=2' }))
|
|
349
|
+
.mockResolvedValueOnce(page([rows[1]], { count: 2, previous: 'http://x/?page=1' }))
|
|
350
|
+
withQuery(<ActivityFeedPanel client={{ list }} />)
|
|
351
|
+
await findInTimeline('Member invited')
|
|
352
|
+
|
|
353
|
+
io.intersect()
|
|
354
|
+
|
|
355
|
+
await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ page: 2 })))
|
|
356
|
+
await findInTimeline('Live')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('separates empty, filtered-empty and error into three distinct states', async () => {
|
|
360
|
+
const empty = jest.fn().mockResolvedValue(page([]))
|
|
361
|
+
const { unmount } = withQuery(<ActivityFeedPanel client={{ list: empty }} />)
|
|
362
|
+
expect(await screen.findByText(/nothing recorded yet/i)).toBeInTheDocument()
|
|
363
|
+
unmount()
|
|
364
|
+
|
|
365
|
+
const filtered = jest.fn().mockResolvedValue(page([]))
|
|
366
|
+
const { unmount: u2 } = withQuery(<ActivityFeedPanel client={{ list: filtered }} />)
|
|
367
|
+
await screen.findByText(/nothing recorded yet/i)
|
|
368
|
+
fireEvent.change(screen.getByLabelText('Search activity'), { target: { value: 'zzz' } })
|
|
369
|
+
expect(await screen.findByText(/no activity matches these filters/i)).toBeInTheDocument()
|
|
370
|
+
u2()
|
|
371
|
+
|
|
372
|
+
const boom = jest.fn().mockRejectedValue(new Error('boom'))
|
|
373
|
+
withQuery(<ActivityFeedPanel client={{ list: boom }} />)
|
|
374
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(/boom|could not load/i)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('renders every action it receives — there is no allowlist', async () => {
|
|
378
|
+
const list = jest.fn().mockResolvedValue(
|
|
379
|
+
page([
|
|
380
|
+
event({ id: 'a', action: 'schema_type_created', type: 'schema_type_created' }),
|
|
381
|
+
event({ id: 'b', action: 'user_logged_in', type: 'user_logged_in' }),
|
|
382
|
+
event({ id: 'c', action: 'wildly_new_thing', type: 'wildly_new_thing' }),
|
|
383
|
+
]),
|
|
384
|
+
)
|
|
385
|
+
withQuery(<ActivityFeedPanel client={{ list }} />)
|
|
386
|
+
await findInTimeline('Schema type created')
|
|
387
|
+
expect(timeline().getByText('User logged in')).toBeInTheDocument()
|
|
388
|
+
expect(timeline().getByText('Wildly new thing')).toBeInTheDocument()
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('hands the loaded rows to the host footer (the reconcile invariant surface)', async () => {
|
|
392
|
+
const list = jest.fn().mockResolvedValue(page(rows))
|
|
393
|
+
withQuery(
|
|
394
|
+
<ActivityFeedPanel
|
|
395
|
+
client={{ list }}
|
|
396
|
+
renderFooter={(events) => <span>head:{activityHead(events)?.state ?? 'none'}</span>}
|
|
397
|
+
/>,
|
|
398
|
+
)
|
|
399
|
+
expect(await screen.findByText('head:live')).toBeInTheDocument()
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// --------------------------------------------------------------------------
|
|
404
|
+
// 6. DESIGN GUARD — no client-side visibility logic anywhere in this layer
|
|
405
|
+
// --------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe('design guard: the UI decides nothing about visibility', () => {
|
|
408
|
+
it('has no role check, no permission branch and no action allowlist in source', () => {
|
|
409
|
+
const dir = path.resolve(__dirname, '..')
|
|
410
|
+
const sources = fs
|
|
411
|
+
.readdirSync(dir)
|
|
412
|
+
.filter((f) => /\.tsx?$/.test(f))
|
|
413
|
+
.map((f) => ({ f, src: fs.readFileSync(path.join(dir, f), 'utf8') }))
|
|
414
|
+
|
|
415
|
+
expect(sources.length).toBeGreaterThan(0)
|
|
416
|
+
const banned = [
|
|
417
|
+
/is_org_admin/,
|
|
418
|
+
/isOrgAdmin/,
|
|
419
|
+
/hasPermission/,
|
|
420
|
+
/canManage/,
|
|
421
|
+
/accessLevel/,
|
|
422
|
+
/resolve_level/,
|
|
423
|
+
/\brole\s*===/,
|
|
424
|
+
/VISIBLE_ACTIONS/,
|
|
425
|
+
/ALLOWED_ACTIONS/,
|
|
426
|
+
/ACTIVITY_ACTIONS/,
|
|
427
|
+
]
|
|
428
|
+
for (const { f, src } of sources) {
|
|
429
|
+
for (const re of banned) {
|
|
430
|
+
expect(`${f}:${re.source}:${re.test(src)}`).toBe(`${f}:${re.source}:false`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering an event stream WITHOUT knowing which event types exist.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole point of startsim-768w.19.5: the backend registers a new
|
|
5
|
+
* event type as a constant plus a permission codename, and the UI must not have
|
|
6
|
+
* to change. So there is no vocabulary here — the lifecycle rows get a richer
|
|
7
|
+
* rendering because they carry extra fields, and everything else is derived
|
|
8
|
+
* from the action string's own shape. An action nobody has ever seen renders as
|
|
9
|
+
* a humanised sentence with a neutral tone; it never falls through to a wrong
|
|
10
|
+
* label like "State changed", and it is never dropped.
|
|
11
|
+
*/
|
|
12
|
+
import type { ActivityTimelineItem, ActivityTone } from '../ActivityTimeline';
|
|
13
|
+
import type {
|
|
14
|
+
ActivityEvent,
|
|
15
|
+
ActivityFilterOption,
|
|
16
|
+
ActivityFilterOptions,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/** Marker dot classes by tone — for hosts rendering their own compact list. */
|
|
20
|
+
export const TONE_DOT: Record<ActivityTone, string> = {
|
|
21
|
+
ok: 'bg-emerald-500',
|
|
22
|
+
warn: 'bg-amber-500',
|
|
23
|
+
error: 'bg-red-500',
|
|
24
|
+
muted: 'bg-muted-foreground/40',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Where a status event came from, in words. `orchestrator` is the ordinary
|
|
28
|
+
* case and needs no badge; the others explain a state the operator did not
|
|
29
|
+
* fire themselves (startsim-bq7s.1). */
|
|
30
|
+
export const SOURCE_NOTE: Record<string, string> = {
|
|
31
|
+
reconcile: 'detected by drift reconcile',
|
|
32
|
+
out_of_band: 'changed outside the control plane',
|
|
33
|
+
backfill: 'recorded when status tracking shipped',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const STATE_LABELS: Record<string, string> = {
|
|
37
|
+
draft: 'Draft',
|
|
38
|
+
provisioning: 'Provisioning',
|
|
39
|
+
live: 'Live',
|
|
40
|
+
paused: 'Paused',
|
|
41
|
+
archived: 'Archived',
|
|
42
|
+
deleted: 'Deleted',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Sentence-case a raw action: `member_invited` -> `Member invited`. */
|
|
46
|
+
export function humanizeAction(action: string): string {
|
|
47
|
+
const words = String(action ?? '').replace(/[_.]+/g, ' ').trim();
|
|
48
|
+
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : 'Event';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The raw action of an event, whichever name the stream uses for it. */
|
|
52
|
+
export function actionOf(event: ActivityEvent): string {
|
|
53
|
+
return (event.action || event.type || '').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Human label for one row: the state a transition produced, the run that
|
|
57
|
+
* failed, or — for every other type, known or not — the action itself. */
|
|
58
|
+
export function activityLabel(event: ActivityEvent): string {
|
|
59
|
+
const action = actionOf(event);
|
|
60
|
+
|
|
61
|
+
if (action === 'lifecycle_failed') {
|
|
62
|
+
const kind = (event.run_kind ?? '').replace(/_/g, ' ').trim();
|
|
63
|
+
return kind ? `${kind.charAt(0).toUpperCase()}${kind.slice(1)} failed` : 'Run failed';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (action === 'state_changed') {
|
|
67
|
+
if (!event.state) return 'State changed';
|
|
68
|
+
// The very first transition is the foundry coming into existence, not a
|
|
69
|
+
// move "to draft" — say so.
|
|
70
|
+
if (event.state === 'draft' && !event.previous_state) return 'Created';
|
|
71
|
+
return STATE_LABELS[event.state] ?? event.state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return humanizeAction(action);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Severity of a row, for the marker.
|
|
79
|
+
*
|
|
80
|
+
* Derived from MORPHOLOGY, not from a list of known actions: a lifecycle row
|
|
81
|
+
* uses the state it produced, and anything else is read off the verb at the end
|
|
82
|
+
* of the action string. An action whose verb is unrecognised is `muted` — the
|
|
83
|
+
* honest answer, and the reason a new event type needs no change here.
|
|
84
|
+
*/
|
|
85
|
+
export function activityTone(event: ActivityEvent): ActivityTone {
|
|
86
|
+
const action = actionOf(event);
|
|
87
|
+
|
|
88
|
+
if (/(^|_)(failed|failure|error|errored)$/.test(action)) return 'error';
|
|
89
|
+
|
|
90
|
+
switch (event.state) {
|
|
91
|
+
case 'live':
|
|
92
|
+
return 'ok';
|
|
93
|
+
case 'paused':
|
|
94
|
+
case 'archived':
|
|
95
|
+
return 'warn';
|
|
96
|
+
case 'deleted':
|
|
97
|
+
return 'error';
|
|
98
|
+
default:
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (/_(removed|deleted|revoked|expired|denied|rejected)$/.test(action)) return 'warn';
|
|
103
|
+
if (/_(created|added|invited|activated|succeeded|completed|deployed)$/.test(action)) return 'ok';
|
|
104
|
+
return 'muted';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The newest state transition in the stream — the entry that must agree with
|
|
108
|
+
* the state badge. Every other class is skipped: a failed run, a member invite
|
|
109
|
+
* or a branding edit does not define a state. */
|
|
110
|
+
export function activityHead(
|
|
111
|
+
events: ActivityEvent[] | undefined,
|
|
112
|
+
): ActivityEvent | undefined {
|
|
113
|
+
return (events ?? []).find((e) => actionOf(e) === 'state_changed' && !!e.state);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The body line for a row: the failure message, else the stated reason, else
|
|
117
|
+
* what was touched. */
|
|
118
|
+
function detailOf(event: ActivityEvent): string {
|
|
119
|
+
if (event.error) return event.error;
|
|
120
|
+
if (event.reason) return event.reason;
|
|
121
|
+
const target = [event.target_type, event.target_id].filter(Boolean).join(' · ');
|
|
122
|
+
return actionOf(event) === 'state_changed' ? '' : target;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Map any stream row onto the shared timeline's item shape. Nothing in here
|
|
126
|
+
* branches on a known type. */
|
|
127
|
+
export function toActivityTimelineItems(
|
|
128
|
+
events: ActivityEvent[] | undefined,
|
|
129
|
+
): ActivityTimelineItem[] {
|
|
130
|
+
return (events ?? []).map((event) => {
|
|
131
|
+
const tone = activityTone(event);
|
|
132
|
+
const actor = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
|
|
133
|
+
const detail = detailOf(event);
|
|
134
|
+
return {
|
|
135
|
+
id: event.id,
|
|
136
|
+
type: actionOf(event),
|
|
137
|
+
title: activityLabel(event),
|
|
138
|
+
occurredAt: event.created_at,
|
|
139
|
+
actor: actor ?? undefined,
|
|
140
|
+
tone,
|
|
141
|
+
subtitle: event.previous_state && event.state ? `from ${event.previous_state}` : undefined,
|
|
142
|
+
note: event.source ? SOURCE_NOTE[event.source] : undefined,
|
|
143
|
+
detail: detail || undefined,
|
|
144
|
+
detailTone: event.error ? 'error' : 'muted',
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function byLabel(a: ActivityFilterOption, b: ActivityFilterOption): number {
|
|
150
|
+
return a.label.localeCompare(b.label);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Filter options derived from the rows the user ACTUALLY RECEIVED — never from
|
|
155
|
+
* a hardcoded vocabulary. Offering a facet the caller's level can never return
|
|
156
|
+
* would leak the existence of events they may not see; deriving it cannot.
|
|
157
|
+
*/
|
|
158
|
+
export function deriveActivityFilterOptions(
|
|
159
|
+
events: ActivityEvent[] | undefined,
|
|
160
|
+
): ActivityFilterOptions {
|
|
161
|
+
const actors = new Map<string, string>();
|
|
162
|
+
const actions = new Map<string, string>();
|
|
163
|
+
|
|
164
|
+
for (const event of events ?? []) {
|
|
165
|
+
const action = actionOf(event);
|
|
166
|
+
if (action && !actions.has(action)) actions.set(action, humanizeAction(action));
|
|
167
|
+
|
|
168
|
+
const actor = event.actor;
|
|
169
|
+
const value = actor?.id || actor?.email || event.actor_email || '';
|
|
170
|
+
if (!value) continue;
|
|
171
|
+
const label = (actor?.name || actor?.email || event.actor_email || value) as string;
|
|
172
|
+
if (!actors.has(value)) actors.set(value, label);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
actors: [...actors].map(([value, label]) => ({ value, label })).sort(byLabel),
|
|
177
|
+
actions: [...actions].map(([value, label]) => ({ value, label })).sort(byLabel),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link ActivityFeedPanel}. startsim-768w.19.5.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
|
|
3
|
+
import type { ActivityFilterBarClassNames } from './activity-filter-bar-default-class-names'
|
|
4
|
+
|
|
5
|
+
export interface ActivityFeedPanelClassNames {
|
|
6
|
+
root?: string
|
|
7
|
+
header?: string
|
|
8
|
+
heading?: string
|
|
9
|
+
title?: string
|
|
10
|
+
description?: string
|
|
11
|
+
filters?: string
|
|
12
|
+
body?: string
|
|
13
|
+
footer?: string
|
|
14
|
+
countLine?: string
|
|
15
|
+
/** Forwarded so an app still overrides one map. */
|
|
16
|
+
filterBar?: ActivityFilterBarClassNames
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ACTIVITY_FEED_PANEL_DEFAULTS: Required<
|
|
20
|
+
Omit<ActivityFeedPanelClassNames, 'filterBar'>
|
|
21
|
+
> = {
|
|
22
|
+
root: 'rounded-lg border border-border bg-card',
|
|
23
|
+
header: 'flex flex-wrap items-start justify-between gap-2 border-b border-border px-5 py-3',
|
|
24
|
+
heading: 'space-y-1',
|
|
25
|
+
title: 'font-medium text-foreground',
|
|
26
|
+
description: 'text-xs text-muted-foreground',
|
|
27
|
+
filters: 'border-b border-border px-5 py-3',
|
|
28
|
+
body: 'max-h-[32rem] overflow-y-auto px-5 py-4',
|
|
29
|
+
footer: 'border-t border-border px-5 py-3 text-sm',
|
|
30
|
+
countLine: 'border-t border-border px-5 py-2 text-xs text-muted-foreground',
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link ActivityFilterBar}. startsim-768w.19.5.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
|
|
3
|
+
|
|
4
|
+
export interface ActivityFilterBarClassNames {
|
|
5
|
+
root?: string
|
|
6
|
+
controls?: string
|
|
7
|
+
search?: string
|
|
8
|
+
select?: string
|
|
9
|
+
date?: string
|
|
10
|
+
dateGroup?: string
|
|
11
|
+
dateSeparator?: string
|
|
12
|
+
chips?: string
|
|
13
|
+
chip?: string
|
|
14
|
+
chipRemove?: string
|
|
15
|
+
clearAll?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ACTIVITY_FILTER_BAR_DEFAULTS: Required<ActivityFilterBarClassNames> = {
|
|
19
|
+
root: 'space-y-2',
|
|
20
|
+
controls: 'flex flex-wrap items-center gap-2',
|
|
21
|
+
search:
|
|
22
|
+
'h-8 min-w-[10rem] flex-1 rounded-md border border-input bg-background px-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring',
|
|
23
|
+
select:
|
|
24
|
+
'h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring',
|
|
25
|
+
dateGroup: 'flex items-center gap-1',
|
|
26
|
+
date:
|
|
27
|
+
'h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring',
|
|
28
|
+
dateSeparator: 'text-xs text-muted-foreground',
|
|
29
|
+
chips: 'flex flex-wrap items-center gap-1.5',
|
|
30
|
+
chip:
|
|
31
|
+
'inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground',
|
|
32
|
+
chipRemove: 'rounded-full px-0.5 text-muted-foreground transition-colors hover:text-foreground',
|
|
33
|
+
clearAll: 'text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline',
|
|
34
|
+
}
|