@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.
@@ -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 { MembersSectionProps } from './MembersSection'
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. */