feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 2m26s
PR Checks / client-build (pull_request) Successful in 2m44s
PR Checks / server-tests (pull_request) Successful in 20m38s

Rust PLAN_REDESIGNS §2 (U-3-U-6, F11; D192, D334), core half. Game-agnostic:
Module-uo's steps get the new controls and nothing else.

Contract (MODULE_API 1.12.0 -> 1.13.0, four optional additions):
- api.registerEventTemplates([{ id, label, description, inputs, build }]):
  inputs are params (<= 8); core checks them, calls build() (10 s), validates
  the result as a spec and answers an unsaved draft.
  POST /admin/events/catalog/templates/:templateId (admin, editor).
- a param's duration: 'minutes' (int/float only); core.lease's minutes has it.
- a lease's example for its value.
- an option row's note (<= 300 chars), shown under the chosen field.

Editor:
- no type tags or bare asterisks; a required field says so in words once a
  save has been tried with it empty (and the save stops before the round trip).
- boolean: toggle when required, [Not set] [Yes] [No] when optional (D334).
- a choice of two or three is a row of buttons (lease values, small sources).
- core.lease drawn as the chosen lease: picked by label; value by the lease's
  type, range, values and example; target picker over target.source (F11);
  minutes with the lease's longest hold.
- a timed step keeps its run open: a phase with no advance rule gets
  after: <longest hold> when a timed step is added; a sentence under any phase
  whose run can end sooner (U-6).
- Start asks "Publish and start" or "Start version N" when the working copy is
  ahead of the published version (draftAhead on GET/PUT/publish; U-5).
- New event can start from a module's template.
- core's own examples name no game (§0.9).

Swagger, routes.manifest/guards and engagement-triggers.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-12 02:08:04 -05:00
parent 43f706c003
commit 3e243ce772
19 changed files with 1603 additions and 72 deletions

View File

@@ -25,8 +25,14 @@ import {
datetimeInputValue,
priceBodyFrom,
worthPricing,
boolValue,
isSegmentable,
noteFor,
missingParams,
guardedAdvance,
shortfallSentence,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType } from '../../../lib/engagementRules.js'
import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
@@ -215,7 +221,34 @@ function SearchableOptions({ sourceId, label, disabled, onPick }) {
}
/**
* One declared param, as the control its type implies (Phase 13).
* A row of buttons for a choice of two or three (Rust PLAN_REDESIGNS §2.4).
*
* A dropdown of two hides the other choice behind a click; a row shows both and
* which one is on. `aria-pressed` carries the state for a screen reader, the
* opacity for everyone else — the same convention the weekday buttons use.
*/
function Segmented({ choices, value, disabled, onPick }) {
const current = value === undefined || value === null ? '' : String(value)
return (
<div role="group" style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{choices.map((c) => {
const on = current === String(c.value)
return (
<button key={String(c.value)} type="button" className="pill" disabled={disabled}
aria-pressed={on}
style={{ fontSize: '0.74rem', opacity: on ? 1 : 0.45 }}
onClick={() => onPick(String(c.value))}>
{c.label}
</button>
)
})}
</div>
)
}
/**
* One declared param, as the control its type implies (Phase 13; reworked for
* MODULE_API 1.13.0, Rust PLAN_REDESIGNS §2.1).
*
* **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
@@ -223,32 +256,66 @@ function SearchableOptions({ sourceId, label, disabled, onPick }) {
* 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:
* **The field is the thing it does, not its type.** There is no ` · int` tag and
* no bare asterisk (U-3): a required field says so in words, and only when it is
* empty at save time (`missing`). The controls:
*
* • **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.
* • **A boolean is a toggle when it is required and three buttons when it is
* optional** (D334). For an OPTIONAL boolean "not set" is a real third state —
* the action's own default — and a two-state control would post `false` for
* every param an author never touched.
* • **A number shows its range and its unit beside it** when the declaration has
* them: a lease's `min`–`max`, a duration's minutes.
* • **A closed set of two or three is a row of buttons**, whether it is a lease's
* `values` or a source that answered with that few (§2.4).
* • **A source-backed param keeps its free-text field** otherwise. 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. A row of buttons replaces the text box only while the source has
* answered and the value is one of its choices.
* • **The chosen option's `note` is shown under the field** (§2.6).
*
* `label`, `range` and `values` are passed by the lease form, which draws one
* lease's fields out of `core.lease`'s generic ones.
*/
function ParamField({ param, value, sourceEntry, disabled, onChange }) {
function ParamField({ param, value, sourceEntry, disabled, onChange, missing, label, range, values }) {
const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } }
const asText = value === undefined || value === null ? '' : String(value)
const placeholder = param.example === undefined || param.example === null ? '' : String(param.example)
let control
let beside = null
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>
const b = boolValue(value)
control = param.required ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<input type="checkbox" role="switch" disabled={disabled} checked={b === true}
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')} />
<span className="sans" style={{ fontSize: '0.8rem' }}>
{b === null ? 'Not set yet' : b ? 'Yes' : 'No'}
</span>
</label>
) : (
<Segmented
disabled={disabled}
value={b === null ? '' : String(b)}
onPick={onChange}
choices={[
{ value: '', label: 'Not set' },
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' },
]}
/>
)
} else if (values && values.length) {
control = values.length <= 3 ? (
<Segmented disabled={disabled} value={asText} onPick={onChange}
choices={values.map((v) => ({ value: v, label: v }))} />
) : (
<select {...common} value={asText} onChange={(e) => onChange(e.target.value)}>
<option value="">Choose…</option>
{values.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
)
} else if (param.type === 'datetime') {
@@ -266,37 +333,67 @@ function ParamField({ param, value, sourceEntry, disabled, onChange }) {
{...common}
type="number"
step={param.type === 'int' ? '1' : 'any'}
min={range?.min ?? undefined}
max={range?.max ?? undefined}
value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
placeholder={placeholder}
style={{ ...common.style, maxWidth: 180 }}
onChange={(e) => onChange(e.target.value)}
/>
)
const parts = []
if (param.duration === 'minutes') parts.push('minutes')
if (range && range.min !== null && range.min !== undefined && range.max !== null && range.max !== undefined) {
parts.push(`${range.min}–${range.max}`)
} else if (range && range.max !== null && range.max !== undefined) {
parts.push(`at most ${range.max}`)
}
if (parts.length) beside = <span className="sans dim" style={{ fontSize: '0.76rem' }}>{parts.join(' · ')}</span>
} else if (param.type === 'url') {
control = (
<input {...common} type="url" value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
<input {...common} type="url" value={asText} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
)
} else {
control = (
<input {...common} value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
<input {...common} value={asText} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
)
}
// A source of two or three answered choices is a row of buttons, and the text
// box goes — unless the value is not one of them, which must stay visible and
// editable rather than look unset.
const segmented = param.source && param.type !== 'boolean' && !values && isSegmentable(sourceEntry)
const offList = segmented && asText !== '' && !sourceEntry.options.some((o) => o.value === asText)
const note = param.source ? noteFor(sourceEntry, value) : null
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>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block' }}>
<span className="field-label">{label || param.name}</span>
{(!segmented || offList) && (
beside
? <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>{control}{beside}</span>
: control
)}
</label>
{segmented && (
<Segmented disabled={disabled} value={asText} onPick={onChange}
choices={sourceEntry.options.map((o) => ({ value: o.value, label: o.label }))} />
)}
{param.source && (
{missing && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
This is required — fill it in before saving.
</span>
)}
{param.description && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{param.description}</span>
)}
{note && (
<span className="sans" style={{ display: 'block', fontSize: '0.76rem', color: '#d9c184', marginTop: 4 }}>{note}</span>
)}
{param.source && !segmented && (
sourceEntry?.searchable
? (
<SearchableOptions
@@ -315,7 +412,223 @@ function ParamField({ param, value, sourceEntry, disabled, onChange }) {
/>
)
)}
</label>
</div>
)
}
/**
* `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11).
*
* `core.lease` declares four generic fields — a lease id, a value as text, a
* target as text, a number of minutes — because one action serves every lease
* there is. Drawn as declared, a Rust admin was shown a skill-gain rate as the
* example and a free-text box for a target the lease has a list of. So the
* lease is picked by its label, and the other three become that lease's own:
*
* • the value as the lease's type — a toggle, a number with the lease's range,
* or its closed set of values — with the lease's own example;
* • the target as a picker over the lease's own `target.source` (F11: the editor
* read only `param.source`, and `core.lease`'s target param has none, on
* purpose — see its declaration);
* • the minutes with the lease's longest hold beside them.
*
* What is written is still `core.lease`'s params, as text where the action says
* text: the action coerces the value to the lease's type, and that stays the one
* place that knows both halves.
*/
function LeaseFields({ action, step, leases, sources, disabled, missing, onParam }) {
const declared = new Map((action.params || []).map((p) => [p.name, p]))
const leaseId = paramValue(step, 'lease')
const lease = leases.find((l) => l.id === leaseId) || null
const target = paramValue(step, 'target')
const owners = [...new Set(leases.map((l) => l.owner || 'other'))]
return (
<>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block' }}>
<span className="field-label">What to borrow</span>
<select className="input" style={{ fontSize: '0.8rem' }} disabled={disabled} value={leaseId || ''}
onChange={(e) => onParam('lease', e.target.value)}>
<option value="">Pick a value to borrow…</option>
{owners.map((owner) => (
<optgroup key={owner} label={owner}>
{leases.filter((l) => (l.owner || 'other') === owner).map((l) => (
<option key={l.id} value={l.id}>{l.label}</option>
))}
</optgroup>
))}
{leaseId && !lease && <option value={leaseId}>{leaseId} (not installed)</option>}
</select>
</label>
{missing.includes('lease') && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
This is required — fill it in before saving.
</span>
)}
{lease?.description && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{lease.description}</span>
)}
{leaseId && !lease && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d9c184' }}>
No installed module declares <code>{leaseId}</code>.
</span>
)}
</div>
{lease && (
<ParamField
param={{
name: 'value',
type: lease.type === 'bool' ? 'boolean' : lease.type,
required: true,
example: lease.example ?? (lease.type === 'int' || lease.type === 'float' ? lease.min : ''),
}}
label="Hold it at"
range={lease.type === 'int' || lease.type === 'float' ? { min: lease.min, max: lease.max } : null}
values={lease.values}
value={paramValue(step, 'value')}
disabled={disabled}
missing={missing.includes('value')}
onChange={(raw) => onParam('value', raw)}
/>
)}
{lease?.target && (
<ParamField
param={{
name: 'target',
type: 'string',
required: true,
example: lease.target.example ?? '',
source: lease.target.source,
description: lease.target.description,
}}
label={lease.target.label}
value={target}
sourceEntry={lease.target.source ? sources[lease.target.source] : null}
disabled={disabled}
missing={missing.includes('target')}
onChange={(raw) => onParam('target', raw)}
/>
)}
{lease && !lease.target && target !== undefined && target !== '' && (
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '0 0 12px' }}>
{lease.label} is a single value and takes no target, so the step will be refused with
<code> {String(target)}</code> in it.{' '}
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={disabled}
onClick={() => onParam('target', '')}>
Clear it
</button>
</p>
)}
{declared.get('minutes') && (
<ParamField
param={declared.get('minutes')}
label="For how long"
range={lease ? { min: 1, max: Math.floor(lease.maxDurationMs / 60_000) } : null}
value={paramValue(step, 'minutes')}
disabled={disabled}
missing={missing.includes('minutes')}
onChange={(raw) => onParam('minutes', raw)}
/>
)}
</>
)
}
/**
* "Start from a template" on a new event (MODULE_API 1.13.0; Rust PLAN_REDESIGNS
* §2.5, D192).
*
* A module's named starting point: the author fills in a few inputs and gets an
* ordinary draft in the editor below — the title, the phases, the steps — which
* they read, change and save like any other. **Nothing is saved here**, and
* nothing about the template follows the definition: it is a shortcut past
* typing, and the editor is still where the event is made.
*
* The inputs are params and are drawn by `ParamField`, so an input with a source
* gets the same dropdown, buttons and search a step's field does.
*/
function TemplatePicker({ templates, sources, loadSource, onDraft }) {
const [chosenId, setChosenId] = useState('')
const [inputs, setInputs] = useState({})
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const chosen = templates.find((t) => t.id === chosenId) || null
useEffect(() => {
for (const p of chosen?.inputs || []) if (p.source) loadSource(p.source)
}, [chosen, loadSource])
const choose = (id) => {
setChosenId(id)
setErrors([])
const t = templates.find((x) => x.id === id)
// Prefilled from the examples, like a fresh step — but only the required
// ones, because an example of a kit is not a kit this deployment has.
setInputs({})
if (t) setInputs(Object.fromEntries(t.inputs.filter((p) => p.required && !p.source).map((p) => [p.name, p.example])))
}
const fill = async () => {
setBusy(true)
setErrors([])
try {
const { draft } = await api.admin.buildEventTemplate(chosen.id, inputs)
onDraft(draft, chosen)
} catch (err) {
setErrors(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
return (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Start from a template</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
A starting point an installed module offers. It fills in the plan below; nothing is saved
until you create the draft, and you can change every part of it first.
</p>
<select className="input" style={{ fontSize: '0.8rem', maxWidth: 360 }} value={chosenId}
onChange={(e) => choose(e.target.value)}>
<option value="">Start from nothing</option>
{templates.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
{chosen && (
<div style={{ marginTop: 12 }}>
{chosen.description && (
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>{chosen.description}</p>
)}
{chosen.inputs.map((p) => (
<ParamField
key={p.name}
param={p}
value={inputs[p.name]}
sourceEntry={p.source ? sources[p.source] : null}
disabled={busy}
onChange={(raw) => setInputs((cur) => {
const next = { ...cur }
if (raw === '' || raw === undefined || raw === null) delete next[p.name]
else next[p.name] = coerceLiteral(p.type, raw)
return next
})}
/>
))}
{errors.length > 0 && (
<ul className="sans" style={{ margin: '0 0 10px', paddingLeft: 18, fontSize: '0.8rem', color: '#d98b84' }}>
{errors.map((e) => <li key={e}>{e}</li>)}
</ul>
)}
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={fill}>
Fill in the plan
</button>
</div>
)}
</div>
)
}
@@ -404,7 +717,7 @@ function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
{!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' : ''}
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'north, south, east' : ''}
onChange={(e) => setRow(i, { value: e.target.value })} />
)}
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled}
@@ -525,11 +838,17 @@ function CapMeter({ report, budgets, stale }) {
* 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 }) {
function StartDialog({ onStart, onCancel, busy, draftAhead, publishedVersion, mayPublish }) {
const [rehearsal, setRehearsal] = useState(false)
const [scope, setScope] = useState('')
const [paramsText, setParamsText] = useState('{}')
const [problem, setProblem] = useState(null)
// Rust PLAN_REDESIGNS §2.3 (U-5). A run is pinned to the PUBLISHED version, so
// an author who saved an edit and pressed Start ran the old plan without being
// told. When the working copy is ahead, the dialog asks which one — and does
// not pick for them: publishing is a decision with consequences of its own
// (scheduled occurrences move to the new version).
const [which, setWhich] = useState(null)
const go = () => {
let params = null
@@ -546,13 +865,43 @@ function StartDialog({ onStart, onCancel, busy }) {
return
}
}
onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) })
if (draftAhead && !which) {
setProblem('Choose which plan to start: your edits, published first, or the published version.')
return
}
onStart(
{ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) },
{ publishFirst: draftAhead && which === 'publish' },
)
}
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>
{draftAhead && (
<div style={{ marginBottom: 12, borderLeft: '3px solid #d9c184', paddingLeft: 10 }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>
<strong>This definition has edits that are not published.</strong>{' '}
<span className="dim">A run uses a published version, never the working copy.</span>
</p>
{mayPublish && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<input type="radio" name="start-which" checked={which === 'publish'} onChange={() => setWhich('publish')} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
Publish and start <span className="dim">— your edits become v{(publishedVersion || 0) + 1}, and scheduled occurrences move to it</span>
</span>
</label>
)}
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="start-which" checked={which === 'published'} onChange={() => setWhich('published')} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
Start version {publishedVersion} <span className="dim">(the published one, without your edits)</span>
</span>
</label>
</div>
)}
<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' }}>
@@ -684,6 +1033,14 @@ export default function EventEditor() {
// refuses is the failure this avoids.
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
// Rust PLAN_REDESIGNS §2.1: `core.lease` is drawn as the chosen lease.
const leases = useMemo(() => catalog?.leases || [], [catalog])
const leaseById = useMemo(() => new Map(leases.map((l) => [l.id, l])), [leases])
// §2.5: a new event may start from a module's template.
const templates = useMemo(() => catalog?.templates || [], [catalog])
// §2.1: a required field says it is empty only once a save has been tried —
// a form that opens covered in red reads as broken before anyone has typed.
const [checkMissing, setCheckMissing] = useState(false)
// Phase 7. source id -> { state: 'loading' | 'ok' | 'failed', options, reason }.
//
@@ -747,10 +1104,16 @@ export default function EventEditor() {
for (const p of actionById.get(step.actionId)?.params || []) {
if (p.source) wanted.add(p.source)
}
// A lease step's target list is the chosen LEASE's (F11), which only
// becomes knowable once a lease is picked.
if (step.actionId === 'core.lease') {
const source = leaseById.get(paramValue(step, 'lease'))?.target?.source
if (source) wanted.add(source)
}
}
}
for (const id of wanted) loadSource(id)
}, [form, actionById, loadSource])
}, [form, actionById, leaseById, loadSource])
/**
* The live cap meter (§I, Phase 13).
@@ -806,14 +1169,6 @@ export default function EventEditor() {
phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
}))
const setStep = (pi, si, patch) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) =>
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
),
}))
const movePhase = (pi, delta) =>
setForm((f) => {
const next = [...f.phases]
@@ -848,13 +1203,65 @@ export default function EventEditor() {
const changeAction = (pi, si, actionId) => {
const action = actionById.get(actionId)
const fresh = blankStep(action)
setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' })
setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' }, { timed: true })
}
/**
* Write one step and, when the change could make it a timed step, keep its
* phase open long enough for it (Rust PLAN_REDESIGNS §2.2, U-6).
*
* Teardown gives a held thing back when the run ends, so a lease "for 10
* minutes" in a one-phase event with no advance rule lasted a second. Only a
* phase with NO rule is given one — `after: <the longest hold>`, on the screen,
* for the author to change; a rule the author chose is left alone and the
* sentence under the phase says what it costs. Only changes that can create or
* lengthen a hold ask (`timed`), so an author who removed the rule on purpose
* and then edits a message is not overruled.
*/
const setStep = (pi, si, patch, { timed = false } = {}) =>
setForm((f) => {
const phases = f.phases.map((p, i) =>
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
)
if (!timed) return { ...f, phases }
const advance = guardedAdvance(phases, pi, actionById)
return { ...f, phases: advance ? phases.map((p, i) => (i === pi ? { ...p, advance } : p)) : phases }
})
const addStep = (pi) =>
setForm((f) => {
const phases = f.phases.map((p, i) => (i === pi ? { ...p, steps: [...p.steps, blankStep(actions[0])] } : p))
const advance = guardedAdvance(phases, pi, actionById)
return { ...f, phases: advance ? phases.map((p, i) => (i === pi ? { ...p, advance } : p)) : phases }
})
/** One param of one step, from a form control. */
const setStepParam = (pi, si, step, param, raw) =>
setStep(pi, si, { paramsText: setParam(step, param.name, raw, param.type) }, { timed: Boolean(param.duration) })
/** Every required field the plan leaves empty, as the sentences the save shows. */
const missingSentences = () => {
const out = []
form.phases.forEach((phase, pi) => phase.steps.forEach((step, si) => {
for (const name of missingParams(step, actionById.get(step.actionId), leaseById)) {
out.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${name} is required`)
}
}))
return out
}
const save = async () => {
setBusy(true)
setProblems([])
setNotice(null)
// §2.1: a required field left empty is named under the field AND in the
// list, here, before the round trip the server would refuse it on anyway.
setCheckMissing(true)
const missing = missingSentences()
if (missing.length) {
setProblems(missing)
return
}
setBusy(true)
// 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)
@@ -946,10 +1353,17 @@ export default function EventEditor() {
* 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) => {
const start = async (body, { publishFirst = false } = {}) => {
setBusy(true)
setProblems([])
try {
// §2.3: "Publish and start" is the two calls an admin would otherwise
// make by hand, in order, and a publish the server refuses stops here
// rather than starting the old version behind the author's back.
if (publishFirst) {
const published = await api.admin.publishEvent(id)
setEvent(published.event)
}
const result = await api.admin.startEventRun(id, body)
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
@@ -1113,7 +1527,8 @@ export default function EventEditor() {
)}
{starting && (
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start} />
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start}
draftAhead={Boolean(event?.draftAhead)} publishedVersion={event?.currentVersion} mayPublish={isAdmin} />
)}
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
@@ -1127,6 +1542,19 @@ export default function EventEditor() {
</div>
)}
{isNew && mayAuthor && templates.length > 0 && (
<TemplatePicker
templates={templates}
sources={sources}
loadSource={loadSource}
onDraft={(draft, template) => {
setForm(formFromDefinition({ title: draft.title, summary: draft.summary, spec: draft.spec }))
setProblems([])
setNotice(`Filled in from "${template.label}". Nothing is saved until you create the draft.`)
}}
/>
)}
{/* ── Basics ── */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
@@ -1399,6 +1827,14 @@ export default function EventEditor() {
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
: 'The phase advances the moment every one of its steps is finished.'}
</p>
{/* §2.2 (U-6). A held value is given back when the RUN ends, so a
plan that can end sooner than its longest hold is said out loud
here, where the rule that decides it is. */}
{shortfallSentence(form.phases, pi, actionById) && (
<p className="sans" style={{ fontSize: '0.78rem', color: '#d9c184', margin: '6px 0 0' }}>
{shortfallSentence(form.phases, pi, actionById)}
</p>
)}
</div>
<div style={{ marginTop: 12 }}>
@@ -1479,14 +1915,31 @@ export default function EventEditor() {
{mode.mode === PARAM_FORM ? (
<div style={{ marginTop: 8 }}>
{(action?.params || []).map((p) => (
{action?.id === 'core.lease' ? (
<LeaseFields
action={action}
step={step}
leases={leases}
sources={sources}
disabled={archived}
missing={checkMissing ? missingParams(step, action, leaseById) : []}
onParam={(name, raw) => {
const declared = action.params.find((p) => p.name === name)
// A new lease changes which hold this is, so it is
// checked like a new duration.
setStep(pi, si, { paramsText: setParam(step, name, raw, declared.type) },
{ timed: name === 'lease' || Boolean(declared.duration) })
}}
/>
) : (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) })}
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(raw) => setStepParam(pi, si, step, p, raw)}
/>
))}
{action && (action.params || []).length === 0 && (
@@ -1520,7 +1973,7 @@ export default function EventEditor() {
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 12 }} disabled={archived}
onClick={() => setPhase(pi, { steps: [...phase.steps, blankStep(actions[0])] })}>
onClick={() => addStep(pi)}>
Add step
</button>
</div>