@startsimpli/ui 0.4.77 → 0.4.79
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 -2
- 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
- package/src/components/team/MembersSection.tsx +7 -2
- package/src/components/team/MembersTable.tsx +16 -0
- package/src/components/team/__tests__/member-lifecycle.test.tsx +34 -0
- package/src/components/team/members-table-default-class-names.ts +4 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/activity — the shared, filterable activity feed
|
|
3
|
+
* (startsim-768w.19.5).
|
|
4
|
+
*
|
|
5
|
+
* A primitive (the generalised `ActivityTimeline`, exported from the package
|
|
6
|
+
* root) plus the page-level composer `ActivityFeedPanel`, so an app that wants
|
|
7
|
+
* a "what happened here" surface holds theme classNames and a thin wrapper
|
|
8
|
+
* instead of its own timeline — rule #9.
|
|
9
|
+
*
|
|
10
|
+
* Nothing in here knows which event types exist. Visibility was decided by the
|
|
11
|
+
* endpoint; the feed renders what it receives, humanising an action it has
|
|
12
|
+
* never seen rather than dropping or mislabelling it.
|
|
13
|
+
*/
|
|
14
|
+
export { ActivityFeedPanel, type ActivityFeedPanelProps } from './ActivityFeedPanel';
|
|
15
|
+
export {
|
|
16
|
+
ACTIVITY_FEED_PANEL_DEFAULTS,
|
|
17
|
+
type ActivityFeedPanelClassNames,
|
|
18
|
+
} from './activity-feed-panel-default-class-names';
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
ActivityFilterBar,
|
|
22
|
+
activityFilterCount,
|
|
23
|
+
type ActivityFilterBarProps,
|
|
24
|
+
} from './ActivityFilterBar';
|
|
25
|
+
export {
|
|
26
|
+
ACTIVITY_FILTER_BAR_DEFAULTS,
|
|
27
|
+
type ActivityFilterBarClassNames,
|
|
28
|
+
} from './activity-filter-bar-default-class-names';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
TONE_DOT,
|
|
32
|
+
SOURCE_NOTE,
|
|
33
|
+
actionOf,
|
|
34
|
+
humanizeAction,
|
|
35
|
+
activityLabel,
|
|
36
|
+
activityTone,
|
|
37
|
+
activityHead,
|
|
38
|
+
toActivityTimelineItems,
|
|
39
|
+
deriveActivityFilterOptions,
|
|
40
|
+
} from './activity-events';
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
ActivityClient,
|
|
44
|
+
ActivityEvent,
|
|
45
|
+
ActivityEventActor,
|
|
46
|
+
ActivityFilterOption,
|
|
47
|
+
ActivityFilterOptions,
|
|
48
|
+
ActivityFilterValue,
|
|
49
|
+
ActivityPage,
|
|
50
|
+
ActivityQuery,
|
|
51
|
+
} from './types';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/activity — the shapes the shared activity feed renders.
|
|
3
|
+
*
|
|
4
|
+
* Structural on purpose: packages/ui imports no API client, so these mirror the
|
|
5
|
+
* event-stream row without depending on @startsimpli/api. `FoundryActivityEvent`
|
|
6
|
+
* is assignable to `ActivityEvent`, and so is any other append-only trail with
|
|
7
|
+
* an action + a timestamp.
|
|
8
|
+
*
|
|
9
|
+
* THE UI DECIDES NOTHING ABOUT VISIBILITY. The stream endpoint already filtered
|
|
10
|
+
* by the caller's resolved permission level, so every row that arrives here is
|
|
11
|
+
* renderable and there is no vocabulary of known types anywhere below.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ActivityEventActor {
|
|
15
|
+
id?: string;
|
|
16
|
+
email?: string | null;
|
|
17
|
+
name?: string | null;
|
|
18
|
+
avatarUrl?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One row of an append-only event stream. Only `id` + `created_at` are
|
|
22
|
+
* required; everything else is per-type detail the renderer treats as
|
|
23
|
+
* optional so an unknown type still renders. */
|
|
24
|
+
export interface ActivityEvent {
|
|
25
|
+
id: string;
|
|
26
|
+
/** The raw event type as the server names it, e.g. `member_invited`. */
|
|
27
|
+
action?: string;
|
|
28
|
+
/** Alias of `action` for streams that name it `type`. */
|
|
29
|
+
type?: string;
|
|
30
|
+
target_type?: string;
|
|
31
|
+
target_id?: string;
|
|
32
|
+
metadata?: Record<string, unknown>;
|
|
33
|
+
actor?: ActivityEventActor | null;
|
|
34
|
+
actor_email?: string | null;
|
|
35
|
+
/** Lifecycle detail — present on state transitions, null on everything else. */
|
|
36
|
+
state?: string | null;
|
|
37
|
+
previous_state?: string | null;
|
|
38
|
+
source?: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
run_id?: string;
|
|
42
|
+
run_kind?: string;
|
|
43
|
+
created_at: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Narrowing the user asked for. Every field can only SUBTRACT from the rows
|
|
47
|
+
* the caller's level already permits — a filter is a preference, never an
|
|
48
|
+
* access decision. */
|
|
49
|
+
export interface ActivityFilterValue {
|
|
50
|
+
/** Actor user id or email. */
|
|
51
|
+
actor?: string;
|
|
52
|
+
/** One action, or several comma-separated. */
|
|
53
|
+
action?: string;
|
|
54
|
+
/** ISO-8601 lower bound on the timestamp (inclusive). */
|
|
55
|
+
since?: string;
|
|
56
|
+
/** ISO-8601 upper bound on the timestamp (inclusive). */
|
|
57
|
+
until?: string;
|
|
58
|
+
/** Free text. */
|
|
59
|
+
q?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ActivityQuery extends ActivityFilterValue {
|
|
63
|
+
page?: number;
|
|
64
|
+
pageSize?: number;
|
|
65
|
+
// Index signature so this satisfies an API client's query-params type
|
|
66
|
+
// (mirrors `FoundryActivityParams`) — the injected client is the app's own.
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ActivityPage {
|
|
71
|
+
count: number;
|
|
72
|
+
next?: string | null;
|
|
73
|
+
previous?: string | null;
|
|
74
|
+
results: ActivityEvent[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The one call the feed needs. The consuming app injects its authed client
|
|
79
|
+
* (`(p) => api.foundry.activity(slug, p)`), which is what keeps @startsimpli/api
|
|
80
|
+
* out of packages/ui and the app wrapper under ten lines.
|
|
81
|
+
*/
|
|
82
|
+
export interface ActivityClient {
|
|
83
|
+
list(params: ActivityQuery): Promise<ActivityPage>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ActivityFilterOption {
|
|
87
|
+
value: string;
|
|
88
|
+
label: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ActivityFilterOptions {
|
|
92
|
+
actors: ActivityFilterOption[];
|
|
93
|
+
actions: ActivityFilterOption[];
|
|
94
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -154,8 +154,16 @@ export * from './pipeline'
|
|
|
154
154
|
export * from './calendar'
|
|
155
155
|
|
|
156
156
|
// Activity components (timeline, quick log, log dialog)
|
|
157
|
-
export { ActivityTimeline } from './ActivityTimeline'
|
|
158
|
-
export type {
|
|
157
|
+
export { ActivityTimeline, actorInitials, dayHeading } from './ActivityTimeline'
|
|
158
|
+
export type {
|
|
159
|
+
ActivityTimelineProps,
|
|
160
|
+
ActivityTimelineItem,
|
|
161
|
+
ActivityTimelineActor,
|
|
162
|
+
ActivityTone,
|
|
163
|
+
} from './ActivityTimeline'
|
|
164
|
+
// The shared, filterable activity feed: filter bar + page-level composer over
|
|
165
|
+
// any append-only event stream (startsim-768w.19.5).
|
|
166
|
+
export * from './activity'
|
|
159
167
|
export { QuickLogButtons } from './QuickLogButtons'
|
|
160
168
|
export type { QuickLogButtonsProps, QuickLogAction } from './QuickLogButtons'
|
|
161
169
|
export { LogActivityDialog } from './LogActivityDialog'
|
|
@@ -36,6 +36,8 @@ export interface MembersSectionProps {
|
|
|
36
36
|
onChangeRole?: (userId: string, role: TeamRole) => void | Promise<void>
|
|
37
37
|
onRemove?: (userId: string) => void | Promise<void>
|
|
38
38
|
onResendInvite?: (userId: string) => void | Promise<void>
|
|
39
|
+
/** Suspend/restore a membership without deleting it (soft exit). */
|
|
40
|
+
onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
|
|
39
41
|
/** When set, the invite dialog is rendered next to the heading. */
|
|
40
42
|
onInvite?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
|
|
41
43
|
onInvited?: (invitation: InvitationLite | void) => void
|
|
@@ -43,8 +45,9 @@ export interface MembersSectionProps {
|
|
|
43
45
|
availableRoles?: TeamRole[]
|
|
44
46
|
inviteRoles?: TeamRole[]
|
|
45
47
|
defaultInviteRole?: TeamRole
|
|
46
|
-
/** Header copy.
|
|
47
|
-
|
|
48
|
+
/** Header copy. A node so a host can badge/icon the heading without
|
|
49
|
+
* re-assembling the header row. `description` receives the member count. */
|
|
50
|
+
title?: React.ReactNode
|
|
48
51
|
description?: (count: number) => React.ReactNode
|
|
49
52
|
/** Set false when the host already renders a card header. */
|
|
50
53
|
showHeader?: boolean
|
|
@@ -70,6 +73,7 @@ export function MembersSection({
|
|
|
70
73
|
onChangeRole,
|
|
71
74
|
onRemove,
|
|
72
75
|
onResendInvite,
|
|
76
|
+
onSetActive,
|
|
73
77
|
onInvite,
|
|
74
78
|
onInvited,
|
|
75
79
|
availableRoles,
|
|
@@ -123,6 +127,7 @@ export function MembersSection({
|
|
|
123
127
|
onChangeRole={onChangeRole}
|
|
124
128
|
onRemove={onRemove}
|
|
125
129
|
onResendInvite={onResendInvite}
|
|
130
|
+
onSetActive={onSetActive}
|
|
126
131
|
emptyState={emptyState}
|
|
127
132
|
classNames={classNames?.table}
|
|
128
133
|
/>
|
|
@@ -46,6 +46,12 @@ export interface MembersTableProps {
|
|
|
46
46
|
* only alongside `canManage` — an expired invite is otherwise a dead end.
|
|
47
47
|
*/
|
|
48
48
|
onResendInvite?: (userId: string) => void | Promise<void>
|
|
49
|
+
/**
|
|
50
|
+
* Suspend or restore a membership without deleting it — the soft exit next to
|
|
51
|
+
* Remove. Offered only alongside `canManage`. Rows render Deactivate while
|
|
52
|
+
* active and Restore once `isActive === false`.
|
|
53
|
+
*/
|
|
54
|
+
onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
|
|
49
55
|
/** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
|
|
50
56
|
emptyState?: React.ReactNode
|
|
51
57
|
/**
|
|
@@ -113,6 +119,7 @@ export function MembersTable({
|
|
|
113
119
|
onChangeRole,
|
|
114
120
|
onRemove,
|
|
115
121
|
onResendInvite,
|
|
122
|
+
onSetActive,
|
|
116
123
|
emptyState,
|
|
117
124
|
showStatus,
|
|
118
125
|
showLastActive,
|
|
@@ -244,6 +251,15 @@ export function MembersTable({
|
|
|
244
251
|
Resend invite
|
|
245
252
|
</button>
|
|
246
253
|
)}
|
|
254
|
+
{onSetActive && (
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
className={cls.deactivateButton}
|
|
258
|
+
onClick={() => onSetActive(m.userId, m.isActive === false)}
|
|
259
|
+
>
|
|
260
|
+
{m.isActive === false ? 'Restore' : 'Deactivate'}
|
|
261
|
+
</button>
|
|
262
|
+
)}
|
|
247
263
|
{onRemove && (
|
|
248
264
|
<button
|
|
249
265
|
type="button"
|
|
@@ -91,6 +91,33 @@ describe('MembersTable — invite lifecycle', () => {
|
|
|
91
91
|
expect(screen.queryByRole('button', { name: /resend/i })).not.toBeInTheDocument()
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
// The soft exit next to Remove. The table already paints a `deactivated`
|
|
95
|
+
// pill, but nothing could ever produce one: Remove (hard-delete) was the only
|
|
96
|
+
// way out of a foundry, so suspending someone meant destroying their role,
|
|
97
|
+
// attribution and history (startsim-768w.19.2).
|
|
98
|
+
it('offers deactivate on an active row and calls it with (userId, false)', () => {
|
|
99
|
+
const onSetActive = jest.fn()
|
|
100
|
+
render(<MembersTable members={rows} canManage onSetActive={onSetActive} />)
|
|
101
|
+
const buttons = screen.getAllByRole('button', { name: /deactivate/i })
|
|
102
|
+
expect(buttons).toHaveLength(3)
|
|
103
|
+
fireEvent.click(buttons[0])
|
|
104
|
+
expect(onSetActive).toHaveBeenCalledWith('u1', false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('offers restore — not deactivate — on an already deactivated row', () => {
|
|
108
|
+
const onSetActive = jest.fn()
|
|
109
|
+
const deactivated: MemberRow[] = [{ ...rows[1], isActive: false }]
|
|
110
|
+
render(<MembersTable members={deactivated} canManage onSetActive={onSetActive} />)
|
|
111
|
+
expect(screen.queryByRole('button', { name: /deactivate/i })).not.toBeInTheDocument()
|
|
112
|
+
fireEvent.click(screen.getByRole('button', { name: /restore/i }))
|
|
113
|
+
expect(onSetActive).toHaveBeenCalledWith('u2', true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('shows no deactivate affordance without canManage', () => {
|
|
117
|
+
render(<MembersTable members={rows} onSetActive={jest.fn()} />)
|
|
118
|
+
expect(screen.queryByRole('button', { name: /deactivate/i })).not.toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
94
121
|
it('stays backwards compatible with rows that carry no lifecycle fields', () => {
|
|
95
122
|
const legacy: MemberRow[] = [
|
|
96
123
|
{ id: 'm1', userId: 'u1', teamId: 't1', role: 'admin', joinedAt: '2026-01-01' },
|
|
@@ -118,6 +145,13 @@ describe('MembersSection — the page-level composer', () => {
|
|
|
118
145
|
expect(onResendInvite).toHaveBeenCalledWith('u2')
|
|
119
146
|
})
|
|
120
147
|
|
|
148
|
+
it('forwards a deactivate from the composer', () => {
|
|
149
|
+
const onSetActive = jest.fn()
|
|
150
|
+
render(<MembersSection members={rows} canManage onSetActive={onSetActive} />)
|
|
151
|
+
fireEvent.click(screen.getAllByRole('button', { name: /deactivate/i })[1])
|
|
152
|
+
expect(onSetActive).toHaveBeenCalledWith('u2', false)
|
|
153
|
+
})
|
|
154
|
+
|
|
121
155
|
it('surfaces an error line when a mutation failed', () => {
|
|
122
156
|
render(
|
|
123
157
|
<MembersSection members={rows} canManage error="Couldn't update that member" />
|
|
@@ -34,6 +34,8 @@ export interface MembersTableClassNames {
|
|
|
34
34
|
invitedBy?: string
|
|
35
35
|
/** Resend-invite action on a pending/expired row. */
|
|
36
36
|
resendButton?: string
|
|
37
|
+
/** Deactivate / Restore action — the soft exit next to the hard Remove. */
|
|
38
|
+
deactivateButton?: string
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
@@ -70,4 +72,6 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
|
70
72
|
invitedBy: 'text-xs text-muted-foreground/80',
|
|
71
73
|
resendButton:
|
|
72
74
|
'rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary disabled:opacity-50',
|
|
75
|
+
deactivateButton:
|
|
76
|
+
'rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:bg-muted hover:text-foreground disabled:opacity-50',
|
|
73
77
|
}
|