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:
|
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",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"group": "Section heading", // optional; see below
|
"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.
|
so twice.
|
||||||
`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex
|
`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`).
|
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
|
`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
|
its newlines; one item per line is the convention (the `slack` module's quick
|
||||||
replies). Admin config only.
|
replies). Admin config only.
|
||||||
@@ -485,6 +490,9 @@ module.exports = {
|
|||||||
keep it fast (answer from state you already hold; don't fetch upstream on
|
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,
|
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||||||
so a dead upstream can't break the picker.
|
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`
|
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||||
tile's title, and `moduleApi.variant` carries its `id`.
|
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-host { flex: 1; min-width: 0; }
|
||||||
.endpoint-row .ep-port { width: 92px; flex: none; }
|
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||||
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
.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; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
/* labeled config subsections (manifest field `group`) */
|
/* labeled config subsections (manifest field `group`) */
|
||||||
|
|||||||
@@ -228,6 +228,73 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
read = () => select.value;
|
read = () => select.value;
|
||||||
selectEl = select;
|
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') {
|
} else if (type === 'color') {
|
||||||
// A swatch picker; the value is always a #rrggbb string.
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
field.classList.add('color-field');
|
field.classList.add('color-field');
|
||||||
|
|||||||
+10
-2
@@ -108,9 +108,10 @@ function wireDropdown(btnId, menuId, rebuild) {
|
|||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
function menuItem(label, onClick, { sub = '', danger = false } = {}) {
|
function menuItem(label, onClick, { sub = '', danger = false, disabled = false } = {}) {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'menu-item' + (danger ? ' danger' : '');
|
item.className = 'menu-item' + (danger ? ' danger' : '');
|
||||||
|
item.disabled = disabled;
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
if (sub) {
|
if (sub) {
|
||||||
@@ -706,6 +707,10 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
*/
|
*/
|
||||||
function addTile(moduleId, entry = null) {
|
function addTile(moduleId, entry = null) {
|
||||||
const man = registry.get(moduleId);
|
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 defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||||
const size = {
|
const size = {
|
||||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||||||
@@ -1316,10 +1321,13 @@ function renderAddMenu(menu) {
|
|||||||
title.textContent = man.name;
|
title.textContent = man.name;
|
||||||
menu.appendChild(title);
|
menu.appendChild(title);
|
||||||
for (const entry of entries) {
|
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.appendChild(menuItem(entry.name, () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
addTile(man.id, entry);
|
addTile(man.id, entry);
|
||||||
}, { sub: entry.description || '' }));
|
}, { sub: placed ? 'Already on this dashboard' : (entry.description || ''), disabled: placed }));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
menu.appendChild(menuItem(man.name, () => {
|
menu.appendChild(menuItem(man.name, () => {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ body.menu-hidden #topbar {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
.menu-item[disabled] { opacity: 0.45; cursor: default; }
|
||||||
.menu-item.danger { color: var(--danger); }
|
.menu-item.danger { color: var(--danger); }
|
||||||
.menu-note {
|
.menu-note {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -463,6 +463,8 @@ function sanitizeTileEntry(raw) {
|
|||||||
name: String(raw.name),
|
name: String(raw.name),
|
||||||
description: raw.description ? String(raw.description) : '',
|
description: raw.description ? String(raw.description) : '',
|
||||||
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
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']) {
|
for (const key of ['minSize', 'defaultSize']) {
|
||||||
const size = raw[key];
|
const size = raw[key];
|
||||||
@@ -1055,6 +1057,9 @@ function applyConfigPatch(id, patch) {
|
|||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
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') {
|
else if (type === 'endpoint') {
|
||||||
const src = value && typeof value === 'object' ? value : {};
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
value = {
|
value = {
|
||||||
|
|||||||
Reference in New Issue
Block a user