@weft-js/warp 0.0.0-stage → 0.1.0

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 (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,174 @@
1
+ import { AlertCircleIcon, UploadIcon } from '@weft-js/warp-icons'
2
+ import { announce, autoMap, badRows, checkImport, importRecords, isCsvFile, missingTargets, parseCsv, plural, SKIP_COLUMN, uid, type CsvTable, type ImportIssue, type ImportTarget } from '@weft-js/warp-core'
3
+ import { derived, state } from '@weft-js/core'
4
+ import { Alert } from '../Alert'
5
+ import { Button } from '../Button'
6
+ import { Progress } from '../Progress'
7
+ import { toast } from '../Toast'
8
+ import { PlainTable } from './PlainTable'
9
+ import { StepBar } from './StepBar'
10
+ import { InlineSelect, Sweep, afterFlush } from './atoms'
11
+ import '../styles'
12
+ import '@weft-js/warp-core/styles/flows/tokens.css'
13
+ import '@weft-js/warp-core/styles/flows/base.css'
14
+ import '@weft-js/warp-core/styles/rich/helpers.css'
15
+ import '@weft-js/warp-core/styles/layout/tokens.css'
16
+ import '@weft-js/warp-core/styles/layout/row.css'
17
+ import '@weft-js/warp-core/styles/layout/stack.css'
18
+ import '@weft-js/warp-core/styles/inputs/field.css'
19
+ import '@weft-js/warp-core/styles/inputs/file.css'
20
+ import '@weft-js/warp-core/styles/flows/import-map.css'
21
+ import '@weft-js/warp-core/styles/flows/import-preview.css'
22
+
23
+ /** Props for CsvImport. */
24
+ export interface CsvImportProps {
25
+ targets: ImportTarget[]
26
+ noun?: [one: string, many: string]
27
+ rowLimit?: number
28
+ /** "Use the sample": a name and its text. */
29
+ sample?: { name: string; text: string }
30
+ /** Runs the import; report progress from 0 to 100. The job is the server's: leaving the page is fine. */
31
+ onImport: (records: Record<string, string>[], progress: (percent: number) => void) => Promise<void>
32
+ }
33
+
34
+ type Stage = 'upload' | 'map' | 'review' | 'done'
35
+ const STEPS = [{ id: 'upload', title: 'Upload' }, { id: 'map', title: 'Map' }, { id: 'review', title: 'Review' }, { id: 'done', title: 'Import' }]
36
+ const SAY: Record<Stage, string> = { upload: 'upload', map: 'map columns', review: 'review', done: 'importing' }
37
+ const ORDER: Stage[] = ['upload', 'map', 'review', 'done']
38
+ const PREVIEW_ROWS = 50
39
+
40
+ /**
41
+ * The CSV import wizard (09 §4.7), Upload → Map → Review → Import: parsing, mapping and validation are warp-core's. Each
42
+ * step's heading takes focus. Bad cells say why inline; a required target with no column is reported once.
43
+ */
44
+ export function CsvImport({ targets, noun = ['person', 'people'], rowLimit = 5000, sample, onImport }: CsvImportProps) {
45
+ const id = uid('csv')
46
+ const stage = state<Stage>('upload')
47
+ const file = state('')
48
+ const table = state<CsvTable>({ headers: [], rows: [] })
49
+ const map = state<string[]>([])
50
+ const percent = state(0)
51
+ const finished = state(false)
52
+ const dragging = state(false)
53
+ const issue = state('')
54
+ let heading: HTMLElement | undefined
55
+
56
+ const at = derived(() => ORDER.indexOf(stage.value))
57
+ const options = derived(() => [...targets.map(t => ({ value: t.id, label: t.label })), { value: SKIP_COLUMN, label: 'Don’t import' }])
58
+ const missing = derived(() => missingTargets(map.value, targets))
59
+ const issues = derived<ImportIssue[]>(() => checkImport(table.value.rows, map.value, targets))
60
+ const bad = derived(() => badRows(issues.value))
61
+ const good = derived(() => (missing.value.length ? 0 : table.value.rows.length - bad.value))
62
+ const count = (n: number) => plural(n, noun[0], noun[1])
63
+ const go = (next: Stage) => {
64
+ stage.value = next
65
+ announceStep(next)
66
+ afterFlush(() => heading?.focus())
67
+ }
68
+ const announceStep = (s: Stage) => { if (heading) announce(heading.ownerDocument, `Import, step ${ORDER.indexOf(s) + 1} of 4, ${SAY[s]}`) }
69
+ const load = (name: string, text: string) => {
70
+ const t = parseCsv(text)
71
+ if (t.problem) { issue.value = t.problem; return }
72
+ if (t.rows.length > rowLimit) { issue.value = `This file has ${t.rows.length.toLocaleString('en-US')} rows. The limit is ${rowLimit.toLocaleString('en-US')}.`; return }
73
+ issue.value = ''
74
+ file.value = name
75
+ table.value = t
76
+ map.value = autoMap(t.headers, targets)
77
+ go('map')
78
+ }
79
+ const take = async (f?: File | null) => {
80
+ if (!f) return
81
+ if (!isCsvFile(f.name, f.type)) return void toast.show({ title: 'That isn’t a CSV file', description: `${f.name} wasn’t uploaded. Export it as .csv and try again.`, tone: 'critical' })
82
+ load(f.name, await f.text())
83
+ }
84
+ const run = async () => {
85
+ const records = importRecords(table.value.rows, map.value, issues.value)
86
+ go('done')
87
+ percent.value = 0
88
+ finished.value = false
89
+ try {
90
+ await onImport(records, p => (percent.value = p))
91
+ percent.value = 100
92
+ finished.value = true
93
+ toast.show({ title: `${count(records.length)} imported`, description: 'They’ll get an invite email.', tone: 'positive' })
94
+ } catch {
95
+ toast.show({ title: 'The import stopped', description: 'Nothing was lost. Try again.', tone: 'critical', action: { label: 'Retry', onClick: run } })
96
+ }
97
+ }
98
+ const again = () => { table.value = { headers: [], rows: [] }; map.value = []; file.value = ''; go('upload') }
99
+ const cellIssue = (row: number, target: string) => issues.value.find(x => x.row === row && x.path === target)
100
+ const preview = derived(() => table.value.rows.slice(0, PREVIEW_ROWS).map((cells, row) => ({ row, cells })))
101
+ const columns = derived(() => table.value.headers.map((h, col) => ({ h, col, target: map.value[col] })))
102
+ const kept = derived(() => columns.value.filter(c => c.target !== SKIP_COLUMN))
103
+ const titleOf = (target: string) => targets.find(t => t.id === target)?.label ?? target
104
+
105
+ return (
106
+ <div data-scope="stack">
107
+ <StepBar label="Import progress" steps={STEPS} current={at.value} />
108
+ <h3 ref={n => (heading = n)} data-part="step-title" tabindex="-1">{STEPS[at.value]!.title} {noun[1]}</h3>
109
+ {stage.value === 'upload' && (
110
+ <>
111
+ <div data-scope="dropzone" data-sweep="" data-pin={dragging.value ? '' : undefined} onDragOver={(e: DragEvent) => { e.preventDefault(); dragging.value = true }} onDragLeave={() => (dragging.value = false)} onDrop={(e: DragEvent) => { e.preventDefault(); dragging.value = false; void take(e.dataTransfer?.files[0]) }}>
112
+ <Sweep />
113
+ <input type="file" accept=".csv,text/csv" aria-labelledby={`${id}-t`} aria-describedby={`${id}-h`} onChange={(e: Event) => { void take((e.currentTarget as HTMLInputElement).files?.[0]); (e.currentTarget as HTMLInputElement).value = '' }} />
114
+ <span data-part="icon" aria-hidden="true"><UploadIcon /></span>
115
+ <span data-part="drop-copy"><span data-part="title" id={`${id}-t`}>Drop a CSV here or <u>browse</u></span></span>
116
+ <span data-part="hint" id={`${id}-h`}>Up to {rowLimit.toLocaleString('en-US')} rows · first row is the header</span>
117
+ </div>
118
+ {issue.value && <Alert tone="critical" live title="That file can’t be read">{issue.value}</Alert>}
119
+ {sample && <div><Button emphasis="link" onClick={() => load(sample.name, sample.text)}>Use the sample</Button></div>}
120
+ </>
121
+ )}
122
+ {stage.value === 'map' && (
123
+ <>
124
+ <span class="muted"><b>{file.value}</b> · {plural(table.value.rows.length, 'row')} · {plural(table.value.headers.length, 'column')}</span>
125
+ <PlainTable label="Column mapping" scope="import-map" caption="Map each CSV column to a field" head={<tr><th scope="col">CSV column</th><th scope="col">Sample</th><th scope="col">Imports as</th></tr>}>
126
+ {columns.value.map(c => (
127
+ <tr key={c.h}>
128
+ <th scope="row"><code>{c.h}</code></th>
129
+ <td class="muted">{table.value.rows[0]?.[c.col]}</td>
130
+ <td><InlineSelect label={`${c.h} imports as`} value={c.target!} options={options.value} onValueChange={v => { const next = [...map.value]; next[c.col] = v; map.value = next }} /></td>
131
+ </tr>
132
+ ))}
133
+ </PlainTable>
134
+ </>
135
+ )}
136
+ {stage.value === 'review' && (
137
+ <>
138
+ {missing.value.length > 0
139
+ ? <Alert tone="critical" title={`No column is mapped to ${missing.value.map(t => t.label).join(', ')}`}>Go back and map one to continue.</Alert>
140
+ : bad.value > 0
141
+ ? <Alert tone="caution" title={`${bad.value} of ${plural(table.value.rows.length, 'row')} need a fix`}>Fix them in the file and upload again, or import the {good.value} good rows now and skip the rest.</Alert>
142
+ : <Alert tone="positive" title={`All ${plural(table.value.rows.length, 'row')} look good`} />}
143
+ <PlainTable label="Import preview" scope="import-preview" caption="Preview of the first rows" head={<tr>{kept.value.map(c => <th key={c.h} scope="col">{titleOf(c.target!)}</th>)}</tr>}>
144
+ {preview.value.map(r => (
145
+ <tr key={r.row} data-invalid={issues.value.some(x => x.row === r.row) ? '' : undefined}>
146
+ {kept.value.map(c => <Cell key={c.h} value={r.cells[c.col]!} issue={cellIssue(r.row, c.target!)} />)}
147
+ </tr>
148
+ ))}
149
+ </PlainTable>
150
+ </>
151
+ )}
152
+ {stage.value === 'done' && (
153
+ <>
154
+ <Progress label={finished.value ? 'Imported' : 'Importing'} value={percent.value} />
155
+ {finished.value
156
+ ? <div data-scope="row"><span>{count(good.value)} imported.</span><Button emphasis="link" onClick={again}>Import another file</Button></div>
157
+ : <span class="muted">You can leave this page; we’ll email you when it finishes.</span>}
158
+ </>
159
+ )}
160
+ {(stage.value === 'map' || stage.value === 'review') && (
161
+ <div data-scope="row" data-justify="between">
162
+ <Button emphasis="ghost" tone="neutral" onClick={() => go(stage.value === 'map' ? 'upload' : 'map')}>Back</Button>
163
+ {stage.value === 'map'
164
+ ? <Button onClick={() => go('review')}>Preview</Button>
165
+ : <Button disabled={good.value === 0} onClick={run}>{`Import ${plural(good.value, 'row')}`}</Button>}
166
+ </div>
167
+ )}
168
+ </div>
169
+ )
170
+ }
171
+
172
+ function Cell({ value, issue }: { value: string; issue?: ImportIssue | undefined }) {
173
+ return <td data-invalid={issue ? '' : undefined}>{value}{issue && <span data-part="error"><AlertCircleIcon />{issue.message}</span>}</td>
174
+ }
@@ -0,0 +1,110 @@
1
+ import { TrashIcon, type IconComponent } from '@weft-js/warp-icons'
2
+ import { confirmMatches } from '@weft-js/warp-core'
3
+ import { derived, state, type State, untrack, watch } from '@weft-js/core'
4
+ import { Button, type Tone } from '../Button'
5
+ import { Input } from '../Field'
6
+ import { Modal } from '../Modal'
7
+ import { Glyph } from '../parts'
8
+ import { toast } from '../Toast'
9
+ import { latch } from './atoms'
10
+ import { SettingsSection } from './Settings'
11
+ import '../styles'
12
+ import '@weft-js/warp-core/styles/flows/tokens.css'
13
+ import '@weft-js/warp-core/styles/flows/base.css'
14
+ import '@weft-js/warp-core/styles/rich/helpers.css'
15
+ import '@weft-js/warp-core/styles/layout/tokens.css'
16
+ import '@weft-js/warp-core/styles/layout/row.css'
17
+ import '@weft-js/warp-core/styles/layout/stack.css'
18
+ import '@weft-js/warp-core/styles/flows/meter.css' // the result mark
19
+ import '@weft-js/warp-core/styles/flows/confirm.css'
20
+
21
+ /** The danger zone (09 §4.4): the one critical-bordered section. Rows are SettingRows. */
22
+ export function DangerZone({ id = 'danger', title = 'Danger zone', description, children }: { id?: string; title?: unknown; description?: unknown; children: unknown }) {
23
+ return <SettingsSection id={id} title={title} description={description} tone="critical">{children}</SettingsSection>
24
+ }
25
+
26
+ /** Props for ConfirmDialog. */
27
+ export interface ConfirmDialogProps {
28
+ open: State<boolean>
29
+ title: unknown
30
+ description?: unknown
31
+ /** The confirm button: says what it does, with the count on a batch ("Remove 2 members"). */
32
+ action: unknown
33
+ cancel?: string
34
+ tone?: Tone | undefined
35
+ /** Drawn at the top of the body, in the result mark. */
36
+ glyph?: string | IconComponent
37
+ children?: unknown
38
+ onConfirm: () => Promise<void> | void
39
+ onCancel?: () => void
40
+ }
41
+
42
+ const failed = (retry: () => unknown) => toast.show({ title: 'That didn’t go through', description: 'Nothing changed. Try again.', tone: 'critical', action: { label: 'Retry', onClick: retry } })
43
+
44
+ /** A plain AlertDialog (05 §3): focus on Cancel. Pending on the confirm button; the dialog closes when it's done. */
45
+ export function ConfirmDialog({ open, title, description, action, cancel = 'Cancel', tone, glyph, children, onConfirm, onCancel }: ConfirmDialogProps) {
46
+ const armed = latch(untrack(() => open))
47
+ const busy = state(false)
48
+ const go = async () => {
49
+ if (busy.value) return
50
+ busy.value = true
51
+ try { await onConfirm(); open.value = false }
52
+ catch { failed(go) }
53
+ finally { busy.value = false }
54
+ }
55
+ return (
56
+ <>
57
+ {armed.value && (
58
+ <Modal open={open} alert size="sm" title={title} description={description} mark={glyph ? <span data-part="mark" data-tone={tone === 'critical' ? 'critical' : undefined}>{typeof glyph === 'string' ? <Glyph d={glyph} /> : glyph({})}</span> : undefined} footer={<><Button emphasis="frame" autofocus data-close="" onClick={() => onCancel?.()}>{cancel}</Button><Button tone={tone} loading={busy.value} onClick={go}>{action}</Button></>}>
59
+ {children}
60
+ </Modal>
61
+ )}
62
+ </>
63
+ )
64
+ }
65
+
66
+ /** Props for TypeToConfirm. */
67
+ export interface TypeToConfirmProps {
68
+ open: State<boolean>
69
+ title: unknown
70
+ description?: unknown
71
+ /** The exact text to type: the project's or key's name. */
72
+ confirmText: string
73
+ /** What goes, listed. */
74
+ items?: string[]
75
+ action: unknown
76
+ glyph?: string | IconComponent
77
+ onConfirm: () => Promise<void> | void
78
+ }
79
+
80
+ /**
81
+ * Type-to-confirm (09 §4.4): an AlertDialog whose field sweeps critical. Deviation from 05: focus goes to the field,
82
+ * not Cancel. The confirm stays disabled until the text matches exactly, and Enter submits only then.
83
+ */
84
+ export function TypeToConfirm({ open, title, description, confirmText, items, action, glyph = TrashIcon, onConfirm }: TypeToConfirmProps) {
85
+ const armed = latch(untrack(() => open))
86
+ const typed = state('')
87
+ const busy = state(false)
88
+ const ok = derived(() => confirmMatches(typed.value, confirmText))
89
+ watch(() => open.value, on => { if (on) typed.value = '' })
90
+ const go = async () => {
91
+ if (!ok.value || busy.value) return
92
+ busy.value = true
93
+ try { await onConfirm(); open.value = false }
94
+ catch { failed(go) }
95
+ finally { busy.value = false }
96
+ }
97
+ const submit = (e: Event) => { e.preventDefault(); void go() }
98
+ return (
99
+ <>
100
+ {armed.value && (
101
+ <Modal open={open} alert size="sm" tone="critical" title={title} description={description} mark={<span data-part="mark" data-tone="critical">{typeof glyph === 'string' ? <Glyph d={glyph} /> : glyph({})}</span>} footer={<><Button emphasis="frame" data-close="">Cancel</Button><Button tone="critical" disabled={!ok.value} loading={busy.value} onClick={go}>{action}</Button></>}>
102
+ <form data-scope="confirm" novalidate onSubmit={submit}>
103
+ {items && <ul>{items.map(t => <li key={t}>{t}</li>)}</ul>}
104
+ <Input label={`Type ${confirmText} to confirm`} autofocus autocomplete="off" spellcheck="false" value={typed.value} onInput={e => (typed.value = e.currentTarget.value)} />
105
+ </form>
106
+ </Modal>
107
+ )}
108
+ </>
109
+ )
110
+ }
@@ -0,0 +1,200 @@
1
+ import { ArrowUpRightIcon } from '@weft-js/warp-icons'
2
+ import { httpsEndpoint, uid } from '@weft-js/warp-core'
3
+ import { derived, state, type State, untrack, watch } from '@weft-js/core'
4
+ import { Alert } from '../Alert'
5
+ import { Badge } from '../Badge'
6
+ import { Button, type Tone } from '../Button'
7
+ import { Checkbox } from '../Choice'
8
+ import { Drawer } from '../Drawer'
9
+ import { Input } from '../Field'
10
+ import { Modal } from '../Modal'
11
+ import { Table, type Column } from '../Table'
12
+ import { toast } from '../Toast'
13
+ import { SecretReveal, useReveal } from './Secret'
14
+ import { afterFlush, guarded, latch, read } from './atoms'
15
+ import '../styles'
16
+ import '@weft-js/warp-core/styles/flows/tokens.css'
17
+ import '@weft-js/warp-core/styles/flows/base.css'
18
+ import '@weft-js/warp-core/styles/rich/helpers.css'
19
+ import '@weft-js/warp-core/styles/layout/tokens.css'
20
+ import '@weft-js/warp-core/styles/layout/row.css'
21
+ import '@weft-js/warp-core/styles/layout/stack.css'
22
+ import '@weft-js/warp-core/styles/flows/integrations.css'
23
+ import '@weft-js/warp-core/styles/flows/integration.css'
24
+ import '@weft-js/warp-core/styles/flows/choice.css'
25
+
26
+ /** Where an integration stands. */
27
+ export type IntegrationState = 'connected' | 'error' | 'off' | 'pending'
28
+ /** One card of the integrations grid. */
29
+ export type Integration = { id: string; name: string; summary: string; state: IntegrationState; detail?: string | undefined; failure?: string | undefined; can?: string[] | undefined }
30
+ const BADGE: Record<IntegrationState, [string, Tone | undefined, boolean]> = { connected: ['Connected', 'positive', true], error: ['Needs attention', 'critical', true], off: ['Not connected', undefined, false], pending: ['Connecting…', 'caution', true] }
31
+
32
+ /** Props for IntegrationsGrid. */
33
+ export interface IntegrationsGridProps {
34
+ items: readonly Integration[] | State<readonly Integration[]>
35
+ /** Open the provider's consent page; the card's `state` then comes back through `items`. */
36
+ onConnect: (item: Integration) => void
37
+ /** The Configure sheet's fields. */
38
+ settings?: ((item: Integration) => unknown) | undefined
39
+ onSave?: (item: Integration) => void
40
+ onDisconnect: (item: Integration) => void
41
+ onRestore?: (item: Integration) => void
42
+ }
43
+
44
+ function Card({ item, onConnect, onConfigure }: { item: Integration; onConnect: () => void; onConfigure: () => void }) {
45
+ let configure: HTMLElement | undefined
46
+ const id = uid('ig')
47
+ const badge = derived(() => BADGE[item.state])
48
+ watch(() => item.state, (now, was) => {
49
+ if (was !== 'pending' || now !== 'connected') return
50
+ toast.show({ title: `${untrack(() => item.name)} connected`, tone: 'positive' })
51
+ afterFlush(() => configure?.focus())
52
+ })
53
+ return (
54
+ <li data-scope="integration" data-state={item.state}>
55
+ <span data-part="logo" aria-hidden="true">{item.name[0]}</span>
56
+ <span data-part="info">
57
+ <b id={id}>{item.name}</b>
58
+ <Badge tone={badge.value[1]} dot={badge.value[2]}>{badge.value[0]}</Badge>
59
+ <span class="muted">{item.state === 'off' ? item.summary : item.detail}</span>
60
+ </span>
61
+ {item.failure && <span data-part="actions"><Alert tone="critical" live>{item.failure}</Alert></span>}
62
+ <span data-part="actions">
63
+ {item.state === 'off' && <Button emphasis="frame" size="sm" aria-describedby={id} onClick={onConnect}>Connect</Button>}
64
+ {item.state === 'error' && <Button size="sm" tone="critical" aria-describedby={id} onClick={onConnect}>Reconnect</Button>}
65
+ {item.state === 'connected' && <Button ref={n => (configure = n)} emphasis="ghost" tone="neutral" size="sm" aria-describedby={id} onClick={onConfigure}>Configure</Button>}
66
+ </span>
67
+ </li>
68
+ )
69
+ }
70
+
71
+ /**
72
+ * The integrations grid (09 §4.6): a status badge with a dot, one line, one action; a failing card takes a critical
73
+ * border and the page's only critical solid. Connect lists what the provider can do, then leaves for its consent page.
74
+ */
75
+ export function IntegrationsGrid({ items, onConnect, settings, onSave, onDisconnect, onRestore }: IntegrationsGridProps) {
76
+ const list = derived(() => read(items))
77
+ const connecting = state(false)
78
+ const configuring = state(false)
79
+ const armedC = latch(connecting)
80
+ const armedS = latch(configuring)
81
+ const pick = state<Integration | undefined>(undefined)
82
+ const go = () => { connecting.value = false; onConnect(pick.value!) }
83
+ const name = derived(() => pick.value?.name ?? '')
84
+ const disconnect = () => {
85
+ const x = pick.value!
86
+ configuring.value = false
87
+ onDisconnect(x)
88
+ toast.show({ title: `${x.name} disconnected`, action: onRestore && { label: 'Undo', onClick: () => onRestore(x) } })
89
+ }
90
+ return (
91
+ <>
92
+ <ul data-scope="integrations">
93
+ {list.value.map(x => <Card key={x.id} item={x} onConnect={() => { pick.value = x; connecting.value = true }} onConfigure={() => { pick.value = x; configuring.value = true }} />)}
94
+ </ul>
95
+ {armedC.value && (
96
+ <Modal open={connecting} size="sm" title={`Connect ${name.value}`} description="You can disconnect any time." footer={<><Button emphasis="frame" data-close="">Cancel</Button><Button autofocus onClick={go}>Continue to {name.value}<ArrowUpRightIcon /></Button></>}>
97
+ <div data-scope="stack">
98
+ <ul>{(pick.value?.can ?? []).map(t => <li key={t}>{t}</li>)}</ul>
99
+ <span class="muted">Next, {name.value} asks you to approve this in a new window.</span>
100
+ </div>
101
+ </Modal>
102
+ )}
103
+ {armedS.value && (
104
+ <Drawer open={configuring} title={name.value} description={pick.value?.detail} footer={<><Button emphasis="soft" tone="critical" onClick={disconnect}>Disconnect {name.value}</Button><Button data-close="" onClick={() => onSave?.(pick.value!)}>Save</Button></>}>
105
+ {pick.value && settings?.(pick.value)}
106
+ </Drawer>
107
+ )}
108
+ </>
109
+ )
110
+ }
111
+
112
+ /** One webhook endpoint. */
113
+ export type Webhook = { id: string; url: string; events: string[]; lastDelivery?: string; healthy: boolean }
114
+
115
+ /** Props for Webhooks. */
116
+ export interface WebhooksProps {
117
+ hooks: readonly Webhook[] | State<readonly Webhook[]>
118
+ events: string[]
119
+ /** Returns the signing secret, shown once. */
120
+ onAdd: (url: string, events: string[]) => Promise<string>
121
+ /** Posts a sample event. */
122
+ onTest: (hook: Webhook) => Promise<{ status: number; ms: number }>
123
+ }
124
+
125
+ /**
126
+ * A URL that breaks where a reader would: after a slash or a query's `&` and `=`, before a dot, `?` or `#`, never inside a
127
+ * word, nor after a hyphen in a host name. Each piece is an inline block, so lines break only between pieces (`<wbr>`
128
+ * says so to anything reading the markup), and a piece longer than the line still breaks inside as a last resort.
129
+ */
130
+ export const urlBreaks = (url: string): string[] => url.split(/(?<=[/&=])(?!\/)|(?=[.?#])/).filter(Boolean)
131
+ const Url = ({ href }: { href: string }) => (
132
+ <code data-part="url" style="white-space:normal;word-break:normal;overflow-wrap:break-word">
133
+ {urlBreaks(href).map((part, i) => [i ? <wbr /> : null, <span style="display:inline-block;max-inline-size:100%">{part}</span>])}
134
+ </code>
135
+ )
136
+
137
+ /** Webhooks (09 §4.6): a DataTable of endpoints. Send test reports the code and time; a new signing secret is shown once. */
138
+ export function Webhooks({ hooks, events, onAdd, onTest }: WebhooksProps) {
139
+ const adding = state(false)
140
+ const armed = latch(adding)
141
+ const { secret, seen } = useReveal(adding)
142
+ const dialog = guarded(adding, () => !secret.value || seen.value)
143
+ const url = state('')
144
+ const picked = state<string[]>([])
145
+ const urlError = state('')
146
+ const eventError = state('')
147
+ const tried = state(false)
148
+ const busy = state(false)
149
+ const list = derived(() => read(hooks))
150
+ const check = () => { urlError.value = !url.value.trim() ? 'Enter the URL we should call.' : httpsEndpoint(url.value) ? '' : 'Use a full https:// URL.' }
151
+ const test = async (h: Webhook) => {
152
+ try {
153
+ const r = await onTest(h)
154
+ toast.show({ title: `Test delivered: ${r.status}`, description: `${h.url} answered in ${r.ms} ms.`, tone: r.status < 300 ? 'positive' : 'critical' })
155
+ } catch { toast.show({ title: 'The test didn’t send', tone: 'critical', action: { label: 'Retry', onClick: () => test(h) } }) }
156
+ }
157
+ const save = async () => {
158
+ if (busy.value) return
159
+ tried.value = true
160
+ check()
161
+ eventError.value = picked.value.length ? '' : 'Pick at least one event.'
162
+ if (urlError.value || eventError.value) return
163
+ busy.value = true
164
+ try { secret.value = await onAdd(url.value.trim(), picked.value) }
165
+ catch { toast.show({ title: 'The endpoint wasn’t added', tone: 'critical', action: { label: 'Retry', onClick: save } }) }
166
+ finally { busy.value = false }
167
+ }
168
+ const open = () => { url.value = ''; picked.value = events.slice(0, 2); urlError.value = ''; eventError.value = ''; tried.value = false; adding.value = true }
169
+ const columns: Column<Webhook>[] = [
170
+ { id: 'url', header: 'Endpoint', rowHeader: true, card: 'title', cell: h => <Url href={h.url} /> },
171
+ { id: 'events', header: 'Events', value: h => h.events.join(', ') },
172
+ { id: 'lastDelivery', header: 'Last delivery', value: h => h.lastDelivery ?? 'None yet' },
173
+ { id: 'healthy', header: 'Health', card: 'badge', value: h => (h.healthy ? 'Healthy' : 'Failing'), cell: h => <Badge tone={h.healthy ? 'positive' : 'critical'} dot>{h.healthy ? 'Healthy' : 'Failing'}</Badge> },
174
+ { id: 'test', header: 'Actions', value: () => '', cell: h => <Button emphasis="link" aria-label={`Send test to ${h.url}`} onClick={() => test(h)}>Send test</Button> },
175
+ ]
176
+ const tools = derived(() => <Button emphasis="frame" onClick={open}>Add endpoint</Button>) // one build: Table reads the prop twice
177
+ const footer = derived(() => (secret.value
178
+ ? <Button disabled={!seen.value} onClick={() => (dialog.value = false)}>Done</Button>
179
+ : <><Button emphasis="frame" data-close="">Cancel</Button><Button loading={busy.value} onClick={save}>Add endpoint</Button></>))
180
+ return (
181
+ <div data-scope="stack">
182
+ <Table caption="Webhook endpoints" captionHidden rows={list} columns={columns} picker={false} heading={3} actions={tools.value} empty={{ title: 'No webhooks yet', text: 'Add an endpoint to get events.' }} />
183
+ {armed.value && (
184
+ <Modal open={dialog} alert={!!secret.value} size="sm" title={secret.value ? 'Copy your signing secret' : 'Add a webhook endpoint'} description={secret.value ? 'You won’t see it again. Use it to verify our signature.' : 'We POST JSON to this URL for each event you pick.'} footer={<>{footer.value}</>}>
185
+ {secret.value
186
+ ? <SecretReveal label="Signing secret" secret={secret.value} done={seen} ack="I’ve saved this secret somewhere safe" />
187
+ : (
188
+ <form data-scope="stack" style="padding-block-start:var(--warp-space-related)" novalidate onSubmit={(e: Event) => { e.preventDefault(); void save() }}>
189
+ <Input label="Endpoint URL" type="url" inputmode="url" placeholder="https://" autofocus hint="Must be https." value={url.value} error={urlError.value} onInput={e => { url.value = e.currentTarget.value; if (tried.value) check() }} />
190
+ <fieldset data-scope="choice">
191
+ <legend>Events</legend>
192
+ {events.map(v => <Checkbox key={v} checked={picked.value.includes(v)} error={v === events[0] && eventError.value ? eventError.value : undefined} onChange={(e: Event) => { picked.value = (e.currentTarget as HTMLInputElement).checked ? [...picked.value, v] : picked.value.filter(x => x !== v); eventError.value = '' }}><code>{v}</code></Checkbox>)}
193
+ </fieldset>
194
+ </form>
195
+ )}
196
+ </Modal>
197
+ )}
198
+ </div>
199
+ )
200
+ }
@@ -0,0 +1,64 @@
1
+ import { adminEmail, inviteProblem } from '@weft-js/warp-core'
2
+ import { state, type State, untrack, watch } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { Modal } from '../Modal'
5
+ import { Select } from '../Select'
6
+ import { toast } from '../Toast'
7
+ import { afterFlush, latch } from './atoms'
8
+ import { TagsInput } from './TagsInput'
9
+ import '../styles'
10
+ import '@weft-js/warp-core/styles/flows/tokens.css'
11
+ import '@weft-js/warp-core/styles/flows/base.css'
12
+ import '@weft-js/warp-core/styles/rich/helpers.css'
13
+ import '@weft-js/warp-core/styles/layout/tokens.css'
14
+ import '@weft-js/warp-core/styles/layout/row.css'
15
+ import '@weft-js/warp-core/styles/layout/stack.css'
16
+
17
+ /** Props for InviteDialog. */
18
+ export interface InviteDialogProps {
19
+ open: State<boolean>
20
+ roles: { value: string; label: string }[]
21
+ defaultRole?: string
22
+ /** Pending invites use a seat; the check runs before anything is sent. */
23
+ seatsLeft: number
24
+ /** Send them; a thrown error keeps the dialog and its tags. */
25
+ onSend: (emails: string[], role: string) => Promise<void> | void
26
+ }
27
+
28
+ /** Invite (09 §4.2): a multi-email tags field and a role, checked against the seats left before sending. */
29
+ export function InviteDialog({ open, roles, defaultRole, seatsLeft, onSend }: InviteDialogProps) {
30
+ const armed = latch(untrack(() => open))
31
+ const emails = state<string[]>([])
32
+ const role = state(untrack(() => defaultRole ?? roles[0]?.value ?? ''))
33
+ const error = state('')
34
+ const busy = state(false)
35
+ let root: HTMLElement | undefined
36
+ watch(() => open.value, on => { if (on) { emails.value = []; error.value = '' } })
37
+ const send = async () => {
38
+ if (busy.value) return
39
+ const typed = root?.querySelector<HTMLInputElement>('input[data-part="input"]')
40
+ typed?.blur()
41
+ const problem = inviteProblem(emails.value, seatsLeft)
42
+ if (problem) { error.value = problem; afterFlush(() => typed?.focus()); return }
43
+ busy.value = true
44
+ try {
45
+ await onSend(emails.value, role.value)
46
+ open.value = false
47
+ toast.show({ title: emails.value.length === 1 ? 'Invite sent' : `${emails.value.length} invites sent`, description: emails.value.join(', '), tone: 'positive' })
48
+ } catch {
49
+ toast.show({ title: 'The invites didn’t send', description: 'Your list is still here.', tone: 'critical', action: { label: 'Retry', onClick: send } })
50
+ } finally { busy.value = false }
51
+ }
52
+ return (
53
+ <>
54
+ {armed.value && (
55
+ <Modal open={open} title="Invite people" description="They get an email with a link that works for 7 days." footer={<><Button emphasis="frame" data-close="">Cancel</Button><Button loading={busy.value} onClick={send}>Send invites</Button></>}>
56
+ <form ref={n => (root = n)} data-scope="stack" style="padding-block-start:var(--warp-space-related)" novalidate onSubmit={(e: Event) => e.preventDefault()}>
57
+ <TagsInput label="Email addresses" autofocus hint="Enter or comma adds one. Paste a list to add many." error={error.value} value={emails} valid={adminEmail} onEdit={() => (error.value = '')} onSubmit={() => void send()} />
58
+ <Select label="Role" options={roles} value={role.value} onChange={(e: Event) => (role.value = (e.currentTarget as HTMLInputElement).value)} />
59
+ </form>
60
+ </Modal>
61
+ )}
62
+ </>
63
+ )
64
+ }