diff --git a/public/packets.js b/public/packets.js index 19200572..b2756653 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1678,7 +1678,12 @@
-
+
+
+ +
+
+
@@ -1789,8 +1794,23 @@ // --- Observer multi-select --- const obsMenu = document.getElementById('observerMenu'); + const obsList = document.getElementById('observerList'); + const obsSearchInput = document.getElementById('observerSearchInput'); const obsTrigger = document.getElementById('observerTrigger'); const selectedObservers = new Set(filters.observer ? filters.observer.split(',') : []); + function applyObserverSearchFilter() { + const raw = (obsSearchInput.value || '').trim().toLowerCase(); + // #1884 — default to substring matching so "brussels" finds "ON4XYZ + // Brussels"; a leading ^ opts into prefix-only matching for narrowing + // down a shared prefix like "BE". + const anchored = raw.startsWith('^'); + const term = anchored ? raw.slice(1) : raw; + obsList.querySelectorAll('.multi-select-item[data-obs-name]').forEach((item) => { + const name = item.dataset.obsName; + const matches = !term || (anchored ? name.startsWith(term) : name.includes(term)); + item.style.display = matches ? '' : 'none'; + }); + } function buildObserverMenu() { const allChecked = selectedObservers.size === 0; let html = ``; @@ -1802,11 +1822,15 @@ } else { for (const o of observers) { const checked = selectedObservers.has(String(o.id)) ? 'checked' : ''; - html += ``; + const name = o.name || String(o.id); + html += ``; } } - obsMenu.innerHTML = html; + obsList.innerHTML = html; + applyObserverSearchFilter(); } + obsSearchInput.addEventListener('click', (e) => e.stopPropagation()); + obsSearchInput.addEventListener('input', applyObserverSearchFilter); // #1693 — expose for loadObservers() to refresh on resolve. _rebuildObserverMenu = () => { buildObserverMenu(); updateObsTrigger(); }; function updateObsTrigger() { @@ -1822,9 +1846,22 @@ } buildObserverMenu(); updateObsTrigger(); - obsTrigger.addEventListener('click', (e) => { e.stopPropagation(); obsMenu.classList.toggle('open'); typeMenu.classList.remove('open'); }); + obsTrigger.addEventListener('click', (e) => { + e.stopPropagation(); + obsMenu.classList.toggle('open'); + typeMenu.classList.remove('open'); + // #1884 — don't autofocus on touch devices; it raises the on-screen + // keyboard over the list the user is about to tap. + const isTouch = window.matchMedia('(pointer: coarse)').matches; + if (obsMenu.classList.contains('open') && !isTouch) obsSearchInput.focus(); + }); obsMenu.addEventListener('change', (e) => { const id = e.target.dataset.obsId; + // #1884 — obsSearchInput lives inside obsMenu, so its own change + // events (blur/Enter) bubble here too; without this guard they run + // the else branch below and rebuild the list mid-click, dropping + // whatever checkbox the user just pressed. + if (!id) return; if (id === '__all__') { selectedObservers.clear(); } else { @@ -2000,6 +2037,8 @@ var obMenu = document.getElementById('observerMenu'); if (obMenu) obMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; }); document.getElementById('observerTrigger').textContent = 'All Observers ▾'; + obsSearchInput.value = ''; + applyObserverSearchFilter(); // Reset type multi-select var typeMenu = document.getElementById('typeMenu'); diff --git a/public/style.css b/public/style.css index d3e49aec..b541f3f7 100644 --- a/public/style.css +++ b/public/style.css @@ -3552,6 +3552,16 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); } box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0; display: none; } .multi-select-menu.open { display: block; } +.multi-select-search-wrap { + position: sticky; top: 0; z-index: 1; background: var(--card-bg, #fff); + padding: 4px 8px 6px; border-bottom: 1px solid var(--border); +} +.multi-select-search { + width: 100%; box-sizing: border-box; padding: 5px 8px; border-radius: 5px; + border: 1px solid var(--border); background: var(--input-bg); color: var(--text); + font-size: 13px; +} +.multi-select-search:focus { border-color: var(--accent); outline: none; } .multi-select-item { display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap; diff --git a/test-all.sh b/test-all.sh index 1563a183..9e15b728 100755 --- a/test-all.sh +++ b/test-all.sh @@ -146,7 +146,9 @@ node test-node-reach-coverage.js node test-nodes-export-wiring.js node test-nodes-export.js node test-observer-iata-1188.js +node test-observer-menu-interactions.js node test-observer-naive-clock-1478.js +node test-observer-search-filter.js node test-observers-headings.js node test-packet-filter-time.js node test-packet-filter-ux.js diff --git a/test-clear-filters.js b/test-clear-filters.js index b8c9184d..2340bf9f 100644 --- a/test-clear-filters.js +++ b/test-clear-filters.js @@ -124,7 +124,10 @@ function extractClearHandler() { else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } } } assert(fnEnd > fnStart, 'could not find end of clear handler'); - return src.substring(fnStart + 1, fnEnd); + // The handler also resets the observer search box (#1884). These cases do + // not test it (test-observer-menu-interactions.js does), so give it stand-ins. + const searchStubs = "const obsSearchInput = { value: '' }; function applyObserverSearchFilter() {}\n"; + return searchStubs + src.substring(fnStart + 1, fnEnd); } /** diff --git a/test-observer-menu-interactions.js b/test-observer-menu-interactions.js new file mode 100644 index 00000000..32d98144 --- /dev/null +++ b/test-observer-menu-interactions.js @@ -0,0 +1,148 @@ +/* test-observer-menu-interactions.js — behavioral tests for the observer + * dropdown's event handlers (#1884 follow-up review). Exercises the actual + * handler bodies extracted from packets.js, not source-grep tautology. + * + * Covers three interaction bugs found after the matcher itself (tested in + * test-observer-search-filter.js) was fixed: + * 1. obsSearchInput's own change/blur events bubble into obsMenu's change + * handler and must not be mistaken for a checkbox toggle. + * 2. Clear Filters must reset the search box and re-apply the filter. + * 3. The dropdown must not autofocus the search box on touch devices. + */ +'use strict'; + +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-observer-menu-interactions.js ---'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +const SRC = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8'); + +/** + * Extract the source between a marker ending in "{" and its matching "}". + */ +function extractBlock(marker) { + const markerIdx = SRC.indexOf(marker); + assert(markerIdx !== -1, `marker not found: ${marker}`); + const fnStart = markerIdx + marker.length - 1; + assert(SRC[fnStart] === '{', `marker must end just before "{": ${marker}`); + let depth = 0, fnEnd = -1; + for (let i = fnStart; i < SRC.length; i++) { + if (SRC[i] === '{') depth++; + else if (SRC[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } } + } + assert(fnEnd > fnStart, `could not find end of block: ${marker}`); + return SRC.substring(fnStart + 1, fnEnd); +} + +function stubEl(overrides) { + return Object.assign({ + value: '', + textContent: '', + style: {}, + classList: { add() {}, remove() {}, contains() { return false; }, toggle() {} }, + querySelectorAll: () => [], + }, overrides); +} + +// --- 1. obsMenu change handler must ignore events with no data-obs-id --- +// (the search input's own change/blur events bubble up to this listener) +{ + const body = extractBlock("obsMenu.addEventListener('change', (e) => {"); + + function run(target, { calls, selectedObservers, filters }) { + const localStorage = { setItem: () => calls.push('setItem'), removeItem: () => calls.push('removeItem') }; + const buildObserverMenu = () => calls.push('buildObserverMenu'); + const updateObsTrigger = () => calls.push('updateObsTrigger'); + const updatePacketsUrl = () => calls.push('updatePacketsUrl'); + const renderTableRows = () => calls.push('renderTableRows'); + const fn = new Function( + 'e', 'selectedObservers', 'filters', 'localStorage', + 'buildObserverMenu', 'updateObsTrigger', 'updatePacketsUrl', 'renderTableRows', + body + ); + fn(target, selectedObservers, filters, localStorage, buildObserverMenu, updateObsTrigger, updatePacketsUrl, renderTableRows); + } + + test('change event with no data-obs-id (bubbled from search input) is a no-op', () => { + const calls = []; + const selectedObservers = new Set(['3']); + run({ target: { dataset: {}, checked: false } }, { calls, selectedObservers, filters: {} }); + assert.deepStrictEqual(calls, [], 'no side effects should run for an id-less change event'); + assert.deepStrictEqual([...selectedObservers], ['3'], 'selection must be untouched'); + }); + + test('change event on an actual checkbox still updates the selection', () => { + const calls = []; + const selectedObservers = new Set(); + const filters = {}; + run({ target: { dataset: { obsId: '7' }, checked: true } }, { calls, selectedObservers, filters }); + assert.deepStrictEqual([...selectedObservers], ['7'], 'checking observer 7 should select it'); + assert(calls.includes('buildObserverMenu'), 'buildObserverMenu should run for a real toggle'); + assert(calls.includes('updatePacketsUrl'), 'updatePacketsUrl should run for a real toggle'); + }); +} + +// --- 2. Clear Filters must reset and re-apply the observer search box --- +{ + const body = extractBlock("if (clearBtn) clearBtn.addEventListener('click', function() {"); + + test('Clear Filters resets the search box and re-applies the filter', () => { + const applyCalls = []; + const obsSearchInput = stubEl({ value: 'brussels' }); + const filters = {}; + const localStorage = { removeItem() {}, setItem() {} }; + const RegionFilter = { setSelected() {} }; + const loadPackets = () => {}; + const updatePacketsUrl = () => {}; + const applyObserverSearchFilter = () => applyCalls.push('applied'); + const documentStub = { getElementById: () => stubEl({}) }; + + const fn = new Function( + 'filters', 'localStorage', 'document', 'obsSearchInput', 'applyObserverSearchFilter', + 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + '_observerFilterSet', + body + ); + fn(filters, localStorage, documentStub, obsSearchInput, applyObserverSearchFilter, + 15, 15, RegionFilter, updatePacketsUrl, loadPackets, null); + + assert.strictEqual(obsSearchInput.value, '', 'search box should be cleared'); + assert.deepStrictEqual(applyCalls, ['applied'], 'the filter should be re-applied after clearing'); + }); +} + +// --- 3. Autofocus must be gated on touch (pointer: coarse) devices --- +{ + const body = extractBlock("obsTrigger.addEventListener('click', (e) => {"); + + function run(isTouch) { + const focusCalls = []; + const obsSearchInput = stubEl({ focus: () => focusCalls.push('focus') }); + const obsMenu = { classList: { toggle() {}, contains: () => true } }; + const typeMenu = { classList: { remove() {} } }; + const window_ = { matchMedia: () => ({ matches: isTouch }) }; + const fn = new Function('e', 'obsMenu', 'typeMenu', 'obsSearchInput', 'window', body); + fn({ stopPropagation() {} }, obsMenu, typeMenu, obsSearchInput, window_); + return focusCalls; + } + + test('touch devices (pointer: coarse) do not get the search box autofocused', () => { + assert.deepStrictEqual(run(true), [], 'focus() should not be called on touch devices'); + }); + + test('non-touch devices still get the search box autofocused', () => { + assert.deepStrictEqual(run(false), ['focus'], 'focus() should still be called on desktop/mouse'); + }); +} + +// Summary +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log('All tests passed ✅'); diff --git a/test-observer-search-filter.js b/test-observer-search-filter.js new file mode 100644 index 00000000..ddcdc27d --- /dev/null +++ b/test-observer-search-filter.js @@ -0,0 +1,119 @@ +/* test-observer-search-filter.js — behavioral tests for the observer dropdown + * search box (#1884). Exercises the actual applyObserverSearchFilter logic, + * not source-grep tautology. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-observer-search-filter.js ---'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +/** + * Extract the applyObserverSearchFilter function body from packets.js. + */ +function extractApplyObserverSearchFilter() { + const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8'); + const marker = 'function applyObserverSearchFilter()'; + const idx = src.indexOf(marker); + assert(idx !== -1, 'applyObserverSearchFilter not found in packets.js'); + const fnStart = src.indexOf('{', idx); + let depth = 0, fnEnd = -1; + for (let i = fnStart; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } } + } + assert(fnEnd > fnStart, 'could not find end of applyObserverSearchFilter'); + return src.substring(fnStart + 1, fnEnd); +} + +/** + * Build a minimal sandbox: an obsSearchInput with a settable value, and an + * obsList whose querySelectorAll returns items with a data-obs-name dataset + * and a style.display we can assert on. + */ +function makeItem(name) { + return { dataset: { obsName: name }, style: { display: '' } }; +} + +function runFilter(term, names) { + const items = names.map(makeItem); + const obsSearchInput = { value: term }; + const obsList = { + querySelectorAll: (sel) => { + assert.strictEqual(sel, '.multi-select-item[data-obs-name]'); + return items; + }, + }; + const body = extractApplyObserverSearchFilter(); + const fn = new Function('obsSearchInput', 'obsList', body); + fn(obsSearchInput, obsList); + return items; +} + +const fnBody = extractApplyObserverSearchFilter(); + +test('both includes and startsWith are used (default substring, ^-anchored prefix)', () => { + assert(fnBody.includes('.includes('), 'expected substring includes() in filter body'); + assert(fnBody.includes('startsWith'), 'expected startsWith in filter body for ^-anchored matching'); +}); + +test('empty search term shows every item', () => { + const items = runFilter('', ['on4xyz brussels', 'be1abc', 'be2def']); + for (const it of items) assert.strictEqual(it.style.display, '', `expected visible: ${it.dataset.obsName}`); +}); + +test('default matching is substring, not prefix-only', () => { + const items = runFilter('brussels', ['on4xyz brussels', 'be1abc']); + assert.strictEqual(items[0].style.display, '', 'mid-string "brussels" should match by default (includes)'); + assert.strictEqual(items[1].style.display, 'none', 'be1abc should not match "brussels"'); +}); + +test('default substring match still finds prefix matches too', () => { + const items = runFilter('be1', ['be1abc', 'be2def', 'on4xyz brussels']); + assert.strictEqual(items[0].style.display, '', 'be1abc should match "be1"'); + assert.strictEqual(items[1].style.display, 'none', 'be2def should not match "be1"'); + assert.strictEqual(items[2].style.display, 'none', 'on4xyz brussels should not match "be1"'); +}); + +test('^-anchored term uses prefix-only matching', () => { + const items = runFilter('^be', ['be1abc', 'on4xyz brussels']); + assert.strictEqual(items[0].style.display, '', 'be1abc should match anchored prefix "^be"'); + assert.strictEqual(items[1].style.display, 'none', 'brussels contains "be" but not as a prefix, should not match "^be"'); +}); + +test('bare ^ with no remaining term shows every item', () => { + const items = runFilter('^', ['be1abc', 'on4xyz brussels']); + for (const it of items) assert.strictEqual(it.style.display, '', `expected visible: ${it.dataset.obsName}`); +}); + +test('search term is trimmed before matching', () => { + const items = runFilter(' be1 ', ['be1abc', 'be2def']); + assert.strictEqual(items[0].style.display, '', 'be1abc should match trimmed term "be1"'); + assert.strictEqual(items[1].style.display, 'none', 'be2def should not match trimmed term "be1"'); +}); + +test('matching is case-insensitive relative to the stored lowercase name', () => { + // buildObserverMenu() stores data-obs-name already lowercased; the search + // input itself is lowercased by applyObserverSearchFilter before matching. + const items = runFilter('BE1', ['be1abc']); + assert.strictEqual(items[0].style.display, '', 'uppercase search term should still match lowercase stored name'); +}); + +test('^-anchored search is also case-insensitive', () => { + const items = runFilter('^BE', ['be1abc', 'on4xyz brussels']); + assert.strictEqual(items[0].style.display, '', 'uppercase anchored term should still match lowercase stored name'); + assert.strictEqual(items[1].style.display, 'none', 'brussels should not match anchored "^BE"'); +}); + +// Summary +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log('All tests passed ✅');