feat(events): the authoring UI proper (Phase 13)
Some checks failed
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Failing after 9m1s

Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.

One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.

A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.

Two defects fixed in already-merged code:

  * Creating an event has been impossible since Phase 6. `events/new` was added
    beside `events/:id` and binds no param, and React Router ranks a static
    segment above a dynamic one whatever the order -- so the editor was handed no
    id and fetched /admin/events/undefined. Worse, the failure was invisible:
    `!form` is true for every failed load, so the error state sat behind a
    spinner that never stopped.
  * 12b's searchable sources had no consumer. The server half shipped and the
    only UI that reads a source never sent a term, so the 6,707-entry spawner
    list was picked from a 2,000-entry truncation with nothing saying so.

Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-07 16:34:03 -05:00
parent db8e01e868
commit 8453762e3b
13 changed files with 1913 additions and 149 deletions

View File

@@ -8,6 +8,8 @@ import {
payloadFromForm,
blankPhase,
blankAdvance,
blankWhere,
whereFormFrom,
ADVANCE_KINDS,
blankStep,
describeSchedule,
@@ -15,7 +17,16 @@ import {
SCHEDULE_KINDS,
MONTHLY_NTHS,
WEEKDAYS,
PARAM_FORM,
PARAM_JSON,
paramsMode,
paramValue,
setParam,
datetimeInputValue,
priceBodyFrom,
worthPricing,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType } from '../../../lib/engagementRules.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
@@ -32,13 +43,21 @@ import {
// knowing any of it, and `check:modules` already fails core's build on a UO
// identifier.
//
// **The params box is a raw JSON field and it is captioned as a placeholder**,
// because that is what it is: Phase 13 replaces it with the schema-driven form
// the condition builder already models. What makes it usable in the meantime is
// that a new step arrives PREFILLED from the action's declared examples, and the
// declaration is rendered beside the box — every param's name, type, whether it
// is required, and what a value looks like. All of that was already in the
// catalog; none of it is a second copy of anything.
// **The params are a FORM as of Phase 13**, one control per declared param,
// rendered from a schema core does not understand — §I's *"the condition
// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and
// a step opens in it automatically when the form could not hold what the step
// carries. That rule is the condition builder's own, ported rather than
// reinvented — dropping a param the action does not declare and flattening
// `A and (B or C)` are the same mistake, a save that looks clean and means
// something else.
//
// **The meter beside the timeline is not a lighter dry run.** `POST
// /admin/events/price` dispatches nothing, so it knows nothing a module knows —
// whether the landmark exists, whether the shard is up. It answers the half core
// can answer alone, which is what a plan would SPEND, and it can therefore run on
// a debounce while somebody types. The dry run stays the thing that asks the
// modules, and the screen labels them apart.
/**
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
@@ -88,9 +107,7 @@ function ParamOptions({ entry, label, disabled, onPick }) {
disabled={disabled}
onChange={(e) => { if (e.target.value) onPick(e.target.value) }}
>
<option value="">
{disabled ? 'Fix the params JSON to pick a value' : `Pick from ${label}…`}
</option>
<option value="">{`Pick from ${label}…`}</option>
{grouped
? groups.map((g) => (
<optgroup key={g} label={g}>
@@ -104,6 +121,484 @@ function ParamOptions({ entry, label, disabled, onPick }) {
)
}
/**
* A source too large for a dropdown, as a search box (Phase 13).
*
* **The first source that needed this made it unavoidable.** Phase 12b's spawner
* target is 6,707 spawn points against `MAX_OPTIONS`' bound of 2,000, so a flat
* list drops two thirds of the world and says nothing about which two thirds —
* an author picking from it would be choosing from a truncation they cannot see.
* The sidecar half of that shipped in 12b; this is what asks.
*
* `searchable` on the answer decides which control is drawn, rather than the
* length of the list: inferring it from a truncated answer reads correctly right
* up until a small deployment's list happens to fit, at which point the same
* source is a dropdown on one shard and a search box on another.
*
* A term is sent on a debounce and the answer is dropped if it is not the one
* for the term still in the box — a slow source answering after a faster one
* would otherwise repaint the list under the author's cursor with results for
* something they have finished typing.
*/
function SearchableOptions({ sourceId, label, disabled, onPick }) {
const [term, setTerm] = useState('')
const [state, setState] = useState({ status: 'idle', options: [] })
const latest = useRef('')
useEffect(() => {
const wanted = term.trim()
latest.current = wanted
if (!wanted) {
setState({ status: 'idle', options: [] })
return undefined
}
setState((s) => ({ ...s, status: 'loading' }))
const timer = setTimeout(async () => {
try {
const answer = await api.admin.eventOptions(sourceId, wanted)
if (latest.current !== wanted) return
setState(
answer?.ok
? { status: 'ok', options: answer.options || [] }
: { status: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
)
} catch (err) {
if (latest.current !== wanted) return
setState({ status: 'failed', options: [], reason: err.message || 'this list could not be read' })
}
}, 250)
return () => clearTimeout(timer)
}, [term, sourceId])
return (
<div style={{ marginTop: 4 }}>
<input
className="input"
style={{ fontSize: '0.75rem' }}
value={term}
disabled={disabled}
placeholder={`Search ${label}…`}
onChange={(e) => setTerm(e.target.value)}
/>
{state.status === 'loading' && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Searching…</div>
)}
{state.status === 'failed' && (
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
{state.reason} — type the value by hand.
</div>
)}
{state.status === 'ok' && state.options.length === 0 && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
Nothing matches “{term}”.
</div>
)}
{state.status === 'ok' && state.options.length > 0 && (
<ul style={{ listStyle: 'none', margin: '4px 0 0', padding: 0, maxHeight: 160, overflowY: 'auto' }}>
{state.options.map((o) => (
<li key={o.value}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', width: '100%', textAlign: 'left', marginBottom: 2 }}
onClick={() => { onPick(o.value); setTerm('') }}
>
{o.label}
{o.group && <span className="dim"> · {o.group}</span>}
</button>
</li>
))}
</ul>
)}
</div>
)
}
/**
* One declared param, as the control its type implies (Phase 13).
*
* **Core does not know what any of this means and that is the design.** The
* label is the param's own name, the help is its own description, the placeholder
* is its own example, and the values behind it come from the module that declared
* the source — `check:modules` fails core's build on a UO identifier, so there is
* nowhere for a game word to be written here even by accident.
*
* Two of the controls are worth their own sentence:
*
* • **A boolean is a three-value select, not a checkbox.** A checkbox cannot say
* *"not set"*, and for an OPTIONAL boolean that is a real third state — the
* action's own default. A checkbox would post `false` for every param an author
* never touched.
* • **A source-backed param keeps its free-text field.** The dropdown writes
* into it; it does not replace it. §F: a source is answered by a module that may
* be talking to a sidecar, and an authoring form a shard outage can make
* unusable is a worse failure than the typo the dropdown exists to prevent.
*/
function ParamField({ param, value, sourceEntry, disabled, onChange }) {
const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } }
const asText = value === undefined || value === null ? '' : String(value)
let control
if (param.type === 'boolean') {
control = (
<select
{...common}
value={value === true ? 'true' : value === false ? 'false' : ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Not set{param.required ? '' : ' — the action decides'}</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
)
} else if (param.type === 'datetime') {
control = (
<input
{...common}
type="datetime-local"
value={datetimeInputValue(asText)}
onChange={(e) => onChange(e.target.value)}
/>
)
} else if (param.type === 'int' || param.type === 'float') {
control = (
<input
{...common}
type="number"
step={param.type === 'int' ? '1' : 'any'}
value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
onChange={(e) => onChange(e.target.value)}
/>
)
} else if (param.type === 'url') {
control = (
<input {...common} type="url" value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
onChange={(e) => onChange(e.target.value)} />
)
} else {
control = (
<input {...common} value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
onChange={(e) => onChange(e.target.value)} />
)
}
return (
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">
{param.name}
{param.required && <span style={{ color: '#d98b84' }}> *</span>}
<span className="dim" style={{ fontWeight: 'normal' }}> · {param.type}</span>
</span>
{control}
{param.description && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>{param.description}</span>
)}
{param.source && (
sourceEntry?.searchable
? (
<SearchableOptions
sourceId={param.source}
label={sourceEntry.label || param.source}
disabled={disabled}
onPick={onChange}
/>
)
: (
<ParamOptions
entry={sourceEntry}
label={sourceEntry?.label || param.source}
disabled={disabled}
onPick={onChange}
/>
)
)}
</label>
)
}
/**
* The advance condition, as a builder rather than as JSON (Phase 13).
*
* **This is the engagement condition builder**, and being the same one is the
* point rather than a saving: the grammar is `engagement/conditions.js`, the
* server validates a phase gate with it, and the run console's diagnosis panel
* renders its sentence from the same labels. A second editor here would be a
* second opinion about a grammar core owns — exactly what §I refuses on the read
* side, where the sentence is rendered on the server for the same reason.
*
* It offers the FLAT half of the grammar — one `and`/`or` over a list of
* comparisons — because that is what a dropdown per operator can render
* honestly. A tree it cannot hold opens READ-ONLY with its JSON showing and one
* choice: leave it, or clear it and start again. Flattening `A and (B or C)`
* into `A and B and C` changes which firings release the phase, and an author
* would have no way to know the save had done it.
*/
function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
const variables = trigger?.variables || []
const rows = advance.whereRows || []
if (advance.whereEditable === false) {
return (
<div style={{ marginTop: 10 }}>
<span className="field-label">Only when</span>
<pre className="dim" style={{ fontSize: '0.76rem', margin: 0, whiteSpace: 'pre-wrap' }}>
{advance.whereText}
</pre>
<p className="sans" style={{ fontSize: '0.74rem', color: '#d9c184', margin: '6px 0 0' }}>
This condition nests, and the builder only holds one <code>and</code>/<code>or</code> over a
flat list. It is kept exactly as authored and posted back unchanged — flattening it would
change which firings release the phase without saying so.
</p>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 6 }} disabled={disabled}
onClick={() => onChange(blankWhere())}>
Clear it and start again
</button>
</div>
)
}
const setRow = (i, patch) =>
onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) })
return (
<div style={{ marginTop: 10 }}>
<span className="field-label">Only when</span>
{rows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for
firings that match.
</p>
)}
{rows.length > 1 && (
<label style={{ display: 'block', margin: '6px 0' }}>
<span className="field-label">Match</span>
<select className="input" style={{ maxWidth: 220, fontSize: '0.76rem' }} value={advance.whereOp}
disabled={disabled}
onChange={(e) => onChange({ whereOp: e.target.value })}>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
)}
{rows.map((row, i) => {
const declared = variables.find((v) => v.name === row.variable)
const usable = operatorsForType(operators, declared?.type)
const valueless = row.cmp === 'present' || row.cmp === 'absent'
return (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
<select className="input" style={{ flex: '1 1 150px', fontSize: '0.76rem' }} value={row.variable}
disabled={disabled}
onChange={(e) => setRow(i, { variable: e.target.value, cmp: '' })}>
<option value="">Which variable…</option>
{variables.map((v) => <option key={v.name} value={v.name}>{v.name} ({v.type})</option>)}
</select>
<select className="input" style={{ flex: '0 1 130px', fontSize: '0.76rem' }} value={row.cmp}
disabled={disabled || !row.variable}
onChange={(e) => setRow(i, { cmp: e.target.value })}>
<option value="">is…</option>
{usable.map((o) => <option key={o.cmp} value={o.cmp}>{o.label}</option>)}
</select>
{!valueless && (
<input className="input" style={{ flex: '1 1 140px', fontSize: '0.76rem' }} value={row.value ?? ''}
disabled={disabled || !row.cmp}
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'Yew, Britain, Vesper' : ''}
onChange={(e) => setRow(i, { value: e.target.value })} />
)}
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled}
onClick={() => onChange({ whereRows: rows.filter((_, j) => j !== i) })}>
✕
</button>
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 8 }}
disabled={disabled || !variables.length}
onClick={() => onChange({ whereRows: [...rows, { variable: '', cmp: '', value: '' }] })}>
Add a clause
</button>
{!variables.length && advance.on && (
<span className="sans dim" style={{ fontSize: '0.74rem', marginLeft: 8 }}>
This trigger declares no variables, so there is nothing to narrow on.
</span>
)}
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>
A list operator takes comma-separated values. Every literal is read as the type the trigger
declared, and a variable it does not have comes back named from the save.
</p>
</div>
)
}
/**
* The live cap meter (§I, Phase 13).
*
* Two facts per dimension — what this plan draws, and what the deployment allows
* — because that is all a cap is. §I says the same thing about the run console's
* meter: *"a meter per dimension rather than a sentence, because unlike a gate a
* cap is two numbers and a name and needs no grammar rendered to be read."*
*
* **It says what it does not know.** A step core could not price makes every
* total below an under-count, and an author reading a number smaller than what
* will happen is worse off than one reading no number at all. So `unpriced` is
* rendered as prominently as the totals, not tucked underneath them.
*
* And it does not claim to be the dry run: the caption says so, because a green
* meter beside a plan whose landmarks do not exist would otherwise read as a
* pass.
*/
function CapMeter({ report, budgets, stale }) {
const labelOf = (id) => budgets.find((b) => b.id === id)?.label || id
const unitOf = (id) => budgets.find((b) => b.id === id)?.unit || ''
if (!report) return null
const nothing = report.cost.length === 0 && report.unpriced.length === 0
return (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 12, opacity: stale ? 0.55 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.88rem' }}>
What this plan draws
{stale && <span className="dim" style={{ fontWeight: 'normal' }}> · recalculating…</span>}
</h3>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{report.steps} step{report.steps === 1 ? '' : 's'}
</span>
</div>
{nothing && (
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.8rem' }}>
Nothing in this plan spends a capped resource.
</p>
)}
{report.cost.length > 0 && (
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse', marginTop: 6 }}>
<tbody>
{report.cost.map((c) => (
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
<td style={{ paddingRight: 12 }}>{labelOf(c.dimension)}</td>
<td style={{ paddingRight: 12 }}>{c.total} {unitOf(c.dimension)}</td>
<td className="dim">
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
</td>
</tr>
))}
</tbody>
</table>
)}
{report.unpriced.length > 0 && (
<div style={{ marginTop: 8, borderLeft: '3px solid #d9c184', paddingLeft: 10 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.78rem' }}>
<strong>These totals are incomplete.</strong>
</p>
<ul className="sans" style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '0.78rem' }}>
{report.unpriced.map((u, i) => (
<li key={`${u.phase}-${u.seq}-${u.code}-${i}`}>
<code className="dim" style={{ fontSize: '0.76rem' }}>phase {u.phase + 1} · step {u.seq + 1}</code>
{' — '}{u.message}
</li>
))}
</ul>
</div>
)}
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.74rem' }}>
Arithmetic only — nothing was dispatched, so this does not know whether the places and things
these steps name exist. The dry run asks the modules that do.
</p>
</div>
)
}
/**
* Starting a run, with the three things the route has always taken (Phase 13).
*
* **Two of them were unreachable from this screen until now**, and one of those
* is not a nicety: `concurrencyKey` is a `{placeholder}` template rendered from
* the RUN's own params, so an event whose key names one could not be started
* correctly from the UI at all — every manual run rendered the same key and the
* second one was refused as an overlap.
*
* **Rehearsal is a real run.** §I: the world changes are real, the announcements
* are ceilinged to `staff`. It is not a dry run and the dialog says so, because
* the two words are near enough to swap in a hurry.
*/
function StartDialog({ onStart, onCancel, busy }) {
const [rehearsal, setRehearsal] = useState(false)
const [scope, setScope] = useState('')
const [paramsText, setParamsText] = useState('{}')
const [problem, setProblem] = useState(null)
const go = () => {
let params = null
const text = paramsText.trim()
if (text && text !== '{}') {
try {
params = JSON.parse(text)
} catch (err) {
setProblem(`The params are not valid JSON (${err.message})`)
return
}
if (!params || typeof params !== 'object' || Array.isArray(params)) {
setProblem('The params must be a JSON object')
return
}
}
onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) })
}
return (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Start a run now</h3>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 10 }}>
<input type="checkbox" checked={rehearsal} onChange={(e) => setRehearsal(e.target.checked)} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
<strong>Rehearsal.</strong>{' '}
<span className="dim">
A real run — every world change actually happens — with its announcements ceilinged to
staff, so no player is told. This is not the dry run.
</span>
</span>
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Scope (optional)</span>
<input className="input" value={scope} onChange={(e) => setScope(e.target.value)} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
Passed to the module verbatim. Core never parses it.
</span>
</label>
<label>
<span className="field-label">Run params (optional)</span>
<textarea className="input" rows={3} spellCheck={false}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.78rem' }}
value={paramsText} onChange={(e) => setParamsText(e.target.value)} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
What the concurrency key’s <code>{'{placeholders}'}</code> are filled from.
</span>
</label>
</div>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84', margin: '10px 0 0' }}>{problem}</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={go}>
Start it
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={onCancel}>
Cancel
</button>
</div>
</div>
)
}
const DORMANT_NOTE =
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
@@ -127,6 +622,14 @@ export default function EventEditor() {
// because a report is a statement about a spec and both of those change it —
// a stale green report beside an edited plan is worse than no report.
const [report, setReport] = useState(null)
// Phase 13. The meter's answer, and whether the plan has moved since it was
// asked — `stale` is what stops the number reading as current while a request
// is in flight, which on a debounce it very often is not.
const [priced, setPriced] = useState(null)
const [priceStale, setPriceStale] = useState(false)
// The start dialog is open. Not a modal: this screen is long, and a dialog
// that covers the plan hides the thing being started.
const [starting, setStarting] = useState(false)
const isAdmin = user?.role === 'admin'
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
@@ -207,7 +710,16 @@ export default function EventEditor() {
setSources((s) => ({
...s,
[sourceId]: answer?.ok
? { state: 'ok', label: answer.label, options: answer.options || [] }
// `searchable` is the SOURCE's answer about itself (Phase 12b), not a
// guess from how long the list came back. A source bounded at 2,000
// that happens to have 40 entries on this deployment is still the one
// that has to be searched on the shard where it has 6,707.
? {
state: 'ok',
label: answer.label,
searchable: Boolean(answer.searchable),
options: answer.options || [],
}
: { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
}))
} catch (err) {
@@ -221,36 +733,6 @@ export default function EventEditor() {
}
}, [])
/**
* Write one param into a step's JSON box, from a picked option.
*
* Re-serialising the whole object rather than splicing text: the box holds an
* object the save path parses, and a string edit that produced valid-looking
* JSON with a duplicate key would be a value the editor and the server read
* differently. `2` because that is what `blankStep` writes, so picking a value
* does not reformat the box under the author's cursor.
*/
const pickParam = (pi, si, step, name, value) => {
let parsed
try {
parsed = JSON.parse(step.paramsText || '{}')
} catch {
return
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
setStep(pi, si, { paramsText: JSON.stringify({ ...parsed, [name]: value }, null, 2) })
}
/** Does this step's JSON box currently hold an object we can write into? */
const paramsParse = (step) => {
try {
const parsed = JSON.parse(step.paramsText || '{}')
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
} catch {
return false
}
}
// Phase 7. Every option source the steps on this page name, resolved once.
//
// An effect rather than a lookup at render time, because resolving one is a
@@ -270,6 +752,52 @@ export default function EventEditor() {
for (const id of wanted) loadSource(id)
}, [form, actionById, loadSource])
/**
* The live cap meter (§I, Phase 13).
*
* **A debounce and a generation counter, and the counter is not optional.**
* Requests started 400ms apart do not necessarily answer in that order, and an
* older answer landing last would leave the meter showing the cost of a plan
* the author has already changed — stale in the one direction that matters,
* silently, with nothing on the screen to say so.
*
* A failure leaves the LAST answer standing rather than blanking the meter or
* raising an error box: this is an aid to authoring, and the plan is still
* saveable, dry-runnable and publishable without it. The staleness marker is
* how the screen stays honest about it.
*/
const priceGeneration = useRef(0)
useEffect(() => {
if (!form || !worthPricing(form)) {
setPriced(null)
setPriceStale(false)
return undefined
}
setPriceStale(true)
const generation = ++priceGeneration.current
const timer = setTimeout(async () => {
try {
const answer = await api.admin.priceEvent(priceBodyFrom(form))
if (priceGeneration.current !== generation) return
setPriced(answer)
setPriceStale(false)
} catch {
if (priceGeneration.current !== generation) return
// Left stale on purpose: a number the screen cannot vouch for is shown
// dimmed rather than replaced by nothing.
setPriceStale(true)
}
}, 400)
return () => clearTimeout(timer)
}, [form])
/** The meter's per-phase rollup, by ordinal, for the timeline. */
const drawByPhase = useMemo(
() => new Map((priced?.phases || []).map((p) => [p.phase, p.draw])),
[priced],
)
const budgets = useMemo(() => catalog?.budgets || [], [catalog])
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
const setPhase = (pi, patch) =>
@@ -330,7 +858,10 @@ export default function EventEditor() {
// A report describes a spec, and saving changes it. A green report left
// standing beside an edited plan is worse than no report at all.
setReport(null)
const built = payloadFromForm(form)
// The declared variable types, so the builder's literals are coerced to
// them before the request rather than compared as strings by a server that
// will rightly refuse them.
const built = payloadFromForm(form, { triggersById: triggerById })
if (!built.ok) {
setProblems(built.errors)
setBusy(false)
@@ -406,21 +937,34 @@ export default function EventEditor() {
}
}
const start = async () => {
/**
* Start a run, with what the dialog collected (Phase 13).
*
* The route has taken `rehearsal`, `scope` and `params` since Phase 10 and this
* screen sent `{}` — so rehearsal, the affordance §I asks for by name, was
* unreachable, and an event whose concurrency key names a `{placeholder}` could
* not be started correctly at all: every manual run rendered the same key and
* the second was refused as an overlap with the first.
*/
const start = async (body) => {
setBusy(true)
setProblems([])
try {
const result = await api.admin.startEventRun(id, {})
const result = await api.admin.startEventRun(id, body)
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
setProblems(err.body?.errors || [err.message])
setStarting(false)
} finally {
setBusy(false)
}
}
if (loading || !form) return <Loading />
// **The error is checked BEFORE the form.** `!form` is true for every failed
// load, so testing it first put the error state permanently behind a spinner:
// the screen that could not load said "Loading…" for ever and named nothing.
if (error) return <ErrorState message={error} />
if (loading || !form) return <Loading />
const archived = event?.state === 'archived'
@@ -465,7 +1009,8 @@ export default function EventEditor() {
</button>
)}
{!isNew && isAdmin && event?.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={start}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => setStarting((s) => !s)}>
Start now
</button>
)}
@@ -567,6 +1112,10 @@ export default function EventEditor() {
</div>
)}
{starting && (
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start} />
)}
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
{problems.length > 0 && (
@@ -715,6 +1264,12 @@ export default function EventEditor() {
</p>
</div>
{/* ── The live cap meter (Phase 13) ──
Above the timeline rather than under it, because what a plan draws is a
fact about the whole plan and an author scrolling twelve steps to find
out they are over is an author who finds out too late. */}
<CapMeter report={priced} budgets={budgets} stale={priceStale} />
{/* ── The phase timeline ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Phases</h3>
@@ -754,6 +1309,20 @@ export default function EventEditor() {
cannot change once runs exist.
</p>
{/* §I asks the timeline for "phases in order, each with its steps, its
advance condition, its cap draw and its failure policy". This is
the cap draw, and it is the phase's own rather than a share of the
total: an author moving a step between phases is asking exactly
this question. */}
{(drawByPhase.get(pi) || []).length > 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
Draws{' '}
{(drawByPhase.get(pi) || [])
.map((d) => `${d.total} ${budgets.find((b) => b.id === d.dimension)?.label || d.dimension}`)
.join(' · ')}
</p>
)}
{/* ── The advance condition (Phase 5) ──
A gate is an ADDITIONAL condition and never a replacement, which is
what the caption has to say: a phase whose steps are still running
@@ -779,8 +1348,16 @@ export default function EventEditor() {
<>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Trigger</span>
{/* Changing the trigger CLEARS the predicate, for the same
reason changing a step's action replaces its params:
every clause names a variable of the old trigger, and the
save would refuse each of them by name. Keeping them
would look like tolerance and behave like a form that
cannot be saved. */}
<select className="input" value={phase.advance.on}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
onChange={(e) => setPhase(pi, {
advance: { ...phase.advance, on: e.target.value, ...blankWhere() },
})}>
<option value="">Choose a trigger…</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
</select>
@@ -795,24 +1372,13 @@ export default function EventEditor() {
</div>
{phase.advance?.kind === 'on' && (
<label style={{ display: 'block', marginTop: 10 }}>
<span className="field-label">Only when (JSON, optional)</span>
<textarea className="input" rows={3} spellCheck={false} value={phase.advance.whereText}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, whereText: e.target.value } })}
placeholder={'{ "variable": "region", "cmp": "eq", "value": "Yew" }'} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
A raw JSON field, and a placeholder for the same reason the params box is one — the
condition builder proper is a later phase. It is checked at save against what the
trigger declares, and a variable the trigger does not have comes back named.
{triggerById.get(phase.advance.on)?.variables?.length > 0 && (
<>
{' '}
<code>{phase.advance.on}</code> declares{' '}
{triggerById.get(phase.advance.on).variables.map((v) => `${v.name} (${v.type})`).join(', ')}.
</>
)}
</span>
</label>
<WhereBuilder
advance={phase.advance}
trigger={triggerById.get(phase.advance.on)}
operators={catalog?.operators || []}
disabled={archived}
onChange={(patch) => setPhase(pi, { advance: { ...phase.advance, ...patch } })}
/>
)}
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
@@ -825,6 +1391,9 @@ export default function EventEditor() {
<div style={{ marginTop: 12 }}>
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)
// Phase 13. Which editor this step gets, and — when it is not the
// author's choice — why.
const mode = paramsMode(step, action)
return (
<div key={si} style={{ borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
@@ -867,56 +1436,71 @@ export default function EventEditor() {
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 12, marginTop: 10 }}>
<label>
<span className="field-label">Params (JSON)</span>
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem' }}
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
A raw JSON field, and a placeholder: the form that renders each param from its
declared type arrives with the authoring pass. What is saved is checked
against the declaration either way.
</span>
</label>
<div>
<span className="field-label">What this action takes</span>
{action ? (
<table className="adm-table" style={{ fontSize: '0.78rem' }}>
<tbody>
{(action.params || []).map((p) => (
<tr key={p.name}>
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
<code>{p.name}</code>
{p.required && <span style={{ color: '#d98b84' }}> *</span>}
</td>
<td className="adm-td dim">{p.type}</td>
<td className="adm-td">
{p.description}
<div className="dim">e.g. <code>{JSON.stringify(p.example)}</code></div>
{p.source && (
<ParamOptions
entry={sources[p.source]}
label={sources[p.source]?.label || p.source}
disabled={!paramsParse(step)}
onPick={(v) => pickParam(pi, si, step, p.name, v)}
/>
)}
</td>
</tr>
))}
{(action.params || []).length === 0 && (
<tr><td className="adm-td dim">This action takes no params.</td></tr>
)}
</tbody>
</table>
) : (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
Pick an action and its parameters are listed here, straight from what the
module declared.
</p>
{/* ── The params (Phase 13) ──
A form of the action's own declaration, with the JSON box
kept as the escape hatch. A step the form cannot hold
without losing something opens in JSON and says why — the
condition builder's rule, and the same one, because
dropping an undeclared param and flattening a nested
condition are the same failure: a save that looks clean
and means something else. */}
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<span className="field-label" style={{ marginBottom: 0 }}>Params</span>
{!mode.forced && action && (action.params || []).length > 0 && (
<button type="button" className="pill" style={{ fontSize: '0.7rem' }}
onClick={() => setStep(pi, si, {
paramsMode: mode.mode === PARAM_JSON ? PARAM_FORM : PARAM_JSON,
})}>
{mode.mode === PARAM_JSON ? 'Edit as a form' : 'Edit as JSON'}
</button>
)}
</div>
{mode.forced && (
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
Opened as JSON: {mode.reason}. Nothing has been dropped — what is here is
exactly what was authored.
</p>
)}
{mode.mode === PARAM_FORM ? (
<div style={{ marginTop: 8 }}>
{(action?.params || []).map((p) => (
<ParamField
key={p.name}
param={p}
value={paramValue(step, p.name)}
sourceEntry={p.source ? sources[p.source] : null}
disabled={archived}
onChange={(raw) => setStep(pi, si, { paramsText: setParam(step, p.name, raw, p.type) })}
/>
))}
{action && (action.params || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
This action takes no params.
</p>
)}
{!action && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
Pick an action and its fields appear here, straight from what the module
declared.
</p>
)}
</div>
) : (
<>
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem', marginTop: 8 }}
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
{action && (action.params || []).length > 0 && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
<code>{action.id}</code> declares{' '}
{(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}.
</span>
)}
</>
)}
</div>
</div>
)