diff --git a/client/src/App.jsx b/client/src/App.jsx index fd7ab39..6a6bb69 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -212,7 +212,11 @@ export default function App() { `/:id` — the same collision, on the other side of the wire. */} } /> } /> - } /> + {/* ONE route, and `new` is a value of `:id` rather than a + path beside it. A static `events/new` outranks the dynamic + segment in React Router whatever the order, so the editor + was handed no `id` at all and asked the API for + `/admin/events/undefined`. */} } /> {/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the server: every route under /admin/engagement re-gates to `admin` diff --git a/client/src/api/client.js b/client/src/api/client.js index 4aa0a9c..e3d486a 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -492,12 +492,25 @@ export const api = { // editor with it. A refusal comes back 200 with `ok: false`, so this never // throws for the case the screen is meant to render: the field degrades to // free text with the reason beside it. - eventOptions: (sourceId) => req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}`), + // Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is + // ignored, never refused, by a source that does not declare itself + // searchable — so passing it is always safe and the field decides whether + // it is a typeahead by reading `searchable` off the answer. + eventOptions: (sourceId, q) => { + const qs = q ? `?${new URLSearchParams({ q }).toString()}` : '' + return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`) + }, // Phase 6. The dry run is admin+editor: it dispatches nothing, and the author // who wrote the definition is who should be able to price it against the caps // before asking an admin to publish it. A report with findings comes back 200 // — the request succeeded, the plan has problems. verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }), + // Phase 13's live cap meter, and NOT a lighter dry run — it dispatches + // nothing, so it knows nothing a module knows. It takes the spec in the + // body rather than an id because the plan it prices is the one in the + // author's hands, which is unsaved between keystrokes, and it records + // nothing, which is what makes it safe to call on a debounce. + priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }), // The switchboard, admin only in BOTH directions: reading which actions a // deployment permits is as much configuration as writing it (§K). One action // per write rather than the whole board, so an action that appeared between diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js index 59fbf0e..8a089e1 100644 --- a/client/src/lib/eventAuthoring.js +++ b/client/src/lib/eventAuthoring.js @@ -14,6 +14,13 @@ // meets at 2am while the thing they are trying to stop keeps running — so the // guards are written twice on purpose and the copy is checked. +// **The condition builder is borrowed, not rebuilt.** §I says the step editor +// reuses "the condition builder, exactly" — and a phase's advance gate is +// literally the engagement grammar, validated on the server by +// `engagement/conditions.js`. Importing the row helpers is what keeps this screen +// from becoming a second opinion about a grammar core owns. +import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js' + // A run that is over. Verbatim `eventRuns.db`'s TERMINAL. export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed'] @@ -115,14 +122,13 @@ const nextPhaseKey = (phases) => { } /** - * A new step, with its params box PREFILLED from the action's declared examples. + * A new step, with its params PREFILLED from the action's declared examples. * * Every param carries a required `example` — that requirement is the reason this - * works — so a fresh `core.announce` step arrives as a JSON object with the right - * keys and plausible values rather than as an empty `{}` an author has to guess - * the shape of. It is the nearest a raw JSON box gets to the schema-driven form - * Phase 13 replaces it with, and it costs nothing the catalog was not already - * serving. + * works — so a fresh `core.announce` step arrives with the right keys and + * plausible values rather than empty. Phase 13 turned the box into a form and + * this stayed exactly as it was: a form whose fields start at the declared + * example is a step an author edits rather than one they compose. */ export function blankStep(action) { const params = {} @@ -152,7 +158,18 @@ export function blankPhase(phases) { * typed under the other option is one an operator learns to be afraid of. */ export function blankAdvance() { - return { kind: '', after: '30m', on: '', count: 1, whereText: '' } + return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() } +} + +/** + * The `where` predicate as the BUILDER holds it (Phase 13). + * + * `whereText` survives beside the rows and is not vestigial: it is what a + * predicate the builder cannot render is shown as, and what is posted for one. + * See `whereFormFrom`. + */ +export function blankWhere() { + return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' } } export const ADVANCE_KINDS = [ @@ -161,7 +178,7 @@ export const ADVANCE_KINDS = [ { value: 'on', label: 'When something happens in the game' }, ] -/** The stored gate, as the form's three fields. */ +/** The stored gate, as the form's fields. */ export function advanceFormFrom(advance) { const blank = blankAdvance() if (!advance) return blank @@ -171,7 +188,34 @@ export function advanceFormFrom(advance) { kind: 'on', on: advance.on || '', count: advance.count ?? 1, - whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '', + ...whereFormFrom(advance.where), + } +} + +/** + * A stored `where` tree → the builder's flat rows (Phase 13). + * + * **This is `conditionRowsFrom` and it is deliberately the same function**, not a + * second one shaped like it. The grammar behind a phase gate is the engagement + * condition grammar — the server validates it with `engagement/conditions.js` + * and renders the diagnosis panel's sentence with the same labels — so an editor + * here that re-decided what a tree looks like would be the second implementation + * §I refuses on the read side for exactly this reason. + * + * A tree the flat editor cannot hold (`A and (B or C)`) comes back + * `whereEditable: false` and is SHOWN as its JSON rather than silently + * flattened: `A and B and C` fires on different events, and an author would have + * no way to know the save had done it to them. + */ +export function whereFormFrom(where) { + const blank = blankWhere() + if (!where) return blank + const rows = conditionRowsFrom(where) + return { + whereOp: rows.op, + whereRows: rows.rows, + whereEditable: rows.editable, + whereText: JSON.stringify(where, null, 2), } } @@ -208,25 +252,37 @@ export function formFromDefinition(event) { /** * One phase's advance gate, as the spec shape — or null when it has none. * - * Only the `where` JSON is checked, and only because text that is not JSON - * cannot be put in a request at all. **Whether the predicate is VALID is the - * server's answer**, and the whole trap of Phase 5 is that it is answered at - * save with the offending variable named — re-deciding it here would be a second - * validator drifting from the one that matters, exactly as with a step's params. + * **Whether the predicate is VALID is still the server's answer.** The builder + * coerces each literal to the type the trigger DECLARED — which is not a second + * validator but the thing that makes the first one's error useful: every value + * in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int` + * variable is refused by `engagement/conditions.js`, rightly, at which point the + * author is reading an error about JSON rather than about what they typed. + * + * A predicate the builder could not render round-trips through `whereText` + * unchanged. That is the point of keeping the text: the alternative to posting it + * back verbatim is dropping an author's tree because this screen could not draw + * it. */ -export function advancePayload(advance, where, errors) { +export function advancePayload(advance, where, errors, variables = []) { if (!advance || !advance.kind) return null if (advance.kind === 'after') return { after: advance.after } const out = { on: advance.on, count: Number(advance.count) || 1 } - const text = String(advance.whereText || '').trim() - if (text) { - try { - out.where = JSON.parse(text) - } catch (err) { - errors.push(`${where}, advance condition: ${err.message}`) + if (advance.whereEditable === false) { + const text = String(advance.whereText || '').trim() + if (text) { + try { + out.where = JSON.parse(text) + } catch (err) { + errors.push(`${where}, advance condition: ${err.message}`) + } } + return out } + + const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables) + if (built) out.where = built return out } @@ -244,10 +300,20 @@ export function advancePayload(advance, where, errors) { * applies the action's risk-class default rather than being told a value the * form invented. */ -export function payloadFromForm(form) { +export function payloadFromForm(form, { triggersById = new Map() } = {}) { const errors = [] const phases = (form.phases || []).map((phase, pi) => { - const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors) + const where = advancePayload( + phase.advance, + `Phase ${pi + 1} "${phase.label || phase.key}"`, + errors, + // The declared types the builder coerces against. A trigger nothing + // registers has none, and every literal then stays the string it was typed + // as — which is right: the gate is dormant, the server carries its `where` + // through unvalidated, and inventing types for it here would edit a + // predicate nobody can currently check. + triggersById.get(phase.advance?.on)?.variables || [], + ) return { key: phase.key, label: phase.label, @@ -431,6 +497,157 @@ export function parseParams(text) { return { params: value } } +// ── Step params, as a form (Phase 13) ───────────────────────────── +// +// §I: the step editor is *"the condition builder, exactly — core serves a +// catalog, the module declared the schema, core renders a form it does not +// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for +// this, and everything the form needs was already in the catalog: a param's +// name, type, whether it is required, its description, its example, and the +// option source behind it. +// +// **The JSON stays as the storage and as the escape hatch, and both halves of +// that matter.** As storage, because `payloadFromForm` already builds a request +// out of it and a second representation would be two things to keep in step. As +// an escape hatch, because a form can only render what the declaration +// describes — and a step may legitimately hold something it does not. +// +// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is +// the reason this reads as a port of it rather than as a new idea: a value the +// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening +// `A and (B or C)` there and dropping an undeclared param here are the same +// mistake — a save that looks clean and means something else. + +/** The two ways a step's params are edited. */ +export const PARAM_FORM = 'form' +export const PARAM_JSON = 'json' + +/** + * Can this step's params be rendered as a form without losing anything? + * + * `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold. + * Three things make one, and none of them is an error — each is a step that has + * to be edited as JSON: + * + * • **the action is dormant.** There is no declaration, so there are no fields. + * A form here would render nothing and look like a step with no params. + * • **a param the action does not declare.** The save refuses it by name, which + * is what the author needs to see — and a form that dropped it would post a + * step that saves cleanly having deleted something they typed. + * • **a value no single control can hold** — an object or an array against a + * scalar declaration. + */ +export function paramsRenderable(action, params) { + if (!action) return { ok: false, reason: 'the module that registered this action is not installed' } + const declared = new Map((action.params || []).map((p) => [p.name, p])) + for (const [name, value] of Object.entries(params || {})) { + if (!declared.has(name)) { + return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` } + } + if (value !== null && typeof value === 'object') { + return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` } + } + } + return { ok: true } +} + +/** + * Which mode should this step open in? + * + * The author's own choice wins whenever the form COULD render the step — an + * author who switched to JSON stays in JSON. What they cannot do is stay in a + * form that would lose something, so an unrenderable step is forced to JSON + * whatever the choice was, and the reason is returned so the screen can say it. + */ +export function paramsMode(step, action) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error } + const renderable = paramsRenderable(action, parsed.params) + if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason } + return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null } +} + +/** One declared param's current value, as the control holds it. */ +export function paramValue(step, name) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return undefined + return parsed.params[name] +} + +/** + * Write one param, and give back the whole box. + * + * **An empty field REMOVES the key rather than posting an empty string**, and + * that is the server's own reading rather than a convenience: `checkParams` + * treats `undefined`, `null` and `''` alike — absent — so a required param left + * blank comes back as *"is required"*, which is the error the author needs, + * instead of as a type complaint about `""`. + * + * **A value that does not parse is passed through as typed.** `coerceLiteral` is + * the engagement builder's, unchanged, and its rule is the one that matters + * here too: half of `-` is not a number, and turning it into `NaN` or `0` while + * somebody is still typing would either post a value they never wrote or make + * the field impossible to type a negative into. The server's type check then + * names the param. + * + * Re-serialising the whole object rather than splicing text, for `pickParam`'s + * reason: a string edit that produced valid-looking JSON with a duplicate key + * would be a value the editor and the server read differently. + */ +export function setParam(step, name, raw, type) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return step?.paramsText || '{}' + const next = { ...parsed.params } + if (raw === '' || raw === undefined || raw === null) delete next[name] + else next[name] = coerceLiteral(type, raw) + return JSON.stringify(next, null, 2) +} + +/** + * A stored `datetime` as a `datetime-local` input wants it, and back. + * + * The server normalises a datetime param to an ISO string (`conditions.js` + * `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The + * slice is the whole conversion in one direction; in the other the input's own + * text is a moment `new Date()` parses, so it is posted as typed and the server + * does the normalising — one implementation of what a datetime is, and it is + * not this one. + */ +export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '') + +/** + * Everything the meter needs out of the form, and nothing else. + * + * The price route takes a spec, not a definition: no title, no schedule, no + * series. Sending the whole payload would put a document in front of a route + * that reads two fields of it — and would fail the moment the rest of the form + * is mid-edit, which is exactly when the meter is being read. + * + * A step whose params do not parse is sent with none rather than dropped, so a + * half-typed JSON box costs its own step's draw and not the phase's. + */ +export function priceBodyFrom(form) { + return { + phases: (form?.phases || []).map((phase) => ({ + key: phase.key || null, + steps: (phase.steps || []).map((step) => ({ + actionId: step.actionId || '', + params: parseParams(step.paramsText).params || {}, + })), + })), + } +} + +/** + * Is this plan worth pricing at all? + * + * A meter that fires on an empty form asks the server what nothing costs, on + * every keystroke of the title field. One step with an action chosen is the + * threshold, because that is the first moment there is an answer. + */ +export const worthPricing = (form) => + (form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId)) + // ── Rendering what happened ──────────────────────────────────────────────── const STATUS_WORDS = { diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx index e25c574..3ce4ea2 100644 --- a/client/src/routes/admin/views/EventEditor.jsx +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -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) }} > - + {grouped ? groups.map((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 ( +
+ setTerm(e.target.value)} + /> + {state.status === 'loading' && ( +
Searching…
+ )} + {state.status === 'failed' && ( +
+ {state.reason} — type the value by hand. +
+ )} + {state.status === 'ok' && state.options.length === 0 && ( +
+ Nothing matches “{term}”. +
+ )} + {state.status === 'ok' && state.options.length > 0 && ( +
    + {state.options.map((o) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +/** + * 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 = ( + + ) + } else if (param.type === 'datetime') { + control = ( + onChange(e.target.value)} + /> + ) + } else if (param.type === 'int' || param.type === 'float') { + control = ( + onChange(e.target.value)} + /> + ) + } else if (param.type === 'url') { + control = ( + onChange(e.target.value)} /> + ) + } else { + control = ( + onChange(e.target.value)} /> + ) + } + + return ( + + ) +} + +/** + * 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 ( +
+ Only when +
+          {advance.whereText}
+        
+

+ This condition nests, and the builder only holds one and/or 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. +

+ +
+ ) + } + + const setRow = (i, patch) => + onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) }) + + return ( +
+ Only when + {rows.length === 0 && ( +

+ Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for + firings that match. +

+ )} + {rows.length > 1 && ( + + )} + {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 ( +
+ + + {!valueless && ( + setRow(i, { value: e.target.value })} /> + )} + +
+ ) + })} + + {!variables.length && advance.on && ( + + This trigger declares no variables, so there is nothing to narrow on. + + )} +

+ 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. +

+
+ ) +} + +/** + * 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 ( +
+
+

+ What this plan draws + {stale && · recalculating…} +

+ + {report.steps} step{report.steps === 1 ? '' : 's'} + +
+ + {nothing && ( +

+ Nothing in this plan spends a capped resource. +

+ )} + + {report.cost.length > 0 && ( + + + {report.cost.map((c) => ( + + + + + + ))} + +
{labelOf(c.dimension)}{c.total} {unitOf(c.dimension)} + {c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`} +
+ )} + + {report.unpriced.length > 0 && ( +
+

+ These totals are incomplete. +

+
    + {report.unpriced.map((u, i) => ( +
  • + phase {u.phase + 1} · step {u.seq + 1} + {' — '}{u.message} +
  • + ))} +
+
+ )} + +

+ 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. +

+
+ ) +} + +/** + * 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 ( +
+

Start a run now

+ + + +
+ +