@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,130 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LeaveTeamDialog — confirm-and-leave modal. The backend enforces the
|
|
5
|
+
* "last owner can't leave" rule (startsim-tsm); this dialog just surfaces
|
|
6
|
+
* the API error inline so the user sees why the action failed.
|
|
7
|
+
*
|
|
8
|
+
* Apps wire `onSubmit` to api.teams.removeMember(team.slug, currentUserId).
|
|
9
|
+
* startsim-o7s.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as React from 'react'
|
|
13
|
+
import type { TeamLite } from './types'
|
|
14
|
+
import {
|
|
15
|
+
LEAVE_DIALOG_DEFAULTS,
|
|
16
|
+
type LeaveTeamDialogClassNames,
|
|
17
|
+
} from './leave-team-dialog-default-class-names'
|
|
18
|
+
|
|
19
|
+
export interface LeaveTeamDialogProps {
|
|
20
|
+
team: TeamLite
|
|
21
|
+
onSubmit?: (team: TeamLite) => Promise<void> | void
|
|
22
|
+
onLeft?: (team: TeamLite) => void
|
|
23
|
+
triggerLabel?: string
|
|
24
|
+
title?: string
|
|
25
|
+
/** Override the confirmation body copy. */
|
|
26
|
+
description?: React.ReactNode
|
|
27
|
+
classNames?: LeaveTeamDialogClassNames
|
|
28
|
+
open?: boolean
|
|
29
|
+
onOpenChange?: (open: boolean) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function LeaveTeamDialog({
|
|
33
|
+
team,
|
|
34
|
+
onSubmit,
|
|
35
|
+
onLeft,
|
|
36
|
+
triggerLabel = 'Leave team',
|
|
37
|
+
title,
|
|
38
|
+
description,
|
|
39
|
+
classNames,
|
|
40
|
+
open: controlledOpen,
|
|
41
|
+
onOpenChange,
|
|
42
|
+
}: LeaveTeamDialogProps) {
|
|
43
|
+
const cls = { ...LEAVE_DIALOG_DEFAULTS, ...(classNames ?? {}) }
|
|
44
|
+
const [internalOpen, setInternalOpen] = React.useState(false)
|
|
45
|
+
const open = controlledOpen ?? internalOpen
|
|
46
|
+
const setOpen = (next: boolean) => {
|
|
47
|
+
if (controlledOpen === undefined) setInternalOpen(next)
|
|
48
|
+
onOpenChange?.(next)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
52
|
+
const [error, setError] = React.useState('')
|
|
53
|
+
|
|
54
|
+
function close() {
|
|
55
|
+
setOpen(false)
|
|
56
|
+
setError('')
|
|
57
|
+
setSubmitting(false)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleConfirm() {
|
|
61
|
+
setError('')
|
|
62
|
+
setSubmitting(true)
|
|
63
|
+
try {
|
|
64
|
+
await onSubmit?.(team)
|
|
65
|
+
onLeft?.(team)
|
|
66
|
+
close()
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// Backend will reject leave when the user is the sole OWNER (startsim-tsm).
|
|
69
|
+
setError(err instanceof Error ? err.message : 'Could not leave team')
|
|
70
|
+
} finally {
|
|
71
|
+
setSubmitting(false)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className={cls.trigger}
|
|
80
|
+
onClick={() => setOpen(true)}
|
|
81
|
+
aria-haspopup="dialog"
|
|
82
|
+
>
|
|
83
|
+
{triggerLabel}
|
|
84
|
+
</button>
|
|
85
|
+
{open && (
|
|
86
|
+
<div className={cls.overlay} role="presentation" onClick={close}>
|
|
87
|
+
<div
|
|
88
|
+
className={cls.panel}
|
|
89
|
+
role="dialog"
|
|
90
|
+
aria-modal="true"
|
|
91
|
+
aria-label={title ?? `Leave ${team.name}`}
|
|
92
|
+
onClick={(e) => e.stopPropagation()}
|
|
93
|
+
>
|
|
94
|
+
<div className={cls.header}>
|
|
95
|
+
<h2 className={cls.title}>{title ?? `Leave ${team.name}`}</h2>
|
|
96
|
+
</div>
|
|
97
|
+
<div className={cls.body}>
|
|
98
|
+
{description ?? (
|
|
99
|
+
<p className={cls.warningText}>
|
|
100
|
+
You will lose access to <strong>{team.name}</strong>. An owner
|
|
101
|
+
can re-invite you later. If you're the only owner you
|
|
102
|
+
must promote someone else first.
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
{error && <p className={cls.errorText} role="alert">{error}</p>}
|
|
106
|
+
</div>
|
|
107
|
+
<div className={cls.footer}>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className={cls.cancelButton}
|
|
111
|
+
onClick={close}
|
|
112
|
+
disabled={submitting}
|
|
113
|
+
>
|
|
114
|
+
Cancel
|
|
115
|
+
</button>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
className={cls.confirmButton}
|
|
119
|
+
onClick={handleConfirm}
|
|
120
|
+
disabled={submitting}
|
|
121
|
+
>
|
|
122
|
+
{submitting ? 'Leaving…' : 'Leave team'}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MembersTable — headless members list. Renders avatar + name + email +
|
|
5
|
+
* role badge + actions per row. When `canManage` is true the row shows a
|
|
6
|
+
* RoleSelector (calls onChangeRole) + a Remove button (calls onRemove).
|
|
7
|
+
*
|
|
8
|
+
* Pagination is intentionally absent — the backend endpoint at
|
|
9
|
+
* /api/v1/teams/{id}/members returns the full list. SSP support is a
|
|
10
|
+
* follow-up bead. startsim-o7s.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from 'react'
|
|
14
|
+
import { RoleSelector } from './RoleSelector'
|
|
15
|
+
import {
|
|
16
|
+
userDisplayName,
|
|
17
|
+
userInitials,
|
|
18
|
+
type MemberRow,
|
|
19
|
+
type TeamRole,
|
|
20
|
+
} from './types'
|
|
21
|
+
import {
|
|
22
|
+
MEMBERS_TABLE_DEFAULTS,
|
|
23
|
+
type MembersTableClassNames,
|
|
24
|
+
} from './members-table-default-class-names'
|
|
25
|
+
|
|
26
|
+
export interface MembersTableProps {
|
|
27
|
+
members: MemberRow[]
|
|
28
|
+
/**
|
|
29
|
+
* When true, expose the role selector + remove button. Apps should pass
|
|
30
|
+
* `canManage = isAdmin || isOwner` derived from useMembership().
|
|
31
|
+
*/
|
|
32
|
+
canManage?: boolean
|
|
33
|
+
onChangeRole?: (userId: string, role: TeamRole) => void | Promise<void>
|
|
34
|
+
onRemove?: (userId: string) => void | Promise<void>
|
|
35
|
+
/** Custom empty-state slot. Defaults to a generic "No members yet" cell. */
|
|
36
|
+
emptyState?: React.ReactNode
|
|
37
|
+
classNames?: MembersTableClassNames
|
|
38
|
+
/** Restrict role-selector options (e.g. hide 'owner' when caller isn't owner). */
|
|
39
|
+
availableRoles?: TeamRole[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ROLE_LABEL: Record<TeamRole, string> = {
|
|
43
|
+
owner: 'Owner',
|
|
44
|
+
admin: 'Admin',
|
|
45
|
+
member: 'Member',
|
|
46
|
+
viewer: 'Viewer',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function MembersTable({
|
|
50
|
+
members,
|
|
51
|
+
canManage = false,
|
|
52
|
+
onChangeRole,
|
|
53
|
+
onRemove,
|
|
54
|
+
emptyState,
|
|
55
|
+
classNames,
|
|
56
|
+
availableRoles,
|
|
57
|
+
}: MembersTableProps) {
|
|
58
|
+
const cls = { ...MEMBERS_TABLE_DEFAULTS, ...(classNames ?? {}) }
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={cls.root}>
|
|
62
|
+
<table className={cls.table}>
|
|
63
|
+
<thead className={cls.thead}>
|
|
64
|
+
<tr className={cls.headerRow}>
|
|
65
|
+
<th className={cls.th} scope="col">Member</th>
|
|
66
|
+
<th className={cls.th} scope="col">Role</th>
|
|
67
|
+
{canManage && (
|
|
68
|
+
<th className={cls.th} scope="col" aria-label="Actions" />
|
|
69
|
+
)}
|
|
70
|
+
</tr>
|
|
71
|
+
</thead>
|
|
72
|
+
<tbody className={cls.tbody}>
|
|
73
|
+
{members.length === 0 && (
|
|
74
|
+
<tr>
|
|
75
|
+
<td className={cls.emptyRow} colSpan={canManage ? 3 : 2}>
|
|
76
|
+
{emptyState ?? 'No members yet.'}
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
)}
|
|
80
|
+
{members.map((m) => (
|
|
81
|
+
<tr key={m.id} className={cls.row}>
|
|
82
|
+
<td className={cls.cell}>
|
|
83
|
+
<div className="flex items-center gap-3">
|
|
84
|
+
{m.user?.avatarUrl ? (
|
|
85
|
+
<img
|
|
86
|
+
src={m.user.avatarUrl}
|
|
87
|
+
alt=""
|
|
88
|
+
className={cls.avatar}
|
|
89
|
+
style={{ objectFit: 'cover' }}
|
|
90
|
+
/>
|
|
91
|
+
) : (
|
|
92
|
+
<span className={cls.avatar} aria-hidden="true">
|
|
93
|
+
{userInitials(m.user)}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
<div className="flex flex-col">
|
|
97
|
+
<span className={cls.name}>
|
|
98
|
+
{userDisplayName(m.user) || m.userId}
|
|
99
|
+
</span>
|
|
100
|
+
{m.user?.email && (
|
|
101
|
+
<span className={cls.email}>{m.user.email}</span>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</td>
|
|
106
|
+
<td className={cls.cell}>
|
|
107
|
+
{canManage && onChangeRole ? (
|
|
108
|
+
<RoleSelector
|
|
109
|
+
value={m.role}
|
|
110
|
+
onChange={(role) => onChangeRole(m.userId, role)}
|
|
111
|
+
availableRoles={availableRoles}
|
|
112
|
+
/>
|
|
113
|
+
) : (
|
|
114
|
+
<span className={cls.roleBadge}>{ROLE_LABEL[m.role]}</span>
|
|
115
|
+
)}
|
|
116
|
+
</td>
|
|
117
|
+
{canManage && (
|
|
118
|
+
<td className={cls.cell}>
|
|
119
|
+
<div className={cls.actions}>
|
|
120
|
+
{onRemove && (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className={cls.removeButton}
|
|
124
|
+
onClick={() => onRemove(m.userId)}
|
|
125
|
+
>
|
|
126
|
+
Remove
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</td>
|
|
131
|
+
)}
|
|
132
|
+
</tr>
|
|
133
|
+
))}
|
|
134
|
+
</tbody>
|
|
135
|
+
</table>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OrgSwitcher — pick between Companies the current user belongs to. Useful
|
|
5
|
+
* for accounts that ended up multi-company via cross-domain invitations.
|
|
6
|
+
*
|
|
7
|
+
* Native <select> for now. The classNames-slot API stays the same when we
|
|
8
|
+
* upgrade to a dropdown menu. startsim-o7s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import type { CompanyLite } from './types'
|
|
13
|
+
import {
|
|
14
|
+
ORG_SWITCHER_DEFAULTS,
|
|
15
|
+
type OrgSwitcherClassNames,
|
|
16
|
+
} from './org-switcher-default-class-names'
|
|
17
|
+
|
|
18
|
+
export interface OrgSwitcherProps {
|
|
19
|
+
companies: CompanyLite[]
|
|
20
|
+
currentCompanyId?: string | null
|
|
21
|
+
onSwitch: (companyId: string) => void | Promise<void>
|
|
22
|
+
/** Hide the visible "Organization" label, keep only the SR-only label. */
|
|
23
|
+
hideLabel?: boolean
|
|
24
|
+
label?: string
|
|
25
|
+
classNames?: OrgSwitcherClassNames
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function OrgSwitcher({
|
|
29
|
+
companies,
|
|
30
|
+
currentCompanyId,
|
|
31
|
+
onSwitch,
|
|
32
|
+
hideLabel = false,
|
|
33
|
+
label = 'Organization',
|
|
34
|
+
classNames,
|
|
35
|
+
}: OrgSwitcherProps) {
|
|
36
|
+
const cls = { ...ORG_SWITCHER_DEFAULTS, ...(classNames ?? {}) }
|
|
37
|
+
// If the caller hasn't pinned currentCompanyId, fall back to the first
|
|
38
|
+
// entry so the <select>'s value matches an option (otherwise React warns).
|
|
39
|
+
const value = currentCompanyId ?? companies[0]?.id ?? ''
|
|
40
|
+
|
|
41
|
+
if (companies.length === 0) return null
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<span className={cls.root}>
|
|
45
|
+
{!hideLabel && (
|
|
46
|
+
<span className={cls.label} aria-hidden="true">
|
|
47
|
+
{label}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
<label className="sr-only" htmlFor="org-switcher-select">
|
|
51
|
+
{label}
|
|
52
|
+
</label>
|
|
53
|
+
<select
|
|
54
|
+
id="org-switcher-select"
|
|
55
|
+
value={value}
|
|
56
|
+
onChange={(e) => onSwitch(e.target.value)}
|
|
57
|
+
className={cls.select}
|
|
58
|
+
aria-label={label}
|
|
59
|
+
>
|
|
60
|
+
{companies.map((c) => (
|
|
61
|
+
<option key={c.id} value={c.id}>
|
|
62
|
+
{c.name}
|
|
63
|
+
</option>
|
|
64
|
+
))}
|
|
65
|
+
</select>
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PendingInvitationCallout — "You've been invited to <team> as <role>" banner
|
|
5
|
+
* with an Accept button. Apps pass an `onAccept` handler that typically
|
|
6
|
+
* resolves the invitation token (from the URL) and calls
|
|
7
|
+
* api.teamInvitations.accept(id, token).
|
|
8
|
+
*
|
|
9
|
+
* Optional `teamName` lets apps render the team's display name even though
|
|
10
|
+
* the invitation row from the backend only carries teamId. startsim-o7s.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from 'react'
|
|
14
|
+
import type { InvitationLite } from './types'
|
|
15
|
+
import {
|
|
16
|
+
PENDING_INVITE_DEFAULTS,
|
|
17
|
+
type PendingInvitationCalloutClassNames,
|
|
18
|
+
} from './pending-invitation-callout-default-class-names'
|
|
19
|
+
|
|
20
|
+
export interface PendingInvitationCalloutProps {
|
|
21
|
+
invitation: InvitationLite
|
|
22
|
+
/** Resolved team name (caller supplies). When omitted we use `team #<id>`. */
|
|
23
|
+
teamName?: string
|
|
24
|
+
/** Called when the user clicks Accept. */
|
|
25
|
+
onAccept?: (invitation: InvitationLite) => Promise<void> | void
|
|
26
|
+
/** Called when the user clicks Decline. Optional — hidden when not provided. */
|
|
27
|
+
onDecline?: (invitation: InvitationLite) => Promise<void> | void
|
|
28
|
+
/** Notification once accept resolves. */
|
|
29
|
+
onAccepted?: (invitation: InvitationLite) => void
|
|
30
|
+
acceptLabel?: string
|
|
31
|
+
declineLabel?: string
|
|
32
|
+
classNames?: PendingInvitationCalloutClassNames
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PendingInvitationCallout({
|
|
36
|
+
invitation,
|
|
37
|
+
teamName,
|
|
38
|
+
onAccept,
|
|
39
|
+
onDecline,
|
|
40
|
+
onAccepted,
|
|
41
|
+
acceptLabel = 'Accept',
|
|
42
|
+
declineLabel = 'Decline',
|
|
43
|
+
classNames,
|
|
44
|
+
}: PendingInvitationCalloutProps) {
|
|
45
|
+
const cls = { ...PENDING_INVITE_DEFAULTS, ...(classNames ?? {}) }
|
|
46
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
47
|
+
const [error, setError] = React.useState('')
|
|
48
|
+
|
|
49
|
+
const renderedTeam = teamName ?? `team #${invitation.teamId}`
|
|
50
|
+
const role = invitation.role
|
|
51
|
+
|
|
52
|
+
async function handleAccept() {
|
|
53
|
+
setError('')
|
|
54
|
+
setSubmitting(true)
|
|
55
|
+
try {
|
|
56
|
+
await onAccept?.(invitation)
|
|
57
|
+
onAccepted?.(invitation)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
setError(err instanceof Error ? err.message : 'Could not accept invitation')
|
|
60
|
+
} finally {
|
|
61
|
+
setSubmitting(false)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handleDecline() {
|
|
66
|
+
setError('')
|
|
67
|
+
setSubmitting(true)
|
|
68
|
+
try {
|
|
69
|
+
await onDecline?.(invitation)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
setError(err instanceof Error ? err.message : 'Could not decline invitation')
|
|
72
|
+
} finally {
|
|
73
|
+
setSubmitting(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div role="status" className={cls.root}>
|
|
79
|
+
<p className={cls.message}>
|
|
80
|
+
You've been invited to <span className={cls.emphasized}>{renderedTeam}</span> as a{' '}
|
|
81
|
+
<span className={cls.emphasized}>{role}</span>.
|
|
82
|
+
</p>
|
|
83
|
+
<div className={cls.actions}>
|
|
84
|
+
{onDecline && (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className={cls.declineButton}
|
|
88
|
+
onClick={handleDecline}
|
|
89
|
+
disabled={submitting}
|
|
90
|
+
>
|
|
91
|
+
{declineLabel}
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
className={cls.acceptButton}
|
|
97
|
+
onClick={handleAccept}
|
|
98
|
+
disabled={submitting}
|
|
99
|
+
>
|
|
100
|
+
{submitting ? 'Working…' : acceptLabel}
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
{error && <p className={cls.errorText} role="alert">{error}</p>}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RoleSelector — headless role picker honoring the OWNER/ADMIN/MEMBER/VIEWER
|
|
5
|
+
* hierarchy from @startsimpli/api.
|
|
6
|
+
*
|
|
7
|
+
* Native <select> for now. A Radix Select upgrade is a follow-up — the
|
|
8
|
+
* classNames-slot API stays the same. startsim-o7s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import type { TeamRole } from './types'
|
|
13
|
+
import {
|
|
14
|
+
ROLE_SELECTOR_DEFAULTS,
|
|
15
|
+
type RoleSelectorClassNames,
|
|
16
|
+
} from './role-selector-default-class-names'
|
|
17
|
+
|
|
18
|
+
const ALL_ROLES: TeamRole[] = ['owner', 'admin', 'member', 'viewer']
|
|
19
|
+
|
|
20
|
+
export interface RoleSelectorProps {
|
|
21
|
+
value: TeamRole
|
|
22
|
+
onChange: (role: TeamRole) => void
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Restrict the list of options. Defaults to OWNER/ADMIN/MEMBER/VIEWER.
|
|
26
|
+
* The InviteMemberDialog hides 'owner' from this list by default since you
|
|
27
|
+
* promote owners via update-role, not the invite flow.
|
|
28
|
+
*/
|
|
29
|
+
availableRoles?: TeamRole[]
|
|
30
|
+
/** Label fallback for screen readers when no <label> wraps the component. */
|
|
31
|
+
ariaLabel?: string
|
|
32
|
+
classNames?: RoleSelectorClassNames
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LABELS: Record<TeamRole, string> = {
|
|
36
|
+
owner: 'Owner',
|
|
37
|
+
admin: 'Admin',
|
|
38
|
+
member: 'Member',
|
|
39
|
+
viewer: 'Viewer',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function RoleSelector({
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
disabled,
|
|
46
|
+
availableRoles = ALL_ROLES,
|
|
47
|
+
ariaLabel = 'Role',
|
|
48
|
+
classNames,
|
|
49
|
+
}: RoleSelectorProps) {
|
|
50
|
+
const cls = { ...ROLE_SELECTOR_DEFAULTS, ...(classNames ?? {}) }
|
|
51
|
+
return (
|
|
52
|
+
<span className={cls.root}>
|
|
53
|
+
<select
|
|
54
|
+
aria-label={ariaLabel}
|
|
55
|
+
value={value}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
onChange={(e) => onChange(e.target.value as TeamRole)}
|
|
58
|
+
className={cls.select}
|
|
59
|
+
>
|
|
60
|
+
{availableRoles.map((r) => (
|
|
61
|
+
<option key={r} value={r}>
|
|
62
|
+
{LABELS[r]}
|
|
63
|
+
</option>
|
|
64
|
+
))}
|
|
65
|
+
</select>
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
68
|
+
}
|