@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,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. */
|