@startsimpli/ui 0.4.81 → 0.4.83
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 +3 -3
- package/src/components/ActivityTimeline.tsx +8 -54
- package/src/components/ActorIdentity.tsx +86 -0
- package/src/components/__tests__/actor-identity.test.tsx +101 -0
- package/src/components/activity/AccountActivityPanel.tsx +55 -0
- package/src/components/activity/__tests__/account-activity.test.tsx +153 -0
- package/src/components/activity/__tests__/activity-resend-pill.test.tsx +70 -0
- package/src/components/activity/activity-events.ts +5 -0
- package/src/components/activity/index.ts +5 -0
- package/src/components/index.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.83",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0",
|
|
45
45
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
46
|
-
"@startsimpli/
|
|
47
|
-
"@startsimpli/
|
|
46
|
+
"@startsimpli/auth": "^0.4.31",
|
|
47
|
+
"@startsimpli/api": "^0.5.51"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
|
@@ -16,18 +16,16 @@ import { ComponentType, ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
|
16
16
|
import { formatDistanceToNow } from 'date-fns';
|
|
17
17
|
import { Clock, User } from 'lucide-react';
|
|
18
18
|
import { EmptyState } from './states';
|
|
19
|
+
import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
|
|
20
|
+
import type { ActorIdentityValue } from './ActorIdentity';
|
|
19
21
|
|
|
20
22
|
/** Severity of an entry, for the marker. Presentation only — never a gate. */
|
|
21
23
|
export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
|
|
22
24
|
|
|
23
|
-
/** Who did it. Rendered
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
name?: string | null;
|
|
28
|
-
email?: string | null;
|
|
29
|
-
avatarUrl?: string | null;
|
|
30
|
-
}
|
|
25
|
+
/** Who did it. Rendered by the shared `ActorIdentity` chip, so a feed shows a
|
|
26
|
+
* PERSON — name plus the email that pins the name down — and paints it the
|
|
27
|
+
* same way every other attributed surface does (startsim-768w.19.8.1). */
|
|
28
|
+
export type ActivityTimelineActor = ActorIdentityValue;
|
|
31
29
|
|
|
32
30
|
export interface ActivityTimelineItem {
|
|
33
31
|
id: string;
|
|
@@ -101,39 +99,6 @@ function defaultFormatTime(date: string): string {
|
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
104
|
-
/** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
|
|
105
|
-
export function actorInitials(actor?: ActivityTimelineActor): string {
|
|
106
|
-
const name = (actor?.name ?? '').trim();
|
|
107
|
-
if (name) {
|
|
108
|
-
return name
|
|
109
|
-
.split(/\s+/)
|
|
110
|
-
.slice(0, 2)
|
|
111
|
-
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
112
|
-
.join('');
|
|
113
|
-
}
|
|
114
|
-
const email = (actor?.email ?? '').trim();
|
|
115
|
-
return email ? email[0]!.toUpperCase() : '';
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function actorName(actor?: ActivityTimelineActor): string {
|
|
119
|
-
return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
|
|
120
|
-
}
|
|
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
|
-
|
|
137
102
|
function dayKey(iso: string): string {
|
|
138
103
|
const d = new Date(iso);
|
|
139
104
|
return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
|
|
@@ -195,8 +160,7 @@ function TimelineEntry({
|
|
|
195
160
|
activityColors[activity.type] ||
|
|
196
161
|
(tone ? TONE_MARKER[tone] : undefined) ||
|
|
197
162
|
'bg-muted text-muted-foreground';
|
|
198
|
-
const who =
|
|
199
|
-
const handle = actorHandle(activity.actor);
|
|
163
|
+
const who = actorLabel(activity.actor);
|
|
200
164
|
// With no icon the marker itself becomes the avatar, so the metadata line
|
|
201
165
|
// shows the name alone rather than the same initials twice.
|
|
202
166
|
const markerIsAvatar = !Icon && !!initials;
|
|
@@ -239,17 +203,7 @@ function TimelineEntry({
|
|
|
239
203
|
</div>
|
|
240
204
|
|
|
241
205
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
242
|
-
{
|
|
243
|
-
<span className="flex flex-wrap items-center gap-x-1.5">
|
|
244
|
-
{initials && !markerIsAvatar && (
|
|
245
|
-
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
|
246
|
-
{initials}
|
|
247
|
-
</span>
|
|
248
|
-
)}
|
|
249
|
-
<span>{who}</span>
|
|
250
|
-
{handle && <span className="text-xs text-muted-foreground/80">{handle}</span>}
|
|
251
|
-
</span>
|
|
252
|
-
)}
|
|
206
|
+
<ActorIdentity actor={activity.actor} showInitials={!markerIsAvatar} />
|
|
253
207
|
|
|
254
208
|
{!who && activity.createdBy && (
|
|
255
209
|
<span className="flex items-center gap-1">
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ActorIdentity — ONE way to say "a person did this" (startsim-768w.19.8.1).
|
|
5
|
+
*
|
|
6
|
+
* A display name is FREE TEXT. It is whatever somebody typed into a profile, so
|
|
7
|
+
* it can read like an org ("QA Foundry" — the name of a different foundry) or
|
|
8
|
+
* like nobody at all ("Admin User"), and a reader has nothing to check it
|
|
9
|
+
* against. Alone it is a label, not an identity: the activity feed printing one
|
|
10
|
+
* is what made a correctly-filtered feed look like cross-tenant contamination
|
|
11
|
+
* (startsim-768w.19.8), and the run attribution on Deployments/Builds printed
|
|
12
|
+
* the same string with the same result.
|
|
13
|
+
*
|
|
14
|
+
* So the rule, in one place: the NAME and the EMAIL are rendered together when
|
|
15
|
+
* there is a real name, and the EMAIL ALONE when the name is missing or is just
|
|
16
|
+
* the address echoed back. Every surface that attributes something to a person
|
|
17
|
+
* renders this component rather than its own arrangement of the same two
|
|
18
|
+
* fields (rule #9).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ActorIdentityValue {
|
|
22
|
+
id?: string;
|
|
23
|
+
name?: string | null;
|
|
24
|
+
email?: string | null;
|
|
25
|
+
avatarUrl?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
|
|
29
|
+
export function actorInitials(actor?: ActorIdentityValue): string {
|
|
30
|
+
const name = (actor?.name ?? '').trim();
|
|
31
|
+
if (name) {
|
|
32
|
+
return name
|
|
33
|
+
.split(/\s+/)
|
|
34
|
+
.slice(0, 2)
|
|
35
|
+
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
36
|
+
.join('');
|
|
37
|
+
}
|
|
38
|
+
const email = (actor?.email ?? '').trim();
|
|
39
|
+
return email ? email[0]!.toUpperCase() : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What to CALL them: the real name, else the address. Empty when we know
|
|
43
|
+
* nobody — and an empty label is the signal to render nothing at all, rather
|
|
44
|
+
* than a dangling "by". */
|
|
45
|
+
export function actorLabel(actor?: ActorIdentityValue): string {
|
|
46
|
+
return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The second half of the identity — the address, when the label is something
|
|
50
|
+
* else. Empty when the label already IS the address, because printing it twice
|
|
51
|
+
* is noise rather than corroboration. */
|
|
52
|
+
export function actorHandle(actor?: ActorIdentityValue): string {
|
|
53
|
+
const email = (actor?.email ?? '').trim();
|
|
54
|
+
return email && email !== actorLabel(actor) ? email : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ActorIdentityProps {
|
|
58
|
+
actor?: ActorIdentityValue;
|
|
59
|
+
/** Show the initials avatar. Off for a compact one-line attribution, or where
|
|
60
|
+
* the surrounding row already paints the same initials. */
|
|
61
|
+
showInitials?: boolean;
|
|
62
|
+
className?: string;
|
|
63
|
+
initialsClassName?: string;
|
|
64
|
+
handleClassName?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ActorIdentity({
|
|
68
|
+
actor,
|
|
69
|
+
showInitials = true,
|
|
70
|
+
className = 'flex flex-wrap items-center gap-x-1.5',
|
|
71
|
+
initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
|
|
72
|
+
handleClassName = 'text-xs text-muted-foreground/80',
|
|
73
|
+
}: ActorIdentityProps) {
|
|
74
|
+
const label = actorLabel(actor);
|
|
75
|
+
if (!label) return null;
|
|
76
|
+
const initials = showInitials ? actorInitials(actor) : '';
|
|
77
|
+
const handle = actorHandle(actor);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<span data-testid="actor-identity" className={className}>
|
|
81
|
+
{initials && <span className={initialsClassName}>{initials}</span>}
|
|
82
|
+
<span>{label}</span>
|
|
83
|
+
{handle && <span className={handleClassName}>{handle}</span>}
|
|
84
|
+
</span>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.8.1 (RED) — ONE renderer for an attributed identity.
|
|
3
|
+
*
|
|
4
|
+
* The activity feed learned in 19.8 that a display name must never stand alone:
|
|
5
|
+
* it is free text, so an account called "QA Foundry" reads exactly like the name
|
|
6
|
+
* of a DIFFERENT foundry, and a correct feed then looks like a data leak. The
|
|
7
|
+
* fix was to carry the email alongside the name.
|
|
8
|
+
*
|
|
9
|
+
* The very same string is printed on the Deployments and Builds tabs as run
|
|
10
|
+
* attribution, where it reads "by Admin User · Jul 27, 4:36 PM" — a name that
|
|
11
|
+
* identifies nobody, with no handle to check it against. Two surfaces printing
|
|
12
|
+
* the same identity must not paint it two different ways (rule #9), so the chip
|
|
13
|
+
* the activity feed already uses is extracted here and both consume it.
|
|
14
|
+
*
|
|
15
|
+
* The contract, in one line: NAME + EMAIL when there is a real name, EMAIL ALONE
|
|
16
|
+
* when the "name" is absent or is just the address echoed back.
|
|
17
|
+
*/
|
|
18
|
+
import { render, screen, within } from '@testing-library/react'
|
|
19
|
+
|
|
20
|
+
import { ActorIdentity, actorHandle, actorInitials, actorLabel } from '../ActorIdentity'
|
|
21
|
+
import { ActivityTimeline } from '../ActivityTimeline'
|
|
22
|
+
|
|
23
|
+
describe('actorLabel / actorHandle — a name is never the whole identity', () => {
|
|
24
|
+
it('keeps the name and the email apart when both are real', () => {
|
|
25
|
+
const actor = { name: 'Ada Owner', email: 'ada@ogmc.ai' }
|
|
26
|
+
expect(actorLabel(actor)).toBe('Ada Owner')
|
|
27
|
+
expect(actorHandle(actor)).toBe('ada@ogmc.ai')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('falls back to the email when there is no real name', () => {
|
|
31
|
+
const actor = { name: '', email: 'ops@ogmc.ai' }
|
|
32
|
+
expect(actorLabel(actor)).toBe('ops@ogmc.ai')
|
|
33
|
+
// ...and does NOT then print the same address twice
|
|
34
|
+
expect(actorHandle(actor)).toBe('')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('drops a name that is only the email echoed back', () => {
|
|
38
|
+
const actor = { name: 'ops@ogmc.ai', email: 'ops@ogmc.ai' }
|
|
39
|
+
expect(actorLabel(actor)).toBe('ops@ogmc.ai')
|
|
40
|
+
expect(actorHandle(actor)).toBe('')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('knows nobody when it is handed nobody', () => {
|
|
44
|
+
expect(actorLabel(undefined)).toBe('')
|
|
45
|
+
expect(actorLabel({ name: null, email: null })).toBe('')
|
|
46
|
+
expect(actorInitials({ name: 'Ada Owner' })).toBe('AO')
|
|
47
|
+
expect(actorInitials({ email: 'ops@ogmc.ai' })).toBe('O')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('<ActorIdentity>', () => {
|
|
52
|
+
it('renders the person AND the handle that pins them down', () => {
|
|
53
|
+
render(<ActorIdentity actor={{ name: 'Admin User', email: 'admin@ogmc.ai' }} />)
|
|
54
|
+
const chip = within(screen.getByTestId('actor-identity'))
|
|
55
|
+
expect(chip.getByText('Admin User')).toBeInTheDocument()
|
|
56
|
+
expect(chip.getByText('admin@ogmc.ai')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders the email alone when there is no name — never an empty "by"', () => {
|
|
60
|
+
render(<ActorIdentity actor={{ name: '', email: 'ops@ogmc.ai' }} />)
|
|
61
|
+
const chip = screen.getByTestId('actor-identity')
|
|
62
|
+
expect(chip).toHaveTextContent('ops@ogmc.ai')
|
|
63
|
+
// one occurrence, not the address printed twice
|
|
64
|
+
expect(chip.textContent!.match(/ops@ogmc\.ai/g)).toHaveLength(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders nothing at all when there is nobody to attribute', () => {
|
|
68
|
+
const { container } = render(<ActorIdentity actor={{ name: null, email: null }} />)
|
|
69
|
+
expect(container).toBeEmptyDOMElement()
|
|
70
|
+
expect(screen.queryByTestId('actor-identity')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('can drop the initials avatar for a compact one-line attribution', () => {
|
|
74
|
+
render(<ActorIdentity actor={{ name: 'Ada Owner', email: 'ada@ogmc.ai' }} showInitials={false} />)
|
|
75
|
+
const chip = screen.getByTestId('actor-identity')
|
|
76
|
+
expect(chip).toHaveTextContent('Ada Owner')
|
|
77
|
+
expect(within(chip).queryByText('AO')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('the activity feed renders the SAME chip', () => {
|
|
82
|
+
it('still shows the person and the email on a timeline row', () => {
|
|
83
|
+
render(
|
|
84
|
+
<ActivityTimeline
|
|
85
|
+
activities={[
|
|
86
|
+
{
|
|
87
|
+
id: 'ev-1',
|
|
88
|
+
type: 'member_invited',
|
|
89
|
+
title: 'Member invited',
|
|
90
|
+
occurredAt: '2026-07-27T16:36:00Z',
|
|
91
|
+
actor: { name: 'QA Foundry', email: 'qa-foundry@startsimpli.com' },
|
|
92
|
+
},
|
|
93
|
+
]}
|
|
94
|
+
/>,
|
|
95
|
+
)
|
|
96
|
+
const row = within(document.querySelector('[data-activity-id="ev-1"]') as HTMLElement)
|
|
97
|
+
const chip = within(row.getByTestId('actor-identity'))
|
|
98
|
+
expect(chip.getByText('QA Foundry')).toBeInTheDocument()
|
|
99
|
+
expect(chip.getByText('qa-foundry@startsimpli.com')).toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startsim-768w.19.8.1 — the resend pill, verified on the SURFACE, not just in
|
|
3
|
+
* the mapper.
|
|
4
|
+
*
|
|
5
|
+
* `activityNote()` was added in startsim-768w.19.8 so a re-sent invite stops
|
|
6
|
+
* reading as a second, duplicate invite. Its unit coverage asserted the mapper;
|
|
7
|
+
* nobody could confirm the pill actually reaches the DOM of the panel a user
|
|
8
|
+
* looks at, because on the live feed the resend rows sit below the fold. This
|
|
9
|
+
* drives the real `ActivityFeedPanel` — the same component the Activity tab
|
|
10
|
+
* renders — over two invites for the same person, and asserts the second one
|
|
11
|
+
* carries a visible "resend" while the first does not.
|
|
12
|
+
*/
|
|
13
|
+
import { render, screen, within } from '@testing-library/react'
|
|
14
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
15
|
+
|
|
16
|
+
import { ActivityFeedPanel, type ActivityEvent } from '../index'
|
|
17
|
+
|
|
18
|
+
function inviteRow(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
|
|
19
|
+
return {
|
|
20
|
+
action: 'member_invited',
|
|
21
|
+
type: 'member_invited',
|
|
22
|
+
target_type: 'membership',
|
|
23
|
+
target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
|
|
24
|
+
metadata: { email: 'nordby@ogmc.ai', role: 'admin' },
|
|
25
|
+
actor: { id: 'u-1', email: 'qa-foundry@startsimpli.com', name: '' },
|
|
26
|
+
actor_email: 'qa-foundry@startsimpli.com',
|
|
27
|
+
state: null,
|
|
28
|
+
previous_state: null,
|
|
29
|
+
source: 'control_plane',
|
|
30
|
+
reason: '',
|
|
31
|
+
error: '',
|
|
32
|
+
run_id: '',
|
|
33
|
+
run_kind: '',
|
|
34
|
+
created_at: '2026-07-21T12:00:00Z',
|
|
35
|
+
...over,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RESEND = inviteRow({
|
|
40
|
+
id: 'ev-resend',
|
|
41
|
+
metadata: { email: 'nordby@ogmc.ai', role: 'admin', resend: true },
|
|
42
|
+
created_at: '2026-07-22T12:00:00Z',
|
|
43
|
+
})
|
|
44
|
+
const ORIGINAL = inviteRow({ id: 'ev-original' })
|
|
45
|
+
|
|
46
|
+
function renderPanel(results: ActivityEvent[]) {
|
|
47
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
48
|
+
const client = {
|
|
49
|
+
list: jest.fn().mockResolvedValue({ count: results.length, next: null, previous: null, results }),
|
|
50
|
+
}
|
|
51
|
+
render(
|
|
52
|
+
<QueryClientProvider client={qc}>
|
|
53
|
+
<ActivityFeedPanel client={client} />
|
|
54
|
+
</QueryClientProvider>,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('a resent invite is visibly not a duplicate', () => {
|
|
59
|
+
it('renders the resend pill on the panel a user actually looks at', async () => {
|
|
60
|
+
renderPanel([RESEND, ORIGINAL])
|
|
61
|
+
|
|
62
|
+
const resendRow = (await screen.findByText('resend')).closest('[data-activity-id]')
|
|
63
|
+
expect(resendRow).toHaveAttribute('data-activity-id', 'ev-resend')
|
|
64
|
+
|
|
65
|
+
const original = document.querySelector('[data-activity-id="ev-original"]') as HTMLElement
|
|
66
|
+
expect(within(original).queryByText('resend')).toBeNull()
|
|
67
|
+
// both still name the invitee rather than the UUID they carry
|
|
68
|
+
expect(within(original).getByText('nordby@ogmc.ai')).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -132,6 +132,11 @@ const OPAQUE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
|
|
|
132
132
|
*/
|
|
133
133
|
const TARGET_DESCRIPTORS = [
|
|
134
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',
|
|
135
140
|
'repo', 'slug', 'path', 'source_key', 'provider_key', 'credential_type',
|
|
136
141
|
];
|
|
137
142
|
|
|
@@ -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,
|
package/src/components/index.ts
CHANGED
|
@@ -153,8 +153,14 @@ export * from './pipeline'
|
|
|
153
153
|
// Calendar (month/week/day event grid wrapping react-big-calendar)
|
|
154
154
|
export * from './calendar'
|
|
155
155
|
|
|
156
|
+
// Attributed identity — the ONE chip every "who did this" surface renders, so a
|
|
157
|
+
// display name is never printed alone as if it were an identity
|
|
158
|
+
// (startsim-768w.19.8.1).
|
|
159
|
+
export { ActorIdentity, actorInitials, actorLabel, actorHandle } from './ActorIdentity'
|
|
160
|
+
export type { ActorIdentityProps, ActorIdentityValue } from './ActorIdentity'
|
|
161
|
+
|
|
156
162
|
// Activity components (timeline, quick log, log dialog)
|
|
157
|
-
export { ActivityTimeline,
|
|
163
|
+
export { ActivityTimeline, dayHeading } from './ActivityTimeline'
|
|
158
164
|
export type {
|
|
159
165
|
ActivityTimelineProps,
|
|
160
166
|
ActivityTimelineItem,
|