feat(events): a param that holds several values (MODULE_API 1.14.0)
Rust PLAN_REDESIGNS §12.3 (D344): an event action param may declare `multiple: true`, with an optional `max` (1-100, 20 when left out), on a `string` or `int` param. The permission reward steps take several permissions in one step through it. - registries: the flag and `max` are checked at registration and served in the catalog; refused on other types, on a duration, on a template's inputs, and with a list as the example. A param that does not say it reads back exactly as declared, without the keys. - spec: the value is a JSON array, each item the type's literal, duplicates dropped in order, at most `max`; a bare value is a list of one; `required` means at least one; an empty item is refused. - authorize: a multiple param's example is priced as a list of one, since cost() is handed the array. - editor: a multi-select. Chips above a grouped, filterable checklist for a source that answered; the typeahead for a searchable source; a tag box otherwise. Ticking past max is refused in words. A new step holds the example as a list of one. - run console: params read as `name: value`, a list joined by commas. - MODULE_API 1.13.0 -> 1.14.0 on both halves; engagement-triggers.json and the catalog's OpenAPI note regenerated. Additive only: Module-uo (`^1.10.0`) declares no multiple param. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -22,6 +22,10 @@ import {
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
setParamList,
|
||||
listValue,
|
||||
addToList,
|
||||
filterOptions,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
@@ -456,6 +460,154 @@ function ParamField({ param, value, sourceEntry, disabled, onChange, missing, la
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A param that holds several values, as a multi-select (MODULE_API 1.14.0; Rust
|
||||
* PLAN_REDESIGNS §12.3, D344).
|
||||
*
|
||||
* The chosen values are chips above the control, each with its remove button,
|
||||
* and the control is whichever the param's source allows:
|
||||
*
|
||||
* • **a source that answered** — a grouped checklist in the source's own order,
|
||||
* with a filter box over it, because a server's few hundred permissions are a
|
||||
* list to narrow, not to scroll;
|
||||
* • **a searchable source** — the typeahead, each pick adding a chip;
|
||||
* • **no source, or one that failed** — a tag box, a value per Enter. A failure
|
||||
* is said and the field stays usable, the single-value field's rule (§F).
|
||||
*
|
||||
* Ticking past `max` is refused in words, where the click was. The order the
|
||||
* author picked in is the order stored, and the server keeps it.
|
||||
*/
|
||||
function MultiParamField({ param, value, sourceEntry, disabled, onChange, missing }) {
|
||||
const list = listValue(value)
|
||||
const [term, setTerm] = useState('')
|
||||
const [typed, setTyped] = useState('')
|
||||
const [refused, setRefused] = useState(null)
|
||||
const max = param.max || 20
|
||||
|
||||
const answered = sourceEntry && sourceEntry.state === 'ok'
|
||||
const byValue = new Map(answered ? sourceEntry.options.map((o) => [o.value, o]) : [])
|
||||
const add = (v) => {
|
||||
const next = addToList(list, v, max)
|
||||
if (next.refused) setRefused(next.refused)
|
||||
else {
|
||||
setRefused(null)
|
||||
onChange(next.list)
|
||||
}
|
||||
}
|
||||
const remove = (v) => {
|
||||
setRefused(null)
|
||||
onChange(list.filter((x) => String(x) !== String(v)))
|
||||
}
|
||||
const toggle = (v) => (list.some((x) => String(x) === v) ? remove(v) : add(v))
|
||||
|
||||
const shown = answered && !sourceEntry.searchable ? filterOptions(sourceEntry.options, term) : []
|
||||
const groups = [...new Set(shown.map((o) => o.group || ''))]
|
||||
const chosenNotes = list
|
||||
.map((v) => ({ v, note: byValue.get(String(v))?.note }))
|
||||
.filter((x) => x.note)
|
||||
|
||||
const tagBox = (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
<input className="input" style={{ fontSize: '0.8rem' }} disabled={disabled} value={typed}
|
||||
placeholder={param.example === undefined || param.example === null ? '' : String(param.example)}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
add(typed)
|
||||
setTyped('')
|
||||
}
|
||||
}} />
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled || !typed.trim()}
|
||||
onClick={() => { add(typed); setTyped('') }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
let control
|
||||
if (param.source && sourceEntry?.searchable) {
|
||||
control = (
|
||||
<SearchableOptions sourceId={param.source} label={sourceEntry.label || param.source}
|
||||
disabled={disabled} onPick={add} />
|
||||
)
|
||||
} else if (param.source && answered && sourceEntry.options.length) {
|
||||
control = (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<input className="input" style={{ fontSize: '0.75rem' }} disabled={disabled} value={term}
|
||||
placeholder={`Filter ${sourceEntry.label || param.source}…`} onChange={(e) => setTerm(e.target.value)} />
|
||||
<div role="group" aria-label={param.name}
|
||||
style={{ maxHeight: 240, overflowY: 'auto', marginTop: 4, border: '1px solid rgba(255,255,255,0.08)', padding: '4px 8px' }}>
|
||||
{shown.length === 0 && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem' }}>Nothing matches “{term}”.</div>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<div key={g || '—'}>
|
||||
{g && <div className="field-label" style={{ fontSize: '0.7rem', margin: '6px 0 2px' }}>{g}</div>}
|
||||
{shown.filter((o) => (o.group || '') === g).map((o) => (
|
||||
<label key={o.value} style={{ display: 'flex', alignItems: 'baseline', gap: 6, fontSize: '0.76rem' }}>
|
||||
<input type="checkbox" disabled={disabled}
|
||||
checked={list.some((x) => String(x) === o.value)}
|
||||
onChange={() => toggle(o.value)} />
|
||||
<span>{o.label}</span>
|
||||
{o.note && <span className="dim" style={{ fontSize: '0.7rem' }}>{o.note}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
control = (
|
||||
<>
|
||||
{param.source && (
|
||||
<ParamOptions entry={sourceEntry} label={sourceEntry?.label || param.source} disabled={disabled} onPick={add} />
|
||||
)}
|
||||
{tagBox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span className="field-label">
|
||||
{param.name} <span className="dim" style={{ fontWeight: 'normal' }}>· {list.length} of at most {max}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||
{list.length === 0 && <span className="sans dim" style={{ fontSize: '0.76rem' }}>None chosen yet.</span>}
|
||||
{list.map((v) => (
|
||||
<span key={String(v)} className="pill" style={{ fontSize: '0.74rem', display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||
{byValue.get(String(v))?.label || String(v)}
|
||||
<button type="button" aria-label={`Remove ${v}`} disabled={disabled}
|
||||
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
|
||||
onClick={() => remove(v)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{refused && (
|
||||
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84', marginTop: 4 }}>{refused}</span>
|
||||
)}
|
||||
{control}
|
||||
{missing && (
|
||||
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
|
||||
This is required — choose at least one before saving.
|
||||
</span>
|
||||
)}
|
||||
{param.description && (
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{param.description}</span>
|
||||
)}
|
||||
{chosenNotes.map(({ v, note }) => (
|
||||
<span key={String(v)} className="sans" style={{ display: 'block', fontSize: '0.76rem', color: '#d9c184', marginTop: 4 }}>
|
||||
{byValue.get(String(v))?.label || String(v)}: {note}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11).
|
||||
*
|
||||
@@ -1971,7 +2123,17 @@ export default function EventEditor() {
|
||||
{ timed: name === 'lease' || Boolean(declared.duration) })
|
||||
}}
|
||||
/>
|
||||
) : (action?.params || []).map((p) => (
|
||||
) : (action?.params || []).map((p) => (p.multiple ? (
|
||||
<MultiParamField
|
||||
key={p.name}
|
||||
param={p}
|
||||
value={paramValue(step, p.name)}
|
||||
sourceEntry={p.source ? sources[p.source] : null}
|
||||
disabled={archived}
|
||||
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
|
||||
onChange={(list) => setStep(pi, si, { paramsText: setParamList(step, p.name, list, p.type) })}
|
||||
/>
|
||||
) : (
|
||||
<ParamField
|
||||
key={p.name}
|
||||
param={p}
|
||||
@@ -1981,7 +2143,7 @@ export default function EventEditor() {
|
||||
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
|
||||
onChange={(raw) => setStepParam(pi, si, step, p, raw)}
|
||||
/>
|
||||
))}
|
||||
)))}
|
||||
{action && (action.params || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
This action takes no params.
|
||||
@@ -2002,7 +2164,7 @@ export default function EventEditor() {
|
||||
{action && (action.params || []).length > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
<code>{action.id}</code> declares{' '}
|
||||
{(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}.
|
||||
{(action.params || []).map((p) => `${p.name} (${p.multiple ? `a list of ${p.type}, at most ${p.max}` : p.type}${p.required ? ', required' : ''})`).join(', ')}.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
describeParams,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
@@ -655,7 +656,7 @@ export default function EventRun() {
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(step.params)}
|
||||
{describeParams(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
|
||||
Reference in New Issue
Block a user