@startsimpli/ui 0.4.85 → 0.4.87
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/navigation/__tests__/entity-switcher.test.tsx +14 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +103 -1
- package/src/components/navigation/entity-switcher.tsx +10 -0
- package/src/components/navigation/sidebar.tsx +32 -10
- package/src/components/team/MembersSection.tsx +56 -1
- package/src/components/team/MembersTable.tsx +49 -1
- package/src/components/team/OrgSwitcher.tsx +40 -21
- package/src/components/team/__tests__/member-provenance.test.tsx +132 -0
- package/src/components/team/__tests__/team-components.test.tsx +124 -6
- 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/org-switcher-default-class-names.ts +19 -1
- package/src/components/team/types.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.87",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0",
|
|
45
45
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
46
|
-
"@startsimpli/
|
|
47
|
-
"@startsimpli/
|
|
46
|
+
"@startsimpli/api": "^0.5.53",
|
|
47
|
+
"@startsimpli/auth": "^0.4.32"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
|
@@ -43,6 +43,20 @@ describe('EntitySwitcher — trigger', () => {
|
|
|
43
43
|
expect(trigger).toHaveAttribute('aria-controls', listbox.id)
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
+
it('takes an explicit accessible name for the trigger', () => {
|
|
47
|
+
// `combobox` is not a name-from-content role, so the trigger's visible
|
|
48
|
+
// "Acme Robotics" does NOT name it — without this prop it has no accessible
|
|
49
|
+
// name at all. startsim-768w.21.4 needs one (OrgSwitcher's caption is
|
|
50
|
+
// aria-hidden), and it has to carry the label AND the current entity.
|
|
51
|
+
setup()
|
|
52
|
+
expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-label')
|
|
53
|
+
|
|
54
|
+
setup({ triggerAriaLabel: 'Organization: Acme Robotics' })
|
|
55
|
+
expect(
|
|
56
|
+
screen.getAllByRole('combobox', { name: 'Organization: Acme Robotics' })
|
|
57
|
+
).toHaveLength(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
46
60
|
it('takes a triggerClassName override so it can sit on a dark rail', () => {
|
|
47
61
|
setup({ triggerClassName: 'my-dark-trigger' })
|
|
48
62
|
expect(screen.getByRole('combobox')).toHaveClass('my-dark-trigger')
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
1
|
+
import { render, screen, fireEvent, within } from '@testing-library/react'
|
|
2
|
+
import { usePathname } from 'next/navigation'
|
|
2
3
|
import { Sidebar, SidebarLayout } from '../sidebar'
|
|
3
4
|
|
|
5
|
+
const mockPathname = usePathname as unknown as jest.Mock
|
|
6
|
+
|
|
4
7
|
// next/link and next/navigation are mocked via jest.config.js moduleNameMapper
|
|
5
8
|
|
|
6
9
|
const sections = [
|
|
@@ -161,6 +164,105 @@ describe('Sidebar', () => {
|
|
|
161
164
|
})
|
|
162
165
|
})
|
|
163
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// The rail as PRIMARY nav (startsim-768w.21.3)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A control plane puts its whole section list in the rail — a foundry
|
|
173
|
+
* contributes nine scoped links under a titled group. That promotes the rail
|
|
174
|
+
* from "app shortcuts" to the thing you navigate with, and a tab strip used to
|
|
175
|
+
* provide two things for free that the rail owes back:
|
|
176
|
+
*
|
|
177
|
+
* • the current item ANNOUNCED, not just tinted. Colour is not an accessible
|
|
178
|
+
* name; `aria-current="page"` is the standard for "you are here".
|
|
179
|
+
* • "current" decided by path SEGMENT, not by string prefix.
|
|
180
|
+
* '/foundry/x/builds'.startsWith('/foundry/x/build') is true, so a prefix
|
|
181
|
+
* test lights up two peer surfaces at once — and the nine foundry sections
|
|
182
|
+
* include exactly that pair.
|
|
183
|
+
*
|
|
184
|
+
* Plus a section addressable by its title, so a consumer can assert "these
|
|
185
|
+
* links, under this group" without walking up from a heading — and a pathname
|
|
186
|
+
* that may legitimately be null (the hook returns null outside an app-router
|
|
187
|
+
* provider) instead of throwing.
|
|
188
|
+
*/
|
|
189
|
+
describe('Sidebar — which link is current (768w.21.3)', () => {
|
|
190
|
+
const railLinks = [
|
|
191
|
+
{ href: '/', label: 'Foundries' },
|
|
192
|
+
{ href: '/foundry/mcr/build', label: 'Build with AI' },
|
|
193
|
+
{ href: '/foundry/mcr/builds', label: 'Builds' },
|
|
194
|
+
{ href: '/foundry/mcr/members', label: 'Members' },
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
const renderAt = (pathname: string | null) => {
|
|
198
|
+
mockPathname.mockReturnValue(pathname)
|
|
199
|
+
return render(<Sidebar appName="Foundry" sections={[{ links: railLinks }]} />)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const current = () =>
|
|
203
|
+
screen
|
|
204
|
+
.getAllByRole('link')
|
|
205
|
+
.filter((a) => a.getAttribute('aria-current') === 'page')
|
|
206
|
+
.map((a) => a.getAttribute('href'))
|
|
207
|
+
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
mockPathname.mockReturnValue('/test-path')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('announces the current link with aria-current="page"', () => {
|
|
213
|
+
renderAt('/foundry/mcr/members')
|
|
214
|
+
expect(current()).toEqual(['/foundry/mcr/members'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('does not let a prefix sibling light up too', () => {
|
|
218
|
+
renderAt('/foundry/mcr/builds')
|
|
219
|
+
expect(current()).toEqual(['/foundry/mcr/builds'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('keeps a link current on a deeper URL under it', () => {
|
|
223
|
+
renderAt('/foundry/mcr/members/u-42')
|
|
224
|
+
expect(current()).toEqual(['/foundry/mcr/members'])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('marks the root link current only at the root', () => {
|
|
228
|
+
renderAt('/')
|
|
229
|
+
expect(current()).toEqual(['/'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('renders with no current link rather than throwing on a null pathname', () => {
|
|
233
|
+
// usePathname() is null outside an app-router provider — shared chrome gets
|
|
234
|
+
// rendered in harnesses and tests that have no router.
|
|
235
|
+
expect(() => renderAt(null)).not.toThrow()
|
|
236
|
+
expect(current()).toEqual([])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('still tints the current link, so the marker and the pill agree', () => {
|
|
240
|
+
renderAt('/foundry/mcr/builds')
|
|
241
|
+
expect(screen.getByText('Builds').closest('a')).toHaveClass('bg-primary')
|
|
242
|
+
expect(screen.getByText('Build with AI').closest('a')).not.toHaveClass('bg-primary')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('Sidebar — titled groups (768w.21.3)', () => {
|
|
247
|
+
const sectionsWithTitle = [
|
|
248
|
+
{ links: [{ href: '/', label: 'Foundries' }] },
|
|
249
|
+
{ title: 'My Copyright', links: [{ href: '/foundry/mcr/members', label: 'Members' }] },
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
it('exposes a titled section as a group carrying that name', () => {
|
|
253
|
+
render(<Sidebar appName="Foundry" sections={sectionsWithTitle} />)
|
|
254
|
+
const group = screen.getByRole('group', { name: 'My Copyright' })
|
|
255
|
+
expect(within(group).getByRole('link', { name: 'Members' })).toBeInTheDocument()
|
|
256
|
+
expect(within(group).queryByRole('link', { name: 'Foundries' })).toBeNull()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('keeps the group name while collapsed, when the heading is hidden', () => {
|
|
260
|
+
render(<Sidebar appName="Foundry" sections={sectionsWithTitle} defaultCollapsed />)
|
|
261
|
+
expect(screen.queryByText('My Copyright')).toBeNull()
|
|
262
|
+
expect(screen.getByRole('group', { name: 'My Copyright' })).toBeInTheDocument()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
164
266
|
// ---------------------------------------------------------------------------
|
|
165
267
|
// SidebarLayout
|
|
166
268
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +58,14 @@ export interface EntitySwitcherProps {
|
|
|
58
58
|
triggerPlaceholder?: string
|
|
59
59
|
/** Set false to drop the filter input (short lists). */
|
|
60
60
|
searchable?: boolean
|
|
61
|
+
/**
|
|
62
|
+
* Accessible name for the trigger. Left unset, the trigger is named by its
|
|
63
|
+
* own content — the current item's label — which reads correctly when the
|
|
64
|
+
* surrounding chrome already says WHAT is being switched (the sidebar rail).
|
|
65
|
+
* Set it when the control has to name itself, e.g. a switcher dropped into a
|
|
66
|
+
* settings page whose visible caption is `aria-hidden` (see OrgSwitcher).
|
|
67
|
+
*/
|
|
68
|
+
triggerAriaLabel?: string
|
|
61
69
|
/** Override the trigger chrome — e.g. to sit on a light surface. */
|
|
62
70
|
triggerClassName?: string
|
|
63
71
|
/** Override the popover panel chrome (width, padding, colours). */
|
|
@@ -79,6 +87,7 @@ export function EntitySwitcher({
|
|
|
79
87
|
emptyLabel = 'No matches',
|
|
80
88
|
triggerPlaceholder = 'Select…',
|
|
81
89
|
searchable = true,
|
|
90
|
+
triggerAriaLabel,
|
|
82
91
|
triggerClassName,
|
|
83
92
|
contentClassName,
|
|
84
93
|
disabled,
|
|
@@ -179,6 +188,7 @@ export function EntitySwitcher({
|
|
|
179
188
|
aria-expanded={open}
|
|
180
189
|
aria-controls={listboxId}
|
|
181
190
|
aria-haspopup="listbox"
|
|
191
|
+
aria-label={triggerAriaLabel}
|
|
182
192
|
disabled={disabled}
|
|
183
193
|
className={cn(
|
|
184
194
|
'flex w-full items-center justify-between gap-2 rounded-md border border-white/15 bg-white/5 px-3 py-2',
|
|
@@ -71,7 +71,10 @@ export function Sidebar({
|
|
|
71
71
|
collapsed: controlledCollapsed,
|
|
72
72
|
onCollapsedChange,
|
|
73
73
|
}: SidebarProps) {
|
|
74
|
-
|
|
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() ?? '';
|
|
75
78
|
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
|
|
76
79
|
const collapsed = controlledCollapsed ?? internalCollapsed;
|
|
77
80
|
const setCollapsed = (next: boolean) => {
|
|
@@ -79,12 +82,19 @@ export function Sidebar({
|
|
|
79
82
|
onCollapsedChange?.(next);
|
|
80
83
|
};
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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}/`);
|
|
88
98
|
|
|
89
99
|
return (
|
|
90
100
|
<div
|
|
@@ -115,9 +125,18 @@ export function Sidebar({
|
|
|
115
125
|
{/* Navigation */}
|
|
116
126
|
<nav className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
117
127
|
{sections.map((section, idx) => (
|
|
118
|
-
|
|
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}>
|
|
119
133
|
{section.title && !collapsed && (
|
|
120
|
-
|
|
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
|
+
>
|
|
121
140
|
{section.title}
|
|
122
141
|
</h3>
|
|
123
142
|
)}
|
|
@@ -128,6 +147,9 @@ export function Sidebar({
|
|
|
128
147
|
<Link
|
|
129
148
|
key={link.href}
|
|
130
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}
|
|
131
153
|
className={cn(
|
|
132
154
|
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
133
155
|
isActive
|
|
@@ -141,7 +163,7 @@ export function Sidebar({
|
|
|
141
163
|
{link.icon}
|
|
142
164
|
</span>
|
|
143
165
|
) : null}
|
|
144
|
-
{!collapsed && <span>{link.label}</span>}
|
|
166
|
+
{!collapsed && <span className="min-w-0 truncate">{link.label}</span>}
|
|
145
167
|
</Link>
|
|
146
168
|
);
|
|
147
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 ? (
|
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
* OrgSwitcher — pick between Companies the current user belongs to. Useful
|
|
5
5
|
* for accounts that ended up multi-company via cross-domain invitations.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* A thin adapter over the shared <EntitySwitcher> (startsim-768w.21.4): it maps
|
|
8
|
+
* `CompanyLite` -> `EntitySwitcherItem` and hands the chosen id back. The
|
|
9
|
+
* native <select> this used to render (startsim-o7s, "for now") is gone, so
|
|
10
|
+
* packages/ui ships ONE switcher implementation rather than two.
|
|
11
|
+
*
|
|
12
|
+
* The public props and `classNames` slots are unchanged — `select` now styles
|
|
13
|
+
* the combobox trigger that replaced the <select>, and its defaults are
|
|
14
|
+
* semantic tokens tuned for the LIGHT surfaces this renders on, where
|
|
15
|
+
* EntitySwitcher's own translucent-over-dark rail defaults would wash out.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
import * as React from 'react'
|
|
12
19
|
import type { CompanyLite } from './types'
|
|
20
|
+
import { EntitySwitcher, type EntitySwitcherItem } from '../navigation/entity-switcher'
|
|
13
21
|
import {
|
|
14
22
|
ORG_SWITCHER_DEFAULTS,
|
|
15
23
|
type OrgSwitcherClassNames,
|
|
@@ -34,9 +42,20 @@ export function OrgSwitcher({
|
|
|
34
42
|
classNames,
|
|
35
43
|
}: OrgSwitcherProps) {
|
|
36
44
|
const cls = { ...ORG_SWITCHER_DEFAULTS, ...(classNames ?? {}) }
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
|
|
46
|
+
const items = React.useMemo<EntitySwitcherItem[]>(
|
|
47
|
+
// The slug rides along as the sublabel so it is searchable too — two
|
|
48
|
+
// companies can share a display name, their slugs cannot.
|
|
49
|
+
() => companies.map((c) => ({ id: c.id, label: c.name, sublabel: c.slug || undefined })),
|
|
50
|
+
[companies]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// If the caller hasn't pinned currentCompanyId, fall back to the first entry
|
|
54
|
+
// so the control always names a real company — as the <select> did, where a
|
|
55
|
+
// value matching no option made React warn — rather than falling through to
|
|
56
|
+
// a "Select…" placeholder for a list it is already scoped to.
|
|
57
|
+
const currentId = currentCompanyId ?? companies[0]?.id ?? null
|
|
58
|
+
const currentName = companies.find((c) => c.id === currentId)?.name
|
|
40
59
|
|
|
41
60
|
if (companies.length === 0) return null
|
|
42
61
|
|
|
@@ -47,22 +66,22 @@ export function OrgSwitcher({
|
|
|
47
66
|
{label}
|
|
48
67
|
</span>
|
|
49
68
|
)}
|
|
50
|
-
<
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
id
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
aria-label={label}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
<EntitySwitcher
|
|
70
|
+
items={items}
|
|
71
|
+
currentId={currentId}
|
|
72
|
+
// onSwitch may be async; the <select>'s onChange ignored the promise too.
|
|
73
|
+
onSelect={(id) => {
|
|
74
|
+
void onSwitch(id)
|
|
75
|
+
}}
|
|
76
|
+
// Stands in for the sr-only <label htmlFor> + aria-label the <select>
|
|
77
|
+
// carried. The trigger is a button, so a bare `aria-label={label}`
|
|
78
|
+
// would suppress the company name it renders — the accessible name
|
|
79
|
+
// has to carry both parts.
|
|
80
|
+
triggerAriaLabel={currentName ? `${label}: ${currentName}` : label}
|
|
81
|
+
triggerPlaceholder={`Select ${label.toLowerCase()}…`}
|
|
82
|
+
triggerClassName={cls.select}
|
|
83
|
+
contentClassName={cls.content}
|
|
84
|
+
/>
|
|
66
85
|
</span>
|
|
67
86
|
)
|
|
68
87
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED (startsim-768w.20.4.1): the members list cannot show two populations.
|
|
3
|
+
*
|
|
4
|
+
* Every row renders identically today, so an OPEN foundry's roster puts the
|
|
5
|
+
* three people who RUN the app and the two hundred who merely USE it in one
|
|
6
|
+
* undifferentiated table under the heading "N people can access and manage this
|
|
7
|
+
* foundry". Role does not separate them either — an invited viewer and a
|
|
8
|
+
* self-joined viewer are the same word.
|
|
9
|
+
*
|
|
10
|
+
* The provenance is DATA the backend records (768w.20.4.2). These tests fail
|
|
11
|
+
* until the shared list renders it and offers a way to ask for one population,
|
|
12
|
+
* because per rule 9 "how did this person get here" is a members-list concern,
|
|
13
|
+
* not a foundry-app one — /settings/team has the same two populations the
|
|
14
|
+
* moment any app opens its own door.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
18
|
+
import { MembersTable, MembersSection } from '../index'
|
|
19
|
+
import type { MemberRow } from '../types'
|
|
20
|
+
|
|
21
|
+
const rows: MemberRow[] = [
|
|
22
|
+
{
|
|
23
|
+
id: 'm1',
|
|
24
|
+
userId: 'u1',
|
|
25
|
+
teamId: 'qafoundry',
|
|
26
|
+
role: 'owner',
|
|
27
|
+
joinedAt: '2026-01-01T00:00:00Z',
|
|
28
|
+
inviteStatus: 'active',
|
|
29
|
+
source: 'seed',
|
|
30
|
+
user: { id: 'u1', email: 'owner@ogmc.ai' },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'm2',
|
|
34
|
+
userId: 'u2',
|
|
35
|
+
teamId: 'qafoundry',
|
|
36
|
+
role: 'admin',
|
|
37
|
+
joinedAt: '2026-02-01T00:00:00Z',
|
|
38
|
+
inviteStatus: 'active',
|
|
39
|
+
source: 'invited',
|
|
40
|
+
invitedByEmail: 'owner@ogmc.ai',
|
|
41
|
+
user: { id: 'u2', email: 'nordby@ogmc.ai' },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'm3',
|
|
45
|
+
userId: 'u3',
|
|
46
|
+
teamId: 'qafoundry',
|
|
47
|
+
role: 'viewer',
|
|
48
|
+
joinedAt: '2026-07-01T00:00:00Z',
|
|
49
|
+
inviteStatus: 'active',
|
|
50
|
+
source: 'self_signup',
|
|
51
|
+
user: { id: 'u3', email: 'fan@example.com' },
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
describe('MembersTable shows how each person got here (768w.20.4.1)', () => {
|
|
56
|
+
it('renders a joined-how column when the rows carry provenance', () => {
|
|
57
|
+
render(<MembersTable members={rows} />)
|
|
58
|
+
expect(screen.getByRole('columnheader', { name: /how they joined/i })).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('separates an operator from an end user at a glance', () => {
|
|
62
|
+
const { container } = render(<MembersTable members={rows} />)
|
|
63
|
+
const badges = Array.from(container.querySelectorAll('[data-member-source]'))
|
|
64
|
+
expect(badges.map((b) => b.getAttribute('data-member-source'))).toEqual([
|
|
65
|
+
'seed',
|
|
66
|
+
'invited',
|
|
67
|
+
'self_signup',
|
|
68
|
+
])
|
|
69
|
+
// Product language, not the wire value — "self_signup" is not a thing a
|
|
70
|
+
// person reads.
|
|
71
|
+
expect(screen.getByText(/signed up/i)).toBeInTheDocument()
|
|
72
|
+
expect(screen.getByText(/invited/i)).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('drops the column entirely for a backend that does not report it', () => {
|
|
76
|
+
const legacy = rows.map(({ source: _source, ...rest }) => rest)
|
|
77
|
+
render(<MembersTable members={legacy} />)
|
|
78
|
+
expect(screen.queryByRole('columnheader', { name: /how they joined/i })).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('MembersSection asks the SERVER for one population (768w.20.4.1)', () => {
|
|
83
|
+
it('offers a filter and reports the choice back to the host', () => {
|
|
84
|
+
const onSourceChange = jest.fn()
|
|
85
|
+
render(
|
|
86
|
+
<MembersSection
|
|
87
|
+
members={rows}
|
|
88
|
+
teamId="qafoundry"
|
|
89
|
+
sourceFilter={{ value: 'all', onChange: onSourceChange }}
|
|
90
|
+
/>,
|
|
91
|
+
)
|
|
92
|
+
const select = screen.getByLabelText(/how they joined/i) as HTMLSelectElement
|
|
93
|
+
fireEvent.change(select, { target: { value: 'self_signup' } })
|
|
94
|
+
expect(onSourceChange).toHaveBeenCalledWith('self_signup')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('offers exactly the three populations plus "everyone"', () => {
|
|
98
|
+
render(
|
|
99
|
+
<MembersSection
|
|
100
|
+
members={rows}
|
|
101
|
+
teamId="qafoundry"
|
|
102
|
+
sourceFilter={{ value: 'all', onChange: jest.fn() }}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
const select = screen.getByLabelText(/how they joined/i) as HTMLSelectElement
|
|
106
|
+
expect(Array.from(select.options).map((o) => o.value)).toEqual([
|
|
107
|
+
'all',
|
|
108
|
+
'invited',
|
|
109
|
+
'self_signup',
|
|
110
|
+
'seed',
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not filter the rows itself — the list is paginated', () => {
|
|
115
|
+
// A browser-side filter over one PAGE shrinks pages and loses people off the
|
|
116
|
+
// end (repo rule 8). The composer renders exactly what it was handed.
|
|
117
|
+
render(
|
|
118
|
+
<MembersSection
|
|
119
|
+
members={rows}
|
|
120
|
+
teamId="qafoundry"
|
|
121
|
+
sourceFilter={{ value: 'self_signup', onChange: jest.fn() }}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
expect(screen.getByText('owner@ogmc.ai')).toBeInTheDocument()
|
|
125
|
+
expect(screen.getByText('fan@example.com')).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders no filter at all when the host does not offer one', () => {
|
|
129
|
+
render(<MembersSection members={rows} teamId="qafoundry" />)
|
|
130
|
+
expect(screen.queryByLabelText(/how they joined/i)).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
1
|
+
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
2
3
|
import {
|
|
3
4
|
MembersTable,
|
|
4
5
|
RoleSelector,
|
|
@@ -370,20 +371,41 @@ describe('LeaveTeamDialog', () => {
|
|
|
370
371
|
|
|
371
372
|
// ---------- OrgSwitcher ----------------------------------------------------
|
|
372
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Since startsim-768w.21.4 OrgSwitcher is a thin adapter over the shared
|
|
376
|
+
* <EntitySwitcher>: the control is a combobox trigger + popover listbox, not a
|
|
377
|
+
* native <select>, so the assertions read the current company off the trigger
|
|
378
|
+
* and pick from role=option instead of setting `select.value`. The public props
|
|
379
|
+
* and classNames slots are unchanged, and every behaviour the <select> version
|
|
380
|
+
* had is still pinned below.
|
|
381
|
+
*/
|
|
373
382
|
describe('OrgSwitcher', () => {
|
|
374
383
|
const companies: CompanyLite[] = [
|
|
375
384
|
{ id: 'c1', slug: 'acme', name: 'Acme' },
|
|
376
385
|
{ id: 'c2', slug: 'beta', name: 'Beta' },
|
|
377
386
|
]
|
|
378
387
|
|
|
379
|
-
it('renders an option per company + fires onSwitch on
|
|
388
|
+
it('renders an option per company + fires onSwitch on pick', async () => {
|
|
389
|
+
const user = userEvent.setup()
|
|
380
390
|
const onSwitch = jest.fn()
|
|
381
|
-
render(
|
|
391
|
+
const { container } = render(
|
|
382
392
|
<OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={onSwitch} />
|
|
383
393
|
)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
394
|
+
|
|
395
|
+
// One switcher in packages/ui, not two: no native <select> is left.
|
|
396
|
+
expect(container.querySelector('select')).toBeNull()
|
|
397
|
+
|
|
398
|
+
const trigger = screen.getByRole('combobox')
|
|
399
|
+
expect(trigger).toHaveTextContent('Acme')
|
|
400
|
+
|
|
401
|
+
await user.click(trigger)
|
|
402
|
+
expect(await screen.findAllByRole('option')).toHaveLength(2)
|
|
403
|
+
expect(screen.getByRole('option', { name: /Acme/ })).toHaveAttribute(
|
|
404
|
+
'aria-selected',
|
|
405
|
+
'true'
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
await user.click(screen.getByRole('option', { name: /Beta/ }))
|
|
387
409
|
expect(onSwitch).toHaveBeenCalledWith('c2')
|
|
388
410
|
})
|
|
389
411
|
|
|
@@ -393,4 +415,100 @@ describe('OrgSwitcher', () => {
|
|
|
393
415
|
)
|
|
394
416
|
expect(container.firstChild).toBeNull()
|
|
395
417
|
})
|
|
418
|
+
|
|
419
|
+
it('falls back to the first company when currentCompanyId is not pinned', () => {
|
|
420
|
+
render(<OrgSwitcher companies={companies} onSwitch={() => {}} />)
|
|
421
|
+
// Never a bare "Select organization…" placeholder for a list it is already
|
|
422
|
+
// scoped to — the <select> pinned its value the same way.
|
|
423
|
+
expect(screen.getByRole('combobox')).toHaveTextContent('Acme')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('names the trigger with the label AND the current company', () => {
|
|
427
|
+
render(
|
|
428
|
+
<OrgSwitcher companies={companies} currentCompanyId="c2" onSwitch={() => {}} />
|
|
429
|
+
)
|
|
430
|
+
// Replaces the sr-only <label> + aria-label the <select> had. A button
|
|
431
|
+
// named only "Organization" would hide which org is in scope.
|
|
432
|
+
expect(screen.getByRole('combobox')).toHaveAttribute(
|
|
433
|
+
'aria-label',
|
|
434
|
+
'Organization: Beta'
|
|
435
|
+
)
|
|
436
|
+
expect(
|
|
437
|
+
screen.getByRole('combobox', { name: /organization: beta/i })
|
|
438
|
+
).toBeInTheDocument()
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('hides the visible caption with hideLabel but keeps the accessible name', () => {
|
|
442
|
+
const { rerender } = render(
|
|
443
|
+
<OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={() => {}} />
|
|
444
|
+
)
|
|
445
|
+
expect(screen.getByText('Organization')).toBeInTheDocument()
|
|
446
|
+
|
|
447
|
+
rerender(
|
|
448
|
+
<OrgSwitcher
|
|
449
|
+
companies={companies}
|
|
450
|
+
currentCompanyId="c1"
|
|
451
|
+
onSwitch={() => {}}
|
|
452
|
+
hideLabel
|
|
453
|
+
/>
|
|
454
|
+
)
|
|
455
|
+
expect(screen.queryByText('Organization')).not.toBeInTheDocument()
|
|
456
|
+
expect(screen.getByRole('combobox')).toHaveAttribute(
|
|
457
|
+
'aria-label',
|
|
458
|
+
'Organization: Acme'
|
|
459
|
+
)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('honours a custom label everywhere it surfaces', () => {
|
|
463
|
+
render(
|
|
464
|
+
<OrgSwitcher
|
|
465
|
+
companies={companies}
|
|
466
|
+
currentCompanyId="c1"
|
|
467
|
+
onSwitch={() => {}}
|
|
468
|
+
label="Workspace"
|
|
469
|
+
/>
|
|
470
|
+
)
|
|
471
|
+
expect(screen.getByText('Workspace')).toBeInTheDocument()
|
|
472
|
+
expect(screen.getByRole('combobox')).toHaveAttribute(
|
|
473
|
+
'aria-label',
|
|
474
|
+
'Workspace: Acme'
|
|
475
|
+
)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('routes the classNames slots at the label and the trigger', () => {
|
|
479
|
+
render(
|
|
480
|
+
<OrgSwitcher
|
|
481
|
+
companies={companies}
|
|
482
|
+
currentCompanyId="c1"
|
|
483
|
+
onSwitch={() => {}}
|
|
484
|
+
classNames={{ label: 'my-label', select: 'my-trigger' }}
|
|
485
|
+
/>
|
|
486
|
+
)
|
|
487
|
+
expect(screen.getByText('Organization')).toHaveClass('my-label')
|
|
488
|
+
expect(screen.getByRole('combobox')).toHaveClass('my-trigger')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('filters companies by name or slug — the upgrade the <select> could not do', async () => {
|
|
492
|
+
const user = userEvent.setup()
|
|
493
|
+
render(
|
|
494
|
+
<OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={() => {}} />
|
|
495
|
+
)
|
|
496
|
+
await user.click(screen.getByRole('combobox'))
|
|
497
|
+
// "beta" is Beta's slug as well as its name; either way it is the only hit.
|
|
498
|
+
await user.type(await screen.findByRole('textbox'), 'BET')
|
|
499
|
+
|
|
500
|
+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1))
|
|
501
|
+
expect(screen.getByRole('option')).toHaveTextContent('Beta')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('tolerates an async onSwitch', async () => {
|
|
505
|
+
const user = userEvent.setup()
|
|
506
|
+
const onSwitch = jest.fn().mockResolvedValue(undefined)
|
|
507
|
+
render(
|
|
508
|
+
<OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={onSwitch} />
|
|
509
|
+
)
|
|
510
|
+
await user.click(screen.getByRole('combobox'))
|
|
511
|
+
await user.click(await screen.findByRole('option', { name: /Beta/ }))
|
|
512
|
+
expect(onSwitch).toHaveBeenCalledWith('c2')
|
|
513
|
+
})
|
|
396
514
|
})
|
|
@@ -13,7 +13,9 @@ export type { MembersTableClassNames } from './members-table-default-class-names
|
|
|
13
13
|
export { MEMBERS_TABLE_DEFAULTS } from './members-table-default-class-names'
|
|
14
14
|
|
|
15
15
|
export { MembersSection } from './MembersSection'
|
|
16
|
-
export type {
|
|
16
|
+
export type {
|
|
17
|
+
MembersSectionProps, MemberSourceFilter, MemberSourceFilterValue,
|
|
18
|
+
} from './MembersSection'
|
|
17
19
|
export type { MembersSectionClassNames } from './members-section-default-class-names'
|
|
18
20
|
export { MEMBERS_SECTION_DEFAULTS } from './members-section-default-class-names'
|
|
19
21
|
|
|
@@ -57,6 +59,7 @@ export {
|
|
|
57
59
|
type MemberUserLite,
|
|
58
60
|
type MemberRow,
|
|
59
61
|
type InviteStatus,
|
|
62
|
+
type MemberSource,
|
|
60
63
|
type InvitationLite,
|
|
61
64
|
type DomainVerificationMethod,
|
|
62
65
|
type DomainClaimLite,
|
|
@@ -12,6 +12,10 @@ export interface MembersSectionClassNames {
|
|
|
12
12
|
body?: string
|
|
13
13
|
errorText?: string
|
|
14
14
|
helpText?: string
|
|
15
|
+
/** Row holding the list's narrowing controls (startsim-768w.20.4). */
|
|
16
|
+
filterBar?: string
|
|
17
|
+
filterLabel?: string
|
|
18
|
+
filterSelect?: string
|
|
15
19
|
/** Forwarded to the nested primitives so an app still overrides one map. */
|
|
16
20
|
table?: MembersTableClassNames
|
|
17
21
|
inviteDialog?: InviteMemberDialogClassNames
|
|
@@ -28,4 +32,8 @@ export const MEMBERS_SECTION_DEFAULTS: Required<
|
|
|
28
32
|
body: 'space-y-3',
|
|
29
33
|
errorText: 'text-sm text-destructive',
|
|
30
34
|
helpText: 'text-xs text-muted-foreground',
|
|
35
|
+
filterBar: 'flex flex-wrap items-center gap-2',
|
|
36
|
+
filterLabel: 'text-xs font-medium text-muted-foreground',
|
|
37
|
+
filterSelect:
|
|
38
|
+
'rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground',
|
|
31
39
|
}
|
|
@@ -34,6 +34,13 @@ export interface MembersTableClassNames {
|
|
|
34
34
|
lastActiveEmpty?: string
|
|
35
35
|
/** "Invited by …" line under a member who hasn't activated yet. */
|
|
36
36
|
invitedBy?: string
|
|
37
|
+
/** Shared shape of the "how they joined" pill (startsim-768w.20.4). */
|
|
38
|
+
sourceBadge?: string
|
|
39
|
+
/** Per-origin colour. The point of the column is that an operator and an
|
|
40
|
+
* end user do not look the same, so these must not collapse to one class. */
|
|
41
|
+
sourceInvited?: string
|
|
42
|
+
sourceSelfSignup?: string
|
|
43
|
+
sourceSeed?: string
|
|
37
44
|
/** Resend-invite action on a pending/expired row. */
|
|
38
45
|
resendButton?: string
|
|
39
46
|
/** Deactivate / Restore action — the soft exit next to the hard Remove. */
|
|
@@ -73,6 +80,12 @@ export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
|
73
80
|
lastActive: 'text-muted-foreground',
|
|
74
81
|
lastActiveEmpty: 'text-muted-foreground/70',
|
|
75
82
|
invitedBy: 'text-xs text-muted-foreground/80',
|
|
83
|
+
sourceBadge:
|
|
84
|
+
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
|
|
85
|
+
sourceInvited: 'border-border bg-muted text-muted-foreground',
|
|
86
|
+
// The population an operator most needs to pick out of a long list.
|
|
87
|
+
sourceSelfSignup: 'border-primary/30 bg-primary/10 text-primary',
|
|
88
|
+
sourceSeed: 'border-border bg-muted/60 text-muted-foreground',
|
|
76
89
|
resendButton:
|
|
77
90
|
'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',
|
|
78
91
|
deactivateButton:
|
|
@@ -3,12 +3,30 @@
|
|
|
3
3
|
export interface OrgSwitcherClassNames {
|
|
4
4
|
root?: string
|
|
5
5
|
label?: string
|
|
6
|
+
/**
|
|
7
|
+
* The switcher control itself. Named `select` since startsim-o7s, when it
|
|
8
|
+
* WAS a native `<select>`; startsim-768w.21.4 swapped the implementation for
|
|
9
|
+
* the shared `<EntitySwitcher>` and this slot now styles its combobox
|
|
10
|
+
* trigger. The name is kept so existing overrides keep landing on the same
|
|
11
|
+
* visual element.
|
|
12
|
+
*/
|
|
6
13
|
select?: string
|
|
14
|
+
/**
|
|
15
|
+
* The popover panel that replaced the browser's native dropdown
|
|
16
|
+
* (startsim-768w.21.4) — width/padding/colour of the list surface.
|
|
17
|
+
*/
|
|
18
|
+
content?: string
|
|
7
19
|
}
|
|
8
20
|
|
|
9
21
|
export const ORG_SWITCHER_DEFAULTS: Required<OrgSwitcherClassNames> = {
|
|
10
22
|
root: 'inline-flex items-center gap-2',
|
|
11
23
|
label: 'text-xs font-medium uppercase tracking-wide text-muted-foreground',
|
|
24
|
+
// EntitySwitcher's own trigger defaults are translucent-over-dark, tuned for
|
|
25
|
+
// the `bg-gray-900` sidebar rail. OrgSwitcher renders on LIGHT surfaces
|
|
26
|
+
// (settings pages, headers), so each of those has to be overridden with a
|
|
27
|
+
// semantic token — including the `hover:` / `focus-visible:` variants, which
|
|
28
|
+
// tailwind-merge cannot fold into an unprefixed override.
|
|
12
29
|
select:
|
|
13
|
-
'rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground
|
|
30
|
+
'w-auto min-w-44 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
|
|
31
|
+
content: 'w-64',
|
|
14
32
|
}
|
|
@@ -44,6 +44,16 @@ export interface MemberUserLite {
|
|
|
44
44
|
*/
|
|
45
45
|
export type InviteStatus = 'active' | 'pending' | 'expired'
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* HOW someone came to be on this list (startsim-768w.20.4).
|
|
49
|
+
*
|
|
50
|
+
* The distinction `role` cannot make. Once a workspace can be joined without an
|
|
51
|
+
* invite, its member list carries two populations — the handful of people who
|
|
52
|
+
* RUN it and everyone who merely USES it — and an invited viewer reads exactly
|
|
53
|
+
* like a self-joined viewer.
|
|
54
|
+
*/
|
|
55
|
+
export type MemberSource = 'invited' | 'self_signup' | 'seed'
|
|
56
|
+
|
|
47
57
|
export interface MemberRow {
|
|
48
58
|
id: string
|
|
49
59
|
userId: string
|
|
@@ -54,6 +64,9 @@ export interface MemberRow {
|
|
|
54
64
|
user?: MemberUserLite
|
|
55
65
|
/** Lifecycle state. Absent for backends that don't report it (older list APIs). */
|
|
56
66
|
inviteStatus?: InviteStatus
|
|
67
|
+
/** How they got here. Absent for backends that don't record it — the table
|
|
68
|
+
* then drops the column rather than rendering an empty one. */
|
|
69
|
+
source?: MemberSource
|
|
57
70
|
/** Last sign-in, or null for someone who has never signed in. */
|
|
58
71
|
lastLoginAt?: string | null
|
|
59
72
|
/** When the outstanding invite link dies. Null once there is no live invite. */
|