feat(packets): add filter to All Observer dropdown (#1884)

Lets users type a prefix to filter the observer checkbox list, instead
of scrolling a long list.

---------

Co-authored-by: efiten <erwin.fiten@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Herlin
2026-09-13 20:36:13 +02:00
committed by GitHub
co-authored by efiten Claude Opus 5
parent a059299588
commit 2c6d7bb6ae
6 changed files with 326 additions and 5 deletions
+43 -4
View File
@@ -1678,7 +1678,12 @@
<div class="filter-group filter-group-dropdowns">
<div class="multi-select-wrap" id="observerFilterWrap">
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers ▾</button>
<div class="multi-select-menu" id="observerMenu"></div>
<div class="multi-select-menu" id="observerMenu">
<div class="multi-select-search-wrap">
<input type="text" id="observerSearchInput" class="multi-select-search" placeholder="Search observers…" autocomplete="off" aria-label="Search observers" title="Matches anywhere in the name. Start with ^ to match only from the beginning, e.g. ^BE">
</div>
<div class="multi-select-list" id="observerList"></div>
</div>
</div>
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
<div id="packetsAreaFilter" style="display:none;vertical-align:middle"></div>
@@ -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 = `<label class="multi-select-item"><input type="checkbox" data-obs-id="__all__" ${allChecked ? 'checked' : ''}> All Observers</label>`;
@@ -1802,11 +1822,15 @@
} else {
for (const o of observers) {
const checked = selectedObservers.has(String(o.id)) ? 'checked' : '';
html += `<label class="multi-select-item"><input type="checkbox" data-obs-id="${o.id}" ${checked}> ${escapeHtml(o.name || o.id)}</label>`;
const name = o.name || String(o.id);
html += `<label class="multi-select-item" data-obs-name="${escapeHtml(name.toLowerCase())}"><input type="checkbox" data-obs-id="${o.id}" ${checked}> ${escapeHtml(name)}</label>`;
}
}
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');
+10
View File
@@ -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;
+2
View File
@@ -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
+4 -1
View File
@@ -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);
}
/**
+148
View File
@@ -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 ✅');
+119
View File
@@ -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 ✅');