@startsimpli/ui 0.4.83 → 0.4.85
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 +1 -1
- package/src/components/index.ts +2 -4
- package/src/components/navigation/__tests__/entity-switcher.test.tsx +258 -0
- package/src/components/navigation/__tests__/switcher-slot.test.tsx +102 -0
- package/src/components/navigation/app-shell.tsx +8 -0
- package/src/components/navigation/entity-switcher.tsx +293 -0
- package/src/components/navigation/index.ts +6 -0
- package/src/components/navigation/sidebar.tsx +13 -0
- package/src/components/team/MembersSection.tsx +7 -0
- package/src/components/team/MembersTable.tsx +38 -3
- package/src/components/team/__tests__/member-detail.test.tsx +223 -0
- package/src/components/team/members-table-default-class-names.ts +3 -0
- package/src/components/team/pages/MemberDetailPanel.tsx +221 -0
- package/src/components/team/pages/index.ts +10 -0
- package/src/components/team/pages/member-detail-panel-default-class-names.ts +68 -0
- package/src/components/team/types.ts +10 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EntitySwitcher — a searchable "which record am I scoped to?" control
|
|
5
|
+
* (startsim-768w.21.1).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately entity-agnostic: it takes `{ id, label, sublabel?, badge? }`
|
|
8
|
+
* items and hands back an id. It knows nothing about companies, foundries,
|
|
9
|
+
* projects or workspaces, so the same control serves all of them — the caller
|
|
10
|
+
* maps its domain objects into `EntitySwitcherItem[]`.
|
|
11
|
+
*
|
|
12
|
+
* Built on the existing Radix popover + Input primitives; no new dependency.
|
|
13
|
+
*
|
|
14
|
+
* Its first consumer is the dark `bg-gray-900` sidebar rail, so the defaults
|
|
15
|
+
* are translucent-over-dark and inherit the surrounding text colour rather
|
|
16
|
+
* than hard-coding a light-only look. Anything that doesn't suit a given
|
|
17
|
+
* surface is overridable via `triggerClassName` / `contentClassName`.
|
|
18
|
+
*
|
|
19
|
+
* a11y follows the WAI-ARIA combobox + listbox pattern:
|
|
20
|
+
* - trigger is `role="combobox"` with `aria-expanded` / `aria-controls`
|
|
21
|
+
* - the list is `role="listbox"`, rows are `role="option"`
|
|
22
|
+
* - `aria-selected` marks the *current* entity (what the app is scoped to)
|
|
23
|
+
* - `aria-activedescendant` (+ `data-highlighted`) tracks the *keyboard*
|
|
24
|
+
* highlight, which moves with ArrowUp/ArrowDown independently of selection
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as React from 'react'
|
|
28
|
+
import { Check, ChevronsUpDown } from 'lucide-react'
|
|
29
|
+
import { cn } from '../../lib/utils'
|
|
30
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
|
31
|
+
import { Input } from '../ui/input'
|
|
32
|
+
|
|
33
|
+
export interface EntitySwitcherItem {
|
|
34
|
+
/** Stable identifier handed back to `onSelect`. */
|
|
35
|
+
id: string
|
|
36
|
+
/** Primary display text — also the first thing the filter matches on. */
|
|
37
|
+
label: string
|
|
38
|
+
/** Secondary line (slug, org, subtitle). Filtered on as well. */
|
|
39
|
+
sublabel?: string
|
|
40
|
+
/** Optional trailing adornment (status pill, count, icon). */
|
|
41
|
+
badge?: React.ReactNode
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EntitySwitcherProps {
|
|
45
|
+
/** Everything the user can switch to. */
|
|
46
|
+
items: EntitySwitcherItem[]
|
|
47
|
+
/** The id currently in scope; drives the trigger label + `aria-selected`. */
|
|
48
|
+
currentId?: string | null
|
|
49
|
+
/** Fired with the chosen item's id. Fires even when it re-picks `currentId`. */
|
|
50
|
+
onSelect: (id: string) => void
|
|
51
|
+
/** Filter input placeholder. */
|
|
52
|
+
placeholder?: string
|
|
53
|
+
/** Accessible name for the filter input. */
|
|
54
|
+
searchLabel?: string
|
|
55
|
+
/** Shown instead of the list when the filter matches nothing. */
|
|
56
|
+
emptyLabel?: string
|
|
57
|
+
/** Trigger text when `currentId` matches no item (or there is none yet). */
|
|
58
|
+
triggerPlaceholder?: string
|
|
59
|
+
/** Set false to drop the filter input (short lists). */
|
|
60
|
+
searchable?: boolean
|
|
61
|
+
/** Override the trigger chrome — e.g. to sit on a light surface. */
|
|
62
|
+
triggerClassName?: string
|
|
63
|
+
/** Override the popover panel chrome (width, padding, colours). */
|
|
64
|
+
contentClassName?: string
|
|
65
|
+
/** Disable the whole control. */
|
|
66
|
+
disabled?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function optionDomId(baseId: string, itemId: string) {
|
|
70
|
+
return `${baseId}-opt-${String(itemId).replace(/[^A-Za-z0-9_-]/g, '_')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function EntitySwitcher({
|
|
74
|
+
items,
|
|
75
|
+
currentId,
|
|
76
|
+
onSelect,
|
|
77
|
+
placeholder = 'Search…',
|
|
78
|
+
searchLabel = 'Search',
|
|
79
|
+
emptyLabel = 'No matches',
|
|
80
|
+
triggerPlaceholder = 'Select…',
|
|
81
|
+
searchable = true,
|
|
82
|
+
triggerClassName,
|
|
83
|
+
contentClassName,
|
|
84
|
+
disabled,
|
|
85
|
+
}: EntitySwitcherProps) {
|
|
86
|
+
const baseId = React.useId()
|
|
87
|
+
const listboxId = `${baseId}-listbox`
|
|
88
|
+
const inputId = `${baseId}-filter`
|
|
89
|
+
|
|
90
|
+
const [open, setOpen] = React.useState(false)
|
|
91
|
+
const [query, setQuery] = React.useState('')
|
|
92
|
+
const [highlight, setHighlight] = React.useState(0)
|
|
93
|
+
|
|
94
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
95
|
+
const listRef = React.useRef<HTMLUListElement>(null)
|
|
96
|
+
|
|
97
|
+
const current = items.find((i) => i.id === currentId)
|
|
98
|
+
|
|
99
|
+
const filtered = React.useMemo(() => {
|
|
100
|
+
const q = query.trim().toLowerCase()
|
|
101
|
+
if (!q) return items
|
|
102
|
+
return items.filter(
|
|
103
|
+
(i) =>
|
|
104
|
+
i.label.toLowerCase().includes(q) ||
|
|
105
|
+
(i.sublabel ? i.sublabel.toLowerCase().includes(q) : false)
|
|
106
|
+
)
|
|
107
|
+
}, [items, query])
|
|
108
|
+
|
|
109
|
+
// Keep the highlight inside the (possibly just-filtered) list.
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
setHighlight((h) => (filtered.length === 0 ? 0 : Math.min(h, filtered.length - 1)))
|
|
112
|
+
}, [filtered.length])
|
|
113
|
+
|
|
114
|
+
const handleOpenChange = (next: boolean) => {
|
|
115
|
+
setOpen(next)
|
|
116
|
+
if (next) {
|
|
117
|
+
setQuery('')
|
|
118
|
+
// Open on whatever is currently in scope, so Enter is a no-op re-pick
|
|
119
|
+
// rather than a surprise jump to the first row.
|
|
120
|
+
const idx = items.findIndex((i) => i.id === currentId)
|
|
121
|
+
setHighlight(idx >= 0 ? idx : 0)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const choose = (id: string) => {
|
|
126
|
+
onSelect(id)
|
|
127
|
+
setOpen(false)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
131
|
+
if (event.key === 'ArrowDown') {
|
|
132
|
+
event.preventDefault()
|
|
133
|
+
if (filtered.length === 0) return
|
|
134
|
+
setHighlight((h) => (h + 1) % filtered.length)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (event.key === 'ArrowUp') {
|
|
138
|
+
event.preventDefault()
|
|
139
|
+
if (filtered.length === 0) return
|
|
140
|
+
setHighlight((h) => (h - 1 + filtered.length) % filtered.length)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (event.key === 'Home') {
|
|
144
|
+
event.preventDefault()
|
|
145
|
+
setHighlight(0)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (event.key === 'End') {
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
setHighlight(Math.max(filtered.length - 1, 0))
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (event.key === 'Enter') {
|
|
154
|
+
const item = filtered[highlight]
|
|
155
|
+
if (!item) return
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
choose(item.id)
|
|
158
|
+
}
|
|
159
|
+
// Escape is left to the popover's dismiss layer, which also restores
|
|
160
|
+
// focus to the trigger.
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const activeItem = filtered[highlight]
|
|
164
|
+
const activeDescendant = activeItem ? optionDomId(baseId, activeItem.id) : undefined
|
|
165
|
+
|
|
166
|
+
// Keep the highlighted row in view on long lists.
|
|
167
|
+
React.useEffect(() => {
|
|
168
|
+
if (!open || !activeDescendant) return
|
|
169
|
+
const el = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(activeDescendant)}`)
|
|
170
|
+
el?.scrollIntoView?.({ block: 'nearest' })
|
|
171
|
+
}, [open, activeDescendant])
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
175
|
+
<PopoverTrigger asChild>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
role="combobox"
|
|
179
|
+
aria-expanded={open}
|
|
180
|
+
aria-controls={listboxId}
|
|
181
|
+
aria-haspopup="listbox"
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
className={cn(
|
|
184
|
+
'flex w-full items-center justify-between gap-2 rounded-md border border-white/15 bg-white/5 px-3 py-2',
|
|
185
|
+
'text-left text-sm font-medium text-inherit transition-colors',
|
|
186
|
+
'hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40',
|
|
187
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
188
|
+
triggerClassName
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
<span className="min-w-0 flex-1 truncate">{current ? current.label : triggerPlaceholder}</span>
|
|
192
|
+
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-60" aria-hidden="true" />
|
|
193
|
+
</button>
|
|
194
|
+
</PopoverTrigger>
|
|
195
|
+
|
|
196
|
+
<PopoverContent
|
|
197
|
+
align="start"
|
|
198
|
+
className={cn('w-64 p-0', contentClassName)}
|
|
199
|
+
onOpenAutoFocus={(event) => {
|
|
200
|
+
// Focus the filter (or the list) rather than the panel itself, so the
|
|
201
|
+
// very first ArrowDown moves the highlight.
|
|
202
|
+
event.preventDefault()
|
|
203
|
+
if (searchable) inputRef.current?.focus()
|
|
204
|
+
else listRef.current?.focus()
|
|
205
|
+
}}
|
|
206
|
+
onKeyDown={handleKeyDown}
|
|
207
|
+
>
|
|
208
|
+
{searchable && (
|
|
209
|
+
<div className="border-b p-2">
|
|
210
|
+
<label className="sr-only" htmlFor={inputId}>
|
|
211
|
+
{searchLabel}
|
|
212
|
+
</label>
|
|
213
|
+
<Input
|
|
214
|
+
id={inputId}
|
|
215
|
+
ref={inputRef}
|
|
216
|
+
type="text"
|
|
217
|
+
value={query}
|
|
218
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
219
|
+
placeholder={placeholder}
|
|
220
|
+
autoComplete="off"
|
|
221
|
+
aria-autocomplete="list"
|
|
222
|
+
aria-controls={listboxId}
|
|
223
|
+
aria-activedescendant={activeDescendant}
|
|
224
|
+
className="h-8"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<ul
|
|
230
|
+
ref={listRef}
|
|
231
|
+
id={listboxId}
|
|
232
|
+
role="listbox"
|
|
233
|
+
tabIndex={searchable ? -1 : 0}
|
|
234
|
+
aria-label={searchLabel}
|
|
235
|
+
aria-activedescendant={searchable ? undefined : activeDescendant}
|
|
236
|
+
className="max-h-64 overflow-y-auto p-1 outline-none"
|
|
237
|
+
>
|
|
238
|
+
{filtered.map((item, index) => {
|
|
239
|
+
const isCurrent = item.id === currentId
|
|
240
|
+
const isHighlighted = index === highlight
|
|
241
|
+
return (
|
|
242
|
+
<li
|
|
243
|
+
key={item.id}
|
|
244
|
+
id={optionDomId(baseId, item.id)}
|
|
245
|
+
role="option"
|
|
246
|
+
aria-selected={isCurrent}
|
|
247
|
+
data-highlighted={isHighlighted}
|
|
248
|
+
onClick={() => choose(item.id)}
|
|
249
|
+
onMouseEnter={() => setHighlight(index)}
|
|
250
|
+
className={cn(
|
|
251
|
+
'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
|
|
252
|
+
isHighlighted && 'bg-accent text-accent-foreground'
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
<Check
|
|
256
|
+
className={cn('h-4 w-4 flex-shrink-0', isCurrent ? 'opacity-100' : 'opacity-0')}
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
/>
|
|
259
|
+
<span className="min-w-0 flex-1">
|
|
260
|
+
<span className="block truncate">{item.label}</span>
|
|
261
|
+
{item.sublabel && (
|
|
262
|
+
<span
|
|
263
|
+
className={cn(
|
|
264
|
+
'block truncate text-xs',
|
|
265
|
+
// The sublabel has to follow the row's foreground, not
|
|
266
|
+
// the panel's. `text-muted-foreground` is picked to sit
|
|
267
|
+
// on `bg-popover`; left alone it stays that grey when
|
|
268
|
+
// the row flips to `bg-accent`, and on a saturated
|
|
269
|
+
// accent that lands at ~1.1:1 — browser-verified as
|
|
270
|
+
// washed out to the point of unreadable
|
|
271
|
+
// (startsim-768w.21.1.3). Dimming the accent foreground
|
|
272
|
+
// instead keeps the primary/secondary hierarchy while
|
|
273
|
+
// staying legible on whatever accent the theme sets.
|
|
274
|
+
isHighlighted ? 'text-accent-foreground/80' : 'text-muted-foreground'
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{item.sublabel}
|
|
278
|
+
</span>
|
|
279
|
+
)}
|
|
280
|
+
</span>
|
|
281
|
+
{item.badge ? <span className="flex-shrink-0">{item.badge}</span> : null}
|
|
282
|
+
</li>
|
|
283
|
+
)
|
|
284
|
+
})}
|
|
285
|
+
</ul>
|
|
286
|
+
|
|
287
|
+
{filtered.length === 0 && (
|
|
288
|
+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyLabel}</div>
|
|
289
|
+
)}
|
|
290
|
+
</PopoverContent>
|
|
291
|
+
</Popover>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Navigation chrome: the app rail, the shell that assembles it, grouped nav
|
|
2
|
+
// sections and the generic entity switcher that scopes the rail.
|
|
3
|
+
export * from './sidebar'
|
|
4
|
+
export * from './app-shell'
|
|
5
|
+
export * from './grouped-nav'
|
|
6
|
+
export * from './entity-switcher'
|
|
@@ -27,6 +27,13 @@ export interface SidebarProps {
|
|
|
27
27
|
* Navigation sections
|
|
28
28
|
*/
|
|
29
29
|
sections: SidebarSection[];
|
|
30
|
+
/**
|
|
31
|
+
* Contextual switcher rendered directly under the logo, above the nav —
|
|
32
|
+
* "which record is this rail scoped to?" (e.g. an <EntitySwitcher> over the
|
|
33
|
+
* user's foundries). Hidden while collapsed: a 5rem rail can only show a
|
|
34
|
+
* truncated combobox, which is noise rather than navigation.
|
|
35
|
+
*/
|
|
36
|
+
switcher?: ReactNode;
|
|
30
37
|
/**
|
|
31
38
|
* User dropdown content (rendered at bottom of sidebar)
|
|
32
39
|
*/
|
|
@@ -56,6 +63,7 @@ export interface SidebarProps {
|
|
|
56
63
|
export function Sidebar({
|
|
57
64
|
appName,
|
|
58
65
|
sections,
|
|
66
|
+
switcher,
|
|
59
67
|
userDropdown,
|
|
60
68
|
className,
|
|
61
69
|
collapsible = true,
|
|
@@ -99,6 +107,11 @@ export function Sidebar({
|
|
|
99
107
|
</Link>
|
|
100
108
|
</div>
|
|
101
109
|
|
|
110
|
+
{/* Contextual switcher (scope of this rail) */}
|
|
111
|
+
{switcher && !collapsed && (
|
|
112
|
+
<div className="px-4 pt-4">{switcher}</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
102
115
|
{/* Navigation */}
|
|
103
116
|
<nav className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
104
117
|
{sections.map((section, idx) => (
|
|
@@ -38,6 +38,11 @@ export interface MembersSectionProps {
|
|
|
38
38
|
onResendInvite?: (userId: string) => void | Promise<void>
|
|
39
39
|
/** Suspend/restore a membership without deleting it (soft exit). */
|
|
40
40
|
onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
|
|
41
|
+
/**
|
|
42
|
+
* Drill into one person (startsim-768w.19.6). Passed straight through to the
|
|
43
|
+
* table, which makes each row a click target — the way into MemberDetailPanel.
|
|
44
|
+
*/
|
|
45
|
+
onSelectMember?: (userId: string) => void
|
|
41
46
|
/** When set, the invite dialog is rendered next to the heading. */
|
|
42
47
|
onInvite?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
|
|
43
48
|
onInvited?: (invitation: InvitationLite | void) => void
|
|
@@ -74,6 +79,7 @@ export function MembersSection({
|
|
|
74
79
|
onRemove,
|
|
75
80
|
onResendInvite,
|
|
76
81
|
onSetActive,
|
|
82
|
+
onSelectMember,
|
|
77
83
|
onInvite,
|
|
78
84
|
onInvited,
|
|
79
85
|
availableRoles,
|
|
@@ -128,6 +134,7 @@ export function MembersSection({
|
|
|
128
134
|
onRemove={onRemove}
|
|
129
135
|
onResendInvite={onResendInvite}
|
|
130
136
|
onSetActive={onSetActive}
|
|
137
|
+
onSelectMember={onSelectMember}
|
|
131
138
|
emptyState={emptyState}
|
|
132
139
|
classNames={classNames?.table}
|
|
133
140
|
/>
|
|
@@ -52,6 +52,14 @@ export interface MembersTableProps {
|
|
|
52
52
|
* active and Restore once `isActive === false`.
|
|
53
53
|
*/
|
|
54
54
|
onSetActive?: (userId: string, isActive: boolean) => void | Promise<void>
|
|
55
|
+
/**
|
|
56
|
+
* Drill into one person (startsim-768w.19.6). When set, each row becomes a
|
|
57
|
+
* button-row: clicking it hands back that member's user id so the host can
|
|
58
|
+
* open the detail surface. The row ACTIONS (role, resend, deactivate, remove)
|
|
59
|
+
* deliberately stop the click, so managing a member never doubles as
|
|
60
|
+
* selecting them.
|
|
61
|
+
*/
|
|
62
|
+
onSelectMember?: (userId: string) => void
|
|
55
63
|
/** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
|
|
56
64
|
emptyState?: React.ReactNode
|
|
57
65
|
/**
|
|
@@ -120,6 +128,7 @@ export function MembersTable({
|
|
|
120
128
|
onRemove,
|
|
121
129
|
onResendInvite,
|
|
122
130
|
onSetActive,
|
|
131
|
+
onSelectMember,
|
|
123
132
|
emptyState,
|
|
124
133
|
showStatus,
|
|
125
134
|
showLastActive,
|
|
@@ -178,8 +187,26 @@ export function MembersTable({
|
|
|
178
187
|
const canResend =
|
|
179
188
|
canManage && !!onResendInvite &&
|
|
180
189
|
(m.inviteStatus === 'pending' || m.inviteStatus === 'expired')
|
|
190
|
+
const selectable = !!onSelectMember
|
|
181
191
|
return (
|
|
182
|
-
<tr
|
|
192
|
+
<tr
|
|
193
|
+
key={m.id}
|
|
194
|
+
className={selectable ? `${cls.row} ${cls.selectableRow}`.trim() : cls.row}
|
|
195
|
+
onClick={selectable ? () => onSelectMember!(m.userId) : undefined}
|
|
196
|
+
onKeyDown={
|
|
197
|
+
selectable
|
|
198
|
+
? (e) => {
|
|
199
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
onSelectMember!(m.userId)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
: undefined
|
|
205
|
+
}
|
|
206
|
+
tabIndex={selectable ? 0 : undefined}
|
|
207
|
+
role={selectable ? 'button' : undefined}
|
|
208
|
+
aria-label={selectable ? `View ${displayName}` : undefined}
|
|
209
|
+
>
|
|
183
210
|
<td className={cls.cell}>
|
|
184
211
|
<div className="flex items-center gap-3">
|
|
185
212
|
{m.user?.avatarUrl ? (
|
|
@@ -228,7 +255,12 @@ export function MembersTable({
|
|
|
228
255
|
)}
|
|
229
256
|
</td>
|
|
230
257
|
)}
|
|
231
|
-
|
|
258
|
+
{/* Role + actions are CONTROLS, not navigation: a click here
|
|
259
|
+
must change the member, never also select them. */}
|
|
260
|
+
<td
|
|
261
|
+
className={tight(cls.cell, cls.tightColumn)}
|
|
262
|
+
onClick={(e) => e.stopPropagation()}
|
|
263
|
+
>
|
|
232
264
|
{canManage && onChangeRole ? (
|
|
233
265
|
<RoleSelector
|
|
234
266
|
value={m.role}
|
|
@@ -240,7 +272,10 @@ export function MembersTable({
|
|
|
240
272
|
)}
|
|
241
273
|
</td>
|
|
242
274
|
{canManage && (
|
|
243
|
-
<td
|
|
275
|
+
<td
|
|
276
|
+
className={tight(cls.cell, cls.tightColumn)}
|
|
277
|
+
onClick={(e) => e.stopPropagation()}
|
|
278
|
+
>
|
|
244
279
|
<div className={cls.actions}>
|
|
245
280
|
{canResend && (
|
|
246
281
|
<button
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member detail — "what has this person done" (startsim-768w.19.6 / RED .19.6.1).
|
|
3
|
+
*
|
|
4
|
+
* The live foundry Members tab renders rows nobody can drill into. There is no
|
|
5
|
+
* per-member surface anywhere: last login, activation/invite state, who invited
|
|
6
|
+
* them, when their role last changed and — the actual question — what they have
|
|
7
|
+
* DONE are all unanswerable from the UI, even though the backend has recorded
|
|
8
|
+
* every one of those facts for months.
|
|
9
|
+
*
|
|
10
|
+
* THE DESIGN GUARD, stated once: a member's activity is not a new feed. It is
|
|
11
|
+
* THE one permission-filtered event stream narrowed by actor, so this panel
|
|
12
|
+
* embeds the shared `ActivityFeedPanel` with a client the host pre-scoped to
|
|
13
|
+
* that actor. There is no second timeline, no second client and no level check
|
|
14
|
+
* below this line — packages/ui decides nothing about visibility.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs'
|
|
17
|
+
import * as path from 'node:path'
|
|
18
|
+
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
|
19
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
20
|
+
|
|
21
|
+
import { MembersTable, MembersSection, MemberDetailPanel } from '../index'
|
|
22
|
+
import type { MemberRow } from '../types'
|
|
23
|
+
import type { ActivityPage, ActivityQuery } from '../../activity'
|
|
24
|
+
|
|
25
|
+
// --------------------------------------------------------------------------
|
|
26
|
+
// helpers
|
|
27
|
+
// --------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function withQuery(ui: React.ReactElement) {
|
|
30
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
31
|
+
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const activated: MemberRow = {
|
|
35
|
+
id: 'm1',
|
|
36
|
+
userId: 'u1',
|
|
37
|
+
teamId: 'marketing-agents',
|
|
38
|
+
role: 'admin',
|
|
39
|
+
joinedAt: '2026-05-02T09:00:00Z',
|
|
40
|
+
inviteStatus: 'active',
|
|
41
|
+
lastLoginAt: '2026-07-25T18:30:00Z',
|
|
42
|
+
activatedAt: '2026-05-03T11:00:00Z',
|
|
43
|
+
roleChangedAt: '2026-06-14T08:00:00Z',
|
|
44
|
+
invitedByEmail: 'owner@ogmc.ai',
|
|
45
|
+
isActive: true,
|
|
46
|
+
user: { id: 'u1', email: 'nordby@ogmc.ai', firstName: 'Erik', lastName: 'Nordby' },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const neverSignedIn: MemberRow = {
|
|
50
|
+
id: 'm2',
|
|
51
|
+
userId: 'u2',
|
|
52
|
+
teamId: 'marketing-agents',
|
|
53
|
+
role: 'member',
|
|
54
|
+
joinedAt: '2026-07-20T00:00:00Z',
|
|
55
|
+
inviteStatus: 'expired',
|
|
56
|
+
lastLoginAt: null,
|
|
57
|
+
inviteExpiresAt: '2026-07-27T00:00:00Z',
|
|
58
|
+
invitedByEmail: 'owner@ogmc.ai',
|
|
59
|
+
isActive: true,
|
|
60
|
+
user: { id: 'u2', email: 'lapsed@ogmc.ai' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An activity client the HOST already narrowed to one actor — exactly what the
|
|
64
|
+
* panel receives, and the only reason it needs no filtering of its own. */
|
|
65
|
+
function scopedClient(rows: Array<{ id: string; action: string; created_at: string }>) {
|
|
66
|
+
const calls: ActivityQuery[] = []
|
|
67
|
+
return {
|
|
68
|
+
calls,
|
|
69
|
+
client: {
|
|
70
|
+
list: (params: ActivityQuery): Promise<ActivityPage> => {
|
|
71
|
+
calls.push(params)
|
|
72
|
+
return Promise.resolve({ count: rows.length, next: null, previous: null, results: rows })
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const oneEvent = [
|
|
79
|
+
{ id: 'e1', action: 'member_invited', created_at: '2026-07-25T18:31:00Z' },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
// --------------------------------------------------------------------------
|
|
83
|
+
// 1. the panel exists and answers the five questions
|
|
84
|
+
// --------------------------------------------------------------------------
|
|
85
|
+
describe('MemberDetailPanel', () => {
|
|
86
|
+
it('renders identity, last login, invite state and role history', async () => {
|
|
87
|
+
const { client } = scopedClient(oneEvent)
|
|
88
|
+
withQuery(<MemberDetailPanel member={activated} activityClient={client} />)
|
|
89
|
+
|
|
90
|
+
expect(screen.getByText('Erik Nordby')).toBeInTheDocument()
|
|
91
|
+
expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
|
|
92
|
+
|
|
93
|
+
const facts = screen.getByTestId('member-facts')
|
|
94
|
+
expect(within(facts).getByTestId('fact-last-login')).toHaveTextContent(/2026/)
|
|
95
|
+
expect(within(facts).getByTestId('fact-activated')).toHaveTextContent(/2026/)
|
|
96
|
+
expect(within(facts).getByTestId('fact-invited-by')).toHaveTextContent('owner@ogmc.ai')
|
|
97
|
+
expect(within(facts).getByTestId('fact-role-changed')).toHaveTextContent(/2026/)
|
|
98
|
+
expect(screen.getByTestId('member-invite-status')).toHaveAttribute(
|
|
99
|
+
'data-invite-status', 'active',
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('says so explicitly when someone has never signed in', async () => {
|
|
104
|
+
const { client } = scopedClient([])
|
|
105
|
+
withQuery(<MemberDetailPanel member={neverSignedIn} activityClient={client} />)
|
|
106
|
+
expect(screen.getByTestId('fact-last-login')).toHaveTextContent(/never/i)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('embeds the shared activity feed, driven by the injected scoped client', async () => {
|
|
110
|
+
const { client, calls } = scopedClient(oneEvent)
|
|
111
|
+
withQuery(<MemberDetailPanel member={activated} activityClient={client} />)
|
|
112
|
+
|
|
113
|
+
await waitFor(() => expect(screen.getByTestId('activity-feed-panel')).toBeInTheDocument())
|
|
114
|
+
await waitFor(() => expect(calls.length).toBeGreaterThan(0))
|
|
115
|
+
await waitFor(() =>
|
|
116
|
+
expect(within(screen.getByTestId('activity-timeline')).getByText('Member invited'))
|
|
117
|
+
.toBeInTheDocument(),
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('offers Resend for a pending/expired member and calls onResendInvite', async () => {
|
|
122
|
+
const { client } = scopedClient([])
|
|
123
|
+
const onResendInvite = jest.fn()
|
|
124
|
+
withQuery(
|
|
125
|
+
<MemberDetailPanel
|
|
126
|
+
member={neverSignedIn}
|
|
127
|
+
activityClient={client}
|
|
128
|
+
canManage
|
|
129
|
+
onResendInvite={onResendInvite}
|
|
130
|
+
/>,
|
|
131
|
+
)
|
|
132
|
+
fireEvent.click(screen.getByRole('button', { name: /resend invite/i }))
|
|
133
|
+
expect(onResendInvite).toHaveBeenCalledWith('u2')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not offer Resend to someone who has already activated', async () => {
|
|
137
|
+
const { client } = scopedClient([])
|
|
138
|
+
withQuery(
|
|
139
|
+
<MemberDetailPanel
|
|
140
|
+
member={activated}
|
|
141
|
+
activityClient={client}
|
|
142
|
+
canManage
|
|
143
|
+
onResendInvite={jest.fn()}
|
|
144
|
+
/>,
|
|
145
|
+
)
|
|
146
|
+
expect(screen.queryByRole('button', { name: /resend invite/i })).toBeNull()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// --------------------------------------------------------------------------
|
|
151
|
+
// 2. the roster row is the way in
|
|
152
|
+
// --------------------------------------------------------------------------
|
|
153
|
+
describe('MembersTable selection', () => {
|
|
154
|
+
it('calls onSelectMember with the user id when a row is clicked', () => {
|
|
155
|
+
const onSelectMember = jest.fn()
|
|
156
|
+
render(<MembersTable members={[activated, neverSignedIn]} onSelectMember={onSelectMember} />)
|
|
157
|
+
fireEvent.click(screen.getByText('nordby@ogmc.ai'))
|
|
158
|
+
expect(onSelectMember).toHaveBeenCalledWith('u1')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('does not select the row when a row ACTION is clicked', () => {
|
|
162
|
+
const onSelectMember = jest.fn()
|
|
163
|
+
const onRemove = jest.fn()
|
|
164
|
+
render(
|
|
165
|
+
<MembersTable
|
|
166
|
+
members={[activated]}
|
|
167
|
+
canManage
|
|
168
|
+
onRemove={onRemove}
|
|
169
|
+
onSelectMember={onSelectMember}
|
|
170
|
+
/>,
|
|
171
|
+
)
|
|
172
|
+
fireEvent.click(screen.getByRole('button', { name: /remove/i }))
|
|
173
|
+
expect(onRemove).toHaveBeenCalledWith('u1')
|
|
174
|
+
expect(onSelectMember).not.toHaveBeenCalled()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('still renders the Last active column', () => {
|
|
178
|
+
render(<MembersTable members={[activated, neverSignedIn]} />)
|
|
179
|
+
expect(screen.getByText('Last active')).toBeInTheDocument()
|
|
180
|
+
expect(screen.getByText('Never')).toBeInTheDocument()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('MembersSection passes selection through to the table', () => {
|
|
184
|
+
const onSelectMember = jest.fn()
|
|
185
|
+
render(<MembersSection members={[activated]} onSelectMember={onSelectMember} />)
|
|
186
|
+
fireEvent.click(screen.getByText('nordby@ogmc.ai'))
|
|
187
|
+
expect(onSelectMember).toHaveBeenCalledWith('u1')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// --------------------------------------------------------------------------
|
|
192
|
+
// 3. DESIGN GUARD — one feed, one client, no visibility logic
|
|
193
|
+
// --------------------------------------------------------------------------
|
|
194
|
+
describe('member detail design guards', () => {
|
|
195
|
+
const src = () =>
|
|
196
|
+
fs.readFileSync(
|
|
197
|
+
path.resolve(__dirname, '..', 'pages', 'MemberDetailPanel.tsx'),
|
|
198
|
+
'utf8',
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
it('lives in packages/ui beside the other page composers', () => {
|
|
202
|
+
expect(fs.existsSync(path.resolve(__dirname, '..', 'pages', 'MemberDetailPanel.tsx')))
|
|
203
|
+
.toBe(true)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('embeds the shared ActivityFeedPanel rather than its own timeline', () => {
|
|
207
|
+
expect(src()).toMatch(/ActivityFeedPanel/)
|
|
208
|
+
expect(src()).not.toMatch(/useInfiniteQuery/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('imports no API client and makes no visibility decision', () => {
|
|
212
|
+
const text = src()
|
|
213
|
+
// The client is INJECTED — packages/ui never takes @startsimpli/api as a
|
|
214
|
+
// dependency. (Naming it in prose is fine; importing it is not.)
|
|
215
|
+
expect(text).not.toMatch(/from\s+['"]@startsimpli\/api/)
|
|
216
|
+
for (const banned of [
|
|
217
|
+
/is_org_admin/, /isOrgAdmin/, /accessLevel/, /resolve_level/,
|
|
218
|
+
/visible_actions/, /\brole\s*===\s*'owner'/,
|
|
219
|
+
]) {
|
|
220
|
+
expect(`${banned.source}:${banned.test(text)}`).toBe(`${banned.source}:false`)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -8,6 +8,8 @@ export interface MembersTableClassNames {
|
|
|
8
8
|
th?: string
|
|
9
9
|
tbody?: string
|
|
10
10
|
row?: string
|
|
11
|
+
/** Added to a row when `onSelectMember` makes it a drill-down affordance. */
|
|
12
|
+
selectableRow?: string
|
|
11
13
|
cell?: string
|
|
12
14
|
avatar?: string
|
|
13
15
|
name?: string
|
|
@@ -46,6 +48,7 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
|
46
48
|
th: 'px-4 py-2.5 font-medium',
|
|
47
49
|
tbody: 'divide-y divide-border',
|
|
48
50
|
row: 'transition-colors hover:bg-muted/40',
|
|
51
|
+
selectableRow: 'cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
49
52
|
cell: 'px-4 py-3 align-middle',
|
|
50
53
|
avatar:
|
|
51
54
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary',
|