mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions
PluginOrderList draws one row per enabled plugin and, once drawn, rewrites its hidden inputs (plugin_rotation_order, vegas_plugin_order, vegas_excluded_plugins) from those rows. A disabled plugin has no row, so merely opening the Display or Rotation & Durations tab took it out of the inputs, and the next save of that form stored the lists without it. Exclude Clock from Vegas, disable it, change the brightness, re-enable it: Clock was scrolling in Vegas again and had moved to the end of the rotation. syncInputs now keeps the saved ids that have no row. In the order, each one keeps its saved slot and the rows fill the other slots in their current order, with rows not in the saved order last, as before. In the exclusions they follow the unchecked rows. Only string ids are carried over, once each: /config/main refuses a list holding anything else, which would block every later save of the tab. Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids) and is in run_all.js and the README. The durations DOM suite now reads only its own rows' ids from the input, since a rig's saved order can hold others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ server has none.
|
||||
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
||||
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
||||
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||
| `unit/test_plugin_order_list.js` | no | `widgets/plugin-order-list.js` (the Vegas and rotation order lists): a disabled plugin, which gets no row, keeps its slot in the saved order and its Vegas exclusion when the list rewrites its hidden inputs, around reordering and include/exclude; only string ids are carried over, once each |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
|
||||
@@ -98,7 +98,11 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
|
||||
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
|
||||
const $ = id => doc.getElementById(id);
|
||||
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
|
||||
// The rows' ids, in order. The input also keeps saved ids that have no row
|
||||
// (a disabled plugin's place, see test/js/unit/test_plugin_order_list.js),
|
||||
// and the saved order comes from whatever config the server has.
|
||||
const SHOWN = plugins.filter(p => p.enabled).map(p => p.id);
|
||||
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]').filter(id => SHOWN.includes(id));
|
||||
async function swap() {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = partial;
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
||||
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
||||
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
|
||||
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
|
||||
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||
'unit/test_plugin_order_list.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// The shared plugin order list (widgets/plugin-order-list.js) keeps what it
|
||||
// does not show.
|
||||
//
|
||||
// It lists enabled plugins only, and rewrites its hidden inputs from those
|
||||
// rows as soon as it has drawn them. A disabled plugin's place in the order
|
||||
// and its Vegas exclusion used to vanish from the inputs on that rewrite, so
|
||||
// any later save of the Display or Rotation & Durations tab stored them
|
||||
// without it: re-enabled, the plugin came back at the end of the rotation and
|
||||
// scrolling in Vegas again. Runs the shipped widget in a vm with a minimal
|
||||
// fake DOM -- no jsdom and no server needed, so it runs under
|
||||
// test/test_js_unit_suites.py too.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const WIDGET = path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/plugin-order-list.js');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
class FakeElement {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.className = '';
|
||||
this.value = '';
|
||||
this.checked = false;
|
||||
this.listeners = {};
|
||||
this._text = '';
|
||||
}
|
||||
appendChild(child) {
|
||||
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
insertBefore(child, ref) {
|
||||
if (!ref) return this.appendChild(child);
|
||||
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||
child.parent = this;
|
||||
this.children.splice(this.children.indexOf(ref), 0, child);
|
||||
return child;
|
||||
}
|
||||
get previousElementSibling() {
|
||||
const siblings = this.parent ? this.parent.children : [];
|
||||
return siblings[siblings.indexOf(this) - 1] || null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
const siblings = this.parent ? this.parent.children : [];
|
||||
const i = siblings.indexOf(this);
|
||||
return i < 0 ? null : siblings[i + 1] || null;
|
||||
}
|
||||
set textContent(value) { this._text = value; this.children = []; }
|
||||
get textContent() { return this._text; }
|
||||
setAttribute() {}
|
||||
focus() {}
|
||||
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
||||
fire(type, event) { (this.listeners[type] || []).forEach(fn => fn.call(this, event || {})); }
|
||||
descendants() { return this.children.flatMap(c => [c, ...c.descendants()]); }
|
||||
querySelectorAll(selector) {
|
||||
const cls = selector.replace(/^\./, '');
|
||||
return this.descendants().filter(e => e.className.split(/\s+/).includes(cls));
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
}
|
||||
|
||||
/** Run the widget over `plugins` with the given saved inputs; resolves once it has drawn. */
|
||||
async function mount({ plugins, order, excluded }) {
|
||||
const els = {
|
||||
list: new FakeElement('div'),
|
||||
order: Object.assign(new FakeElement('input'), { value: JSON.stringify(order) }),
|
||||
};
|
||||
if (excluded !== undefined) {
|
||||
els.excluded = Object.assign(new FakeElement('input'), { value: JSON.stringify(excluded) });
|
||||
}
|
||||
const context = {
|
||||
console,
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: (id) => els[id] || null,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createTextNode: (text) => new FakeElement('#text'),
|
||||
},
|
||||
fetch: () => Promise.resolve({
|
||||
json: () => Promise.resolve({ status: 'success', data: { plugins } }),
|
||||
}),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(WIDGET, 'utf8'), context);
|
||||
context.window.PluginOrderList.init({
|
||||
containerId: 'list', orderInputId: 'order',
|
||||
excludedInputId: excluded !== undefined ? 'excluded' : undefined,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const rows = () => els.list.querySelectorAll('.plugin-order-item');
|
||||
return {
|
||||
rows,
|
||||
rowIds: () => rows().map(r => r.dataset.pluginId),
|
||||
order: () => JSON.parse(els.order.value),
|
||||
excluded: () => JSON.parse(els.excluded.value),
|
||||
row: (id) => rows().find(r => r.dataset.pluginId === id),
|
||||
};
|
||||
}
|
||||
|
||||
const PLUGINS = [
|
||||
{ id: 'weather', name: 'Weather', enabled: true },
|
||||
{ id: 'clock', name: 'Clock', enabled: false },
|
||||
{ id: 'stocks', name: 'Stocks', enabled: true },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
console.log('\nVegas: a disabled plugin keeps its place and its exclusion');
|
||||
{
|
||||
const t = await mount({ plugins: PLUGINS, order: ['weather', 'clock', 'stocks'], excluded: ['clock'] });
|
||||
ok('only enabled plugins get a row', same(t.rowIds(), ['weather', 'stocks']), t.rowIds());
|
||||
ok('drawing the list keeps the disabled plugin in the order, in its place',
|
||||
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||
ok('drawing the list keeps its exclusion', same(t.excluded(), ['clock']), t.excluded());
|
||||
|
||||
// Move Stocks up: the rows swap, and Clock stays in its saved slot.
|
||||
const up = t.row('stocks').querySelectorAll('.plugin-order-move')[0];
|
||||
up.fire('click');
|
||||
ok('reordering the rows fills the other slots in the new order',
|
||||
same(t.order(), ['stocks', 'clock', 'weather']), t.order());
|
||||
|
||||
const include = t.row('weather').querySelector('.plugin-order-include');
|
||||
include.checked = false;
|
||||
include.fire('change');
|
||||
ok('unchecking a row adds it, and the disabled exclusion stays',
|
||||
same([...t.excluded()].sort(), ['clock', 'weather']), t.excluded());
|
||||
include.checked = true;
|
||||
include.fire('change');
|
||||
ok('checking it again removes only that one', same(t.excluded(), ['clock']), t.excluded());
|
||||
}
|
||||
|
||||
console.log('\nRotation order: the same, without exclusions');
|
||||
{
|
||||
const plugins = [
|
||||
{ id: 'clock', enabled: true },
|
||||
{ id: 'off', enabled: false },
|
||||
{ id: 'weather', enabled: true },
|
||||
{ id: 'new', enabled: true },
|
||||
];
|
||||
const t = await mount({ plugins, order: ['clock', 'off', 'weather'] });
|
||||
ok('the disabled plugin keeps its slot; a plugin not in the saved order goes last',
|
||||
same(t.order(), ['clock', 'off', 'weather', 'new']), t.order());
|
||||
}
|
||||
|
||||
console.log('\nOnly what the server would accept is carried over');
|
||||
{
|
||||
const t = await mount({ plugins: PLUGINS, order: ['weather', 7, 'clock', null, 'clock', 'stocks'],
|
||||
excluded: ['clock', 3, 'clock'] });
|
||||
// /config/main refuses a list holding anything but strings, which would
|
||||
// block every later Display save; a repeated id is kept once.
|
||||
ok('non-string and repeated saved ids are dropped from the order',
|
||||
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user