@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.
- package/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- 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
|
+
}
|