@startsimpli/ui 0.4.77 → 0.4.78

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.77",
3
+ "version": "0.4.78",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -42,8 +42,8 @@
42
42
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
- "@startsimpli/auth": "^0.4.31",
46
- "@startsimpli/api": "^0.5.46"
45
+ "@startsimpli/api": "^0.5.48",
46
+ "@startsimpli/auth": "^0.4.31"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@startsimpli/api": {
@@ -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
  }