@startsimpli/ui 0.4.85 → 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/navigation/__tests__/navigation.test.tsx +103 -1
- 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/__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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.86",
|
|
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": {
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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 ? (
|
|
@@ -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
|
+
})
|
|
@@ -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:
|
|
@@ -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. */
|