@startsimpli/ui 0.4.14 → 0.4.15
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/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
2
|
+
import {
|
|
3
|
+
MembersTable,
|
|
4
|
+
RoleSelector,
|
|
5
|
+
InviteMemberDialog,
|
|
6
|
+
PendingInvitationCallout,
|
|
7
|
+
DomainClaimCard,
|
|
8
|
+
LeaveTeamDialog,
|
|
9
|
+
OrgSwitcher,
|
|
10
|
+
} from '../index'
|
|
11
|
+
import type { MemberRow, InvitationLite, DomainClaimLite, TeamLite, CompanyLite } from '../types'
|
|
12
|
+
|
|
13
|
+
// ---------- MembersTable ---------------------------------------------------
|
|
14
|
+
|
|
15
|
+
describe('MembersTable', () => {
|
|
16
|
+
const baseMembers: MemberRow[] = [
|
|
17
|
+
{
|
|
18
|
+
id: 'm1',
|
|
19
|
+
userId: 'u1',
|
|
20
|
+
teamId: 't1',
|
|
21
|
+
role: 'owner',
|
|
22
|
+
joinedAt: '2025-01-01',
|
|
23
|
+
user: { id: 'u1', email: 'owner@x.com', firstName: 'Olivia', lastName: 'Owner' },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'm2',
|
|
27
|
+
userId: 'u2',
|
|
28
|
+
teamId: 't1',
|
|
29
|
+
role: 'member',
|
|
30
|
+
joinedAt: '2025-01-02',
|
|
31
|
+
user: { id: 'u2', email: 'mem@x.com', firstName: 'Mike', lastName: 'Member' },
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
it('renders a row per member with name + email + role', () => {
|
|
36
|
+
render(<MembersTable members={baseMembers} />)
|
|
37
|
+
expect(screen.getByText('Olivia Owner')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText('owner@x.com')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getAllByText('Owner').length).toBeGreaterThan(0)
|
|
40
|
+
expect(screen.getByText('Mike Member')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('hides the actions column when canManage is false', () => {
|
|
44
|
+
render(<MembersTable members={baseMembers} />)
|
|
45
|
+
expect(screen.queryByText('Remove')).not.toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('shows RoleSelector + Remove when canManage is true', () => {
|
|
49
|
+
const onChangeRole = jest.fn()
|
|
50
|
+
const onRemove = jest.fn()
|
|
51
|
+
render(
|
|
52
|
+
<MembersTable
|
|
53
|
+
members={baseMembers}
|
|
54
|
+
canManage
|
|
55
|
+
onChangeRole={onChangeRole}
|
|
56
|
+
onRemove={onRemove}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
const removes = screen.getAllByText('Remove')
|
|
60
|
+
expect(removes).toHaveLength(2)
|
|
61
|
+
fireEvent.click(removes[1])
|
|
62
|
+
expect(onRemove).toHaveBeenCalledWith('u2')
|
|
63
|
+
|
|
64
|
+
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]
|
|
65
|
+
fireEvent.change(selects[1], { target: { value: 'admin' } })
|
|
66
|
+
expect(onChangeRole).toHaveBeenCalledWith('u2', 'admin')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('respects classNames overrides', () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<MembersTable members={baseMembers} classNames={{ root: 'my-root-class' }} />
|
|
72
|
+
)
|
|
73
|
+
expect(container.firstChild).toHaveClass('my-root-class')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('shows the empty state when no members', () => {
|
|
77
|
+
render(<MembersTable members={[]} />)
|
|
78
|
+
expect(screen.getByText('No members yet.')).toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// ---------- RoleSelector ---------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe('RoleSelector', () => {
|
|
85
|
+
it('renders the supplied roles and fires onChange', () => {
|
|
86
|
+
const onChange = jest.fn()
|
|
87
|
+
render(
|
|
88
|
+
<RoleSelector
|
|
89
|
+
value="member"
|
|
90
|
+
onChange={onChange}
|
|
91
|
+
availableRoles={['admin', 'member', 'viewer']}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
const select = screen.getByRole('combobox') as HTMLSelectElement
|
|
95
|
+
expect(select.value).toBe('member')
|
|
96
|
+
// Only the three supplied roles should be rendered.
|
|
97
|
+
const options = Array.from(select.querySelectorAll('option')).map((o) => o.value)
|
|
98
|
+
expect(options).toEqual(['admin', 'member', 'viewer'])
|
|
99
|
+
fireEvent.change(select, { target: { value: 'admin' } })
|
|
100
|
+
expect(onChange).toHaveBeenCalledWith('admin')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('honors disabled', () => {
|
|
104
|
+
render(<RoleSelector value="viewer" onChange={() => {}} disabled />)
|
|
105
|
+
expect(screen.getByRole('combobox')).toBeDisabled()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ---------- InviteMemberDialog --------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe('InviteMemberDialog', () => {
|
|
112
|
+
it('does not render the panel until the trigger is clicked', () => {
|
|
113
|
+
render(<InviteMemberDialog teamId="t1" onSubmit={jest.fn()} />)
|
|
114
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
115
|
+
fireEvent.click(screen.getByRole('button', { name: /invite member/i }))
|
|
116
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('submits email + role + teamId via onSubmit and shows a success line', async () => {
|
|
120
|
+
const onSubmit = jest.fn().mockResolvedValue({ id: 'i1' } as InvitationLite)
|
|
121
|
+
const onInvited = jest.fn()
|
|
122
|
+
render(
|
|
123
|
+
<InviteMemberDialog
|
|
124
|
+
teamId="t1"
|
|
125
|
+
onSubmit={onSubmit}
|
|
126
|
+
onInvited={onInvited}
|
|
127
|
+
/>
|
|
128
|
+
)
|
|
129
|
+
fireEvent.click(screen.getByRole('button', { name: /invite member/i }))
|
|
130
|
+
|
|
131
|
+
fireEvent.change(screen.getByLabelText('Email'), {
|
|
132
|
+
target: { value: 'newhire@x.com' },
|
|
133
|
+
})
|
|
134
|
+
fireEvent.change(screen.getByLabelText('Invite role'), {
|
|
135
|
+
target: { value: 'admin' },
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
await act(async () => {
|
|
139
|
+
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
143
|
+
email: 'newhire@x.com',
|
|
144
|
+
teamId: 't1',
|
|
145
|
+
role: 'admin',
|
|
146
|
+
})
|
|
147
|
+
expect(onInvited).toHaveBeenCalled()
|
|
148
|
+
expect(screen.getByText(/Invitation sent to newhire@x.com/)).toBeInTheDocument()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('surfaces submit errors inline', async () => {
|
|
152
|
+
const onSubmit = jest.fn().mockRejectedValue(new Error('domain not allowed'))
|
|
153
|
+
render(<InviteMemberDialog teamId="t1" onSubmit={onSubmit} />)
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: /invite member/i }))
|
|
155
|
+
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'a@x.com' } })
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!)
|
|
159
|
+
})
|
|
160
|
+
expect(screen.getByRole('alert')).toHaveTextContent('domain not allowed')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('honors classNames slots', () => {
|
|
164
|
+
render(
|
|
165
|
+
<InviteMemberDialog
|
|
166
|
+
teamId="t1"
|
|
167
|
+
onSubmit={jest.fn()}
|
|
168
|
+
classNames={{ trigger: 'my-trigger' }}
|
|
169
|
+
/>
|
|
170
|
+
)
|
|
171
|
+
expect(screen.getByRole('button', { name: /invite member/i })).toHaveClass(
|
|
172
|
+
'my-trigger'
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ---------- PendingInvitationCallout --------------------------------------
|
|
178
|
+
|
|
179
|
+
describe('PendingInvitationCallout', () => {
|
|
180
|
+
const invitation: InvitationLite = {
|
|
181
|
+
id: 'i1',
|
|
182
|
+
email: 'a@x.com',
|
|
183
|
+
teamId: 't1',
|
|
184
|
+
role: 'admin',
|
|
185
|
+
expiresAt: '2099-01-01',
|
|
186
|
+
isExpired: false,
|
|
187
|
+
isAccepted: false,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('renders the message + team name + role', () => {
|
|
191
|
+
render(
|
|
192
|
+
<PendingInvitationCallout
|
|
193
|
+
invitation={invitation}
|
|
194
|
+
teamName="Acme Eng"
|
|
195
|
+
/>
|
|
196
|
+
)
|
|
197
|
+
expect(screen.getByText(/You.*invited/i)).toBeInTheDocument()
|
|
198
|
+
expect(screen.getByText('Acme Eng')).toBeInTheDocument()
|
|
199
|
+
expect(screen.getByText('admin')).toBeInTheDocument()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('falls back to "team #<id>" when no team name is supplied', () => {
|
|
203
|
+
render(<PendingInvitationCallout invitation={invitation} />)
|
|
204
|
+
expect(screen.getByText('team #t1')).toBeInTheDocument()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('calls onAccept + onAccepted', async () => {
|
|
208
|
+
const onAccept = jest.fn().mockResolvedValue(undefined)
|
|
209
|
+
const onAccepted = jest.fn()
|
|
210
|
+
render(
|
|
211
|
+
<PendingInvitationCallout
|
|
212
|
+
invitation={invitation}
|
|
213
|
+
teamName="Acme"
|
|
214
|
+
onAccept={onAccept}
|
|
215
|
+
onAccepted={onAccepted}
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
await act(async () => {
|
|
219
|
+
fireEvent.click(screen.getByRole('button', { name: /accept/i }))
|
|
220
|
+
})
|
|
221
|
+
expect(onAccept).toHaveBeenCalledWith(invitation)
|
|
222
|
+
expect(onAccepted).toHaveBeenCalledWith(invitation)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('only renders Decline when an onDecline is provided', () => {
|
|
226
|
+
const { rerender } = render(
|
|
227
|
+
<PendingInvitationCallout invitation={invitation} teamName="Acme" />
|
|
228
|
+
)
|
|
229
|
+
expect(screen.queryByRole('button', { name: /decline/i })).not.toBeInTheDocument()
|
|
230
|
+
rerender(
|
|
231
|
+
<PendingInvitationCallout
|
|
232
|
+
invitation={invitation}
|
|
233
|
+
teamName="Acme"
|
|
234
|
+
onDecline={jest.fn()}
|
|
235
|
+
/>
|
|
236
|
+
)
|
|
237
|
+
expect(screen.getByRole('button', { name: /decline/i })).toBeInTheDocument()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ---------- DomainClaimCard ------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe('DomainClaimCard', () => {
|
|
244
|
+
const baseClaim: DomainClaimLite = {
|
|
245
|
+
id: 'd1',
|
|
246
|
+
companyId: 'c1',
|
|
247
|
+
domain: 'acme.com',
|
|
248
|
+
verified: false,
|
|
249
|
+
verificationToken: 'startsim-verify=xyz',
|
|
250
|
+
createdAt: '2025-01-01',
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
it('renders the DNS TXT block when a verification token is present', () => {
|
|
254
|
+
render(<DomainClaimCard claim={baseClaim} />)
|
|
255
|
+
expect(screen.getByText('acme.com')).toBeInTheDocument()
|
|
256
|
+
expect(screen.getByText('startsim-verify=xyz')).toBeInTheDocument()
|
|
257
|
+
expect(screen.getByRole('button', { name: /verify dns/i })).toBeInTheDocument()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('hides verification UI once verified=true', () => {
|
|
261
|
+
render(<DomainClaimCard claim={{ ...baseClaim, verified: true }} />)
|
|
262
|
+
expect(screen.getByText('Verified')).toBeInTheDocument()
|
|
263
|
+
expect(screen.queryByRole('button', { name: /verify dns/i })).not.toBeInTheDocument()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('initiate-email then submit-code calls the verify handler', async () => {
|
|
267
|
+
const onInitiateEmail = jest.fn().mockResolvedValue(undefined)
|
|
268
|
+
const onVerifyEmail = jest.fn().mockResolvedValue(undefined)
|
|
269
|
+
render(
|
|
270
|
+
<DomainClaimCard
|
|
271
|
+
claim={baseClaim}
|
|
272
|
+
onInitiateEmail={onInitiateEmail}
|
|
273
|
+
onVerifyEmail={onVerifyEmail}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
await act(async () => {
|
|
277
|
+
fireEvent.click(screen.getByRole('button', { name: /email attestation/i }))
|
|
278
|
+
})
|
|
279
|
+
expect(onInitiateEmail).toHaveBeenCalledWith(baseClaim)
|
|
280
|
+
|
|
281
|
+
const code = screen.getByLabelText('Attestation code')
|
|
282
|
+
fireEvent.change(code, { target: { value: '123456' } })
|
|
283
|
+
await act(async () => {
|
|
284
|
+
fireEvent.click(screen.getByRole('button', { name: /submit code/i }))
|
|
285
|
+
})
|
|
286
|
+
expect(onVerifyEmail).toHaveBeenCalledWith(baseClaim, '123456')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('renders Revoke when onRevoke is provided', () => {
|
|
290
|
+
render(<DomainClaimCard claim={baseClaim} onRevoke={jest.fn()} />)
|
|
291
|
+
expect(screen.getByRole('button', { name: /revoke/i })).toBeInTheDocument()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ---------- LeaveTeamDialog ------------------------------------------------
|
|
296
|
+
|
|
297
|
+
describe('LeaveTeamDialog', () => {
|
|
298
|
+
const team: TeamLite = { id: 't1', slug: 'eng', name: 'Engineering', companyId: 'c1' }
|
|
299
|
+
|
|
300
|
+
it('opens on trigger click, fires onSubmit + onLeft on confirm', async () => {
|
|
301
|
+
const onSubmit = jest.fn().mockResolvedValue(undefined)
|
|
302
|
+
const onLeft = jest.fn()
|
|
303
|
+
render(<LeaveTeamDialog team={team} onSubmit={onSubmit} onLeft={onLeft} />)
|
|
304
|
+
fireEvent.click(screen.getByRole('button', { name: /leave team/i }))
|
|
305
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
306
|
+
await act(async () => {
|
|
307
|
+
// Click the confirm button (red); it's the second 'Leave team' on screen.
|
|
308
|
+
fireEvent.click(screen.getAllByRole('button', { name: /leave team/i })[1])
|
|
309
|
+
})
|
|
310
|
+
expect(onSubmit).toHaveBeenCalledWith(team)
|
|
311
|
+
expect(onLeft).toHaveBeenCalledWith(team)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('surfaces backend "last owner" errors inline', async () => {
|
|
315
|
+
const onSubmit = jest.fn().mockRejectedValue(new Error('Cannot remove the last owner'))
|
|
316
|
+
render(<LeaveTeamDialog team={team} onSubmit={onSubmit} />)
|
|
317
|
+
fireEvent.click(screen.getByRole('button', { name: /leave team/i }))
|
|
318
|
+
await act(async () => {
|
|
319
|
+
fireEvent.click(screen.getAllByRole('button', { name: /leave team/i })[1])
|
|
320
|
+
})
|
|
321
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
322
|
+
/cannot remove the last owner/i
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// ---------- OrgSwitcher ----------------------------------------------------
|
|
328
|
+
|
|
329
|
+
describe('OrgSwitcher', () => {
|
|
330
|
+
const companies: CompanyLite[] = [
|
|
331
|
+
{ id: 'c1', slug: 'acme', name: 'Acme' },
|
|
332
|
+
{ id: 'c2', slug: 'beta', name: 'Beta' },
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
it('renders an option per company + fires onSwitch on change', () => {
|
|
336
|
+
const onSwitch = jest.fn()
|
|
337
|
+
render(
|
|
338
|
+
<OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={onSwitch} />
|
|
339
|
+
)
|
|
340
|
+
const select = screen.getByRole('combobox') as HTMLSelectElement
|
|
341
|
+
expect(select.value).toBe('c1')
|
|
342
|
+
fireEvent.change(select, { target: { value: 'c2' } })
|
|
343
|
+
expect(onSwitch).toHaveBeenCalledWith('c2')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('renders nothing when there are no companies', () => {
|
|
347
|
+
const { container } = render(
|
|
348
|
+
<OrgSwitcher companies={[]} currentCompanyId={null} onSwitch={() => {}} />
|
|
349
|
+
)
|
|
350
|
+
expect(container.firstChild).toBeNull()
|
|
351
|
+
})
|
|
352
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link DomainClaimCard}. startsim-o7s. */
|
|
2
|
+
export interface DomainClaimCardClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
header?: string
|
|
5
|
+
domain?: string
|
|
6
|
+
statusBadge?: string
|
|
7
|
+
statusVerified?: string
|
|
8
|
+
statusUnverified?: string
|
|
9
|
+
body?: string
|
|
10
|
+
sectionLabel?: string
|
|
11
|
+
tokenRow?: string
|
|
12
|
+
tokenCode?: string
|
|
13
|
+
helpText?: string
|
|
14
|
+
actions?: string
|
|
15
|
+
primaryButton?: string
|
|
16
|
+
secondaryButton?: string
|
|
17
|
+
revokeButton?: string
|
|
18
|
+
errorText?: string
|
|
19
|
+
codeInput?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DOMAIN_CLAIM_DEFAULTS: Required<DomainClaimCardClassNames> = {
|
|
23
|
+
root: 'rounded-xl border border-gray-200 bg-white p-5',
|
|
24
|
+
header: 'flex items-center justify-between gap-3',
|
|
25
|
+
domain: 'text-base font-semibold text-gray-900',
|
|
26
|
+
statusBadge: 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
27
|
+
statusVerified: 'bg-green-100 text-green-800',
|
|
28
|
+
statusUnverified: 'bg-yellow-100 text-yellow-800',
|
|
29
|
+
body: 'mt-4 space-y-3 text-sm text-gray-700',
|
|
30
|
+
sectionLabel: 'text-xs font-medium uppercase tracking-wide text-gray-500',
|
|
31
|
+
tokenRow: 'flex items-center gap-2',
|
|
32
|
+
tokenCode:
|
|
33
|
+
'flex-1 rounded-md bg-gray-50 px-3 py-2 font-mono text-xs text-gray-900',
|
|
34
|
+
helpText: 'text-xs text-gray-500',
|
|
35
|
+
actions: 'mt-4 flex flex-wrap items-center gap-2',
|
|
36
|
+
primaryButton:
|
|
37
|
+
'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
|
|
38
|
+
secondaryButton:
|
|
39
|
+
'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50',
|
|
40
|
+
revokeButton:
|
|
41
|
+
'rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50',
|
|
42
|
+
errorText: 'mt-2 text-sm text-red-600',
|
|
43
|
+
codeInput:
|
|
44
|
+
'w-24 rounded-md border border-gray-300 px-2 py-1.5 text-sm tracking-widest outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless team-management UI components (startsim-o7s).
|
|
3
|
+
*
|
|
4
|
+
* Each component accepts a `classNames` slot map with sensible Tailwind
|
|
5
|
+
* defaults so each app's `/settings/team` and `/settings/domains` page
|
|
6
|
+
* is a thin wrapper, not a re-implementation. Same pattern as the
|
|
7
|
+
* SignupForm/ResetPasswordForm/SignInForm quartet shipped earlier.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { MembersTable } from './MembersTable'
|
|
11
|
+
export type { MembersTableProps } from './MembersTable'
|
|
12
|
+
export type { MembersTableClassNames } from './members-table-default-class-names'
|
|
13
|
+
export { MEMBERS_TABLE_DEFAULTS } from './members-table-default-class-names'
|
|
14
|
+
|
|
15
|
+
export { RoleSelector } from './RoleSelector'
|
|
16
|
+
export type { RoleSelectorProps } from './RoleSelector'
|
|
17
|
+
export type { RoleSelectorClassNames } from './role-selector-default-class-names'
|
|
18
|
+
export { ROLE_SELECTOR_DEFAULTS } from './role-selector-default-class-names'
|
|
19
|
+
|
|
20
|
+
export { InviteMemberDialog } from './InviteMemberDialog'
|
|
21
|
+
export type { InviteMemberDialogProps } from './InviteMemberDialog'
|
|
22
|
+
export type { InviteMemberDialogClassNames } from './invite-member-dialog-default-class-names'
|
|
23
|
+
export { INVITE_DIALOG_DEFAULTS } from './invite-member-dialog-default-class-names'
|
|
24
|
+
|
|
25
|
+
export { PendingInvitationCallout } from './PendingInvitationCallout'
|
|
26
|
+
export type { PendingInvitationCalloutProps } from './PendingInvitationCallout'
|
|
27
|
+
export type { PendingInvitationCalloutClassNames } from './pending-invitation-callout-default-class-names'
|
|
28
|
+
export { PENDING_INVITE_DEFAULTS } from './pending-invitation-callout-default-class-names'
|
|
29
|
+
|
|
30
|
+
export { DomainClaimCard } from './DomainClaimCard'
|
|
31
|
+
export type { DomainClaimCardProps } from './DomainClaimCard'
|
|
32
|
+
export type { DomainClaimCardClassNames } from './domain-claim-card-default-class-names'
|
|
33
|
+
export { DOMAIN_CLAIM_DEFAULTS } from './domain-claim-card-default-class-names'
|
|
34
|
+
|
|
35
|
+
export { LeaveTeamDialog } from './LeaveTeamDialog'
|
|
36
|
+
export type { LeaveTeamDialogProps } from './LeaveTeamDialog'
|
|
37
|
+
export type { LeaveTeamDialogClassNames } from './leave-team-dialog-default-class-names'
|
|
38
|
+
export { LEAVE_DIALOG_DEFAULTS } from './leave-team-dialog-default-class-names'
|
|
39
|
+
|
|
40
|
+
export { OrgSwitcher } from './OrgSwitcher'
|
|
41
|
+
export type { OrgSwitcherProps } from './OrgSwitcher'
|
|
42
|
+
export type { OrgSwitcherClassNames } from './org-switcher-default-class-names'
|
|
43
|
+
export { ORG_SWITCHER_DEFAULTS } from './org-switcher-default-class-names'
|
|
44
|
+
|
|
45
|
+
// Shared types
|
|
46
|
+
export {
|
|
47
|
+
userDisplayName,
|
|
48
|
+
userInitials,
|
|
49
|
+
type TeamRole,
|
|
50
|
+
type TeamLite,
|
|
51
|
+
type CompanyLite,
|
|
52
|
+
type MemberUserLite,
|
|
53
|
+
type MemberRow,
|
|
54
|
+
type InvitationLite,
|
|
55
|
+
type DomainVerificationMethod,
|
|
56
|
+
type DomainClaimLite,
|
|
57
|
+
} from './types'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link InviteMemberDialog}. startsim-o7s. */
|
|
2
|
+
export interface InviteMemberDialogClassNames {
|
|
3
|
+
trigger?: string
|
|
4
|
+
overlay?: string
|
|
5
|
+
panel?: string
|
|
6
|
+
header?: string
|
|
7
|
+
title?: string
|
|
8
|
+
body?: string
|
|
9
|
+
fieldRow?: string
|
|
10
|
+
label?: string
|
|
11
|
+
input?: string
|
|
12
|
+
errorText?: string
|
|
13
|
+
successText?: string
|
|
14
|
+
footer?: string
|
|
15
|
+
cancelButton?: string
|
|
16
|
+
submitButton?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const INVITE_DIALOG_DEFAULTS: Required<InviteMemberDialogClassNames> = {
|
|
20
|
+
trigger:
|
|
21
|
+
'inline-flex items-center rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700',
|
|
22
|
+
overlay:
|
|
23
|
+
'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4',
|
|
24
|
+
panel:
|
|
25
|
+
'w-full max-w-md rounded-xl bg-white shadow-xl ring-1 ring-black/5',
|
|
26
|
+
header: 'border-b border-gray-100 px-6 py-4',
|
|
27
|
+
title: 'text-base font-semibold text-gray-900',
|
|
28
|
+
body: 'space-y-4 px-6 py-4',
|
|
29
|
+
fieldRow: '',
|
|
30
|
+
label: 'block text-sm font-medium text-gray-700 mb-1',
|
|
31
|
+
input:
|
|
32
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
33
|
+
errorText: 'text-sm text-red-600',
|
|
34
|
+
successText: 'text-sm text-green-600',
|
|
35
|
+
footer:
|
|
36
|
+
'flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-3',
|
|
37
|
+
cancelButton:
|
|
38
|
+
'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50',
|
|
39
|
+
submitButton:
|
|
40
|
+
'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link LeaveTeamDialog}. startsim-o7s. */
|
|
2
|
+
export interface LeaveTeamDialogClassNames {
|
|
3
|
+
trigger?: string
|
|
4
|
+
overlay?: string
|
|
5
|
+
panel?: string
|
|
6
|
+
header?: string
|
|
7
|
+
title?: string
|
|
8
|
+
body?: string
|
|
9
|
+
warningText?: string
|
|
10
|
+
errorText?: string
|
|
11
|
+
footer?: string
|
|
12
|
+
cancelButton?: string
|
|
13
|
+
confirmButton?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const LEAVE_DIALOG_DEFAULTS: Required<LeaveTeamDialogClassNames> = {
|
|
17
|
+
trigger:
|
|
18
|
+
'inline-flex items-center rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50',
|
|
19
|
+
overlay:
|
|
20
|
+
'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4',
|
|
21
|
+
panel: 'w-full max-w-md rounded-xl bg-white shadow-xl ring-1 ring-black/5',
|
|
22
|
+
header: 'border-b border-gray-100 px-6 py-4',
|
|
23
|
+
title: 'text-base font-semibold text-gray-900',
|
|
24
|
+
body: 'space-y-3 px-6 py-4 text-sm text-gray-700',
|
|
25
|
+
warningText: 'text-sm text-gray-600',
|
|
26
|
+
errorText: 'text-sm text-red-600',
|
|
27
|
+
footer:
|
|
28
|
+
'flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-3',
|
|
29
|
+
cancelButton:
|
|
30
|
+
'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50',
|
|
31
|
+
confirmButton:
|
|
32
|
+
'rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50',
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link MembersTable}. startsim-o7s. */
|
|
2
|
+
export interface MembersTableClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
table?: string
|
|
5
|
+
thead?: string
|
|
6
|
+
headerRow?: string
|
|
7
|
+
th?: string
|
|
8
|
+
tbody?: string
|
|
9
|
+
row?: string
|
|
10
|
+
cell?: string
|
|
11
|
+
avatar?: string
|
|
12
|
+
name?: string
|
|
13
|
+
email?: string
|
|
14
|
+
roleBadge?: string
|
|
15
|
+
actions?: string
|
|
16
|
+
removeButton?: string
|
|
17
|
+
emptyRow?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
|
|
21
|
+
root: 'w-full overflow-x-auto rounded-lg border border-gray-200',
|
|
22
|
+
table: 'w-full text-sm text-left',
|
|
23
|
+
thead: 'bg-gray-50 text-xs uppercase tracking-wide text-gray-500',
|
|
24
|
+
headerRow: '',
|
|
25
|
+
th: 'px-4 py-2 font-medium',
|
|
26
|
+
tbody: 'divide-y divide-gray-100 bg-white',
|
|
27
|
+
row: 'hover:bg-gray-50',
|
|
28
|
+
cell: 'px-4 py-3 align-middle',
|
|
29
|
+
avatar:
|
|
30
|
+
'flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-xs font-medium text-gray-600',
|
|
31
|
+
name: 'font-medium text-gray-900',
|
|
32
|
+
email: 'text-gray-500',
|
|
33
|
+
roleBadge:
|
|
34
|
+
'inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700',
|
|
35
|
+
actions: 'flex items-center justify-end gap-2',
|
|
36
|
+
removeButton:
|
|
37
|
+
'rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-100 disabled:opacity-50',
|
|
38
|
+
emptyRow: 'px-4 py-6 text-center text-sm text-gray-500',
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link OrgSwitcher}. startsim-o7s. */
|
|
2
|
+
export interface OrgSwitcherClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
label?: string
|
|
5
|
+
select?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const ORG_SWITCHER_DEFAULTS: Required<OrgSwitcherClassNames> = {
|
|
9
|
+
root: 'inline-flex items-center gap-2',
|
|
10
|
+
label: 'text-xs font-medium uppercase tracking-wide text-gray-500',
|
|
11
|
+
select:
|
|
12
|
+
'rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link PendingInvitationCallout}. startsim-o7s. */
|
|
2
|
+
export interface PendingInvitationCalloutClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
message?: string
|
|
5
|
+
emphasized?: string
|
|
6
|
+
actions?: string
|
|
7
|
+
acceptButton?: string
|
|
8
|
+
declineButton?: string
|
|
9
|
+
errorText?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PENDING_INVITE_DEFAULTS: Required<PendingInvitationCalloutClassNames> = {
|
|
13
|
+
root: 'flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3',
|
|
14
|
+
message: 'flex-1 text-sm text-blue-900',
|
|
15
|
+
emphasized: 'font-semibold',
|
|
16
|
+
actions: 'flex items-center gap-2',
|
|
17
|
+
acceptButton:
|
|
18
|
+
'rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50',
|
|
19
|
+
declineButton:
|
|
20
|
+
'rounded-md border border-blue-300 px-3 py-1.5 text-sm font-medium text-blue-700 hover:bg-blue-100',
|
|
21
|
+
errorText: 'mt-2 text-sm text-red-600',
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link RoleSelector}. startsim-o7s. */
|
|
2
|
+
export interface RoleSelectorClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
select?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const ROLE_SELECTOR_DEFAULTS: Required<RoleSelectorClassNames> = {
|
|
8
|
+
root: 'inline-block',
|
|
9
|
+
select:
|
|
10
|
+
'rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50',
|
|
11
|
+
}
|