diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 674c231..c2da220 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -64,13 +64,67 @@ shell code changes, ever. Both schemas map field names to specs: ``` -{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint", +{ "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint", "label": "Shown next to the field", "default": , - "options": [ { "value": "a", "label": "A" }, … ] // "select" only + "group": "Section heading", // optional; see below + "showWhen": "", // optional; see below + "help": "One line shown under the control", // optional, admin config only + "options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only + "optionsRoute": "/devices", // "select" only, admin config only + "optionsDependsOn": "" // with optionsRoute; see below } ``` +`boolean` renders as a checkbox; `switch` is the same true/false value shown +as a toggle (use it for a feature on/off that other fields depend on). + +`group` gathers consecutive fields under a labeled subsection in the admin +page, so a module with several distinct concerns (e.g. a ProPresenter +connection and an LTC audio input) reads as clearly separated blocks. Fields +with no `group` render loose, before/after the grouped ones in schema order. + +A manifest-level `configGroups` map can dress a group up further, keyed by +the group name: + +```json +"configGroups": { + "Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" }, + "Show / hide": { "collapsed": true, "columns": 3 } +} +``` + +- `collapsed`: `true` (starts folded), `false` (starts open but can fold), + or `"whenSet"` — folded once the group already holds values (a saved + password, or anything other than the schema default), open while it is + still empty. Omit it and the group is a plain, always-open block. +- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a + long checklist of booleans compact. +- `help`: a line of guidance at the top of the group. + +`help` on a field puts one line of guidance under its control — allowed +placeholders, where a value comes from. Keep labels short and put the +explanation there. + +`showWhen: ""` hides a field until the boolean/`switch` field named +`` is on — e.g. the audio device and channel appear only once the LTC +listener switch is enabled. The hidden field still keeps its stored value. + +A `select` whose choices are only knowable at runtime (audio devices, serial +ports, discovered sources) sets `optionsRoute` instead of — or as a fallback +refresh of — static `options`: the admin page GETs +`/api/modules/` and expects `{ "options": [ { "value", +"label" }, … ] }` from one of the module's own server routes. The saved value +stays selectable even when the route is down or the option has vanished. (See +`propresenter-timers`' "Audio device" for a worked example.) + +An option may carry a `group`: options sharing one render under a single +``, so a tree (Planning Center folders, device classes) is +browsable inside the select. `optionsDependsOn: ""` makes the admin +page fetch the options with that other field's current value appended as a +query parameter (`?=`), now and again whenever it changes — e.g. +a plan list that follows the chosen service type. (Both in `pco-plan`.) + `password` fields are admin-config only in practice: their values are stored in the server's `modules.json` (in the per-machine data directory, see the README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin @@ -468,9 +522,9 @@ client-side failures (a tile that fails to start says so in the tile). Center is a cloud API, so the module reads its base URL from the environment instead of admin config: run ProdDash with `PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any - Application ID + Secret in `/admin`, and pick "Sunday Service" in - `/modules/pco-plan/setup.html`. Its item titles match `pp-mock.js`, so - running both mocks exercises the ProPresenter matching. + Application ID + Secret under PCO Plan in `/admin`, save, then pick + "Sunday Service" from the Service type list. Its item titles match + `pp-mock.js`, so running both mocks exercises the ProPresenter matching. **The resilience checklist** — every module must pass this before it's done: diff --git a/modules/pco-plan/setup.html b/modules/pco-plan/setup.html deleted file mode 100644 index 9593838..0000000 --- a/modules/pco-plan/setup.html +++ /dev/null @@ -1,372 +0,0 @@ - - - - - -PCO Plan — Planning Center setup - - - - -
-
-

PCO Plan · Planning Center setup

- ← Admin - Dashboard -
-

Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.

- -
-

Currently following

-
Loading…
-
-
- -
-

Service types

-
-
Loading folders from Planning Center…
-
- -
Credentials (Application ID + Secret from api.planningcenteronline.com, or an OAuth token) are entered under PCO Plan in Admin. Saving here re-initialises the module; open tiles pick up the new plan on their own.
-
-
- - - - diff --git a/public/admin/admin.css b/public/admin/admin.css index 4a7c0e7..5487cd6 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -113,6 +113,83 @@ body { overflow: auto; } .endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } +/* labeled config subsections (manifest field `group`) */ +.field-group { + margin: 14px 0 4px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 9px; +} +.field-group-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 10px; +} +.field-group .field:last-child { margin-bottom: 0; } + +/* collapsible groups (manifest configGroups[name].collapsed) */ +details.field-group > summary.field-group-title { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 0; + user-select: none; +} +details.field-group > summary.field-group-title::-webkit-details-marker { display: none; } +details.field-group > summary.field-group-title::before { + content: ""; + width: 0; height: 0; + border-left: 5px solid var(--muted); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform 0.15s; +} +details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); } +details.field-group[open] > summary.field-group-title { margin-bottom: 10px; } +details.field-group > summary.field-group-title:hover { color: var(--text); } + +/* multi-column groups (configGroups[name].columns): a neat checklist grid */ +.field-group-body.is-columns { + display: grid; + grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); + gap: 4px 16px; +} +.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } +.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } +@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } } + +.field-group-help, +.field-help { + display: block; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted); +} +.field-group-help { margin: -4px 0 8px; } +.field-help { margin-top: 2px; } +.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +/* showWhen: a hidden dependent field must actually collapse — `.field`'s + own `display:flex` (an author rule) otherwise outranks the [hidden] UA + default and the field stays visible. */ +.module-form .field[hidden] { display: none; } + +/* a boolean rendered as a toggle (manifest type `switch`) */ +.field.switch-field { + flex-direction: row; + align-items: center; + gap: 10px; + color: var(--text); + cursor: pointer; + max-width: none; +} +.field.switch-field .switch-label { font-size: 13px; } + .module-actions { display: flex; align-items: center; diff --git a/public/admin/admin.js b/public/admin/admin.js index 9f43bbc..891c28b 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -83,19 +83,43 @@ function healthText(mod) { return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; } -function fieldFor(key, spec, value, passwordSet) { +function fieldFor(key, spec, value, passwordSet, moduleId) { const type = spec?.type || 'string'; const label = spec?.label || key; const field = document.createElement('label'); - field.className = 'field' + (type === 'boolean' ? ' check' : ''); + field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); let read; + // For boolean/switch fields, the control other fields may depend on + // (showWhen) — returned so the caller can wire visibility. + let control = null; + // For selects: the element (optionsDependsOn sources) and, with an + // optionsRoute, a function that (re)loads the options. + let selectEl = null; + let refetch = null; - if (type === 'boolean') { + if (type === 'switch') { + // Same toggle as the module enable switch, inline with its label. + const toggle = document.createElement('span'); + toggle.className = 'switch'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(value); + const track = document.createElement('span'); + track.className = 'track'; + toggle.append(input, track); + const text = document.createElement('span'); + text.className = 'switch-label'; + text.textContent = label; + field.append(toggle, text); + read = () => input.checked; + control = input; + } else if (type === 'boolean') { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; + control = input; } else if (type === 'endpoint') { // One consistent "Host / IP : Port" control for every module's upstream. const span = document.createElement('span'); @@ -122,19 +146,69 @@ function fieldFor(key, spec, value, passwordSet) { row.append(host, sep, port); field.append(span, row); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); - } else if (type === 'select' && Array.isArray(spec.options)) { + } else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) { const span = document.createElement('span'); span.textContent = label; const select = document.createElement('select'); - for (const opt of spec.options) { - const o = document.createElement('option'); - o.value = String(typeof opt === 'object' ? opt.value : opt); - o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); - select.appendChild(o); + const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); + const fill = (options) => { + // Keep what the admin has picked but not yet saved across a refill + // (a dependent select refetching), else the saved value. + const saved = String(value ?? ''); + const wanted = select.options.length ? select.value : saved; + select.innerHTML = ''; + // The saved value must stay selectable even when it isn't among the + // live options (device unplugged, module route down). + if (saved && !options.some((opt) => optValue(opt) === saved)) { + options = [{ value: saved, label: `${saved} (saved)` }, ...options]; + } + // Nothing saved yet → a blank "choose" entry, unless the module already + // offers a blank option of its own (an "Automatic" default, say). + if (!saved && !options.some((opt) => optValue(opt) === '')) { + options = [{ value: '', label: '— choose —' }, ...options]; + } + // Options may carry a `group` (a folder path, a device class): options + // sharing one render under a single , in first-seen order. + const groups = new Map(); + for (const opt of options) { + const o = document.createElement('option'); + o.value = optValue(opt); + o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); + const g = typeof opt === 'object' && opt.group ? String(opt.group) : ''; + if (!g) { + select.appendChild(o); + continue; + } + if (!groups.has(g)) { + const og = document.createElement('optgroup'); + og.label = g; + groups.set(g, og); + select.appendChild(og); + } + groups.get(g).appendChild(o); + } + select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved; + }; + fill(Array.isArray(spec.options) ? spec.options : []); + if (spec.optionsRoute && moduleId) { + // Options discovered at runtime (audio devices, ports, sources…): + // the module serves them from one of its own routes. With + // optionsDependsOn the caller triggers the fetch (and re-fetches when + // that other field changes), passing its value as a query parameter. + refetch = (query) => { + const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : ''; + return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (Array.isArray(body?.options)) fill(body.options); + }) + .catch(() => { /* keep the static/saved options */ }); + }; + if (!spec.optionsDependsOn) refetch(); } - select.value = String(value ?? ''); field.append(span, select); read = () => select.value; + selectEl = select; } else { const span = document.createElement('span'); span.textContent = label; @@ -149,7 +223,32 @@ function fieldFor(key, spec, value, passwordSet) { field.append(span, input); read = () => (type === 'number' ? Number(input.value) : input.value); } - return { field, read }; + if (spec?.help) { + // One line of guidance under the control (allowed placeholders, where a + // value comes from…) — the label stays short. + const help = document.createElement('small'); + help.className = 'field-help'; + help.textContent = spec.help; + field.appendChild(help); + } + return { field, read, control, selectEl, refetch }; +} + +/** + * Does a config group have anything entered? Drives `collapsed: "whenSet"`: + * a credentials block stays folded once it holds a saved secret or a value + * other than the schema default, and opens by itself while still empty. + */ +function groupHasValues(fields, mod) { + return fields.some(({ key, spec }) => { + const type = spec?.type || 'string'; + if (type === 'password') return Boolean(mod.passwordSet[key]); + if (type === 'boolean' || type === 'switch') return false; + const v = mod.config[key]; + if (type === 'endpoint') return Boolean(v?.host); + if (v === undefined || v === null || String(v) === '') return false; + return !('default' in (spec || {})) || String(v) !== String(spec.default); + }); } function buildModuleCard(mod) { @@ -226,10 +325,88 @@ function buildModuleCard(mod) { const form = document.createElement('div'); form.className = 'module-form'; const readers = new Map(); + const controls = new Map(); // key → boolean/switch input (for showWhen) + const selects = new Map(); // key → select element (optionsDependsOn sources) + const dependents = []; // { field, when } to show/hide by a control's state + const optionDeps = []; // { refetch, on } selects whose options follow another field + const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list) + const collapsibles = []; // { el, meta, fields } groups that fold + // Fields render in schema order; a `group` starts a labeled subsection + // and consecutive same-group fields share it. The manifest's optional + // `configGroups[name]` adds { collapsed, columns, help } for that group. + const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; + let groupName = null; + let container = form; + let currentGroup = null; for (const [key, spec] of schemaKeys) { - const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]); + const g = spec?.group || ''; + if (g !== groupName) { + groupName = g; + currentGroup = null; + if (g) { + const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {}; + const folds = meta.collapsed !== undefined; + const group = document.createElement(folds ? 'details' : 'div'); + group.className = 'field-group'; + const title = document.createElement(folds ? 'summary' : 'div'); + title.className = 'field-group-title'; + title.textContent = g; + group.appendChild(title); + const body = document.createElement('div'); + body.className = 'field-group-body'; + const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0); + if (cols > 1) { + body.classList.add('is-columns'); + body.style.setProperty('--cols', String(cols)); + } + if (meta.help) { + const help = document.createElement('div'); + help.className = 'field-group-help'; + help.textContent = meta.help; + body.appendChild(help); + } + group.appendChild(body); + form.appendChild(group); + container = body; + currentGroup = { el: group, meta, fields: [] }; + if (folds) collapsibles.push(currentGroup); + } else { + container = form; + } + } + const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id); readers.set(key, read); - form.appendChild(field); + if (control) controls.set(key, control); + if (selectEl) selects.set(key, selectEl); + if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn }); + else if (refetch) reloaders.push(() => refetch()); + if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); + if (currentGroup) currentGroup.fields.push({ key, spec }); + container.appendChild(field); + } + // A field with showWhen:"K" is visible only while K's toggle is on. + for (const dep of dependents) { + const ctl = controls.get(dep.when); + if (!ctl) continue; + const sync = () => { dep.field.hidden = !ctl.checked; }; + ctl.addEventListener('change', sync); + sync(); + } + // A select with optionsDependsOn:"K" loads its options with K's current + // value as a query parameter, now and whenever K changes (a plan list + // that follows the chosen service type). + for (const dep of optionDeps) { + const src = selects.get(dep.on) || controls.get(dep.on); + const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) }); + if (src) src.addEventListener('change', run); + reloaders.push(run); + run(); + } + // Collapsible groups: `collapsed: true` starts folded, `false` open, + // "whenSet" folded only once the group already holds values. + for (const grp of collapsibles) { + const c = grp.meta.collapsed; + grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod))); } const actions = document.createElement('div'); actions.className = 'module-actions'; @@ -256,6 +433,10 @@ function buildModuleCard(mod) { status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; status.className = 'admin-status ok'; refreshStatus(); + // What the module can list may have changed (credentials just + // saved → the upstream is reachable now): reload the dynamic selects. + // The re-init is quick but not instant; give it a beat. + setTimeout(() => { for (const reload of reloaders) reload(); }, 800); } catch (e) { status.textContent = e.message; status.className = 'admin-status error'; diff --git a/server.js b/server.js index 98be267..548729d 100644 --- a/server.js +++ b/server.js @@ -485,6 +485,7 @@ function adminModuleView(id) { hasServer: Boolean(man.server), enabled: isEnabled(id), configSchema: man.configSchema || {}, + configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {}, config: values, passwordSet, mountError: entry?.error || '', @@ -505,7 +506,7 @@ function applyConfigPatch(id, patch) { value = current[key] || ''; } if (type === 'number') value = Number(value) || 0; - else if (type === 'boolean') value = Boolean(value); + else if (type === 'boolean' || type === 'switch') value = Boolean(value); else if (type === 'endpoint') { const src = value && typeof value === 'object' ? value : {}; value = {