@startsimpli/ui 0.4.80 → 0.4.82
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 +2 -2
- package/src/components/ActivityTimeline.tsx +19 -2
- package/src/components/activity/AccountActivityPanel.tsx +55 -0
- package/src/components/activity/ActivityFeedPanel.tsx +6 -1
- package/src/components/activity/__tests__/account-activity.test.tsx +153 -0
- package/src/components/activity/__tests__/activity-labels.test.tsx +229 -0
- package/src/components/activity/activity-events.ts +89 -7
- package/src/components/activity/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.82",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0",
|
|
45
45
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
46
46
|
"@startsimpli/auth": "^0.4.31",
|
|
47
|
-
"@startsimpli/api": "^0.5.
|
|
47
|
+
"@startsimpli/api": "^0.5.51"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
|
@@ -119,6 +119,21 @@ function actorName(actor?: ActivityTimelineActor): string {
|
|
|
119
119
|
return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The second half of the identity — the address, when the display name is
|
|
124
|
+
* something else (startsim-768w.19.8).
|
|
125
|
+
*
|
|
126
|
+
* A display name is free text: an account called "QA Foundry" reads exactly
|
|
127
|
+
* like the name of a FOUNDRY, so a correct feed looked to its owner like it was
|
|
128
|
+
* showing another tenant's activity. The email is the unambiguous handle, and
|
|
129
|
+
* it is what turns a name that could be anything into a person. Empty when the
|
|
130
|
+
* name IS the email, because printing it twice is noise.
|
|
131
|
+
*/
|
|
132
|
+
function actorHandle(actor?: ActivityTimelineActor): string {
|
|
133
|
+
const email = (actor?.email ?? '').trim();
|
|
134
|
+
return email && email !== actorName(actor) ? email : '';
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
function dayKey(iso: string): string {
|
|
123
138
|
const d = new Date(iso);
|
|
124
139
|
return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
|
|
@@ -181,6 +196,7 @@ function TimelineEntry({
|
|
|
181
196
|
(tone ? TONE_MARKER[tone] : undefined) ||
|
|
182
197
|
'bg-muted text-muted-foreground';
|
|
183
198
|
const who = actorName(activity.actor);
|
|
199
|
+
const handle = actorHandle(activity.actor);
|
|
184
200
|
// With no icon the marker itself becomes the avatar, so the metadata line
|
|
185
201
|
// shows the name alone rather than the same initials twice.
|
|
186
202
|
const markerIsAvatar = !Icon && !!initials;
|
|
@@ -224,13 +240,14 @@ function TimelineEntry({
|
|
|
224
240
|
|
|
225
241
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
226
242
|
{who && (
|
|
227
|
-
<span className="flex items-center gap-1.5">
|
|
243
|
+
<span className="flex flex-wrap items-center gap-x-1.5">
|
|
228
244
|
{initials && !markerIsAvatar && (
|
|
229
245
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
|
230
246
|
{initials}
|
|
231
247
|
</span>
|
|
232
248
|
)}
|
|
233
|
-
{who}
|
|
249
|
+
<span>{who}</span>
|
|
250
|
+
{handle && <span className="text-xs text-muted-foreground/80">{handle}</span>}
|
|
234
251
|
</span>
|
|
235
252
|
)}
|
|
236
253
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AccountActivityPanel — the PLATFORM-SCOPE activity composer
|
|
5
|
+
* (startsim-768w.19.1.5).
|
|
6
|
+
*
|
|
7
|
+
* The same event stream as `ActivityFeedPanel` renders for one foundry, at the
|
|
8
|
+
* scope ABOVE any foundry: the rows that belong to no single one of them.
|
|
9
|
+
* Sign-ins are the whole population today — central auth mints tokens for every
|
|
10
|
+
* app, so a login is recorded once, for the person, with an empty tenant slug.
|
|
11
|
+
* A user with access to N foundries would otherwise get N duplicate rows or one
|
|
12
|
+
* arbitrarily-attributed one; the row is right, and it needed a reader with a
|
|
13
|
+
* scope of its own.
|
|
14
|
+
*
|
|
15
|
+
* DELIBERATELY ALMOST NOTHING. The paging, filtering, day grouping, infinite
|
|
16
|
+
* scroll and three empty states are `ActivityFeedPanel`'s, and the rendering is
|
|
17
|
+
* `ActivityTimeline`'s — both already built to render whatever the stream
|
|
18
|
+
* returns and to humanise an action they have never seen. What this adds is the
|
|
19
|
+
* COPY: an account-scope surface has to say why its rows name no foundry, and
|
|
20
|
+
* has to describe an empty page without claiming nothing happened, because the
|
|
21
|
+
* server answers a caller who may not read sign-ins with an empty page rather
|
|
22
|
+
* than a 403. Every prop stays overridable — a host that wants different words
|
|
23
|
+
* passes them, it does not fork the panel.
|
|
24
|
+
*
|
|
25
|
+
* The consuming app supplies `{ list: (p) => api.foundry.accountActivity(p) }`
|
|
26
|
+
* and its theme classNames. Nothing else — rule #9.
|
|
27
|
+
*/
|
|
28
|
+
import { ActivityFeedPanel, type ActivityFeedPanelProps } from './ActivityFeedPanel';
|
|
29
|
+
|
|
30
|
+
/** Same props as the feed it composes: there is no account-only knob, and a
|
|
31
|
+
* `slug` would be a lie — these events have no foundry. */
|
|
32
|
+
export type AccountActivityPanelProps = ActivityFeedPanelProps;
|
|
33
|
+
|
|
34
|
+
/** Cache namespace for the platform-scope stream. Deliberately NOT under a
|
|
35
|
+
* foundry key, so invalidating one foundry never refetches it and vice versa. */
|
|
36
|
+
export const ACCOUNT_ACTIVITY_QUERY_KEY = ['account', 'activity'] as const;
|
|
37
|
+
|
|
38
|
+
export function AccountActivityPanel(props: AccountActivityPanelProps) {
|
|
39
|
+
return (
|
|
40
|
+
<ActivityFeedPanel
|
|
41
|
+
queryKey={ACCOUNT_ACTIVITY_QUERY_KEY}
|
|
42
|
+
title="Account activity"
|
|
43
|
+
description="Sign-ins across your account, newest first. These belong to the person rather than to one foundry, so they live here instead of on a foundry's own Activity tab."
|
|
44
|
+
// NOT "nothing has happened": an admin, member or viewer gets an empty
|
|
45
|
+
// page rather than a 403 (a 403 would confirm the rows exist), so copy
|
|
46
|
+
// that asserted the stream was empty would be wrong for most readers.
|
|
47
|
+
emptyTitle="No account activity to show"
|
|
48
|
+
emptyDescription="Sign-ins show up here. Sign-in history — who signed in, when and from where — is visible to foundry owners."
|
|
49
|
+
filteredEmptyTitle="No account activity matches these filters"
|
|
50
|
+
filteredEmptyDescription="Widen the date range or clear a filter to see more."
|
|
51
|
+
errorTitle="Could not load account activity"
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -101,7 +101,12 @@ export function ActivityFeedPanel({
|
|
|
101
101
|
pageSize = 25,
|
|
102
102
|
refetchInterval = false,
|
|
103
103
|
title = 'Activity',
|
|
104
|
-
|
|
104
|
+
// WHO DID WHAT — the user's own framing of this surface, and the line that
|
|
105
|
+
// separates it from a deployment/run history (startsim-768w.19.8). The old
|
|
106
|
+
// copy hedged with "as much as your access level allows", which named a
|
|
107
|
+
// constraint the reader cannot act on; the permission filtering is invisible
|
|
108
|
+
// and correct by design, so it does not belong in the description.
|
|
109
|
+
description = 'Who did what here, newest first — sign-ins, invites and role changes, settings and pipeline edits, and who started each build.',
|
|
105
110
|
headerAside,
|
|
106
111
|
renderFooter,
|
|
107
112
|
showFilters = true,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.1.5 (RED) — the PLATFORM-SCOPE activity surface.
|
|
3
|
+
*
|
|
4
|
+
* `record_login()` writes a `member_login` row for every successful sign-in,
|
|
5
|
+
* with `tenant_slug=""` because a control-plane login belongs to the PERSON, not
|
|
6
|
+
* to one of the N foundries they can reach. The only reader was the per-foundry
|
|
7
|
+
* feed, which filters by slug — so the row was captured, permissioned and
|
|
8
|
+
* unreachable in every UI.
|
|
9
|
+
*
|
|
10
|
+
* THE SURFACE IS A CONSUMER, NOT A SECOND FEED. Everything here already exists:
|
|
11
|
+
* `ActivityFeedPanel` (paging, filters, day grouping, three empty states) over
|
|
12
|
+
* `ActivityTimeline` (the primitive), both built to render whatever the stream
|
|
13
|
+
* returns and to humanise an action they have never seen. The account scope
|
|
14
|
+
* differs by ONE thing — which client call it makes — plus the copy that says
|
|
15
|
+
* why these rows name no foundry. A second timeline here would be the bug.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'node:fs'
|
|
18
|
+
import * as path from 'node:path'
|
|
19
|
+
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
20
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
AccountActivityPanel,
|
|
24
|
+
activityLabel,
|
|
25
|
+
activityTarget,
|
|
26
|
+
toActivityTimelineItems,
|
|
27
|
+
type ActivityEvent,
|
|
28
|
+
type ActivityPage,
|
|
29
|
+
type ActivityQuery,
|
|
30
|
+
} from '../index'
|
|
31
|
+
|
|
32
|
+
function login(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
|
|
33
|
+
return {
|
|
34
|
+
action: 'member_login',
|
|
35
|
+
type: 'member_login',
|
|
36
|
+
target_type: 'user',
|
|
37
|
+
target_id: '3f1c9e40-2b7a-4a11-9c33-8f0b2a5d61ee',
|
|
38
|
+
metadata: { app: 'foundry', method: 'credentials', user_agent: 'Firefox/141.0' },
|
|
39
|
+
actor: { id: 'u-1', email: 'ada@x.com', name: 'Ada Lovelace' },
|
|
40
|
+
actor_email: 'ada@x.com',
|
|
41
|
+
state: null,
|
|
42
|
+
previous_state: null,
|
|
43
|
+
source: '',
|
|
44
|
+
reason: '',
|
|
45
|
+
error: '',
|
|
46
|
+
run_id: '',
|
|
47
|
+
run_kind: '',
|
|
48
|
+
created_at: '2026-07-27T19:38:00Z',
|
|
49
|
+
...over,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function page(results: ActivityEvent[], over: Partial<ActivityPage> = {}): ActivityPage {
|
|
54
|
+
return { count: results.length, next: null, previous: null, results, ...over }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function withQuery(ui: React.ReactElement) {
|
|
58
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
59
|
+
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function timeline() {
|
|
63
|
+
return within(screen.getByTestId('activity-timeline'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('AccountActivityPanel', () => {
|
|
67
|
+
it('renders a login the per-foundry feed could never reach', async () => {
|
|
68
|
+
const list = jest.fn(async () => page([login({ id: 'e1' })]))
|
|
69
|
+
withQuery(<AccountActivityPanel client={{ list }} />)
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(timeline().getByText('Member login')).toBeInTheDocument())
|
|
72
|
+
expect(timeline().getByText('Ada Lovelace')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('says which app the sign-in was for', async () => {
|
|
76
|
+
const list = jest.fn(async () => page([login({ id: 'e2' })]))
|
|
77
|
+
withQuery(<AccountActivityPanel client={{ list }} />)
|
|
78
|
+
|
|
79
|
+
// `app` is the row's own descriptor for what it touched. Without it the
|
|
80
|
+
// target app is captured on the row and invisible on the page — the same
|
|
81
|
+
// shape of bug one level down.
|
|
82
|
+
await waitFor(() => expect(timeline().getByText('foundry')).toBeInTheDocument())
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('pages server-side (rule #8) rather than asking for everything', async () => {
|
|
86
|
+
const list = jest.fn(async (_p: ActivityQuery) => page([login({ id: 'e3' })]))
|
|
87
|
+
withQuery(<AccountActivityPanel client={{ list }} />)
|
|
88
|
+
|
|
89
|
+
await waitFor(() => expect(list).toHaveBeenCalled())
|
|
90
|
+
const params = list.mock.calls[0][0] as ActivityQuery
|
|
91
|
+
expect(params.page).toBe(1)
|
|
92
|
+
expect(typeof params.pageSize).toBe('number')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('takes no slug — the events belong to no foundry', () => {
|
|
96
|
+
const list = jest.fn(async () => page([]))
|
|
97
|
+
// A slug prop would be a type error; the runtime check is that the panel
|
|
98
|
+
// renders from the injected client alone.
|
|
99
|
+
withQuery(<AccountActivityPanel client={{ list }} />)
|
|
100
|
+
expect(screen.getByTestId('activity-feed-panel')).toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('explains an empty page without claiming nothing happened', async () => {
|
|
104
|
+
const list = jest.fn(async () => page([]))
|
|
105
|
+
withQuery(<AccountActivityPanel client={{ list }} />)
|
|
106
|
+
|
|
107
|
+
// An admin/member/viewer gets an empty page rather than a 403, so the copy
|
|
108
|
+
// must not assert "no sign-ins have happened".
|
|
109
|
+
await screen.findByText(/no account activity/i)
|
|
110
|
+
const body = screen.getByTestId('activity-feed-panel').textContent ?? ''
|
|
111
|
+
expect(body).toMatch(/sign-in/i)
|
|
112
|
+
expect(body).toMatch(/owner/i)
|
|
113
|
+
expect(body).not.toMatch(/nothing recorded/i)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('lets a host override the shared copy', async () => {
|
|
117
|
+
const list = jest.fn(async () => page([]))
|
|
118
|
+
withQuery(<AccountActivityPanel client={{ list }} title="Sign-in history" />)
|
|
119
|
+
await waitFor(() => expect(screen.getByText('Sign-in history')).toBeInTheDocument())
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('the platform scope reuses the shared feed', () => {
|
|
124
|
+
it('humanises member_login with no new vocabulary', () => {
|
|
125
|
+
expect(activityLabel(login({ id: 'e4' }))).toBe('Member login')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders the app instead of the opaque user id', () => {
|
|
129
|
+
expect(activityTarget(login({ id: 'e5' }))).toBe('foundry')
|
|
130
|
+
// No app on the row -> nothing, never the UUID.
|
|
131
|
+
expect(activityTarget(login({ id: 'e6', metadata: { method: 'sso' } }))).toBe('')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('maps a login onto the same timeline item shape as any other row', () => {
|
|
135
|
+
const [item] = toActivityTimelineItems([login({ id: 'e7' })])
|
|
136
|
+
expect(item.title).toBe('Member login')
|
|
137
|
+
expect(item.actor?.email).toBe('ada@x.com')
|
|
138
|
+
expect(item.occurredAt).toBe('2026-07-27T19:38:00Z')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('does not render its own list — it composes ActivityFeedPanel', () => {
|
|
142
|
+
const src = fs.readFileSync(
|
|
143
|
+
path.resolve(__dirname, '..', 'AccountActivityPanel.tsx'), 'utf8')
|
|
144
|
+
expect(src).toMatch(/<ActivityFeedPanel/)
|
|
145
|
+
// No list of its own, no second query, no second timeline. (The doc comment
|
|
146
|
+
// may NAME the timeline; what it may not do is import or render one.)
|
|
147
|
+
const banned = [/\.map\(/, /useInfiniteQuery/, /<ActivityTimeline/,
|
|
148
|
+
/from '\.\.\/ActivityTimeline'/]
|
|
149
|
+
for (const re of banned) {
|
|
150
|
+
expect(`${re.source}:${re.test(src)}`).toBe(`${re.source}:false`)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.8 (RED) — the activity feed LABELS a row honestly.
|
|
3
|
+
*
|
|
4
|
+
* The user reported "activity in marketing agents foundry is showing me the
|
|
5
|
+
* activity of a different foundry". It was not a filtering bug: every row
|
|
6
|
+
* carried the right tenant. It was a LABELLING bug that mimics a data leak
|
|
7
|
+
* perfectly, and all three parts of it live in this layer:
|
|
8
|
+
*
|
|
9
|
+
* 1. THE ACTOR CHIP NAMED SOMETHING THAT LOOKS LIKE AN ORG. The account's
|
|
10
|
+
* display name is "QA Foundry", which collides with a DIFFERENT foundry's
|
|
11
|
+
* name — so a correct feed reads as cross-tenant contamination. A row must
|
|
12
|
+
* identify the PERSON unambiguously, which means the email is never
|
|
13
|
+
* dropped in favour of a name that could be anything.
|
|
14
|
+
* 2. THE TARGET WAS A RAW UUID — "membership · dc0d739f-a622-…" tells nobody
|
|
15
|
+
* anything. Resolve it to a human string from the row's own metadata, and
|
|
16
|
+
* when it cannot be resolved render NOTHING rather than a UUID.
|
|
17
|
+
* 3. RESENDS WERE INDISTINGUISHABLE FROM ORIGINAL INVITES. The same invitee
|
|
18
|
+
* appeared twice; the second carried `metadata.resend` and rendered
|
|
19
|
+
* identically, so a correct trail read as a duplicate.
|
|
20
|
+
*
|
|
21
|
+
* DESIGN GUARD, same as the rest of this layer: nothing below branches on a
|
|
22
|
+
* known action. Target resolution reads generic DESCRIPTOR keys off `metadata`,
|
|
23
|
+
* so a brand-new event type is labelled the moment the backend records one.
|
|
24
|
+
*/
|
|
25
|
+
import { render, screen, within } from '@testing-library/react'
|
|
26
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
27
|
+
|
|
28
|
+
import { ActivityTimeline } from '../../ActivityTimeline'
|
|
29
|
+
import {
|
|
30
|
+
ActivityFeedPanel,
|
|
31
|
+
activityNote,
|
|
32
|
+
activityTarget,
|
|
33
|
+
toActivityTimelineItems,
|
|
34
|
+
type ActivityEvent,
|
|
35
|
+
} from '../index'
|
|
36
|
+
|
|
37
|
+
const emptyPage = { count: 0, next: null, previous: null, results: [] }
|
|
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
|
+
const INVITE = event({
|
|
61
|
+
id: 'ev-1',
|
|
62
|
+
action: 'member_invited',
|
|
63
|
+
type: 'member_invited',
|
|
64
|
+
target_type: 'membership',
|
|
65
|
+
target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
|
|
66
|
+
metadata: { email: 'nordby@ogmc.ai', role: 'admin', auto_created: true },
|
|
67
|
+
actor: { id: 'u-qa', email: 'qa-foundry@startsimpli.com', name: 'QA Foundry' },
|
|
68
|
+
actor_email: 'qa-foundry@startsimpli.com',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// --------------------------------------------------------------------------
|
|
72
|
+
// 1. THE ACTOR IS A PERSON, and says so
|
|
73
|
+
// --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('the actor chip identifies a PERSON, not an org', () => {
|
|
76
|
+
it('shows the email alongside a display name that could be read as a company', () => {
|
|
77
|
+
render(<ActivityTimeline activities={toActivityTimelineItems([INVITE])} />)
|
|
78
|
+
// Both halves of the identity. Without the email, "QA Foundry" is
|
|
79
|
+
// indistinguishable from the name of another foundry, which is exactly what
|
|
80
|
+
// the user read as cross-tenant contamination.
|
|
81
|
+
expect(screen.getByText('QA Foundry')).toBeInTheDocument()
|
|
82
|
+
expect(screen.getByText('qa-foundry@startsimpli.com')).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not print the same identity twice when there is no display name', () => {
|
|
86
|
+
const rows = toActivityTimelineItems([
|
|
87
|
+
event({ ...INVITE, id: 'ev-2', actor: { id: 'u-2', email: 'ada@x.com', name: '' } }),
|
|
88
|
+
])
|
|
89
|
+
render(<ActivityTimeline activities={rows} />)
|
|
90
|
+
expect(screen.getAllByText('ada@x.com')).toHaveLength(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('treats a name the server derived from the email as no name at all', () => {
|
|
94
|
+
// Some backends fall back to the email inside the display name. Echoing it
|
|
95
|
+
// twice is noise, not identity.
|
|
96
|
+
const rows = toActivityTimelineItems([
|
|
97
|
+
event({ ...INVITE, id: 'ev-3', actor: { id: 'u-3', email: 'ada@x.com', name: 'ada@x.com' } }),
|
|
98
|
+
])
|
|
99
|
+
render(<ActivityTimeline activities={rows} />)
|
|
100
|
+
expect(screen.getAllByText('ada@x.com')).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('still renders a row the control plane recorded itself, with no actor', () => {
|
|
104
|
+
const rows = toActivityTimelineItems([event({ id: 'ev-4', state: 'live', actor: null })])
|
|
105
|
+
render(<ActivityTimeline activities={rows} />)
|
|
106
|
+
expect(screen.getByText('Live')).toBeInTheDocument()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// --------------------------------------------------------------------------
|
|
111
|
+
// 2. THE TARGET IS A HUMAN STRING, or nothing
|
|
112
|
+
// --------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe('activityTarget resolves what was touched', () => {
|
|
115
|
+
it('names the invitee instead of the membership UUID', () => {
|
|
116
|
+
expect(activityTarget(INVITE)).toBe('nordby@ogmc.ai')
|
|
117
|
+
expect(activityTarget(INVITE)).not.toMatch(/dc0d739f/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('renders NOTHING rather than a UUID when nothing human is recorded', () => {
|
|
121
|
+
const opaque = event({
|
|
122
|
+
id: 'ev-5',
|
|
123
|
+
action: 'thing_happened',
|
|
124
|
+
type: 'thing_happened',
|
|
125
|
+
target_type: 'membership',
|
|
126
|
+
target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
|
|
127
|
+
metadata: {},
|
|
128
|
+
})
|
|
129
|
+
expect(activityTarget(opaque)).toBe('')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('keeps a target id a human can actually use', () => {
|
|
133
|
+
const pr = event({
|
|
134
|
+
id: 'ev-6',
|
|
135
|
+
action: 'app_ui_proposed',
|
|
136
|
+
type: 'app_ui_proposed',
|
|
137
|
+
target_type: 'pull_request',
|
|
138
|
+
target_id: '42',
|
|
139
|
+
metadata: {},
|
|
140
|
+
})
|
|
141
|
+
expect(activityTarget(pr)).toBe('pull_request · 42')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('labels an event type it has never heard of from that row own metadata', () => {
|
|
145
|
+
// No vocabulary: a new backend event only has to record a descriptor.
|
|
146
|
+
const fresh = event({
|
|
147
|
+
id: 'ev-7',
|
|
148
|
+
action: 'schema_type_created',
|
|
149
|
+
type: 'schema_type_created',
|
|
150
|
+
target_type: 'schema_type',
|
|
151
|
+
target_id: 'b1f0f2c4-7f24-4a2f-9d69-6a0f0e9a1111',
|
|
152
|
+
metadata: { name: 'Artist' },
|
|
153
|
+
})
|
|
154
|
+
expect(activityTarget(fresh)).toBe('Artist')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('prefers the row own reason or error over the target, as before', () => {
|
|
158
|
+
const failed = event({
|
|
159
|
+
id: 'ev-8',
|
|
160
|
+
action: 'lifecycle_failed',
|
|
161
|
+
type: 'lifecycle_failed',
|
|
162
|
+
error: 'CodeBuild step exited 1',
|
|
163
|
+
target_type: 'run',
|
|
164
|
+
target_id: 'f496dea4-0000-0000-0000-000000000000',
|
|
165
|
+
})
|
|
166
|
+
const [item] = toActivityTimelineItems([failed])
|
|
167
|
+
expect(item.detail).toBe('CodeBuild step exited 1')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('puts the resolved target on the timeline item instead of the UUID', () => {
|
|
171
|
+
const [item] = toActivityTimelineItems([INVITE])
|
|
172
|
+
expect(item.detail).toBe('nordby@ogmc.ai')
|
|
173
|
+
expect(String(item.detail)).not.toMatch(/dc0d739f/)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// --------------------------------------------------------------------------
|
|
178
|
+
// 3. A RESEND IS NOT A SECOND INVITE
|
|
179
|
+
// --------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe('a resend is distinguishable from the original', () => {
|
|
182
|
+
const RESEND = event({
|
|
183
|
+
...INVITE,
|
|
184
|
+
id: 'ev-9',
|
|
185
|
+
metadata: { email: 'nordby@ogmc.ai', role: 'admin', resend: true },
|
|
186
|
+
created_at: '2026-07-21T12:00:00Z',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('marks the resent row', () => {
|
|
190
|
+
expect(activityNote(RESEND)).toBe('resend')
|
|
191
|
+
expect(activityNote(INVITE)).toBeUndefined()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('keeps the source note for a transition nobody fired', () => {
|
|
195
|
+
const drift = event({ id: 'ev-10', state: 'paused', source: 'reconcile' })
|
|
196
|
+
expect(activityNote(drift)).toBe('detected by drift reconcile')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('renders the two invites for the same person differently', () => {
|
|
200
|
+
render(<ActivityTimeline activities={toActivityTimelineItems([RESEND, INVITE])} />)
|
|
201
|
+
const rows = document.querySelectorAll('[data-activity-id]')
|
|
202
|
+
expect(rows).toHaveLength(2)
|
|
203
|
+
const first = within(rows[0] as HTMLElement)
|
|
204
|
+
const second = within(rows[1] as HTMLElement)
|
|
205
|
+
expect(first.getByText('resend')).toBeInTheDocument()
|
|
206
|
+
expect(second.queryByText('resend')).toBeNull()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// --------------------------------------------------------------------------
|
|
211
|
+
// 4. THE COPY states the tab scope, without an unactionable hedge
|
|
212
|
+
// --------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
describe('the default panel copy', () => {
|
|
215
|
+
it('no longer hedges about the reader access level', () => {
|
|
216
|
+
// The default description is a plain statement of what the tab holds. The
|
|
217
|
+
// permission filtering is invisible and correct by design — naming it only
|
|
218
|
+
// tells the reader about a constraint they cannot act on.
|
|
219
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
220
|
+
render(
|
|
221
|
+
<QueryClientProvider client={qc}>
|
|
222
|
+
<ActivityFeedPanel client={{ list: jest.fn().mockResolvedValue(emptyPage) }} />
|
|
223
|
+
</QueryClientProvider>,
|
|
224
|
+
)
|
|
225
|
+
const panel = within(screen.getByTestId('activity-feed-panel'))
|
|
226
|
+
expect(panel.queryByText(/access level/i)).toBeNull()
|
|
227
|
+
expect(panel.getByText(/who did what/i)).toBeInTheDocument()
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* a humanised sentence with a neutral tone; it never falls through to a wrong
|
|
10
10
|
* label like "State changed", and it is never dropped.
|
|
11
11
|
*/
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
ActivityTimelineActor,
|
|
14
|
+
ActivityTimelineItem,
|
|
15
|
+
ActivityTone,
|
|
16
|
+
} from '../ActivityTimeline';
|
|
13
17
|
import type {
|
|
14
18
|
ActivityEvent,
|
|
15
19
|
ActivityFilterOption,
|
|
@@ -113,13 +117,92 @@ export function activityHead(
|
|
|
113
117
|
return (events ?? []).find((e) => actionOf(e) === 'state_changed' && !!e.state);
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/** A machine id nobody can read — a UUID. Printing one where a human string
|
|
121
|
+
* belongs ("membership · dc0d739f-a622-…") tells the reader nothing, so it is
|
|
122
|
+
* the one value this layer refuses to render (startsim-768w.19.8). */
|
|
123
|
+
const OPAQUE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generic DESCRIPTOR keys — the names an event uses for "the human string for
|
|
127
|
+
* the thing I touched". Deliberately a naming convention, NOT a per-action map:
|
|
128
|
+
* a member invite records `email`, a schema change records `name`, a pipeline
|
|
129
|
+
* change records `source`/`source_key`, and an event type nobody has written
|
|
130
|
+
* yet is labelled the moment it records any of them. Nothing here branches on
|
|
131
|
+
* an action, so registering an event type still needs no UI change.
|
|
132
|
+
*/
|
|
133
|
+
const TARGET_DESCRIPTORS = [
|
|
134
|
+
'email', 'name', 'title', 'label', 'source', 'source_name',
|
|
135
|
+
// `app` is what a platform-scope row touched: a sign-in records which app it
|
|
136
|
+
// minted a token for, and its `target_id` is the actor's own UUID, so without
|
|
137
|
+
// this key the app is captured on the row and invisible on the page
|
|
138
|
+
// (startsim-768w.19.1.5). Same convention, not a login special case.
|
|
139
|
+
'app',
|
|
140
|
+
'repo', 'slug', 'path', 'source_key', 'provider_key', 'credential_type',
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What this event touched, in words a person can read — the invitee's email,
|
|
145
|
+
* the source's name, the schema type — resolved from the row's OWN metadata.
|
|
146
|
+
*
|
|
147
|
+
* Falls back to `type · id` only for an id a human can actually use (a PR
|
|
148
|
+
* number), and to the EMPTY STRING for an opaque one. Rendering nothing is
|
|
149
|
+
* strictly more informative than rendering a UUID.
|
|
150
|
+
*/
|
|
151
|
+
export function activityTarget(event: ActivityEvent): string {
|
|
152
|
+
const meta = event.metadata ?? {};
|
|
153
|
+
for (const key of TARGET_DESCRIPTORS) {
|
|
154
|
+
const value = meta[key];
|
|
155
|
+
if (typeof value !== 'string') continue;
|
|
156
|
+
const text = value.trim();
|
|
157
|
+
if (text && !OPAQUE_ID.test(text)) return text;
|
|
158
|
+
}
|
|
159
|
+
const id = String(event.target_id ?? '').trim();
|
|
160
|
+
if (!id || OPAQUE_ID.test(id)) return '';
|
|
161
|
+
return [event.target_type, id].filter(Boolean).join(' · ');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The small pill next to the title — the thing that makes two otherwise
|
|
166
|
+
* identical rows different.
|
|
167
|
+
*
|
|
168
|
+
* A RESENT invite is the case that mattered: the same invitee appeared twice
|
|
169
|
+
* and the second row rendered identically to the first, so a correct trail read
|
|
170
|
+
* as a duplicated one. `metadata.resend` is the flag the writer already
|
|
171
|
+
* records; this is where it becomes visible. The lifecycle source notes explain
|
|
172
|
+
* a state the operator did not fire themselves (startsim-bq7s.1).
|
|
173
|
+
*/
|
|
174
|
+
export function activityNote(event: ActivityEvent): string | undefined {
|
|
175
|
+
if (event.metadata?.resend === true) return 'resend';
|
|
176
|
+
return event.source ? SOURCE_NOTE[event.source] : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The PERSON behind a row — never an org.
|
|
181
|
+
*
|
|
182
|
+
* The display name is free text a user typed, so it can read exactly like the
|
|
183
|
+
* name of a foundry ("QA Foundry"), and a correct feed then looks like
|
|
184
|
+
* cross-tenant contamination. The email is the one unambiguous handle, so it is
|
|
185
|
+
* carried alongside the name rather than replaced by it; a `name` that is only
|
|
186
|
+
* the email echoed back is dropped, because saying it twice is not identity.
|
|
187
|
+
*/
|
|
188
|
+
export function activityActor(event: ActivityEvent): ActivityTimelineActor | undefined {
|
|
189
|
+
const raw = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
|
|
190
|
+
if (!raw) return undefined;
|
|
191
|
+
const email = (raw.email ?? '').trim();
|
|
192
|
+
const name = (raw.name ?? '').trim();
|
|
193
|
+
return { ...raw, email: email || undefined, name: name && name !== email ? name : undefined };
|
|
194
|
+
}
|
|
195
|
+
|
|
116
196
|
/** The body line for a row: the failure message, else the stated reason, else
|
|
117
|
-
* what was touched. */
|
|
197
|
+
* what was touched, in words. */
|
|
118
198
|
function detailOf(event: ActivityEvent): string {
|
|
119
199
|
if (event.error) return event.error;
|
|
120
200
|
if (event.reason) return event.reason;
|
|
121
|
-
|
|
122
|
-
|
|
201
|
+
// A lifecycle transition already names its outcome in the TITLE, so echoing
|
|
202
|
+
// the target ("state · live") underneath it is noise. Derived from the row
|
|
203
|
+
// carrying a state, not from its action string.
|
|
204
|
+
if (event.state) return '';
|
|
205
|
+
return activityTarget(event);
|
|
123
206
|
}
|
|
124
207
|
|
|
125
208
|
/** Map any stream row onto the shared timeline's item shape. Nothing in here
|
|
@@ -129,17 +212,16 @@ export function toActivityTimelineItems(
|
|
|
129
212
|
): ActivityTimelineItem[] {
|
|
130
213
|
return (events ?? []).map((event) => {
|
|
131
214
|
const tone = activityTone(event);
|
|
132
|
-
const actor = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
|
|
133
215
|
const detail = detailOf(event);
|
|
134
216
|
return {
|
|
135
217
|
id: event.id,
|
|
136
218
|
type: actionOf(event),
|
|
137
219
|
title: activityLabel(event),
|
|
138
220
|
occurredAt: event.created_at,
|
|
139
|
-
actor:
|
|
221
|
+
actor: activityActor(event),
|
|
140
222
|
tone,
|
|
141
223
|
subtitle: event.previous_state && event.state ? `from ${event.previous_state}` : undefined,
|
|
142
|
-
note: event
|
|
224
|
+
note: activityNote(event),
|
|
143
225
|
detail: detail || undefined,
|
|
144
226
|
detailTone: event.error ? 'error' : 'muted',
|
|
145
227
|
};
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* never seen rather than dropping or mislabelling it.
|
|
13
13
|
*/
|
|
14
14
|
export { ActivityFeedPanel, type ActivityFeedPanelProps } from './ActivityFeedPanel';
|
|
15
|
+
export {
|
|
16
|
+
AccountActivityPanel,
|
|
17
|
+
ACCOUNT_ACTIVITY_QUERY_KEY,
|
|
18
|
+
type AccountActivityPanelProps,
|
|
19
|
+
} from './AccountActivityPanel';
|
|
15
20
|
export {
|
|
16
21
|
ACTIVITY_FEED_PANEL_DEFAULTS,
|
|
17
22
|
type ActivityFeedPanelClassNames,
|
|
@@ -35,6 +40,9 @@ export {
|
|
|
35
40
|
activityLabel,
|
|
36
41
|
activityTone,
|
|
37
42
|
activityHead,
|
|
43
|
+
activityActor,
|
|
44
|
+
activityNote,
|
|
45
|
+
activityTarget,
|
|
38
46
|
toActivityTimelineItems,
|
|
39
47
|
deriveActivityFilterOptions,
|
|
40
48
|
} from './activity-events';
|