mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
feat(plugins): search, filter and sort for Installed Plugins, on a shared ListFilter helper (#540)
* feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper The Installed Plugins grid had no way to narrow it down: no search, no way to see only what's enabled, disabled, or out of date. On a rig with a couple dozen plugins that means scrolling the whole grid to find one. The two sections below it already solved this, twice, independently — the Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines (filter state, apply-filters-and-sort, page renderer, pagination strip, active-filter badge, listener wiring). Rather than add a third copy, this extracts the shared machinery and builds the new toolbar on it. New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create() owns debounced search, filter axes, sort, the active-filter count, Clear, and optional pagination/persistence. Callers keep their own card markup via a `render` callback. Three control types cover every axis the page uses: pills (new), select (store category, starlark author) and cycle (the tri-state All -> Installed -> Not Installed button). Installed Plugins gets a compact toolbar: search box, one-click All / Enabled / Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first, recently updated, category). Filters reset on load, so you never come back to a mysteriously short list. No new CSS — this is the first consumer of the .filter-pill rules already sitting unused in app.css. renderInstalledPlugins() is split so it still publishes canonical state while renderInstalledCards() draws only the visible subset; the filtered list is never assigned to window.installedPlugins, which the toggle handler, isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all read as their source of truth. Toggling a plugin while filtered pins its card so it doesn't vanish from under the cursor. The Store and Starlark migrations are behaviour-preserving: same element ids, same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage), same tri-state button markup, same pagination. Verified by differential tests that run the old and new implementations side by side against identical fixtures and compare every observable after each interaction. The only visible change is the pagination attribute (data-store-page/data-starlark-page -> data-list-page), which nothing outside its own click handler referenced. Net -156 lines in plugins_manager.js while adding a feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(plugins): keep raw search text, and stop the store search refetching Two review findings from CodeRabbit on #540. Do not write the trimmed search value back into the input. setSearch() trimmed before storing, and syncControls() then copied that trimmed value back over what the user had typed. Pausing longer than the debounce after typing a space deleted the space (and reset the caret), making multi-word terms effectively untypable. The raw text is now kept alongside the trimmed one: filtering and activeCount() still use the trimmed value, while the input keeps exactly what was typed. Remove the legacy #plugin-search / #plugin-category listeners in initializePlugins(). They bound searchPluginStore as the event handler, so the DOM event arrived as its `fetchCommitInfo` argument — always truthy, which skipped the cached-filter fast path and refetched /api/v3/plugins/store/list with commit info on every keystroke burst and category change. The store's ListFilter controller already filters the cached list, which is what those two controls should do. This double-binding predates this PR (the old code guarded with _listenerSetup and _storeFilterInit, two different flags, so both sets stayed live); it is fixed here because the refactor owns that wiring now. Both fixes are covered by tests that fail without them: the trailing-space regressions in the installed-plugins DOM suite, and a new whole-file jsdom test that counts fetches while typing (1 request at init, 0 thereafter; previously 1 -> 2 -> 3 -> 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(plugins): build pagination via DOM APIs, drop computed member access Addresses the five Codacy security findings, all in list_filter.js. Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high, "unsafe assignment to innerHTML"). The interpolated values were only page integers and local class constants, so there was no injection path, but concatenating markup into innerHTML is the pattern the scanners flag and createElement is no less clear. Each button now also owns its click listener directly instead of the container being re-queried afterwards, and the strip is cleared with textContent = '' rather than by assigning empty markup. No innerHTML assignment remains in the file. haystack() now walks Object.entries(item) and keeps the configured fields, instead of reading item[field] per field ("generic object injection sink"). Field order no longer drives the haystack order, which is irrelevant to the substring test. matches() iterates controls with for...of instead of an index ("variable assigned to object injection sink"). The rendered pagination is unchanged: same buttons, labels, page numbers, disabled states and classes. The old-vs-new differential tests now compare pagination structurally (tag, text, page, disabled, sorted class list) rather than as an HTML string, since building nodes legitimately serialises differently — «/» as characters rather than «/», disabled="" rather than a bare attribute. That comparison is stronger than the string one it replaces, and the real-DOM suite still drives the actual page buttons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(plugins): keep configured field order when building the search haystack The previous commit swapped item[field] for Object.entries(item) to clear a static-analysis object-injection warning, and in doing so changed the order of the haystack: entries follow the object's own key insertion order, not the configured `fields` order. Since the values are concatenated, that order decides which values end up adjacent, so a multi-word query spanning a field boundary matched differently. For store fields [name, description, author, id, ...] and API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched before and stopped matching after. That contradicted the behaviour-preservation claim for the store and starlark migrations, and the differential tests missed it because every fixture query was a single word. Values now come out of a Map built from Object.entries, iterated in `fields` order: the original haystack is restored, and there is still no computed member access for the analyser to flag. Regression coverage for the ordering itself, at both levels: - unit: phrases spanning name->id and category->tags, plus the reverse (object-key) order asserted NOT to match - differential: the same class of query compared old-vs-new, with a guard that the phrase actually matches something so a mutual zero-result cannot pass vacuously Verified both fail without this fix (3 unit, 2 differential) and pass with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * test(web): add JS suites for ListFilter and the plugin-manager grids No JS toolchain exists in this repo, so these are plain node scripts with no framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js` runs everything, skipping the DOM suites (rather than failing) when jsdom is absent or nothing is listening, so it stays useful in a bare checkout. unit/test_list_filter.js ListFilter search/filter/sort/count/sticky, driven through the installed-plugins config eval'd verbatim out of plugins_manager.js so the test cannot drift from the real configuration unit/test_render_cards.js renderInstalledCards markup, both empty states, and escaping of hostile plugin metadata dom/test_installed_dom.js the toolbar in a real DOM, including the HTMX partial re-swap and a getComputedStyle check that .filter-pill[data-active] matches what we emit dom/test_store_dom.js store pagination, per-page, category, tri-state Installed button, persistence across a re-boot dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts requests, so a keystroke cannot refetch the store The DOM suites deliberately fetch the partial and the plugin data from a running web interface instead of using fixtures, so a renamed element id or a changed payload shape fails them loudly. Point them at a rig with a full plugin set when it matters (BASE=http://host:5000); a dev box with two plugins installed passes while exercising very little. Several assertions exist to stop specific bugs recurring: trailing spaces surviving the search debounce, a query spanning two adjacent search fields (haystack field order is load-bearing), and window.installedPlugins staying at full length while the grid is filtered. Others guard against passing vacuously — counting only non-skeleton cards, and checking a search phrase matches something before comparing two result sets. The old-vs-new differential suites that verified the store and starlark migrations are not included: they compared against the pre-refactor code, which now exists only in git history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,3 +49,7 @@ config/backups/
|
||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||
/starlark-apps/
|
||||
skin_renders/
|
||||
|
||||
# JS test deps (test/js)
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Web-interface JS tests
|
||||
|
||||
Covers `web_interface/static/v3/js/plugins/list_filter.js` (the shared
|
||||
search/filter/sort controller) and the plugin-manager grids that use it:
|
||||
Installed Plugins, the Plugin Store, and Starlark Apps.
|
||||
|
||||
There is no JS toolchain in this repo, so these are plain node scripts with no
|
||||
test framework. Each prints `ok`/`FAIL` lines and exits non-zero on failure.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd test/js
|
||||
npm install # jsdom, for the DOM suites only
|
||||
node run_all.js
|
||||
```
|
||||
|
||||
The unit suites need nothing but node. The DOM suites additionally need a
|
||||
running web interface, because they test against the **real** server-rendered
|
||||
HTML and the **real** API rather than fixtures:
|
||||
|
||||
```bash
|
||||
# in another shell, from the repo root
|
||||
EMULATOR=true python3 web_interface/app.py # http://localhost:5000
|
||||
|
||||
# or point the suites at a device
|
||||
BASE=http://10.0.10.169:5000 node run_all.js
|
||||
```
|
||||
|
||||
`run_all.js` skips the DOM suites (rather than failing) when jsdom is missing or
|
||||
nothing is listening, so it stays useful in a bare checkout.
|
||||
|
||||
## The suites
|
||||
|
||||
| Suite | Needs a server | Covers |
|
||||
|---|---|---|
|
||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||
| `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 |
|
||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||
|
||||
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
||||
with two plugins installed will pass while exercising very little.
|
||||
|
||||
## Notes for whoever changes this next
|
||||
|
||||
- The suites read the shipped files off disk and, for the DOM ones, the partial
|
||||
from the running server. They do not keep their own copy of the markup, so
|
||||
renaming an element id will fail them loudly rather than silently pass.
|
||||
- `unit/test_list_filter.js` `eval`s a slice of `plugins_manager.js` located by
|
||||
the text `function installedSortName(plugin)`. If that function is renamed,
|
||||
fix the slice markers rather than pasting a copy of the config into the test.
|
||||
- A few assertions exist specifically to stop earlier bugs coming back:
|
||||
trailing spaces surviving the search debounce; a multi-word query that spans
|
||||
two adjacent search fields (field order in the haystack is load-bearing);
|
||||
`window.installedPlugins` staying at full length while the grid is filtered.
|
||||
- Watch for assertions that can pass vacuously. Several here deliberately guard
|
||||
against it — e.g. counting only non-skeleton cards, and asserting a search
|
||||
phrase matches something before comparing two results.
|
||||
|
||||
The old-vs-new differential suites used to verify that the store and Starlark
|
||||
migrations were behaviour-preserving are not included: they compared against the
|
||||
pre-refactor implementation, which now only exists in git history. See PR #540
|
||||
if that comparison ever needs redoing.
|
||||
@@ -0,0 +1,279 @@
|
||||
// Integration test in a REAL DOM (jsdom):
|
||||
// - HTML comes from the running server's /partials/plugins (real template output)
|
||||
// - list_filter.js is loaded as a real script
|
||||
// - plugin data comes from the running server's real API
|
||||
// - interactions are real dispatched DOM events on the real pill/select nodes
|
||||
// This exercises HTML parsing, attribute reflection, event bubbling and
|
||||
// delegation for real — none of which the hand-rolled shim could vouch for.
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
|
||||
function get(path) {
|
||||
return new Promise((res, rej) => {
|
||||
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); })
|
||||
.on('error', rej);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/plugins');
|
||||
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
|
||||
|
||||
// Collected so an uncaught error inside a handler fails the run instead of
|
||||
// silently vanishing.
|
||||
const jsErrors = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
|
||||
vc.on('error', (...a) => jsErrors.push('console.error: ' + a.join(' ')));
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
|
||||
|
||||
const { window } = dom;
|
||||
const { document } = window;
|
||||
|
||||
// Minimal ambient globals the extracted block expects from plugins_manager.js.
|
||||
window.pluginLog = () => {};
|
||||
window.debugLog = () => {};
|
||||
window.PLUGIN_DEBUG = false;
|
||||
window.installedPlugins = installed;
|
||||
window.escapeHtml = function (text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
// Load the helper as a real <script>.
|
||||
const s = document.createElement('script');
|
||||
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
|
||||
document.body.appendChild(s);
|
||||
|
||||
if (!window.ListFilter) { console.log('FAIL: list_filter.js did not expose window.ListFilter'); process.exit(1); }
|
||||
|
||||
// Pull the installed-plugins wiring out of plugins_manager.js verbatim and run
|
||||
// it as a real script in this document.
|
||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
||||
const a = src.indexOf('function installedSortName(plugin)');
|
||||
const b = src.indexOf('// Set up event delegation for plugin action buttons');
|
||||
const c = src.indexOf('function handlePluginAction(event)');
|
||||
const d = src.indexOf('function findInstalledPlugin(pluginId)');
|
||||
if ([a, b, c, d].some(i => i < 0)) { console.log('FAIL: could not slice plugins_manager.js'); process.exit(1); }
|
||||
|
||||
const s2 = document.createElement('script');
|
||||
s2.textContent = `
|
||||
var installedPlugins = window.installedPlugins;
|
||||
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog;
|
||||
var PLUGIN_DEBUG = false;
|
||||
${src.slice(a, b)}
|
||||
${src.slice(b, c)}
|
||||
${src.slice(c, d)}
|
||||
window.__t = { getInstalledFilter, renderInstalledPlugins, setupInstalledFilterListeners,
|
||||
applyInstalledFiltersAndRender };
|
||||
`;
|
||||
document.body.appendChild(s2);
|
||||
if (jsErrors.length) { console.log('FAIL: errors while loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
|
||||
|
||||
const T = window.__t;
|
||||
const $ = id => document.getElementById(id);
|
||||
const grid = () => $('installed-plugins-grid');
|
||||
const cards = () => grid().querySelectorAll('.plugin-card:not(.installed-skeleton)').length;
|
||||
const pill = v => document.querySelector(`#installed-filter-pills [data-installed-filter="${v}"]`);
|
||||
const litPills = () => [...document.querySelectorAll('#installed-filter-pills [data-installed-filter]')]
|
||||
.filter(b => b.getAttribute('data-active') === 'true')
|
||||
.map(b => b.getAttribute('data-installed-filter'));
|
||||
const countText = () => $('installed-count').textContent.trim();
|
||||
|
||||
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
|
||||
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, cond, extra) => cond ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')));
|
||||
|
||||
console.log('\n── real-DOM integration (jsdom) ──');
|
||||
console.log(` server gave ${installed.length} installed plugins: ` +
|
||||
installed.map(p => `${p.id}(en=${p.enabled},upd=${!!p.update_available})`).join(' '));
|
||||
|
||||
// ── the markup the server actually rendered ────────────────────────────
|
||||
ok('toolbar present in server HTML', !!$('installed-filter-bar'));
|
||||
ok('4 pills parsed from server HTML',
|
||||
document.querySelectorAll('#installed-filter-pills [data-installed-filter]').length === 4);
|
||||
ok('sort dropdown has the 5 agreed options',
|
||||
[...$('installed-sort').options].map(o => o.value).join(',') === 'a-z,z-a,status,recent,category',
|
||||
[...$('installed-sort').options].map(o => o.value));
|
||||
ok('skeletons present before first render', grid().querySelectorAll('.installed-skeleton').length === 3);
|
||||
|
||||
// ── first render ───────────────────────────────────────────────────────
|
||||
T.setupInstalledFilterListeners();
|
||||
T.renderInstalledPlugins(installed);
|
||||
ok('skeletons removed after render', grid().querySelectorAll('.installed-skeleton').length === 0);
|
||||
ok('one card per plugin', cards() === installed.length, cards());
|
||||
ok('count text unfiltered', countText() === `${installed.length} installed`, countText());
|
||||
ok('only the All pill is lit', litPills().join(',') === 'all', litPills());
|
||||
ok('aria-pressed mirrors data-active', pill('all').getAttribute('aria-pressed') === 'true');
|
||||
|
||||
const enabledCount = installed.filter(p => p.enabled).length;
|
||||
const disabledCount = installed.length - enabledCount;
|
||||
const updCount = installed.filter(p => p.update_available).length;
|
||||
|
||||
// ── the CSS hook this feature depends on ───────────────────────────────
|
||||
// .filter-pill[data-active="true"] had never been used by any code before
|
||||
// this change, so confirm the attribute really flips on the real nodes.
|
||||
click(pill('enabled'));
|
||||
ok('Enabled pill lights up', litPills().join(',') === 'enabled', litPills());
|
||||
ok('Enabled filters the grid', cards() === enabledCount, { cards: cards(), expect: enabledCount });
|
||||
ok('count switches to "N of M shown"', countText() === `${enabledCount} of ${installed.length} shown`, countText());
|
||||
ok('Clear button revealed', !$('installed-clear-filters').classList.contains('hidden'));
|
||||
|
||||
click(pill('disabled'));
|
||||
ok('Disabled pill lights, Enabled unlights', litPills().join(',') === 'disabled', litPills());
|
||||
ok('Disabled filters the grid', cards() === disabledCount, { cards: cards(), expect: disabledCount });
|
||||
|
||||
click(pill('updates'));
|
||||
ok('Updates filters the grid', cards() === updCount, { cards: cards(), expect: updCount });
|
||||
ok('updates badge count matches', $('installed-updates-count').textContent === String(updCount),
|
||||
$('installed-updates-count').textContent);
|
||||
ok('badge visible when >0', updCount === 0 || !$('installed-updates-count').classList.contains('hidden'));
|
||||
|
||||
click(pill('all'));
|
||||
ok('back to All shows everything', cards() === installed.length);
|
||||
ok('Clear button hidden again', $('installed-clear-filters').classList.contains('hidden'));
|
||||
|
||||
// ── debounced search, through the real event path ──────────────────────
|
||||
const first = installed[0];
|
||||
type($('installed-search'), first.name.slice(0, 4));
|
||||
await tick(350);
|
||||
ok('search narrows to a match', cards() >= 1 && cards() <= installed.length, cards());
|
||||
ok('search clear ✕ revealed', !$('installed-search-clear').classList.contains('hidden'));
|
||||
|
||||
type($('installed-search'), 'zzz-no-such-plugin');
|
||||
await tick(350);
|
||||
ok('no matches → zero cards', cards() === 0);
|
||||
ok('shows the filter empty state', /No plugins match your filters/.test(grid().textContent));
|
||||
ok('does NOT say "No plugins installed"', !/No plugins installed/.test(grid().textContent));
|
||||
|
||||
// Clear button inside the empty state goes through the delegated handler.
|
||||
const emptyClear = grid().querySelector('[data-action="clear-installed-filters"]');
|
||||
ok('empty state offers a Clear button', !!emptyClear);
|
||||
if (emptyClear) {
|
||||
click(emptyClear);
|
||||
ok('empty-state Clear restores the grid', cards() === installed.length, cards());
|
||||
ok('empty-state Clear empties the search box', $('installed-search').value === '');
|
||||
}
|
||||
|
||||
// ── regression: typing a space must survive the debounce ───────────────
|
||||
// setSearch() trims for matching; if the trimmed value were written back into
|
||||
// the input, pausing mid-phrase would delete the space you just typed and
|
||||
// multi-word terms would be untypable.
|
||||
const si = $('installed-search');
|
||||
type(si, 'web ');
|
||||
await tick(350);
|
||||
ok('trailing space survives the debounce', si.value === 'web ', JSON.stringify(si.value));
|
||||
type(si, 'web u');
|
||||
await tick(350);
|
||||
ok('can keep typing past the space', si.value === 'web u', JSON.stringify(si.value));
|
||||
type(si, ' ');
|
||||
await tick(350);
|
||||
ok('whitespace-only is not treated as an active filter', cards() === installed.length, cards());
|
||||
ok('whitespace-only text is still left in the box', si.value === ' ', JSON.stringify(si.value));
|
||||
type(si, '');
|
||||
await tick(350);
|
||||
|
||||
// ── sorts, via the real select ─────────────────────────────────────────
|
||||
const names = () => [...grid().querySelectorAll('.plugin-card h4')].map(h => h.textContent.trim());
|
||||
change($('installed-sort'), 'a-z');
|
||||
const az = names();
|
||||
change($('installed-sort'), 'z-a');
|
||||
ok('z-a is the reverse of a-z', names().join('|') === [...az].reverse().join('|'),
|
||||
{ az, za: names() });
|
||||
for (const s of ['status', 'recent', 'category']) {
|
||||
change($('installed-sort'), s);
|
||||
ok(`sort "${s}" keeps every card`, cards() === installed.length, cards());
|
||||
}
|
||||
|
||||
// ── Clear Filters resets all three axes at once ────────────────────────
|
||||
type($('installed-search'), first.name.slice(0, 3));
|
||||
await tick(350);
|
||||
click(pill('enabled'));
|
||||
change($('installed-sort'), 'z-a');
|
||||
click($('installed-clear-filters'));
|
||||
ok('Clear resets search box', $('installed-search').value === '');
|
||||
ok('Clear resets sort select', $('installed-sort').value === 'a-z');
|
||||
ok('Clear relights All', litPills().join(',') === 'all', litPills());
|
||||
ok('Clear restores all cards', cards() === installed.length, cards());
|
||||
ok('Clear hides itself', $('installed-clear-filters').classList.contains('hidden'));
|
||||
|
||||
// ── the state-leak guard, on a real DOM ────────────────────────────────
|
||||
click(pill('updates'));
|
||||
ok('grid is filtered', cards() === updCount);
|
||||
ok('window.installedPlugins still full length', window.installedPlugins.length === installed.length,
|
||||
window.installedPlugins.length);
|
||||
click(pill('all'));
|
||||
|
||||
// ── HTMX partial re-swap: the path no unit test could reach ────────────
|
||||
// Replace the section's markup wholesale (what hx-swap does when you leave
|
||||
// and re-enter the tab), then re-run init exactly as initPluginsPage would.
|
||||
click(pill('enabled'));
|
||||
type($('installed-search'), first.name.slice(0, 3));
|
||||
await tick(350);
|
||||
type($('installed-search'), $('installed-search').value + ' '); // trailing space, on purpose
|
||||
await tick(350);
|
||||
const beforeSwap = { search: $('installed-search').value, pills: litPills().join(','), cards: cards() };
|
||||
ok('pre-swap value retains its trailing space', /\s$/.test(beforeSwap.search), JSON.stringify(beforeSwap.search));
|
||||
|
||||
document.getElementById('app').innerHTML = partial; // fresh toolbar at defaults
|
||||
ok('post-swap DOM starts at defaults',
|
||||
$('installed-search').value === '' && litPills().join(',') === 'all');
|
||||
|
||||
T.setupInstalledFilterListeners(); // what initPluginsPage() calls
|
||||
T.renderInstalledPlugins(window.installedPlugins);
|
||||
ok('post-swap: search text restored', $('installed-search').value === beforeSwap.search,
|
||||
{ got: $('installed-search').value, want: beforeSwap.search });
|
||||
ok('post-swap: pill state restored', litPills().join(',') === beforeSwap.pills, litPills());
|
||||
// Search AND pill are both still active, so the restored view must match the
|
||||
// pre-swap view exactly — comparing to it avoids restating the filter logic.
|
||||
ok('post-swap: same view restored', cards() === beforeSwap.cards,
|
||||
{ got: cards(), want: beforeSwap.cards });
|
||||
|
||||
// and the freshly swapped controls must still be live
|
||||
type($('installed-search'), '');
|
||||
await tick(350);
|
||||
ok('post-swap: clearing search re-widens to the pill alone', cards() === enabledCount, cards());
|
||||
click(pill('all'));
|
||||
ok('post-swap: pills still clickable', cards() === installed.length, cards());
|
||||
click(pill('disabled'));
|
||||
ok('post-swap: pill filters again', cards() === disabledCount, cards());
|
||||
click(pill('all'));
|
||||
type($('installed-search'), 'zzz-none');
|
||||
await tick(350);
|
||||
ok('post-swap: search still live', cards() === 0);
|
||||
click(grid().querySelector('[data-action="clear-installed-filters"]'));
|
||||
ok('post-swap: delegated Clear still wired', cards() === installed.length);
|
||||
|
||||
// ── does the stylesheet actually hook the markup? ──────────────────────
|
||||
const css = fs.readFileSync(V3 + '/app.css', 'utf8');
|
||||
const st = document.createElement('style');
|
||||
st.textContent = css;
|
||||
document.head.appendChild(st);
|
||||
click(pill('enabled'));
|
||||
const activeWeight = window.getComputedStyle(pill('enabled')).fontWeight;
|
||||
const idleWeight = window.getComputedStyle(pill('disabled')).fontWeight;
|
||||
ok('.filter-pill[data-active="true"] matches our markup (font-weight 600)',
|
||||
activeWeight === '600', { active: activeWeight, idle: idleWeight });
|
||||
ok('inactive pill does not pick up the active rule', idleWeight !== '600', idleWeight);
|
||||
click(pill('all'));
|
||||
|
||||
ok('no uncaught JS errors anywhere', jsErrors.length === 0, jsErrors.slice(0, 4));
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||
@@ -0,0 +1,132 @@
|
||||
// Proves CodeRabbit's second finding is fixed: typing in the store search or
|
||||
// changing the category must filter the CACHED list, not refetch
|
||||
// /api/v3/plugins/store/list.
|
||||
//
|
||||
// This loads the WHOLE of plugins_manager.js into jsdom (not a slice), so the
|
||||
// legacy initializePlugins() wiring is present if it still exists, and counts
|
||||
// every fetch the page makes.
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/plugins');
|
||||
const store = JSON.parse(await get('/api/v3/plugins/store/list'));
|
||||
const installed = JSON.parse(await get('/api/v3/plugins/installed'));
|
||||
|
||||
const loadErrors = [];
|
||||
const vc = new VirtualConsole(); // quiet, but keep real errors
|
||||
vc.on('jsdomError', e => loadErrors.push(String(e.message || e).split('\n')[0]));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/', pretendToBeVisual: true });
|
||||
const { window } = dom;
|
||||
|
||||
// Record every request the page attempts; serve from the payloads above.
|
||||
const calls = [];
|
||||
window.fetch = (url, opts) => {
|
||||
const u = String(url);
|
||||
calls.push(u);
|
||||
let body = { status: 'success', data: {} };
|
||||
if (u.includes('/plugins/store/list')) body = store;
|
||||
else if (u.includes('/plugins/installed')) body = installed;
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
};
|
||||
window.EventSource = function () { return { addEventListener() {}, close() {} }; };
|
||||
window.confirm = () => false;
|
||||
window.alert = () => {};
|
||||
window.scrollTo = () => {};
|
||||
|
||||
// Globals plugins_manager.js expects from app.js (it declares /* global debugLog */).
|
||||
const s0 = window.document.createElement('script');
|
||||
s0.textContent = `
|
||||
window.debugLog = function () {};
|
||||
window.showNotification = function () {};
|
||||
window.showError = function () {};
|
||||
window.updateSystemStatus = function () {};
|
||||
window.Alpine = undefined;
|
||||
window.htmx = { process: function () {}, ajax: function () {} };
|
||||
`;
|
||||
window.document.body.appendChild(s0);
|
||||
|
||||
const s = window.document.createElement('script');
|
||||
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
|
||||
window.document.body.appendChild(s);
|
||||
|
||||
const s2 = window.document.createElement('script');
|
||||
s2.textContent = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
||||
window.document.body.appendChild(s2);
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
console.log('\n── store search must not refetch (CodeRabbit #2) ──');
|
||||
if (loadErrors.length) console.log(' load errors: ' + loadErrors.slice(0, 3).join(' | '));
|
||||
ok('whole plugins_manager.js evaluated', typeof window.initPluginsPage === 'function');
|
||||
|
||||
if (typeof window.initPluginsPage === 'function') window.initPluginsPage();
|
||||
await tick(600);
|
||||
|
||||
const storeListCalls = () => calls.filter(u => u.includes('/plugins/store/list')).length;
|
||||
const afterInit = storeListCalls();
|
||||
console.log(` init issued ${afterInit} store/list request(s) — that part is expected`);
|
||||
ok('init loaded the store at least once', afterInit >= 1, calls);
|
||||
|
||||
const search = window.document.getElementById('plugin-search');
|
||||
const category = window.document.getElementById('plugin-category');
|
||||
ok('store search input present', !!search);
|
||||
|
||||
// Type a realistic burst, then wait past both debounce windows (300 ms each).
|
||||
const before = storeListCalls();
|
||||
for (const v of ['w', 'we', 'wea', 'weat', 'weath', 'weathe', 'weather']) {
|
||||
search.value = v;
|
||||
search.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
await tick(40);
|
||||
}
|
||||
await tick(700);
|
||||
ok('typing 7 characters issued ZERO new store/list fetches',
|
||||
storeListCalls() === before, { before, after: storeListCalls(), calls: calls.slice(-4) });
|
||||
|
||||
const beforeCat = storeListCalls();
|
||||
if (category) {
|
||||
category.value = 'sports';
|
||||
category.dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||
await tick(700);
|
||||
ok('changing category issued ZERO new store/list fetches',
|
||||
storeListCalls() === beforeCat, { before: beforeCat, after: storeListCalls() });
|
||||
}
|
||||
|
||||
// ...and the filtering still actually happened.
|
||||
// Clear both axes before counting: 'weather' AND category=sports legitimately
|
||||
// matches nothing, so counting here would measure the filter, not the render.
|
||||
const beforeClear = storeListCalls();
|
||||
search.value = '';
|
||||
search.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
if (category) { category.value = ''; category.dispatchEvent(new window.Event('change', { bubbles: true })); }
|
||||
await tick(700);
|
||||
ok('clearing the filters also issued ZERO fetches', storeListCalls() === beforeClear,
|
||||
{ before: beforeClear, after: storeListCalls() });
|
||||
|
||||
// Count only real cards: the partial ships loading skeletons that also carry
|
||||
// .plugin-card, which would let this pass on a dead page.
|
||||
const grid = window.document.getElementById('plugin-store-grid');
|
||||
const real = grid.querySelectorAll('.plugin-card:not(.animate-pulse)').length;
|
||||
ok('the grid rendered real cards from the cache', real > 0, { real, html: grid.innerHTML.slice(0, 160) });
|
||||
|
||||
ok('no leftover _listenerSetup flag on the search input',
|
||||
search && search._listenerSetup === undefined, search && search._listenerSetup);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 6).join('\n')); process.exit(1); });
|
||||
@@ -0,0 +1,221 @@
|
||||
// Real-DOM (jsdom) test of the migrated Plugin Store toolbar, against the
|
||||
// server's actual 48-plugin registry — enough data for 4 pages, so pagination,
|
||||
// the ellipsis strip and per-page changes are exercised for real.
|
||||
// The card renderer itself is stubbed: Step 2 did not touch it, and stubbing
|
||||
// keeps the assertions on the parts that did change.
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const get = path => new Promise((res, rej) =>
|
||||
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
|
||||
function slice(src, a, b) {
|
||||
const i = src.indexOf(a), j = src.indexOf(b, i + 1);
|
||||
if (i < 0 || j < 0) throw new Error('cannot slice: ' + a);
|
||||
return src.slice(i, j);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/plugins');
|
||||
const storeJson = JSON.parse(await get('/api/v3/plugins/store/list'));
|
||||
const storePlugins = (storeJson.data && storeJson.data.plugins) || storeJson.plugins || [];
|
||||
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
|
||||
|
||||
const jsErrors = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
|
||||
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
|
||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
|
||||
const { window } = dom;
|
||||
const { document } = window;
|
||||
|
||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
||||
|
||||
function boot() {
|
||||
const s = document.createElement('script');
|
||||
// IIFE-wrapped so a second boot gets a fresh closure instead of clashing on
|
||||
// the top-level `const ListFilter`; it still publishes via window.ListFilter.
|
||||
s.textContent = '(function(){\n' + fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8') + '\n})();';
|
||||
document.body.appendChild(s);
|
||||
|
||||
const s2 = document.createElement('script');
|
||||
s2.textContent = `(function(){
|
||||
${slice(src, 'const safeLocalStorage = {', '\n// ')}
|
||||
var installedPlugins = ${JSON.stringify(installed)};
|
||||
window.installedPlugins = installedPlugins;
|
||||
var pluginStoreCache = ${JSON.stringify(storePlugins)};
|
||||
window.__renderCalls = 0;
|
||||
// Stand-in for renderPluginStore: real DOM nodes, minimal markup.
|
||||
function renderPluginStore(plugins) {
|
||||
window.__renderCalls++;
|
||||
const grid = document.getElementById('plugin-store-grid');
|
||||
grid.innerHTML = (plugins || []).map(p =>
|
||||
'<div class="plugin-card" data-id="' + p.id + '"></div>').join('');
|
||||
}
|
||||
${slice(src, 'function isStorePluginInstalled(', '// Expose searchPluginStore')}
|
||||
window.__t = { applyStoreFiltersAndSort, setupStoreFilterListeners, getStoreFilter };
|
||||
})();`;
|
||||
document.body.appendChild(s2);
|
||||
}
|
||||
boot();
|
||||
if (jsErrors.length) { console.log('FAIL loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
|
||||
|
||||
const T = window.__t;
|
||||
const $ = id => document.getElementById(id);
|
||||
const cards = () => $('plugin-store-grid').querySelectorAll('.plugin-card').length;
|
||||
const ids = () => [...$('plugin-store-grid').querySelectorAll('.plugin-card')].map(c => c.dataset.id);
|
||||
const info = () => $('store-results-info').textContent.trim();
|
||||
const infoBot = () => $('store-results-info-bottom') ? $('store-results-info-bottom').textContent.trim() : null;
|
||||
const pagTop = () => $('store-pagination-top').innerHTML;
|
||||
const pageBtns = () => [...$('store-pagination-top').querySelectorAll('[data-list-page]')];
|
||||
const clickPage = label => {
|
||||
const b = pageBtns().find(x => x.textContent.trim() === String(label));
|
||||
if (!b) throw new Error('no page button labelled ' + label);
|
||||
b.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
};
|
||||
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
|
||||
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 240) : '')));
|
||||
|
||||
console.log(`\n── store, real DOM, ${storePlugins.length} plugins from the live registry ──`);
|
||||
|
||||
T.setupStoreFilterListeners();
|
||||
T.applyStoreFiltersAndSort();
|
||||
|
||||
const N = storePlugins.length;
|
||||
ok('page 1 holds 12 cards', cards() === 12, cards());
|
||||
ok('results info counts all ' + N, info() === `Showing 1–12 of ${N} plugins`, info());
|
||||
ok('bottom info matches top', infoBot() === info(), { top: info(), bottom: infoBot() });
|
||||
ok('pagination rendered', pageBtns().length > 0);
|
||||
ok('per-page control shows 12', $('store-per-page').value === '12');
|
||||
|
||||
const totalPages = Math.ceil(N / 12);
|
||||
ok(`last page button is ${totalPages}`,
|
||||
pageBtns().some(b => b.textContent.trim() === String(totalPages)), pagTop().slice(0, 200));
|
||||
ok('prev is disabled on page 1', pageBtns()[0].hasAttribute('disabled'));
|
||||
|
||||
const p1 = ids();
|
||||
clickPage(2);
|
||||
ok('page 2 renders 12 different cards', cards() === 12 && ids().join() !== p1.join());
|
||||
ok('page 2 info', info() === `Showing 13–24 of ${N} plugins`, info());
|
||||
clickPage(totalPages);
|
||||
ok('last page info ends at ' + N, new RegExp(`–${N} of ${N} plugins$`).test(info()), info());
|
||||
ok('next is disabled on the last page',
|
||||
pageBtns()[pageBtns().length - 1].hasAttribute('disabled'));
|
||||
ok('ellipsis appears for a far page', /…|…/.test(pagTop()), pagTop().slice(0, 120));
|
||||
clickPage(1);
|
||||
ok('back on page 1 shows the original slice', ids().join() === p1.join());
|
||||
|
||||
// ── per-page ───────────────────────────────────────────────────────────
|
||||
change($('store-per-page'), '48');
|
||||
ok('per-page 48 shows all in one page', cards() === Math.min(48, N), cards());
|
||||
ok('pagination hidden when one page', pagTop().trim() === '' || pageBtns().length === 0, pagTop().slice(0, 80));
|
||||
ok('per-page persisted', window.localStorage.getItem('storePerPage') === '48');
|
||||
change($('store-per-page'), '12');
|
||||
ok('back to 12 restores pagination', cards() === 12 && pageBtns().length > 0);
|
||||
|
||||
// ── sorts ──────────────────────────────────────────────────────────────
|
||||
const firstOf = () => ids()[0];
|
||||
change($('store-sort'), 'a-z');
|
||||
const azFirst = firstOf();
|
||||
change($('store-sort'), 'z-a');
|
||||
ok('z-a changes the leading card', firstOf() !== azFirst, { az: azFirst, za: firstOf() });
|
||||
ok('sort persisted to localStorage', window.localStorage.getItem('storeSort') === 'z-a');
|
||||
for (const s of ['category', 'author', 'newest']) {
|
||||
change($('store-sort'), s);
|
||||
ok(`sort "${s}" still fills a page`, cards() === 12, cards());
|
||||
}
|
||||
change($('store-sort'), 'a-z');
|
||||
|
||||
// ── category ───────────────────────────────────────────────────────────
|
||||
const cat = [...$('plugin-category').options].map(o => o.value).find(v => v && storePlugins.some(p => (p.category || '').toLowerCase() === v.toLowerCase()));
|
||||
if (cat) {
|
||||
change($('plugin-category'), cat);
|
||||
const expect = storePlugins.filter(p => (p.category || '').toLowerCase() === cat.toLowerCase()).length;
|
||||
ok(`category "${cat}" filters to ${expect}`, cards() === Math.min(expect, 12), { cards: cards(), expect });
|
||||
ok('badge reports active filters', /filter/.test($('store-active-filters').textContent),
|
||||
$('store-active-filters').textContent);
|
||||
change($('plugin-category'), '');
|
||||
ok('category cleared restores', cards() === 12);
|
||||
} else { console.log(' -- no usable category option in the template, skipped'); }
|
||||
|
||||
// ── tri-state installed button ─────────────────────────────────────────
|
||||
const instBtn = $('store-filter-installed');
|
||||
ok('starts as All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'), instBtn.innerHTML);
|
||||
click(instBtn);
|
||||
ok('→ Installed, green', /Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-green-50')
|
||||
&& !instBtn.classList.contains('bg-white'), instBtn.innerHTML);
|
||||
const instCount = storePlugins.filter(p => installed.some(i => i.id === p.id ||
|
||||
(p.plugin_path && i.id === p.plugin_path.split('/').pop()))).length;
|
||||
ok('Installed count matches the installed set', cards() === Math.min(instCount, 12), { cards: cards(), expect: instCount });
|
||||
click(instBtn);
|
||||
ok('→ Not Installed, red', /Not Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-red-50'), instBtn.innerHTML);
|
||||
ok('Not Installed count is the complement', cards() === Math.min(N - instCount, 12), { cards: cards(), expect: N - instCount });
|
||||
click(instBtn);
|
||||
ok('→ back to All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'));
|
||||
|
||||
// ── search ─────────────────────────────────────────────────────────────
|
||||
const term = (storePlugins[0].name || storePlugins[0].id).slice(0, 4);
|
||||
type($('plugin-search'), term);
|
||||
await tick(400);
|
||||
ok(`search "${term}" narrows the set`, cards() > 0 && cards() <= 12, cards());
|
||||
type($('plugin-search'), 'zzz-definitely-nothing');
|
||||
await tick(400);
|
||||
ok('no matches → 0 cards', cards() === 0);
|
||||
ok('no-match info text', info() === 'No plugins match your filters', info());
|
||||
type($('plugin-search'), '');
|
||||
await tick(400);
|
||||
ok('search cleared restores', cards() === 12);
|
||||
|
||||
// ── clear filters ──────────────────────────────────────────────────────
|
||||
change($('store-per-page'), '24');
|
||||
change($('store-sort'), 'z-a');
|
||||
if (cat) change($('plugin-category'), cat);
|
||||
click(instBtn);
|
||||
type($('plugin-search'), term);
|
||||
await tick(400);
|
||||
ok('clear button visible with filters active', !$('store-clear-filters').classList.contains('hidden'));
|
||||
click($('store-clear-filters'));
|
||||
ok('clear resets search', $('plugin-search').value === '');
|
||||
ok('clear resets sort to a-z', $('store-sort').value === 'a-z');
|
||||
ok('clear resets category', $('plugin-category').value === '');
|
||||
ok('clear resets installed button to All', /All/.test(instBtn.innerHTML));
|
||||
ok('clear PRESERVES per-page 24', $('store-per-page').value === '24', $('store-per-page').value);
|
||||
ok('clear shows a full 24-card page', cards() === 24, cards());
|
||||
ok('clear hides itself', $('store-clear-filters').classList.contains('hidden'));
|
||||
|
||||
// ── persistence across a reload ────────────────────────────────────────
|
||||
change($('store-sort'), 'author');
|
||||
change($('store-per-page'), '48');
|
||||
const saved = { sort: window.localStorage.getItem('storeSort'), per: window.localStorage.getItem('storePerPage') };
|
||||
ok('prefs written under the original keys', saved.sort === 'author' && saved.per === '48', saved);
|
||||
|
||||
// Re-swap the partial (fresh controls at defaults) and re-boot the module, as
|
||||
// a page reload would.
|
||||
document.getElementById('app').innerHTML = partial;
|
||||
ok('fresh markup starts at defaults',
|
||||
$('store-sort').value === 'a-z' && $('store-per-page').value === '12');
|
||||
const controllerBefore = window.__t;
|
||||
boot(); // fresh module instances, fresh localStorage read
|
||||
ok('re-boot produced a new module instance', window.__t !== controllerBefore);
|
||||
window.__t.setupStoreFilterListeners();
|
||||
window.__t.applyStoreFiltersAndSort();
|
||||
ok('reload restores sort=author into the control', $('store-sort').value === 'author', $('store-sort').value);
|
||||
ok('reload restores per-page=48 into the control', $('store-per-page').value === '48', $('store-per-page').value);
|
||||
ok('reload renders with the restored page size', cards() === Math.min(48, N), cards());
|
||||
|
||||
ok('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3));
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "ledmatrix-webui-js-tests",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "JS tests for the v3 web interface (ListFilter and the plugin-manager grids)",
|
||||
"scripts": {
|
||||
"test": "node run_all.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3"
|
||||
}
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs the web-interface JS suites.
|
||||
//
|
||||
// node run_all.js unit suites, plus DOM suites if a
|
||||
// web interface is reachable
|
||||
// BASE=http://10.0.10.169:5000 node run_all.js point the DOM suites at a rig
|
||||
//
|
||||
// Unit suites need nothing but node. The DOM suites need `npm install` (jsdom)
|
||||
// and a running web interface, because they deliberately test against the real
|
||||
// server-rendered HTML and the real API rather than fixtures.
|
||||
const { spawnSync } = require('child_process');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
const req = http.get(url, r => { r.resume(); res(r.statusCode < 500); });
|
||||
req.on('error', () => res(false));
|
||||
req.setTimeout(4000, () => { req.destroy(); res(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function run(file) {
|
||||
const r = spawnSync(process.execPath, [path.join(__dirname, file)],
|
||||
{ stdio: 'inherit', cwd: __dirname, env: process.env });
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const results = [];
|
||||
for (const f of UNIT) results.push([f, run(f)]);
|
||||
|
||||
const haveJsdom = fs.existsSync(path.join(__dirname, 'node_modules', 'jsdom'));
|
||||
const up = await reachable(BASE + '/');
|
||||
|
||||
if (!haveJsdom) {
|
||||
console.log(`\nSKIPPING DOM suites: jsdom not installed (run: npm install)\n`);
|
||||
} else if (!up) {
|
||||
console.log(`\nSKIPPING DOM suites: no web interface reachable at ${BASE}`);
|
||||
console.log(` start one with: EMULATOR=true python3 web_interface/app.py`);
|
||||
console.log(` or point at a rig: BASE=http://<host>:5000 node run_all.js\n`);
|
||||
} else {
|
||||
console.log(`\nDOM suites against ${BASE}\n`);
|
||||
for (const f of DOM) results.push([f, run(f)]);
|
||||
}
|
||||
|
||||
const failed = results.filter(([, ok]) => !ok);
|
||||
console.log('\n─── summary ───');
|
||||
results.forEach(([f, ok]) => console.log(` ${ok ? 'PASS' : 'FAIL'} ${f}`));
|
||||
if (failed.length) { console.log(`\n${failed.length} suite(s) failed\n`); process.exit(1); }
|
||||
console.log('\nall suites passed\n');
|
||||
})();
|
||||
@@ -0,0 +1,288 @@
|
||||
// Functional test for ListFilter + the installed-plugins config extracted
|
||||
// verbatim from plugins_manager.js. Runs under node with a minimal DOM shim.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
// ── minimal DOM shim ───────────────────────────────────────────────────────
|
||||
class El {
|
||||
constructor(id, attrs = {}) {
|
||||
this.id = id; this._attrs = { ...attrs }; this.value = '';
|
||||
this.textContent = ''; this.innerHTML = ''; this.title = '';
|
||||
this._classes = new Set(); this.children = []; this._listeners = {};
|
||||
this.parentEl = null;
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...c) => c.forEach(x => self._classes.add(x)),
|
||||
remove: (...c) => c.forEach(x => self._classes.delete(x)),
|
||||
contains: c => self._classes.has(c),
|
||||
toggle: (c, force) => {
|
||||
const on = force === undefined ? !self._classes.has(c) : !!force;
|
||||
if (on) self._classes.add(c); else self._classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
};
|
||||
}
|
||||
setAttribute(k, v) { this._attrs[k] = String(v); }
|
||||
getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }
|
||||
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
|
||||
fire(type, target) {
|
||||
// Real DOM listeners get `this` === the element the listener is bound to;
|
||||
// the sort/select handlers rely on that, so mirror it.
|
||||
(this._listeners[type] || []).forEach(fn =>
|
||||
fn.call(this, { target: target || this, currentTarget: this, key: undefined,
|
||||
preventDefault() {}, stopPropagation() {} }));
|
||||
}
|
||||
append(child) { child.parentEl = this; this.children.push(child); return child; }
|
||||
querySelectorAll(sel) {
|
||||
const m = /^\[([^\]=]+)\]$/.exec(sel);
|
||||
if (m) return this.children.filter(c => c.getAttribute(m[1]) !== null);
|
||||
return [];
|
||||
}
|
||||
contains(node) { return node === this || this.children.includes(node); }
|
||||
closest(sel) {
|
||||
const m = /^\[([^\]=]+)\]$/.exec(sel);
|
||||
let n = this;
|
||||
while (n) { if (m && n.getAttribute(m[1]) !== null) return n; n = n.parentEl; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new Map();
|
||||
function mk(id, attrs) { const e = new El(id, attrs); if (id) registry.set(id, e); return e; }
|
||||
|
||||
global.document = {
|
||||
getElementById: id => registry.get(id) || null,
|
||||
querySelector: sel => {
|
||||
let m = /^#([\w-]+)$/.exec(sel);
|
||||
if (m) return registry.get(m[1]) || null;
|
||||
m = /^#([\w-]+)\s+\[([\w-]+)="([^"]+)"\]$/.exec(sel);
|
||||
if (m) {
|
||||
const parent = registry.get(m[1]);
|
||||
if (!parent) return null;
|
||||
return parent.children.find(c => c.getAttribute(m[2]) === m[3]) || null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
dispatchEvent() {}, addEventListener() {},
|
||||
};
|
||||
global.CustomEvent = class { constructor(t, o) { this.type = t; Object.assign(this, o); } };
|
||||
global.window = global;
|
||||
|
||||
// ── build the toolbar exactly as plugins.html declares it ─────────────────
|
||||
mk('installed-search');
|
||||
mk('installed-search-clear');
|
||||
mk('installed-sort');
|
||||
mk('installed-clear-filters');
|
||||
mk('installed-count');
|
||||
mk('installed-updates-count');
|
||||
mk('installed-plugins-grid');
|
||||
const pills = mk('installed-filter-pills');
|
||||
['all', 'enabled', 'disabled', 'updates'].forEach(v => {
|
||||
const b = new El(null, { 'data-installed-filter': v });
|
||||
pills.append(b);
|
||||
});
|
||||
document.getElementById('installed-sort').value = 'a-z';
|
||||
|
||||
// ── load the helper ───────────────────────────────────────────────────────
|
||||
const ListFilter = require(path.join(V3, 'js/plugins/list_filter.js'));
|
||||
global.ListFilter = ListFilter;
|
||||
|
||||
// ── extract the installed-plugins config verbatim from plugins_manager.js ──
|
||||
const src = fs.readFileSync(path.join(V3, 'plugins_manager.js'), 'utf8');
|
||||
const start = src.indexOf('function installedSortName(plugin)');
|
||||
const endMark = 'function setupInstalledFilterListeners()';
|
||||
const end = src.indexOf(endMark);
|
||||
if (start < 0 || end < 0) { console.error('FAIL: could not locate installed filter block'); process.exit(1); }
|
||||
const block = src.slice(start, end);
|
||||
|
||||
// deps the block expects from its enclosing IIFE
|
||||
let rendered = null;
|
||||
global.installedPlugins = [];
|
||||
global.pluginLog = () => {};
|
||||
function renderInstalledCards(list, total) { rendered = { list, total }; }
|
||||
|
||||
eval(block); // defines installedSortName/comparators/getInstalledFilter/etc.
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────
|
||||
const P = (id, o) => Object.assign(
|
||||
{ id, name: id, enabled: true, update_available: false, category: 'general',
|
||||
description: '', author: 'someone', tags: [], version: '1.0.0', last_updated: null }, o);
|
||||
|
||||
const FIXTURE = [
|
||||
P('zulu-clock', { name: 'Zulu Clock', enabled: true, category: 'time', last_updated: '2026-01-05' }),
|
||||
P('alpha-weather', { name: 'Alpha Weather', enabled: false, category: 'weather', last_updated: '2026-03-11', update_available: true, latest_version: '2.0.0' }),
|
||||
P('mid-stocks', { name: 'Mid Stocks', enabled: true, category: 'financial', last_updated: null, update_available: true, latest_version: '3.1.0' }),
|
||||
P('no-date', { name: 'No Date Here', enabled: false, category: 'general', last_updated: null }),
|
||||
P('taggy', { name: 'Taggy', enabled: true, category: 'media', last_updated: '2026-06-30', tags: ['hockey', 'nhl'] }),
|
||||
P('bad-date', { name: 'Bad Date', enabled: true, category: 'general', last_updated: 'not-a-date' }),
|
||||
];
|
||||
global.installedPlugins = FIXTURE;
|
||||
window.installedPlugins = FIXTURE;
|
||||
|
||||
const ctl = getInstalledFilter();
|
||||
if (!ctl) { console.error('FAIL: controller not created'); process.exit(1); }
|
||||
ctl.bind();
|
||||
|
||||
// ── assertions ────────────────────────────────────────────────────────────
|
||||
let pass = 0, fail = 0;
|
||||
function ok(label, cond, extra) {
|
||||
if (cond) { pass++; console.log(' ok ' + label); }
|
||||
else { fail++; console.log(' FAIL ' + label + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
||||
}
|
||||
const ids = () => rendered.list.map(p => p.id);
|
||||
const countText = () => document.getElementById('installed-count').textContent;
|
||||
const badge = () => document.getElementById('installed-updates-count');
|
||||
const pillOf = v => pills.children.find(c => c.getAttribute('data-installed-filter') === v);
|
||||
const setPill = v => pills.fire('click', pillOf(v));
|
||||
|
||||
console.log('\n1. default state (no filters)');
|
||||
ctl.apply();
|
||||
ok('renders all 6', rendered.list.length === 6, ids());
|
||||
ok('total is 6', rendered.total === 6);
|
||||
ok('sorted A→Z', ids().join(',') === 'alpha-weather,bad-date,mid-stocks,no-date,taggy,zulu-clock', ids());
|
||||
ok('count reads "6 installed"', countText() === '6 installed', countText());
|
||||
ok('updates badge shows 2', badge().textContent === '2' && !badge().classList.contains('hidden'), badge().textContent);
|
||||
ok('clear button hidden', document.getElementById('installed-clear-filters').classList.contains('hidden'));
|
||||
|
||||
console.log('\n2. pill: enabled / disabled partition');
|
||||
setPill('enabled');
|
||||
const enabledIds = ids();
|
||||
ok('enabled → 4', enabledIds.length === 4, enabledIds);
|
||||
ok('count reads "4 of 6 shown"', countText() === '4 of 6 shown', countText());
|
||||
ok('clear button visible', !document.getElementById('installed-clear-filters').classList.contains('hidden'));
|
||||
ok('enabled pill lit', pillOf('enabled').getAttribute('data-active') === 'true');
|
||||
ok('all pill unlit', pillOf('all').getAttribute('data-active') === 'false');
|
||||
ok('aria-pressed set', pillOf('enabled').getAttribute('aria-pressed') === 'true');
|
||||
setPill('disabled');
|
||||
const disabledIds = ids();
|
||||
ok('disabled → 2', disabledIds.length === 2, disabledIds);
|
||||
ok('partition is exact, no overlap/loss',
|
||||
enabledIds.length + disabledIds.length === 6 &&
|
||||
!enabledIds.some(i => disabledIds.includes(i)));
|
||||
|
||||
console.log('\n3. pill: updates');
|
||||
setPill('updates');
|
||||
ok('updates → only update_available', ids().join(',') === 'alpha-weather,mid-stocks', ids());
|
||||
ok('badge count unaffected by filtering', badge().textContent === '2');
|
||||
|
||||
console.log('\n4. search');
|
||||
setPill('all');
|
||||
const searchEl = document.getElementById('installed-search');
|
||||
function search(text) { searchEl.value = text; ctl.setSearch(text); }
|
||||
search('weather');
|
||||
ok('name match', ids().join(',') === 'alpha-weather', ids());
|
||||
search('WEATHER');
|
||||
ok('case-insensitive', ids().join(',') === 'alpha-weather', ids());
|
||||
search('nhl');
|
||||
ok('matches tags[]', ids().join(',') === 'taggy', ids());
|
||||
search('financial');
|
||||
ok('matches category', ids().join(',') === 'mid-stocks', ids());
|
||||
search('someone');
|
||||
ok('matches author → all 6', ids().length === 6);
|
||||
search(' zulu ');
|
||||
ok('trims whitespace', ids().join(',') === 'zulu-clock', ids());
|
||||
search('zzzznope');
|
||||
ok('no match → empty list, total preserved', rendered.list.length === 0 && rendered.total === 6);
|
||||
ok('count reads "0 of 6 shown"', countText() === '0 of 6 shown', countText());
|
||||
// The haystack joins fields in the CONFIGURED order (name, id, description,
|
||||
// author, category, tags), so a query may straddle a field boundary. The
|
||||
// fixtures deliberately insert `id` before `name`, so reading the object's own
|
||||
// entry order instead would break this.
|
||||
search('zulu clock zulu-clock');
|
||||
ok('phrase spanning name→id matches (field order preserved)',
|
||||
ids().join(',') === 'zulu-clock', ids());
|
||||
search('zulu-clock zulu clock');
|
||||
ok('the reverse (object key order) does NOT match', rendered.list.length === 0, ids());
|
||||
search('taggy media');
|
||||
ok('phrase spanning category→tags respects field order', ids().length === 0, ids());
|
||||
search('');
|
||||
ok('cleared → all 6 back', ids().length === 6);
|
||||
ok('count back to "6 installed"', countText() === '6 installed', countText());
|
||||
|
||||
console.log('\n5. search + pill combine (AND)');
|
||||
setPill('enabled');
|
||||
search('a');
|
||||
const both = rendered.list;
|
||||
ok('all results enabled', both.every(p => p.enabled), ids());
|
||||
ok('all results match "a"', both.every(p => (p.name + p.id + p.category + p.author).toLowerCase().includes('a')));
|
||||
search('');
|
||||
setPill('all');
|
||||
|
||||
console.log('\n6. sorts');
|
||||
const sortEl = document.getElementById('installed-sort');
|
||||
function sort(v) { sortEl.value = v; sortEl.fire('change'); }
|
||||
sort('z-a');
|
||||
ok('z-a reverses', ids().join(',') === 'zulu-clock,taggy,no-date,mid-stocks,bad-date,alpha-weather', ids());
|
||||
sort('status');
|
||||
const st = ids();
|
||||
ok('status: updates first', st.slice(0, 2).sort().join(',') === 'alpha-weather,mid-stocks', st);
|
||||
ok('status: disabled last', st[st.length - 1] === 'no-date', st);
|
||||
sort('recent');
|
||||
const rec = ids();
|
||||
ok('recent: newest first', rec[0] === 'taggy' && rec[1] === 'alpha-weather' && rec[2] === 'zulu-clock', rec);
|
||||
ok('recent: missing/unparseable dates last',
|
||||
['bad-date', 'mid-stocks', 'no-date'].every(i => rec.indexOf(i) >= 3), rec);
|
||||
ok('recent: nothing dropped', rec.length === 6);
|
||||
sort('category');
|
||||
const cat = ids();
|
||||
ok('category groups', cat.join(',') === 'mid-stocks,bad-date,no-date,taggy,zulu-clock,alpha-weather', cat);
|
||||
sort('a-z');
|
||||
|
||||
console.log('\n7. clear filters');
|
||||
search('taggy'); setPill('updates'); sort('z-a');
|
||||
ok('3 axes active', ctl.activeCount() === 3, ctl.activeCount());
|
||||
document.getElementById('installed-clear-filters').fire('click');
|
||||
ok('reset → all 6', ids().length === 6, ids());
|
||||
ok('reset clears search input', searchEl.value === '');
|
||||
ok('reset restores sort to a-z', ctl.state.sort === 'a-z' && sortEl.value === 'a-z');
|
||||
ok('reset relights All pill', pillOf('all').getAttribute('data-active') === 'true');
|
||||
ok('activeCount back to 0', ctl.activeCount() === 0);
|
||||
|
||||
console.log('\n8. sticky: the just-toggled card must not vanish');
|
||||
setPill('enabled');
|
||||
ok('zulu-clock visible while enabled', ids().includes('zulu-clock'));
|
||||
ctl.sticky.add('zulu-clock');
|
||||
FIXTURE[0].enabled = false; // simulate the toggle landing
|
||||
ctl.apply();
|
||||
ok('still visible after being disabled', ids().includes('zulu-clock'), ids());
|
||||
ok('but total unchanged', rendered.total === 6);
|
||||
setPill('enabled'); // user touches toolbar → sticky clears
|
||||
ok('sticky cleared on toolbar use', !ids().includes('zulu-clock'), ids());
|
||||
FIXTURE[0].enabled = true;
|
||||
setPill('all');
|
||||
|
||||
console.log('\n9. state-leak guard: window.installedPlugins must stay whole');
|
||||
search('taggy');
|
||||
ok('grid shows 1', rendered.list.length === 1);
|
||||
ok('window.installedPlugins still 6', window.installedPlugins.length === 6, window.installedPlugins.length);
|
||||
search('');
|
||||
|
||||
console.log('\n10. updates badge when nothing needs updating');
|
||||
const noUpd = FIXTURE.map(p => ({ ...p, update_available: false }));
|
||||
window.installedPlugins = noUpd; global.installedPlugins = noUpd;
|
||||
ctl.apply();
|
||||
ok('badge hidden at 0', badge().classList.contains('hidden'));
|
||||
ok('updates pill dimmed', pillOf('updates').classList.contains('opacity-50'));
|
||||
ok('pill title explains', /No plugins have updates/.test(pillOf('updates').title), pillOf('updates').title);
|
||||
window.installedPlugins = FIXTURE; global.installedPlugins = FIXTURE;
|
||||
ctl.apply();
|
||||
ok('badge back at 2', badge().textContent === '2' && !badge().classList.contains('hidden'));
|
||||
ok('pill undimmed', !pillOf('updates').classList.contains('opacity-50'));
|
||||
|
||||
console.log('\n11. empty installed list');
|
||||
window.installedPlugins = []; global.installedPlugins = [];
|
||||
ctl.apply();
|
||||
ok('renders nothing, total 0', rendered.list.length === 0 && rendered.total === 0);
|
||||
ok('count reads "0 installed"', countText() === '0 installed', countText());
|
||||
|
||||
console.log('\n12. defensive: missing fields');
|
||||
const ragged = [{ id: 'bare' }, { id: 'nulls', name: null, tags: null, category: null, last_updated: null }];
|
||||
window.installedPlugins = ragged; global.installedPlugins = ragged;
|
||||
sort('recent'); ctl.apply();
|
||||
ok('no throw on ragged records, both rendered', rendered.list.length === 2, ids());
|
||||
search('bare');
|
||||
ok('search works on ragged records', ids().join(',') === 'bare', ids());
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,109 @@
|
||||
// Verifies renderInstalledCards (extracted verbatim from plugins_manager.js):
|
||||
// the two empty states, the card markup, and that it never publishes state.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
|
||||
|
||||
function slice(fromMark, toMark) {
|
||||
const a = src.indexOf(fromMark);
|
||||
const b = src.indexOf(toMark, a + 1);
|
||||
if (a < 0 || b < 0) { console.error('FAIL: cannot locate ' + fromMark); process.exit(1); }
|
||||
return src.slice(a, b);
|
||||
}
|
||||
|
||||
const container = {
|
||||
innerHTML: '',
|
||||
querySelectorAll: () => [], // no skeletons in this harness
|
||||
};
|
||||
// escapeHtml() escapes via a detached element, so mirror what a browser does
|
||||
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
|
||||
class FakeEl {
|
||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||
get textContent() { return this._t || ''; }
|
||||
get innerHTML() {
|
||||
return (this._t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
global.document = {
|
||||
getElementById: id => (id === 'installed-plugins-grid' ? container : null),
|
||||
createElement: () => new FakeEl(),
|
||||
};
|
||||
global.window = global;
|
||||
global.pluginLog = () => {};
|
||||
global.PLUGIN_DEBUG = false;
|
||||
global.debugLog = () => {};
|
||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
||||
|
||||
eval(slice('function escapeHtml(text)', '\nfunction ', ));
|
||||
eval(slice('function renderInstalledCards(plugins, total)',
|
||||
'// Set up event delegation for plugin action buttons'));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
const SENTINEL = ['do', 'not', 'touch'];
|
||||
window.installedPlugins = SENTINEL;
|
||||
|
||||
console.log('\n1. no plugins installed at all');
|
||||
renderInstalledCards([], 0);
|
||||
ok('shows "No plugins installed"', /No plugins installed/.test(container.innerHTML));
|
||||
ok('does NOT show the filter empty state', !/No plugins match/.test(container.innerHTML));
|
||||
ok('uses the plug icon', /fa-plug/.test(container.innerHTML));
|
||||
|
||||
console.log('\n2. plugins installed but none match the filters');
|
||||
renderInstalledCards([], 12);
|
||||
ok('shows "No plugins match your filters"', /No plugins match your filters/.test(container.innerHTML));
|
||||
ok('does NOT claim nothing is installed', !/No plugins installed/.test(container.innerHTML));
|
||||
ok('reports the installed total', /12 plugins installed/.test(container.innerHTML), container.innerHTML.match(/\d+ plugins? installed/));
|
||||
ok('offers a Clear filters button', /data-action="clear-installed-filters"/.test(container.innerHTML));
|
||||
ok('uses the filter icon', /fa-filter/.test(container.innerHTML));
|
||||
|
||||
console.log('\n3. singular/plural of the installed total');
|
||||
renderInstalledCards([], 1);
|
||||
ok('"1 plugin installed" (singular)', /1 plugin installed/.test(container.innerHTML) && !/1 plugins/.test(container.innerHTML));
|
||||
|
||||
console.log('\n4. real cards');
|
||||
const plugins = [
|
||||
{ id: 'alpha', name: 'Alpha', author: 'Ann', version: '1.0.0', category: 'weather',
|
||||
description: 'Shows weather', enabled: true, tags: ['a', 'b'], verified: true },
|
||||
{ id: 'beta', name: 'Beta', author: 'Bob', version: '1.0.0', latest_version: '2.0.0',
|
||||
update_available: true, category: 'time', description: 'Clock', enabled: false },
|
||||
];
|
||||
renderInstalledCards(plugins, 5);
|
||||
const html = container.innerHTML;
|
||||
ok('renders 2 cards', (html.match(/class="plugin-card"/g) || []).length === 2);
|
||||
ok('enabled card shows Enabled', /<span>Enabled<\/span>/.test(html));
|
||||
ok('disabled card shows Disabled', /<span>Disabled<\/span>/.test(html));
|
||||
ok('update badge shows target version', /v2\.0\.0 available/.test(html));
|
||||
ok('update button labelled "Update to v2.0.0"', /Update to v2\.0\.0/.test(html));
|
||||
ok('pulsing ring class on the outdated one', /plugin-update-available/.test(html));
|
||||
ok('verified badge present', /Verified/.test(html));
|
||||
ok('tags rendered', /badge-info">a</.test(html) && /badge-info">b</.test(html));
|
||||
ok('per-plugin action hooks present',
|
||||
/data-action="configure"/.test(html) && /data-action="update"/.test(html) &&
|
||||
/data-action="uninstall"/.test(html) && /data-action="toggle"/.test(html));
|
||||
ok('no empty state alongside cards', !/empty-state/.test(html));
|
||||
|
||||
console.log('\n5. it must not publish state');
|
||||
ok('window.installedPlugins untouched', window.installedPlugins === SENTINEL, window.installedPlugins);
|
||||
|
||||
console.log('\n6. total defaults to list length when omitted');
|
||||
renderInstalledCards([], undefined);
|
||||
ok('omitted total + empty list → "no plugins installed"', /No plugins installed/.test(container.innerHTML));
|
||||
|
||||
console.log('\n7. XSS: hostile plugin metadata is escaped');
|
||||
renderInstalledCards([{
|
||||
id: 'evil', name: '<img src=x onerror=alert(1)>', author: '"><script>bad()</script>',
|
||||
version: '1.0.0', category: 'x', description: '<b>nope</b>', enabled: true, tags: ['<i>t</i>'],
|
||||
}], 1);
|
||||
const evil = container.innerHTML;
|
||||
ok('no raw <img', !/<img src=x/.test(evil));
|
||||
ok('no raw <script', !/<script>bad/.test(evil));
|
||||
ok('no raw <b> from description', !/<b>nope<\/b>/.test(evil));
|
||||
ok('no raw <i> from tags', !/<i>t<\/i>/.test(evil));
|
||||
ok('escaped entities present instead', /</.test(evil));
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* ListFilter — shared search / filter / sort controller for the card-grid
|
||||
* sections of the Plugin Manager.
|
||||
*
|
||||
* The Installed Plugins, Plugin Store and Starlark Apps sections all need the
|
||||
* same machinery: debounced text search over a few fields, a handful of filter
|
||||
* axes, a sort dropdown, an active-filter count with a Clear button, and a
|
||||
* re-render. This owns that machinery; the caller keeps ownership of its own
|
||||
* card markup via the `render` callback.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const ctl = ListFilter.create({
|
||||
* getItems: () => window.installedPlugins || [],
|
||||
* render: (visible, total) => renderCards(visible, total),
|
||||
* search: { el: 'my-search', fields: ['name', 'id', 'tags'] },
|
||||
* sort: { el: 'my-sort', default: 'a-z', comparators: { 'a-z': fn } },
|
||||
* controls: [{ type: 'pills', el: '#my-pills', attr: 'data-my-filter',
|
||||
* key: 'filter', default: 'all', test: (item, v) => true }],
|
||||
* clearEl: 'my-clear',
|
||||
* });
|
||||
* ctl.bind(); // idempotent — safe to call after every HTMX partial swap
|
||||
* ctl.apply(); // filter + sort + render
|
||||
*
|
||||
* Optional `pagination` slices the result set and renders page controls; the
|
||||
* `render` callback then receives just the current page. Optional `persist`
|
||||
* takes read/write callbacks so the caller — not this helper — owns its
|
||||
* storage keys.
|
||||
*
|
||||
* Element references are DOM ids, except `controls[].el` which is a CSS
|
||||
* selector for the pill container.
|
||||
*/
|
||||
const ListFilter = (function () {
|
||||
'use strict';
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer = null;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function byId(id) {
|
||||
return id ? document.getElementById(id) : null;
|
||||
}
|
||||
|
||||
// Filter axes compare against their default to decide "is this axis active",
|
||||
// so null/undefined/'' must not be conflated with a real selection.
|
||||
function sameValue(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a === null || a === undefined) return b === null || b === undefined;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the lowercased search haystack. Array fields (e.g. tags) are
|
||||
// flattened in, matching the existing store/starlark search behaviour.
|
||||
//
|
||||
// Values are read out of a Map rather than via item[field], which keeps
|
||||
// static analysers from flagging a computed member access as an
|
||||
// object-injection sink. Iteration follows `fields`, NOT the object's own
|
||||
// key order: the fields are concatenated, so their order decides which
|
||||
// values end up adjacent, and a multi-word query can span a field boundary.
|
||||
function haystack(item, fields) {
|
||||
if (!item) return '';
|
||||
const values = new Map(Object.entries(item));
|
||||
const parts = [];
|
||||
(fields || []).forEach(field => {
|
||||
const value = values.get(field);
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => { if (v) parts.push(String(v)); });
|
||||
} else if (value) {
|
||||
parts.push(String(value));
|
||||
}
|
||||
});
|
||||
return parts.join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
function create(config) {
|
||||
const cfg = config || {};
|
||||
const searchCfg = cfg.search || null;
|
||||
const sortCfg = cfg.sort || null;
|
||||
const controls = Array.isArray(cfg.controls) ? cfg.controls : [];
|
||||
const pageCfg = cfg.pagination || null;
|
||||
const persistCfg = cfg.persist || null;
|
||||
const idOf = typeof cfg.idOf === 'function' ? cfg.idOf : (item => item && item.id);
|
||||
|
||||
// Defaults double as the "inactive" value for each axis.
|
||||
const defaults = {};
|
||||
if (searchCfg) {
|
||||
defaults.search = ''; // trimmed — what filtering and activeCount use
|
||||
defaults.searchRaw = ''; // exactly what the user typed — what the input shows
|
||||
}
|
||||
if (sortCfg) defaults.sort = sortCfg.default !== undefined ? sortCfg.default : 'a-z';
|
||||
controls.forEach(c => {
|
||||
defaults[c.key] = c.default !== undefined ? c.default : null;
|
||||
});
|
||||
|
||||
const state = Object.assign({}, defaults);
|
||||
|
||||
// page/perPage sit outside `defaults` on purpose: Clear Filters returns
|
||||
// to page 1 but must NOT reset a per-page size the user chose.
|
||||
if (pageCfg) {
|
||||
state.page = 1;
|
||||
state.perPage = pageCfg.defaultPerPage || 12;
|
||||
}
|
||||
|
||||
// Seed persisted values. The caller supplies read()/write() so storage
|
||||
// keys stay where they always were.
|
||||
if (persistCfg && typeof persistCfg.read === 'function') {
|
||||
const saved = persistCfg.read() || {};
|
||||
if (sortCfg && saved.sort !== undefined && saved.sort !== null) state.sort = saved.sort;
|
||||
if (pageCfg && saved.perPage) state.perPage = saved.perPage;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (persistCfg && typeof persistCfg.write === 'function') persistCfg.write(state);
|
||||
}
|
||||
|
||||
// Ids that stay visible even when they no longer match the active
|
||||
// filters. Populated by the caller when the user acts on a card (e.g.
|
||||
// toggling a plugin off while filtering by Enabled) so the card they
|
||||
// just clicked doesn't vanish underneath the cursor. Cleared as soon as
|
||||
// the user touches the toolbar.
|
||||
const sticky = new Set();
|
||||
|
||||
function activeCount() {
|
||||
let n = 0;
|
||||
if (searchCfg && state.search) n++;
|
||||
if (sortCfg && !sameValue(state.sort, defaults.sort)) n++;
|
||||
controls.forEach(c => {
|
||||
if (!sameValue(state[c.key], defaults[c.key])) n++;
|
||||
});
|
||||
return n;
|
||||
}
|
||||
|
||||
function matches(item) {
|
||||
if (searchCfg && state.search) {
|
||||
if (!haystack(item, searchCfg.fields).includes(state.search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const c of controls) {
|
||||
const value = state[c.key];
|
||||
if (sameValue(value, defaults[c.key])) continue; // axis inactive
|
||||
if (typeof c.test === 'function' && !c.test(item, value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function compute() {
|
||||
const all = (typeof cfg.getItems === 'function' ? cfg.getItems() : null) || [];
|
||||
const total = all.length;
|
||||
const list = all.filter(item => {
|
||||
if (sticky.size > 0 && sticky.has(idOf(item))) return true;
|
||||
return matches(item);
|
||||
});
|
||||
|
||||
if (sortCfg && sortCfg.comparators) {
|
||||
// An unrecognised sort key falls back to the default comparator,
|
||||
// matching the switch-with-default the store code used.
|
||||
const cmp = sortCfg.comparators[state.sort] || sortCfg.comparators[defaults.sort];
|
||||
if (typeof cmp === 'function') list.sort(cmp);
|
||||
}
|
||||
return { list: list, total: total };
|
||||
}
|
||||
|
||||
// Reflect current state back onto the controls, so programmatic changes
|
||||
// and a fresh partial swap both land on a correctly-lit toolbar.
|
||||
function syncControls() {
|
||||
if (searchCfg) {
|
||||
// The toolbar markup is rebuilt on every HTMX partial swap while
|
||||
// this controller (and its state) survives — put the text back.
|
||||
const el = byId(searchCfg.el);
|
||||
const text = state.searchRaw !== undefined ? state.searchRaw : state.search;
|
||||
if (el && el.value !== text) el.value = text;
|
||||
}
|
||||
if (sortCfg) {
|
||||
const el = byId(sortCfg.el);
|
||||
if (el && el.value !== state.sort) el.value = state.sort;
|
||||
}
|
||||
if (pageCfg && pageCfg.perPageEl) {
|
||||
const el = byId(pageCfg.perPageEl);
|
||||
if (el && el.value !== String(state.perPage)) el.value = String(state.perPage);
|
||||
}
|
||||
controls.forEach(c => {
|
||||
const value = state[c.key];
|
||||
if (c.type === 'pills') {
|
||||
const container = document.querySelector(c.el);
|
||||
if (!container) return;
|
||||
container.querySelectorAll('[' + c.attr + ']').forEach(btn => {
|
||||
const on = btn.getAttribute(c.attr) === String(value);
|
||||
btn.setAttribute('data-active', on ? 'true' : 'false');
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
} else if (c.type === 'select') {
|
||||
const el = byId(c.el);
|
||||
if (el && el.value !== (value === null ? '' : value)) {
|
||||
el.value = value === null ? '' : value;
|
||||
}
|
||||
} else if (c.type === 'cycle') {
|
||||
const btn = byId(c.el);
|
||||
// The caller renders cycle buttons so each section keeps its
|
||||
// own label/icon/class treatment.
|
||||
if (btn && typeof c.render === 'function') c.render(btn, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateChrome(list, total) {
|
||||
const n = activeCount();
|
||||
|
||||
const countEl = byId(cfg.countEl);
|
||||
if (countEl && typeof cfg.countFormat === 'function') {
|
||||
countEl.textContent = cfg.countFormat(list.length, total, n > 0);
|
||||
}
|
||||
|
||||
const activeEl = byId(cfg.activeCountEl);
|
||||
if (activeEl) {
|
||||
activeEl.classList.toggle('hidden', n === 0);
|
||||
activeEl.textContent = n + ' filter' + (n !== 1 ? 's' : '') + ' active';
|
||||
}
|
||||
|
||||
const clearEl = byId(cfg.clearEl);
|
||||
if (clearEl) clearEl.classList.toggle('hidden', n === 0);
|
||||
|
||||
if (searchCfg && searchCfg.clearEl) {
|
||||
const searchClear = byId(searchCfg.clearEl);
|
||||
if (searchClear) searchClear.classList.toggle('hidden', !state.search);
|
||||
}
|
||||
|
||||
syncControls();
|
||||
|
||||
if (typeof cfg.onChrome === 'function') cfg.onChrome(state, list, total);
|
||||
}
|
||||
|
||||
// Page-number strip with leading/trailing ellipsis, producing the same
|
||||
// controls the plugin store has always rendered.
|
||||
//
|
||||
// Built with createElement rather than by concatenating an HTML string.
|
||||
// Nothing interpolated here is user-controlled — only page integers and
|
||||
// these class constants — but assembling markup into innerHTML is the
|
||||
// pattern static analysers flag as an XSS sink, and building nodes is no
|
||||
// less clear. It also lets each button own its listener directly instead
|
||||
// of re-querying the container afterwards.
|
||||
const PAGE_BTN_CLASS = 'px-3 py-1 text-sm rounded-md border transition-colors';
|
||||
const PAGE_ACTIVE_CLASS = 'bg-blue-600 text-white border-blue-600';
|
||||
const PAGE_NORMAL_CLASS = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer';
|
||||
const PAGE_DISABLED_CLASS = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed';
|
||||
|
||||
function renderPagination(containerId, totalPages, currentPage) {
|
||||
const container = byId(containerId);
|
||||
if (!container) return;
|
||||
|
||||
// textContent = '' drops the previous strip without parsing markup.
|
||||
container.textContent = '';
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const goTo = target => {
|
||||
if (target >= 1 && target <= totalPages && target !== currentPage) {
|
||||
state.page = target;
|
||||
// Page moves re-slice only; filters and sort are unchanged.
|
||||
apply(true);
|
||||
const grid = byId(pageCfg && pageCfg.scrollToEl);
|
||||
if (grid && typeof grid.scrollIntoView === 'function') {
|
||||
grid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addPageButton = (label, target, variant) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = PAGE_BTN_CLASS + ' ' + (
|
||||
variant === 'active' ? PAGE_ACTIVE_CLASS
|
||||
: variant === 'disabled' ? PAGE_DISABLED_CLASS
|
||||
: PAGE_NORMAL_CLASS);
|
||||
btn.setAttribute('data-list-page', String(target));
|
||||
if (variant === 'disabled') btn.disabled = true;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', () => goTo(target));
|
||||
container.appendChild(btn);
|
||||
};
|
||||
|
||||
addPageButton('\u00ab', currentPage - 1, currentPage <= 1 ? 'disabled' : 'normal');
|
||||
|
||||
const pages = [];
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push('...');
|
||||
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (currentPage < totalPages - 2) pages.push('...');
|
||||
if (totalPages > 1) pages.push(totalPages);
|
||||
|
||||
pages.forEach(entry => {
|
||||
if (entry === '...') {
|
||||
const gap = document.createElement('span');
|
||||
gap.className = 'px-2 py-1 text-sm text-gray-400';
|
||||
gap.textContent = '\u2026';
|
||||
container.appendChild(gap);
|
||||
} else {
|
||||
addPageButton(String(entry), entry, entry === currentPage ? 'active' : 'normal');
|
||||
}
|
||||
});
|
||||
|
||||
addPageButton('\u00bb', currentPage + 1, currentPage >= totalPages ? 'disabled' : 'normal');
|
||||
}
|
||||
|
||||
function apply(skipPageReset) {
|
||||
const result = compute();
|
||||
|
||||
if (!pageCfg) {
|
||||
updateChrome(result.list, result.total);
|
||||
if (typeof cfg.render === 'function') cfg.render(result.list, result.total);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!skipPageReset) state.page = 1;
|
||||
|
||||
const total = result.list.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
|
||||
if (state.page > totalPages) state.page = totalPages;
|
||||
|
||||
const start = (state.page - 1) * state.perPage;
|
||||
const end = Math.min(start + state.perPage, total);
|
||||
const pageItems = result.list.slice(start, end);
|
||||
|
||||
const info = total > 0
|
||||
? (typeof pageCfg.infoFormat === 'function'
|
||||
? pageCfg.infoFormat(start + 1, end, total)
|
||||
: `Showing ${start + 1}\u2013${end} of ${total}`)
|
||||
: (pageCfg.emptyText || 'No results match your filters');
|
||||
[pageCfg.infoEl, pageCfg.infoBottomEl].forEach(id => {
|
||||
const el = byId(id);
|
||||
if (el) el.textContent = info;
|
||||
});
|
||||
|
||||
renderPagination(pageCfg.topEl, totalPages, state.page);
|
||||
renderPagination(pageCfg.bottomEl, totalPages, state.page);
|
||||
|
||||
updateChrome(result.list, result.total);
|
||||
if (typeof cfg.render === 'function') cfg.render(pageItems, result.total);
|
||||
return result;
|
||||
}
|
||||
|
||||
function setSearch(value) {
|
||||
// Keep the raw text so syncControls can put it back verbatim. Writing
|
||||
// the trimmed value into the input would eat a trailing space (and
|
||||
// reset the caret) mid-word, which makes multi-word terms untypable.
|
||||
state.searchRaw = value || '';
|
||||
state.search = state.searchRaw.trim();
|
||||
sticky.clear();
|
||||
apply();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// Only the filter axes reset; a chosen page size is a preference,
|
||||
// not a filter, so it survives Clear Filters.
|
||||
Object.assign(state, defaults);
|
||||
if (pageCfg) state.page = 1;
|
||||
sticky.clear();
|
||||
if (searchCfg) {
|
||||
const el = byId(searchCfg.el);
|
||||
if (el) el.value = '';
|
||||
}
|
||||
persist();
|
||||
syncControls();
|
||||
apply();
|
||||
}
|
||||
|
||||
function bind() {
|
||||
if (searchCfg) {
|
||||
const input = byId(searchCfg.el);
|
||||
if (input && !input._listFilterInit) {
|
||||
input._listFilterInit = true;
|
||||
const run = debounce(() => setSearch(input.value), searchCfg.debounceMs || 300);
|
||||
input.addEventListener('input', run);
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
setSearch('');
|
||||
}
|
||||
});
|
||||
}
|
||||
const searchClear = searchCfg.clearEl ? byId(searchCfg.clearEl) : null;
|
||||
if (searchClear && !searchClear._listFilterInit) {
|
||||
searchClear._listFilterInit = true;
|
||||
searchClear.addEventListener('click', () => {
|
||||
const el = byId(searchCfg.el);
|
||||
if (el) el.value = '';
|
||||
setSearch('');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (sortCfg) {
|
||||
const el = byId(sortCfg.el);
|
||||
if (el && !el._listFilterInit) {
|
||||
el._listFilterInit = true;
|
||||
el.addEventListener('change', function () {
|
||||
state.sort = this.value;
|
||||
sticky.clear();
|
||||
persist();
|
||||
apply();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
controls.forEach(c => {
|
||||
if (c.type === 'pills') {
|
||||
const container = document.querySelector(c.el);
|
||||
if (!container || container._listFilterInit) return;
|
||||
container._listFilterInit = true;
|
||||
// Delegated, so the pills survive any markup re-render.
|
||||
container.addEventListener('click', event => {
|
||||
const btn = event.target.closest('[' + c.attr + ']');
|
||||
if (!btn || !container.contains(btn)) return;
|
||||
state[c.key] = btn.getAttribute(c.attr);
|
||||
sticky.clear();
|
||||
apply();
|
||||
});
|
||||
} else if (c.type === 'select') {
|
||||
const el = byId(c.el);
|
||||
if (!el || el._listFilterInit) return;
|
||||
el._listFilterInit = true;
|
||||
el.addEventListener('change', function () {
|
||||
state[c.key] = this.value;
|
||||
sticky.clear();
|
||||
apply();
|
||||
});
|
||||
} else if (c.type === 'cycle') {
|
||||
const btn = byId(c.el);
|
||||
if (!btn || btn._listFilterInit) return;
|
||||
btn._listFilterInit = true;
|
||||
const values = Array.isArray(c.values) ? c.values : [null];
|
||||
btn.addEventListener('click', () => {
|
||||
const at = values.findIndex(v => sameValue(v, state[c.key]));
|
||||
state[c.key] = values[(at + 1) % values.length];
|
||||
sticky.clear();
|
||||
apply();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (pageCfg && pageCfg.perPageEl) {
|
||||
const el = byId(pageCfg.perPageEl);
|
||||
if (el && !el._listFilterInit) {
|
||||
el._listFilterInit = true;
|
||||
el.addEventListener('change', function () {
|
||||
state.perPage = parseInt(this.value) || (pageCfg.defaultPerPage || 12);
|
||||
persist();
|
||||
apply();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const clearEl = byId(cfg.clearEl);
|
||||
if (clearEl && !clearEl._listFilterInit) {
|
||||
clearEl._listFilterInit = true;
|
||||
clearEl.addEventListener('click', reset);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: state,
|
||||
sticky: sticky,
|
||||
activeCount: activeCount,
|
||||
bind: bind,
|
||||
apply: apply,
|
||||
reset: reset,
|
||||
setSearch: setSearch,
|
||||
syncControls: syncControls,
|
||||
};
|
||||
}
|
||||
|
||||
return { create: create };
|
||||
})();
|
||||
|
||||
// Export
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ListFilter;
|
||||
} else {
|
||||
window.ListFilter = ListFilter;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -974,7 +974,9 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
||||
|
||||
<!-- Shared search/filter/sort controller for the plugin card grids -->
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
|
||||
|
||||
<!-- Load config utilities -->
|
||||
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
|
||||
|
||||
|
||||
@@ -29,6 +29,58 @@
|
||||
<span id="installed-count" class="text-sm text-gray-500 font-medium">0 installed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search / Filter / Sort Bar -->
|
||||
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1 min-w-[12rem]">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
|
||||
<input type="text"
|
||||
id="installed-search"
|
||||
class="form-control text-sm pl-9 pr-8 py-2"
|
||||
placeholder="Search installed plugins…"
|
||||
autocomplete="off"
|
||||
aria-label="Search installed plugins">
|
||||
<button type="button" id="installed-search-clear"
|
||||
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Clear search">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status filter pills -->
|
||||
<div id="installed-filter-pills" class="flex items-center gap-1.5" role="group" aria-label="Filter installed plugins by status">
|
||||
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
|
||||
data-installed-filter="all" data-active="true" aria-pressed="true">All</button>
|
||||
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
|
||||
data-installed-filter="enabled" data-active="false" aria-pressed="false">Enabled</button>
|
||||
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
|
||||
data-installed-filter="disabled" data-active="false" aria-pressed="false">Disabled</button>
|
||||
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
|
||||
data-installed-filter="updates" data-active="false" aria-pressed="false"
|
||||
title="Show only plugins with a newer version available">
|
||||
Updates<span id="installed-updates-count" class="badge badge-info ml-1.5 hidden">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<select id="installed-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white" aria-label="Sort installed plugins">
|
||||
<option value="a-z">A → Z</option>
|
||||
<option value="z-a">Z → A</option>
|
||||
<option value="status">Updates first</option>
|
||||
<option value="recent">Recently updated</option>
|
||||
<option value="category">Category</option>
|
||||
</select>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Clear -->
|
||||
<button type="button" id="installed-clear-filters"
|
||||
class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
|
||||
<i class="fas fa-times mr-1"></i>Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="installed-plugins-content" class="block">
|
||||
<div id="installed-plugins-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
<!-- Skeleton cards shown while installed plugins load -->
|
||||
|
||||
Reference in New Issue
Block a user