Shell: single-instance tile entries, and a multiselect admin field
Two small pieces of contract for modules that offer several tiles from live
data:
- A `tiles()` entry may say `single: true`: at most one such tile per
dashboard. The + picker greys the entry out once it is placed ("Already on
this dashboard") and `addTile` refuses a second — for a control surface
that would only confuse in duplicate, a send box say.
- Admin config gains `multiselect`: several picks from one list, the same
`options` / `optionsRoute` as `select`, one switch per option, stored as an
array of values. A saved value the list no longer offers stays checked and
marked rather than being dropped in silence. The wrapper is a <label>, so
it is told to label nothing — otherwise a click anywhere would flip the
first switch.
Both documented in the module guide.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
babe8263eb
commit
7d34604f2f
@@ -104,7 +104,7 @@ the app); its settings are kept for a later reinstall.
|
||||
Both schemas map field names to specs:
|
||||
|
||||
```
|
||||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
|
||||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "color" | "password" | "endpoint",
|
||||
"label": "Shown next to the field",
|
||||
"default": <value>,
|
||||
"group": "Section heading", // optional; see below
|
||||
@@ -125,6 +125,11 @@ shown or enabled, off means hidden or disabled, and the label shouldn't say
|
||||
so twice.
|
||||
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
||||
default; the tile applies it as a CSS custom property — see `pco-plan`).
|
||||
`multiselect` is several picks from one list — the same `options` /
|
||||
`optionsRoute` as `select`, rendered as one switch per option and stored as
|
||||
an array of values. A saved value the list no longer offers stays checked and
|
||||
marked, so nothing is dropped behind the admin's back (admin config only).
|
||||
|
||||
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||||
its newlines; one item per line is the convention (the `slack` module's quick
|
||||
replies). Admin config only.
|
||||
@@ -485,6 +490,9 @@ module.exports = {
|
||||
keep it fast (answer from state you already hold; don't fetch upstream on
|
||||
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||||
so a dead upstream can't break the picker.
|
||||
- `single: true` on an entry allows at most one such tile per dashboard: the
|
||||
picker greys the entry out once it is placed. Use it for a control surface
|
||||
that would only confuse in duplicate — a send box, a master switch.
|
||||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||
tile's title, and `moduleApi.variant` carries its `id`.
|
||||
|
||||
@@ -156,6 +156,12 @@ body { overflow: auto; }
|
||||
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||
/* several picks from a list (manifest type `multiselect`) */
|
||||
.field.multiselect-field { cursor: default; }
|
||||
.field.multiselect-field .multiselect { display: flex; flex-direction: column; gap: 6px; }
|
||||
.multiselect-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.multiselect-empty { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||
|
||||
/* labeled config subsections (manifest field `group`) */
|
||||
|
||||
@@ -228,6 +228,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||
field.append(span, select);
|
||||
read = () => select.value;
|
||||
selectEl = select;
|
||||
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||
// Several picks from one list — a switch per option, stored as an array
|
||||
// of values. A saved value the list no longer offers stays checked and
|
||||
// marked, so a channel the account has left isn't dropped in silence.
|
||||
field.classList.add('multiselect-field');
|
||||
// The wrapper is a <label>; with several controls inside, a click on the
|
||||
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
|
||||
field.htmlFor = '';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
const list = document.createElement('div');
|
||||
list.className = 'multiselect';
|
||||
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
|
||||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||
const fill = (options) => {
|
||||
for (const input of list.querySelectorAll('input')) {
|
||||
if (input.checked) chosen.add(input.value);
|
||||
else chosen.delete(input.value);
|
||||
}
|
||||
list.innerHTML = '';
|
||||
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
|
||||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||||
for (const opt of all) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'multiselect-row';
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.value = optValue(opt);
|
||||
input.checked = chosen.has(input.value);
|
||||
const track = document.createElement('span');
|
||||
track.className = 'track';
|
||||
toggle.append(input, track);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'switch-label';
|
||||
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||
row.append(toggle, text);
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target === input) return; // the box toggles itself
|
||||
input.checked = !input.checked;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
list.appendChild(row);
|
||||
}
|
||||
if (!all.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'multiselect-empty';
|
||||
none.textContent = 'Nothing to choose from yet.';
|
||||
list.appendChild(none);
|
||||
}
|
||||
};
|
||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||
if (spec.optionsRoute && moduleId) {
|
||||
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();
|
||||
}
|
||||
field.append(span, list);
|
||||
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
} else if (type === 'color') {
|
||||
// A swatch picker; the value is always a #rrggbb string.
|
||||
field.classList.add('color-field');
|
||||
|
||||
+10
-2
@@ -108,9 +108,10 @@ function wireDropdown(btnId, menuId, rebuild) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
function menuItem(label, onClick, { sub = '', danger = false } = {}) {
|
||||
function menuItem(label, onClick, { sub = '', danger = false, disabled = false } = {}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'menu-item' + (danger ? ' danger' : '');
|
||||
item.disabled = disabled;
|
||||
const span = document.createElement('span');
|
||||
span.textContent = label;
|
||||
if (sub) {
|
||||
@@ -706,6 +707,10 @@ function buildDivider(L, R, top, bottom) {
|
||||
*/
|
||||
function addTile(moduleId, entry = null) {
|
||||
const man = registry.get(moduleId);
|
||||
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
|
||||
toast(`${entry.name} is already on this dashboard`);
|
||||
return;
|
||||
}
|
||||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||
const size = {
|
||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||||
@@ -1316,10 +1321,13 @@ function renderAddMenu(menu) {
|
||||
title.textContent = man.name;
|
||||
menu.appendChild(title);
|
||||
for (const entry of entries) {
|
||||
// An entry that allows one tile at most is offered greyed out once
|
||||
// that tile exists — a second Send box would only confuse.
|
||||
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
|
||||
menu.appendChild(menuItem(entry.name, () => {
|
||||
menu.hidden = true;
|
||||
addTile(man.id, entry);
|
||||
}, { sub: entry.description || '' }));
|
||||
}, { sub: placed ? 'Already on this dashboard' : (entry.description || ''), disabled: placed }));
|
||||
}
|
||||
} else {
|
||||
menu.appendChild(menuItem(man.name, () => {
|
||||
|
||||
@@ -170,6 +170,7 @@ body.menu-hidden #topbar {
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
.menu-item[disabled] { opacity: 0.45; cursor: default; }
|
||||
.menu-item.danger { color: var(--danger); }
|
||||
.menu-note {
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -463,6 +463,8 @@ function sanitizeTileEntry(raw) {
|
||||
name: String(raw.name),
|
||||
description: raw.description ? String(raw.description) : '',
|
||||
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
||||
// at most one tile of this entry per dashboard (the picker greys it out)
|
||||
single: raw.single === true,
|
||||
};
|
||||
for (const key of ['minSize', 'defaultSize']) {
|
||||
const size = raw[key];
|
||||
@@ -1055,6 +1057,9 @@ function applyConfigPatch(id, patch) {
|
||||
}
|
||||
if (type === 'number') value = Number(value) || 0;
|
||||
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
||||
else if (type === 'multiselect') {
|
||||
value = (Array.isArray(value) ? value : []).map((v) => String(v)).filter(Boolean);
|
||||
}
|
||||
else if (type === 'endpoint') {
|
||||
const src = value && typeof value === 'object' ? value : {};
|
||||
value = {
|
||||
|
||||
Reference in New Issue
Block a user