@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.
@@ -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
+ }
@@ -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 { ActivityTimelineProps, ActivityTimelineItem } from './ActivityTimeline'
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. `description` receives the member count. */
47
- title?: string
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
  }