diff --git a/public/packets.js b/public/packets.js index d5eddcbf6..e4c3069db 100644 --- a/public/packets.js +++ b/public/packets.js @@ -770,7 +770,10 @@ // lose their 120px cap; hops past the column edge go behind the +N pill). let showFullNames = localStorage.getItem('meshcore-full-names') === 'true'; let _colFit = null; // fitColumnsToContent controller for #pktTable - var _pendingUrlRegion = null; + // Region/area filter listeners, removed in destroy() so a region change on + // another page doesn't reload packets and rewrite the URL to #/packets. + let _regionChangeHandler = null; + let _areaChangeHandler = null; var DEFAULT_TIME_WINDOW = 15; @@ -1337,8 +1340,6 @@ savedTimeWindowMin = _urlTimeWindow; localStorage.setItem('meshcore-time-window', String(_urlTimeWindow)); } - var _urlRegion = _initUrlParams.get('region'); - if (_urlRegion) _pendingUrlRegion = _urlRegion; var _urlHash = _initUrlParams.get('hash'); if (_urlHash) filters.hash = _urlHash; var _urlNode = _initUrlParams.get('node'); @@ -1601,6 +1602,8 @@ if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; } if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; } if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; } + if (_regionChangeHandler) { RegionFilter.offChange(_regionChangeHandler); _regionChangeHandler = null; } + if (_areaChangeHandler) { AreaFilter.offChange(_areaChangeHandler); _areaChangeHandler = null; } removeAllByopOverlays(); packets = []; hashIndex = new Map(); selectedId = null; @@ -1886,15 +1889,11 @@ `; - // Init shared RegionFilter component + // Init shared RegionFilter component (it applies a ?region= URL param itself) RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true }); AreaFilter.init(document.getElementById('packetsAreaFilter')); - if (_pendingUrlRegion) { - RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean)); - _pendingUrlRegion = null; - } - RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); - AreaFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); + _regionChangeHandler = RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); + _areaChangeHandler = AreaFilter.onChange(function() { updatePacketsUrl(); loadPackets(); }); // --- Packet Filter Language --- (function() { diff --git a/public/region-filter.js b/public/region-filter.js index 72a250a1b..135a680df 100644 --- a/public/region-filter.js +++ b/public/region-filter.js @@ -27,6 +27,9 @@ } _selected = loadFromStorage(); + // A ?region= link applies before any page reads the selection, so even a + // page that fetches before calling init() uses it on a fresh load. + applyUrlRegion(); /** Fetch the configured quick picks. A failure only means no quick picks. */ async function fetchQuickPicks() { @@ -295,13 +298,49 @@ _listeners = _listeners.filter(function (f) { return f !== fn; }); } + /** Region codes from a ?region= hash param ("#/live?region=SJC,SFO"), or + * null when the URL doesn't set one. */ + function regionsFromUrl() { + if (typeof location === 'undefined' || !location.hash) return null; + var raw = new URLSearchParams(location.hash.split('?')[1] || '').get('region'); + if (!raw) return null; + var codes = raw.split(',').map(function (c) { return c.trim(); }).filter(Boolean); + return codes.length > 0 ? codes : null; + } + + /** Apply a ?region= URL param as the selection, saved like a click in the + * control. Returns true when that changed the selection. */ + function applyUrlRegion() { + var codes = regionsFromUrl(); + if (!codes) return false; + // Once regions have loaded, fetchRegions() no longer cleans the selection, + // so drop unknown codes here; with none left, that's all regions, the same + // as the cleanup does on a fresh load. + if (_loaded) codes = codes.filter(function (c) { return Object.prototype.hasOwnProperty.call(_regions, c); }); + var next = codes.length > 0 ? new Set(codes) : null; + var changed = !sameSelection(_selected, next); + _selected = next; + saveToStorage(); + return changed; + } + /** Initialize filter in a container, fetch regions, render, return promise. - * Options: { dropdown: true } to force dropdown mode regardless of region count */ + * Options: { dropdown: true } to force dropdown mode regardless of region count + * + * A ?region= param in the page's URL overrides the saved selection, so any + * page with a region filter can be deep-linked to a region. On a fresh load + * it was already applied when this script ran. On in-app navigation it's + * applied here, before the await, so a page that loads after init() uses + * it; if it changed the selection, listeners are told once regions load, + * which reloads a page that fetched before calling init(). Unknown codes + * are dropped either way. */ async function initFilter(container, opts) { _container = container; if (opts && opts.dropdown) container._forceDropdown = true; + var urlChanged = applyUrlRegion(); await fetchRegions(); render(container); + if (urlChanged) _listeners.forEach(function (fn) { fn(getSelected()); }); } /** True when two selections (Set or null = all) hold the same codes. */ diff --git a/test-all.sh b/test-all.sh index 758731029..00a865185 100755 --- a/test-all.sh +++ b/test-all.sh @@ -203,6 +203,7 @@ node tests/unit/test-preflight-xss-gate.js node tests/unit/test-pull-to-reconnect-1091.js node tests/unit/test-pull-to-reconnect.js node tests/unit/test-region-filter-setselected.js +node tests/unit/test-region-filter-url-param.js node tests/unit/test-repeater-metric-scatter.js node tests/unit/test-rx-coverage-config-race.js node tests/unit/test-rx-coverage-mine.js diff --git a/tests/unit/test-issue-2012-clear-filters-selection.js b/tests/unit/test-issue-2012-clear-filters-selection.js index f4b21e6b2..48846637f 100644 --- a/tests/unit/test-issue-2012-clear-filters-selection.js +++ b/tests/unit/test-issue-2012-clear-filters-selection.js @@ -273,7 +273,7 @@ test('#2091: list query builder never inherits detail observation', () => { test('#2091: init restores obs from the hash after router strips the query', () => { const readInitialObservation = new Function('location', 'routeParam', 'let directObsId = "stale", directPacketId = null, directPacketHash = null; ' + - 'let savedTimeWindowMin = 15, _pendingUrlRegion = null; const filters = {}, window = {}; ' + + 'let savedTimeWindowMin = 15; const filters = {}, window = {}; ' + 'let showFullNames = false; const localStorage = { getItem: () => null, setItem() {} }; ' + hashParamsSrc + '\n' + initParamsSrc + '\nreturn directObsId;'); for (const route of ['aabbccddeeff0011', 'id/42']) { diff --git a/tests/unit/test-region-filter-url-param.js b/tests/unit/test-region-filter-url-param.js new file mode 100644 index 000000000..536c6ef66 --- /dev/null +++ b/tests/unit/test-region-filter-url-param.js @@ -0,0 +1,185 @@ +/** + * RegionFilter reads ?region= from the page's hash URL. + * + * Only the packets page honored ?region= (with its own one-off code); every + * other page with a region filter (live, nodes, observers, channels, + * analytics, scope audit) ignored it, so a link like #/live?region=SJC opened + * with whatever region the visitor last picked. RegionFilter now applies the + * param itself: it overrides the saved selection and is saved like a click in + * the control. On a fresh load it's in place when the script runs, before any + * page reads it (live fetches nodes before calling init). On in-app + * navigation init() applies it and, if that changed the selection, notifies + * listeners so a page that already fetched reloads. Unknown codes are dropped + * once regions load. + */ +'use strict'; +const REPO_ROOT = require('node:path').resolve(__dirname, '..', '..'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const assert = require('node:assert'); + +let passed = 0, failed = 0; +async function test(name, fn) { + try { await fn(); passed++; console.log(` ✓ ${name}`); } + catch (e) { failed++; console.error(` ✗ ${name}: ${e.message}`); } +} + +const REGIONS = { SJC: 'San Jose', SFO: 'San Francisco', OAK: 'Oakland' }; + +function buildCtx(hash, initialStorage) { + const storage = Object.assign(Object.create(null), initialStorage || {}); + const ctx = { + window: {}, + location: { hash: hash || '' }, + document: { addEventListener() {}, removeEventListener() {} }, + localStorage: { + getItem: (k) => (k in storage ? storage[k] : null), + setItem: (k, v) => { storage[k] = String(v); }, + removeItem: (k) => { delete storage[k]; }, + }, + fetch: async (url) => ({ json: async () => (url === '/api/config/regions' ? REGIONS : {}) }), + URLSearchParams, console, setTimeout, clearTimeout, + }; + ctx.window = ctx; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(REPO_ROOT + '/public/region-filter.js', 'utf8'), ctx); + return { rf: ctx.window.RegionFilter, storage, location: ctx.location }; +} + +function container() { + return { innerHTML: '', style: {}, querySelector: () => null, contains: () => false }; +} + +(async () => { + console.log('RegionFilter applies a ?region= URL param'); + + await test('URL region overrides the saved selection', async () => { + const { rf } = buildCtx('#/live?region=SJC', { 'meshcore-region-filter': '["SFO"]' }); + await rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC']); + }); + + await test('comma-separated codes are all selected, whitespace trimmed', async () => { + const { rf } = buildCtx('#/nodes?region=SJC,%20SFO'); + await rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC', 'SFO']); + }); + + await test('fresh load: selection is in place before init is even called', () => { + const { rf } = buildCtx('#/live?region=SFO', { 'meshcore-region-filter': '["SJC"]' }); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SFO']); + assert.strictEqual(rf.regionQueryString(), '®ion=SFO'); + }); + + await test('fresh load: init does not notify (nothing changed since the script ran)', async () => { + const { rf } = buildCtx('#/live?region=SFO'); + const calls = []; + const p = rf.init(container()); + rf.onChange((sel) => calls.push(sel && Array.from(sel))); + await p; + assert.deepStrictEqual(calls, []); + }); + + await test('in-app navigation: a changed region notifies listeners once regions load', async () => { + const { rf, location } = buildCtx('#/packets', { 'meshcore-region-filter': '["SJC"]' }); + location.hash = '#/live?region=OAK'; + const calls = []; + // live registers its listener right after calling init(), without awaiting + const p = rf.init(container()); + rf.onChange((sel) => calls.push(sel && Array.from(sel))); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['OAK']); + await p; + assert.deepStrictEqual(calls, [['OAK']]); + }); + + await test('in-app navigation: the same region as already selected does not notify', async () => { + const { rf, location } = buildCtx('#/packets', { 'meshcore-region-filter': '["OAK"]' }); + location.hash = '#/live?region=OAK'; + const calls = []; + const p = rf.init(container()); + rf.onChange((sel) => calls.push(sel && Array.from(sel))); + await p; + assert.deepStrictEqual(calls, []); + }); + + await test('selection is in place before init resolves (callers do not await it)', () => { + const { rf } = buildCtx('#/nodes?region=OAK'); + rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['OAK']); + assert.strictEqual(rf.regionQueryString(), '®ion=OAK'); + }); + + await test('URL selection is saved like a click in the control', async () => { + const { rf, storage } = buildCtx('#/live?region=SFO'); + await rf.init(container()); + assert.strictEqual(storage['meshcore-region-filter'], '["SFO"]'); + }); + + await test('unknown codes are dropped once regions load', async () => { + const { rf } = buildCtx('#/live?region=SJC,XXX'); + await rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC']); + }); + + // fetchRegions() only cleans the selection on its first call, so on in-app + // navigation applyUrlRegion() must drop unknown codes itself. + await test('in-app navigation: unknown codes are dropped', async () => { + const { rf, location, storage } = buildCtx('#/packets'); + await rf.init(container()); + location.hash = '#/nodes?region=SJC,XXX'; + await rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC']); + assert.strictEqual(rf.regionQueryString(), '®ion=SJC'); + assert.strictEqual(storage['meshcore-region-filter'], '["SJC"]'); + }); + + await test('in-app navigation: only unknown codes means all regions', async () => { + const { rf, location, storage } = buildCtx('#/packets', { 'meshcore-region-filter': '["SFO"]' }); + await rf.init(container()); + location.hash = '#/nodes?region=XXX'; + await rf.init(container()); + assert.strictEqual(rf.getSelected(), null); + assert.strictEqual(rf.regionQueryString(), ''); + assert.ok(!('meshcore-region-filter' in storage)); + }); + + await test('no region param keeps the saved selection', async () => { + const { rf } = buildCtx('#/live?node=abc', { 'meshcore-region-filter': '["SFO"]' }); + await rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SFO']); + }); + + await test('an empty region param keeps the saved selection', async () => { + const { rf } = buildCtx('#/live?region=', { 'meshcore-region-filter': '["SFO"]' }); + await rf.init(container()); + assert.deepStrictEqual(Array.from(rf.getSelected()), ['SFO']); + }); + + await test('no hash at all keeps "all regions"', async () => { + const { rf } = buildCtx(''); + await rf.init(container()); + assert.strictEqual(rf.getSelected(), null); + }); + + // packets.js registered its region/area listeners without keeping a handle, + // so they outlived the page: after visiting packets, a region change on any + // other page reloaded packets and replaceState'd the URL to #/packets. The + // in-app notify above makes that fire on every region link, so packets must + // remove both listeners in destroy(). + console.log('packets.js removes its region/area listeners on destroy'); + const pktSrc = fs.readFileSync(REPO_ROOT + '/public/packets.js', 'utf8'); + const destroySrc = pktSrc.slice(pktSrc.indexOf(' function destroy() {'), pktSrc.indexOf('\n }\n', pktSrc.indexOf(' function destroy() {'))); + + await test('listeners are registered with a kept handle', () => { + assert.match(pktSrc, /_regionChangeHandler = RegionFilter\.onChange\(/); + assert.match(pktSrc, /_areaChangeHandler = AreaFilter\.onChange\(/); + }); + + await test('destroy() removes both listeners', () => { + assert.match(destroySrc, /RegionFilter\.offChange\(_regionChangeHandler\)/); + assert.match(destroySrc, /AreaFilter\.offChange\(_areaChangeHandler\)/); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed) process.exit(1); +})();