import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAuth } from '../../../contexts/AuthContext.jsx' import { api } from '../../../api/client.js' import { formFromDefinition, payloadFromForm, blankPhase, blankAdvance, ADVANCE_KINDS, blankStep, describeSchedule, scheduleFromForm, SCHEDULE_KINDS, MONTHLY_NTHS, WEEKDAYS, } from '../../../lib/eventAuthoring.js' // Admin → Events → the definition editor (EVENTS.md §I, Phase 3). // // **A vertical timeline, not a node graph**, and that is a decision about what // the engine can actually do rather than a matter of taste. The condition // grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at // depth five — so a canvas would promise power this project has never handed an // operator. Phases in order, each with its steps in order, says exactly what the // runner does with them. // // **Core renders no game word here.** Every label on a step comes from the // action's own registration — its `label`, its params' names, their descriptions // and their examples — so an installed module's vocabulary appears without core // 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. 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.' export default function EventEditor() { const { id } = useParams() const navigate = useNavigate() const { user } = useAuth() const isNew = id === 'new' const [form, setForm] = useState(null) const [event, setEvent] = useState(null) const [catalog, setCatalog] = useState(null) const [series, setSeries] = useState([]) const [versions, setVersions] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [problems, setProblems] = useState([]) const [notice, setNotice] = useState(null) const [busy, setBusy] = useState(false) // The dry run's answer (Phase 6). Cleared on every save and every publish, // 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) const isAdmin = user?.role === 'admin' // Reads here are staff-wide (§K), so a moderator reaches this screen legitimately // — the run console is their whole job and a definition is what a run is OF. But // authoring is `admin` + `editor`, so Save has to follow the route it calls. // Offering it and letting the server answer 403 is the shape §K calls "a gate // nobody notices was missing", only inverted: a button that does nothing. const mayAuthor = isAdmin || user?.role === 'editor' const load = useCallback(async () => { const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()]) setCatalog(cat) setSeries(ser.series || []) if (isNew) { setEvent(null) setVersions([]) setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } })) return } const [{ event: loaded }, { versions: history }] = await Promise.all([ api.admin.getEvent(id), api.admin.listEventVersions(id), ]) setEvent(loaded) setVersions(history || []) setForm(formFromDefinition(loaded)) }, [id, isNew]) useEffect(() => { let alive = true ;(async () => { setLoading(true) try { await load() if (alive) setError(null) } catch (err) { if (alive) setError(err.message) } finally { if (alive) setLoading(false) } })() return () => { alive = false } }, [load]) const actions = useMemo(() => catalog?.actions || [], [catalog]) const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions]) // Phase 5. Served with the actions on the same route, so an EDITOR sees the // same catalog an admin does — `/admin/engagement/triggers` is admin-only, and // an editor writing a trigger id from memory into a field the save path then // refuses is the failure this avoids. const triggers = useMemo(() => catalog?.triggers || [], [catalog]) const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers]) const set = (patch) => setForm((f) => ({ ...f, ...patch })) const setPhase = (pi, patch) => setForm((f) => ({ ...f, 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] const to = pi + delta if (to < 0 || to >= next.length) return f ;[next[pi], next[to]] = [next[to], next[pi]] return { ...f, phases: next } }) const moveStep = (pi, si, delta) => setForm((f) => ({ ...f, phases: f.phases.map((p, i) => { if (i !== pi) return p const steps = [...p.steps] const to = si + delta if (to < 0 || to >= steps.length) return p ;[steps[si], steps[to]] = [steps[to], steps[si]] return { ...p, steps } }), })) /** * Changing a step's action REPLACES its params with the new action's examples. * * The alternative — keeping what was typed — leaves an object whose keys belong * to a different action, and the save refuses it with "x is not a param of y" * for every one of them. Replacing is the honest move and it is not * destructive in any way an author minds: they have just said this step does * something else. */ 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: '' }) } const save = async () => { setBusy(true) setProblems([]) setNotice(null) // 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) if (!built.ok) { setProblems(built.errors) setBusy(false) return } try { if (isNew) { const result = await api.admin.createEvent(built.payload) navigate(`/admin/events/${result.event.id}`, { replace: true }) } else { const result = await api.admin.updateEvent(id, built.payload) setEvent(result.event) setForm(formFromDefinition(result.event)) setNotice('Saved.') } } catch (err) { // The server answers with every problem rather than the first, so an author // fixing a spec does it in one pass rather than six round trips. setProblems(err.body?.errors || [err.message]) } finally { setBusy(false) } } const publish = async () => { setBusy(true) setProblems([]) setNotice(null) setReport(null) try { const result = await api.admin.publishEvent(id) setEvent(result.event) setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || [])) // The re-pin count is said out loud, because an editor who does not know // their fix reached next Friday finds out on Friday. setNotice( result.repinned ? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.` : `Published as v${result.version}.`, ) } catch (err) { setProblems(err.body?.errors || [err.message]) } finally { setBusy(false) } } /** * The dry run (Phase 6). * * `admin, editor` — it dispatches nothing. What it verifies follows the * definition's state, and the server says which: a `ready` definition is * checked against its PUBLISHED version, because that is the only thing that * ever actually runs and it is that pass §K's gate is about; a draft is checked * against the working spec the author is still holding. * * Findings arrive with a 200 — the request succeeded, the plan has problems — * so they are rendered rather than thrown into the error box. */ const verify = async () => { setBusy(true) setProblems([]) setNotice(null) setReport(null) try { const result = await api.admin.verifyEvent(id) setReport(result) if (result.recorded) setEvent(await api.admin.getEvent(id).then((r) => r.event)) } catch (err) { setProblems(err.body?.errors || [err.message]) } finally { setBusy(false) } } const start = async () => { setBusy(true) setProblems([]) try { const result = await api.admin.startEventRun(id, {}) navigate(`/admin/events/runs/${result.run.id}`) } catch (err) { setProblems(err.body?.errors || [err.message]) } finally { setBusy(false) } } if (loading || !form) return if (error) return const archived = event?.state === 'archived' return (

{isNew ? 'New event' : event?.title}

{isNew ? ( 'The slug is derived from the title once and frozen afterwards — the public event page lives at it.' ) : ( <> {event?.slug} · {event?.state} {event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'} )}

{mayAuthor && ( )} {/* The dry run is admin+editor, deliberately wider than publish: an author should be able to find out what their event would cost before asking an admin to commit the deployment to it. */} {!isNew && mayAuthor && ( )} {/* Publish and start are admin ONLY (§N2) and not the same gate as the live controls: publishing commits a definition a schedule will later start unattended. */} {!isNew && isAdmin && ( )} {!isNew && isAdmin && event?.state === 'ready' && ( )}
{!mayAuthor && (

You can read this definition and watch its runs. Editing and publishing an event are an admin or editor's, and starting one is an admin's alone — live control of a run already in flight is yours.

)} {archived && (

This definition is archived. It is kept so its past runs can still be explained, and it cannot be edited or run again.

)} {/* ── §K's gate, said where it can still be acted on ── A published version that nobody has dry-run will not start on its schedule. The alternative to saying so here is an operator finding out on the Friday it did not run, so it is a banner rather than a log line — and only for a definition that actually HAS a schedule to be held. */} {!isNew && event?.state === 'ready' && !event?.currentVersionVerifiedAt && !archived && (

This version has not been dry-run. Scheduled occurrences are held until it is — an event that starts while nobody is watching gets one review, and this is it. Starting it by hand is unaffected.

)} {report && (

{report.report.ok ? 'Dry run passed' : 'Dry run found problems'} {' '} · {report.report.steps} step{report.report.steps === 1 ? '' : 's'} checked against{' '} {/* Which spec was checked. The two answer different questions, and a report that did not say would be read as the other one. */} {report.target === 'version' ? `published v${report.version}` : 'the working draft'} {report.recorded && ' · recorded, so scheduled occurrences may now start'}

{report.report.findings.length > 0 && (
    {report.report.findings.map((f, i) => (
  • {f.phase !== null && ( {f.phase} · step {f.seq + 1} {f.actionId ? ` · ${f.actionId}` : ''} )} {f.phase !== null && ' — '} {f.message}
  • ))}
)} {/* The whole-plan cost, which is the finding no other path can make: a step that fits on its own and does not fit alongside its siblings. */} {report.report.cost.length > 0 && ( {report.report.cost.map((c) => ( ))}
{c.dimension} {c.total} {c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
)} {report.report.findings.length === 0 && report.report.cost.length === 0 && (

Nothing this event does costs a capped resource.

)}
)} {notice &&

{notice}

} {problems.length > 0 && (

That did not save:

    {problems.map((p) =>
  • {p}
  • )}
)} {/* ── Basics ── */}