@startsimpli/ui 0.4.84 → 0.4.86
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/index.ts +2 -4
- package/src/components/navigation/__tests__/entity-switcher.test.tsx +258 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +103 -1
- 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 +45 -10
- package/src/components/team/MembersSection.tsx +56 -1
- package/src/components/team/MembersTable.tsx +49 -1
- package/src/components/team/__tests__/member-provenance.test.tsx +132 -0
- package/src/components/team/index.ts +4 -1
- package/src/components/team/members-section-default-class-names.ts +8 -0
- package/src/components/team/members-table-default-class-names.ts +13 -0
- package/src/components/team/types.ts +13 -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,
|
|
@@ -63,7 +71,10 @@ export function Sidebar({
|
|
|
63
71
|
collapsed: controlledCollapsed,
|
|
64
72
|
onCollapsedChange,
|
|
65
73
|
}: SidebarProps) {
|
|
66
|
-
|
|
74
|
+
// usePathname() is null outside an app-router provider (harnesses, tests,
|
|
75
|
+
// a pages-router host). Shared chrome renders there too, so treat it as
|
|
76
|
+
// "no current page" rather than throwing on `null.startsWith`.
|
|
77
|
+
const pathname = usePathname() ?? '';
|
|
67
78
|
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
|
|
68
79
|
const collapsed = controlledCollapsed ?? internalCollapsed;
|
|
69
80
|
const setCollapsed = (next: boolean) => {
|
|
@@ -71,12 +82,19 @@ export function Sidebar({
|
|
|
71
82
|
onCollapsedChange?.(next);
|
|
72
83
|
};
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
/**
|
|
86
|
+
* "You are here", by path SEGMENT rather than by string prefix.
|
|
87
|
+
*
|
|
88
|
+
* A prefix test lights up peers that merely share a stem —
|
|
89
|
+
* `/foundry/x/builds`.startsWith(`/foundry/x/build`) — and once a control
|
|
90
|
+
* plane puts its whole section list in the rail (startsim-768w.21.3) that
|
|
91
|
+
* pair is real: Build with AI and Builds sat next to each other, both lit.
|
|
92
|
+
* Requiring the next character to be a boundary keeps parent links current on
|
|
93
|
+
* their own sub-pages (`/leads` on `/leads/42`) without the collision. `/` is
|
|
94
|
+
* exact by the same rule, since nothing else starts with `//`.
|
|
95
|
+
*/
|
|
96
|
+
const isActivePath = (href: string) =>
|
|
97
|
+
pathname === href || pathname.startsWith(`${href}/`);
|
|
80
98
|
|
|
81
99
|
return (
|
|
82
100
|
<div
|
|
@@ -99,12 +117,26 @@ export function Sidebar({
|
|
|
99
117
|
</Link>
|
|
100
118
|
</div>
|
|
101
119
|
|
|
120
|
+
{/* Contextual switcher (scope of this rail) */}
|
|
121
|
+
{switcher && !collapsed && (
|
|
122
|
+
<div className="px-4 pt-4">{switcher}</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
102
125
|
{/* Navigation */}
|
|
103
126
|
<nav className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
104
127
|
{sections.map((section, idx) => (
|
|
105
|
-
|
|
128
|
+
// A named group, not an anonymous div: a titled section is a distinct
|
|
129
|
+
// scope ("the sections of THIS foundry"), and the name has to survive
|
|
130
|
+
// the collapsed rail hiding the heading — hence aria-label rather than
|
|
131
|
+
// aria-labelledby (startsim-768w.21.3).
|
|
132
|
+
<div key={idx} role="group" aria-label={section.title}>
|
|
106
133
|
{section.title && !collapsed && (
|
|
107
|
-
|
|
134
|
+
// A group title can be a user-supplied record name (a foundry's
|
|
135
|
+
// branding), so it truncates instead of wrapping the rail open.
|
|
136
|
+
<h3
|
|
137
|
+
className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-3 truncate"
|
|
138
|
+
title={section.title}
|
|
139
|
+
>
|
|
108
140
|
{section.title}
|
|
109
141
|
</h3>
|
|
110
142
|
)}
|
|
@@ -115,6 +147,9 @@ export function Sidebar({
|
|
|
115
147
|
<Link
|
|
116
148
|
key={link.href}
|
|
117
149
|
href={link.href}
|
|
150
|
+
// Colour is not an accessible name — say "you are here".
|
|
151
|
+
aria-current={isActive ? 'page' : undefined}
|
|
152
|
+
title={collapsed ? link.label : undefined}
|
|
118
153
|
className={cn(
|
|
119
154
|
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
120
155
|
isActive
|
|
@@ -128,7 +163,7 @@ export function Sidebar({
|
|
|
128
163
|
{link.icon}
|
|
129
164
|
</span>
|
|
130
165
|
) : null}
|
|
131
|
-
{!collapsed && <span>{link.label}</span>}
|
|
166
|
+
{!collapsed && <span className="min-w-0 truncate">{link.label}</span>}
|
|
132
167
|
</Link>
|
|
133
168
|
);
|
|
134
169
|
})}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import * as React from 'react'
|
|
19
19
|
import { MembersTable } from './MembersTable'
|
|
20
20
|
import { InviteMemberDialog } from './InviteMemberDialog'
|
|
21
|
-
import type { InvitationLite, MemberRow, TeamRole } from './types'
|
|
21
|
+
import type { InvitationLite, MemberRow, MemberSource, TeamRole } from './types'
|
|
22
22
|
import {
|
|
23
23
|
MEMBERS_SECTION_DEFAULTS,
|
|
24
24
|
type MembersSectionClassNames,
|
|
@@ -62,9 +62,43 @@ export interface MembersSectionProps {
|
|
|
62
62
|
inviteSuccessMessage?: (email: string) => string
|
|
63
63
|
emptyState?: React.ReactNode
|
|
64
64
|
helpText?: React.ReactNode
|
|
65
|
+
/**
|
|
66
|
+
* Narrow the list to ONE population (startsim-768w.20.4): the people who were
|
|
67
|
+
* invited, the people who signed themselves up, or the rows the platform
|
|
68
|
+
* seeded.
|
|
69
|
+
*
|
|
70
|
+
* A CONTROLLED prop, not internal state, and the composer never filters
|
|
71
|
+
* `members` itself. That is deliberate: the list is paginated server-side
|
|
72
|
+
* (repo rule 8), so a browser dropping rows from the page it happens to hold
|
|
73
|
+
* would shrink pages and lose people off the end. The host answers the change
|
|
74
|
+
* by re-querying.
|
|
75
|
+
*/
|
|
76
|
+
sourceFilter?: MemberSourceFilter
|
|
65
77
|
classNames?: MembersSectionClassNames
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
/** `all` is the absence of a filter, not a fourth population. */
|
|
81
|
+
export type MemberSourceFilterValue = MemberSource | 'all'
|
|
82
|
+
|
|
83
|
+
export interface MemberSourceFilter {
|
|
84
|
+
value: MemberSourceFilterValue
|
|
85
|
+
onChange: (value: MemberSourceFilterValue) => void
|
|
86
|
+
/** Override the offered options (default: everyone + the three origins). */
|
|
87
|
+
options?: MemberSourceFilterValue[]
|
|
88
|
+
label?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const SOURCE_FILTER_OPTIONS: MemberSourceFilterValue[] = [
|
|
92
|
+
'all', 'invited', 'self_signup', 'seed',
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const SOURCE_FILTER_LABEL: Record<MemberSourceFilterValue, string> = {
|
|
96
|
+
all: 'Everyone',
|
|
97
|
+
invited: 'Invited',
|
|
98
|
+
self_signup: 'Signed up',
|
|
99
|
+
seed: 'Provisioned',
|
|
100
|
+
}
|
|
101
|
+
|
|
68
102
|
const defaultDescription = (count: number) =>
|
|
69
103
|
`${count} ${count === 1 ? 'person has' : 'people have'} access.`
|
|
70
104
|
|
|
@@ -94,9 +128,11 @@ export function MembersSection({
|
|
|
94
128
|
inviteSuccessMessage = (email) => `Invite sent to ${email} — they'll set a password and join`,
|
|
95
129
|
emptyState,
|
|
96
130
|
helpText,
|
|
131
|
+
sourceFilter,
|
|
97
132
|
classNames,
|
|
98
133
|
}: MembersSectionProps) {
|
|
99
134
|
const cls = { ...MEMBERS_SECTION_DEFAULTS, ...(classNames ?? {}) }
|
|
135
|
+
const sourceOptions = sourceFilter?.options ?? SOURCE_FILTER_OPTIONS
|
|
100
136
|
|
|
101
137
|
return (
|
|
102
138
|
<div className={cls.root}>
|
|
@@ -125,6 +161,25 @@ export function MembersSection({
|
|
|
125
161
|
</div>
|
|
126
162
|
)}
|
|
127
163
|
<div className={cls.body}>
|
|
164
|
+
{sourceFilter && (
|
|
165
|
+
<div className={cls.filterBar}>
|
|
166
|
+
<label htmlFor="members-source-filter" className={cls.filterLabel}>
|
|
167
|
+
{sourceFilter.label ?? 'How they joined'}
|
|
168
|
+
</label>
|
|
169
|
+
<select
|
|
170
|
+
id="members-source-filter"
|
|
171
|
+
className={cls.filterSelect}
|
|
172
|
+
value={sourceFilter.value}
|
|
173
|
+
onChange={(e) =>
|
|
174
|
+
sourceFilter.onChange(e.target.value as MemberSourceFilterValue)
|
|
175
|
+
}
|
|
176
|
+
>
|
|
177
|
+
{sourceOptions.map((o) => (
|
|
178
|
+
<option key={o} value={o}>{SOURCE_FILTER_LABEL[o]}</option>
|
|
179
|
+
))}
|
|
180
|
+
</select>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
128
183
|
<MembersTable
|
|
129
184
|
members={members}
|
|
130
185
|
canManage={canManage}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
userDisplayName,
|
|
26
26
|
userInitials,
|
|
27
27
|
type MemberRow,
|
|
28
|
+
type MemberSource,
|
|
28
29
|
type TeamRole,
|
|
29
30
|
} from './types'
|
|
30
31
|
import {
|
|
@@ -68,6 +69,12 @@ export interface MembersTableProps {
|
|
|
68
69
|
*/
|
|
69
70
|
showStatus?: boolean
|
|
70
71
|
showLastActive?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Force the "how they joined" column on/off (startsim-768w.20.4). Default:
|
|
74
|
+
* shown when any row carries `source`, so a backend that doesn't record
|
|
75
|
+
* provenance renders exactly as before.
|
|
76
|
+
*/
|
|
77
|
+
showSource?: boolean
|
|
71
78
|
classNames?: MembersTableClassNames
|
|
72
79
|
/** Restrict role-selector options (e.g. hide 'owner' when caller isn't owner). */
|
|
73
80
|
availableRoles?: TeamRole[]
|
|
@@ -102,6 +109,22 @@ function rowStatus(m: MemberRow): RowStatus | null {
|
|
|
102
109
|
return m.inviteStatus ?? null
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
/**
|
|
113
|
+
* How they got here, in words a person reads (startsim-768w.20.4). "self_signup"
|
|
114
|
+
* is a wire value; "Signed up" is what the column says.
|
|
115
|
+
*/
|
|
116
|
+
const SOURCE_LABEL: Record<MemberSource, string> = {
|
|
117
|
+
invited: 'Invited',
|
|
118
|
+
self_signup: 'Signed up',
|
|
119
|
+
seed: 'Provisioned',
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SOURCE_TITLE: Record<MemberSource, string> = {
|
|
123
|
+
invited: 'Someone on this team added them by email',
|
|
124
|
+
self_signup: 'They created their own account through the app',
|
|
125
|
+
seed: 'Created by the platform when this workspace was set up',
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
/** Short absolute date. Absolute, not "3 days ago": an owner deciding whether to
|
|
106
129
|
* resend an invite wants the actual day. */
|
|
107
130
|
function formatDate(iso: string): string {
|
|
@@ -132,6 +155,7 @@ export function MembersTable({
|
|
|
132
155
|
emptyState,
|
|
133
156
|
showStatus,
|
|
134
157
|
showLastActive,
|
|
158
|
+
showSource,
|
|
135
159
|
classNames,
|
|
136
160
|
availableRoles,
|
|
137
161
|
currentUserId,
|
|
@@ -139,13 +163,21 @@ export function MembersTable({
|
|
|
139
163
|
const cls = { ...MEMBERS_TABLE_DEFAULTS, ...(classNames ?? {}) }
|
|
140
164
|
const withStatus = showStatus ?? members.some((m) => rowStatus(m) !== null)
|
|
141
165
|
const withLastActive = showLastActive ?? members.some((m) => m.lastLoginAt !== undefined)
|
|
166
|
+
const withSource = showSource ?? members.some((m) => m.source !== undefined)
|
|
142
167
|
const statusClass: Record<RowStatus, string> = {
|
|
143
168
|
active: cls.statusActive,
|
|
144
169
|
pending: cls.statusPending,
|
|
145
170
|
expired: cls.statusExpired,
|
|
146
171
|
deactivated: cls.statusDeactivated,
|
|
147
172
|
}
|
|
148
|
-
const
|
|
173
|
+
const sourceClass: Record<MemberSource, string> = {
|
|
174
|
+
invited: cls.sourceInvited,
|
|
175
|
+
self_signup: cls.sourceSelfSignup,
|
|
176
|
+
seed: cls.sourceSeed,
|
|
177
|
+
}
|
|
178
|
+
const columnCount =
|
|
179
|
+
2 + (withStatus ? 1 : 0) + (withLastActive ? 1 : 0) + (withSource ? 1 : 0)
|
|
180
|
+
+ (canManage ? 1 : 0)
|
|
149
181
|
// Role + actions hug their content so the Member column takes the slack —
|
|
150
182
|
// one shared cell class used to split the table down the middle and leave
|
|
151
183
|
// the controls floating in a gap.
|
|
@@ -160,6 +192,9 @@ export function MembersTable({
|
|
|
160
192
|
{withStatus && (
|
|
161
193
|
<th className={tight(cls.th, cls.tightColumn)} scope="col">Status</th>
|
|
162
194
|
)}
|
|
195
|
+
{withSource && (
|
|
196
|
+
<th className={tight(cls.th, cls.tightColumn)} scope="col">How they joined</th>
|
|
197
|
+
)}
|
|
163
198
|
{withLastActive && (
|
|
164
199
|
<th className={tight(cls.th, cls.tightColumn)} scope="col">Last active</th>
|
|
165
200
|
)}
|
|
@@ -246,6 +281,19 @@ export function MembersTable({
|
|
|
246
281
|
)}
|
|
247
282
|
</td>
|
|
248
283
|
)}
|
|
284
|
+
{withSource && (
|
|
285
|
+
<td className={tight(cls.cell, cls.tightColumn)}>
|
|
286
|
+
{m.source && (
|
|
287
|
+
<span
|
|
288
|
+
className={tight(cls.sourceBadge, sourceClass[m.source])}
|
|
289
|
+
data-member-source={m.source}
|
|
290
|
+
title={SOURCE_TITLE[m.source]}
|
|
291
|
+
>
|
|
292
|
+
{SOURCE_LABEL[m.source]}
|
|
293
|
+
</span>
|
|
294
|
+
)}
|
|
295
|
+
</td>
|
|
296
|
+
)}
|
|
249
297
|
{withLastActive && (
|
|
250
298
|
<td className={tight(cls.cell, cls.tightColumn)}>
|
|
251
299
|
{m.lastLoginAt ? (
|