@@ -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 f or 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 write s
* 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 fail ure 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). F or 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 ha s
* them: a lease's `min`– `max`, a duration's minutes.
* • **A closed set of tw o or three is a row of buttons**, whether it is a lease's
* `values` or a so urc e 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 = { valu e === 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 < / optio n>
< 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" rol e="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' }
< / spa n>
< / 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 }
onCha nge = { ( raw ) => setStep ( pi , si , { paramsText : set Param( step , p . name, raw , p . type ) } ) }
missi ng= { checkMissing && missing Params ( 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 >