feat(events): a param that holds several values (MODULE_API 1.14.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 13m55s

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:
2026-10-12 15:40:30 -05:00
parent ca7ec53b14
commit 743f1bc8b2
14 changed files with 616 additions and 14 deletions

View File

@@ -133,7 +133,9 @@ const nextPhaseKey = (phases) => {
export function blankStep(action) {
const params = {}
for (const p of action?.params || []) {
if (p.required || p.example !== undefined) params[p.name] = p.example
// A list's example is one value (MODULE_API 1.14.0), so the step starts
// holding that one.
if (p.required || p.example !== undefined) params[p.name] = p.multiple ? [p.example] : p.example
}
return {
actionId: action?.id || '',
@@ -545,7 +547,9 @@ export const PARAM_JSON = 'json'
* 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.
* scalar declaration, or anything but a flat list against a `multiple` one
* (MODULE_API 1.14.0). A bare value there is a list of one, as the server
* reads it, and the multi-select shows it as one chip.
*/
export function paramsRenderable(action, params) {
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
@@ -554,6 +558,13 @@ export function paramsRenderable(action, params) {
if (!declared.has(name)) {
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
}
if (declared.get(name).multiple) {
const items = Array.isArray(value) ? value : [value]
if (items.some((v) => v !== null && typeof v === 'object')) {
return { ok: false, reason: `"${name}" holds a structure, which a list of values cannot hold` }
}
continue
}
if (value !== null && typeof value === 'object') {
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
}
@@ -625,6 +636,81 @@ export function setParam(step, name, raw, type) {
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
// ── A param that holds several values (MODULE_API 1.14.0, Rust PLAN_REDESIGNS §12.3) ──
/**
* A `multiple` param's value as the list the multi-select draws: an array as
* stored, a bare value as a list of one (the server's own reading), and nothing
* as the empty list.
*/
export function listValue(value) {
if (value === undefined || value === null || value === '') return []
return Array.isArray(value) ? value : [value]
}
/**
* Write a `multiple` param's whole list, and give back the whole box.
*
* Each item is coerced as `setParam` coerces one value, duplicates dropped
* keeping the first, and **an empty list removes the key** — `setParam`'s rule,
* for its reason: the server reads absent and `[]` alike, and "is required" is
* the error a required list left empty should get.
*/
export function setParamList(step, name, items, type) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return step?.paramsText || '{}'
const next = { ...parsed.params }
const out = []
for (const raw of items || []) {
if (raw === '' || raw === undefined || raw === null) continue
const value = coerceLiteral(type, raw)
if (!out.some((v) => v === value)) out.push(value)
}
if (out.length) next[name] = out
else delete next[name]
return JSON.stringify(next, null, 2)
}
/**
* Add one value to a list, or say why not. `{ list }` or `{ refused }`, so the
* control can put the sentence where the click was rather than silently doing
* nothing past `max`.
*/
export function addToList(list, value, max) {
const text = String(value ?? '').trim()
if (!text) return { list }
if (list.some((v) => String(v) === text)) return { list }
if (max && list.length >= max) return { refused: `This takes at most ${max} — remove one first.` }
return { list: [...list, text] }
}
/**
* The options a filter box leaves, matched on value, label and group alike,
* case-insensitively. The order is the source's own — a source sorts its rows
* (§12.2) and the editor keeps it.
*/
export function filterOptions(options, term) {
const t = String(term || '').trim().toLowerCase()
if (!t) return options || []
return (options || []).filter((o) =>
[o.value, o.label, o.group].some((s) => String(s || '').toLowerCase().includes(t)))
}
/**
* One step's params as a line the run console can show: `name: value`, a list
* joined by commas, anything else as its JSON. Shown rather than the raw object
* so a list of ten permissions reads as ten names, not as brackets and quotes.
*/
export function describeParams(params) {
if (!params || typeof params !== 'object') return ''
return Object.entries(params)
.map(([k, v]) => {
if (Array.isArray(v) && v.every((x) => x === null || typeof x !== 'object')) return `${k}: ${v.join(', ')}`
return `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`
})
.join(' · ')
}
// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ──
//
// What the controls need to decide and nothing the server re-decides: which
@@ -671,7 +757,7 @@ export function missingParams(step, action, leasesById = new Map()) {
if (!action) return []
const parsed = parseParams(step?.paramsText)
if (parsed.error) return []
const empty = (v) => v === undefined || v === null || v === ''
const empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
if (action.id === 'core.lease') {
const lease = leasesById.get(parsed.params.lease)