@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.
Files changed (71) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  34. package/src/components/team/index.ts +57 -0
  35. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  36. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  37. package/src/components/team/members-table-default-class-names.ts +39 -0
  38. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  39. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  40. package/src/components/team/role-selector-default-class-names.ts +11 -0
  41. package/src/components/team/types.ts +97 -0
  42. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  43. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  44. package/src/components/workflows/NodeInspector.tsx +257 -0
  45. package/src/components/workflows/NodePalette.tsx +119 -0
  46. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  47. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  48. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  49. package/src/components/workflows/WorkflowNode.tsx +198 -0
  50. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  51. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  52. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  53. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  54. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  55. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  56. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  57. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  58. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  59. package/src/components/workflows/exec-status.ts +90 -0
  60. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  61. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  62. package/src/components/workflows/index.ts +78 -0
  63. package/src/components/workflows/layout/auto-layout.ts +142 -0
  64. package/src/components/workflows/node-icons.ts +31 -0
  65. package/src/components/workflows/serialization.ts +171 -0
  66. package/src/components/workflows/theme/categories.ts +96 -0
  67. package/src/components/workflows/types.ts +231 -0
  68. package/src/components/workflows/workflows.css +29 -0
  69. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  70. package/src/components/workspace/SplitPane.tsx +174 -0
  71. 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&apos;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&apos;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
+ }