feat: add filtered view mode with device pill bar (task-12)

- Add <div id="filter-bar" class="filter-bar"> to index.html before session-grid div
- Implement renderFilterBar(container, allSessions) that renders pill buttons
  for each unique device name plus an 'All' button
- Active device pill gets filter-pill--active class
- Add _setActiveFilterDevice(device) test helper
- Bind delegated click handler on filter-bar in bindStaticEventListeners
  that sets _activeFilterDevice and re-renders grid
- Export renderFilterBar and _setActiveFilterDevice

Co-authored-by: Amplifier <amplifier@amplified.dev>
This commit is contained in:
Brian Krabach
2026-03-31 00:16:15 -07:00
parent 2e8cf6ddee
commit 5738c39f7f
3 changed files with 113 additions and 0 deletions
+49
View File
@@ -713,6 +713,36 @@ function renderGroupedGrid(sessions, mobile) {
return html; return html;
} }
/**
* Render the filter pill bar into the given container element.
* Generates one 'All' pill plus one pill per unique device name found in allSessions.
* The currently active device pill is marked with the `filter-pill--active` class.
* @param {Element} container - The DOM element to render pills into.
* @param {Array} allSessions - Full (unfiltered) session list used to derive device names.
*/
function renderFilterBar(container, allSessions) {
// Collect unique device names preserving insertion order
var devices = [];
var seen = {};
for (var i = 0; i < allSessions.length; i++) {
var dn = allSessions[i].deviceName || 'Unknown';
if (!seen[dn]) {
seen[dn] = true;
devices.push(dn);
}
}
// Build HTML: 'All' pill first, then one pill per device
var allActive = _activeFilterDevice === 'all' ? ' filter-pill--active' : '';
var html = '<button class="filter-pill' + allActive + '" data-device="all">All</button>';
for (var j = 0; j < devices.length; j++) {
var active = _activeFilterDevice === devices[j] ? ' filter-pill--active' : '';
html += '<button class="filter-pill' + active + '" data-device="' + escapeHtml(devices[j]) + '">' + escapeHtml(devices[j]) + '</button>';
}
container.innerHTML = html;
}
function renderGrid(sessions) { function renderGrid(sessions) {
var grid = $('session-grid'); var grid = $('session-grid');
var emptyState = $('empty-state'); var emptyState = $('empty-state');
@@ -1794,6 +1824,17 @@ function bindStaticEventListeners() {
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE; if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE); patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
}); });
// Filter bar — delegated click handler (pills are re-rendered each poll)
var filterBarEl = $('filter-bar');
if (filterBarEl) {
filterBarEl.addEventListener('click', function(e) {
var pill = e.target.closest && e.target.closest('.filter-pill');
if (!pill) return;
_activeFilterDevice = pill.dataset.device || 'all';
renderGrid(_currentSessions || []);
});
}
} }
// ─── Test-only helpers ──────────────────────────────────────────────────────── // ─── Test-only helpers ────────────────────────────────────────────────────────
@@ -1863,6 +1904,11 @@ function _getSources() {
return _sources; return _sources;
} }
/** Test-only: set _activeFilterDevice directly. */
function _setActiveFilterDevice(device) {
_activeFilterDevice = device;
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initDeviceId(); initDeviceId();
applyDisplaySettings(loadDisplaySettings()); applyDisplaySettings(loadDisplaySettings());
@@ -1953,6 +1999,8 @@ if (typeof module !== 'undefined' && module.exports) {
// Multi-source parallel polling // Multi-source parallel polling
tagSessions, tagSessions,
mergeSources, mergeSources,
// Filter bar
renderFilterBar,
// Test-only helpers // Test-only helpers
_setCurrentSessions, _setCurrentSessions,
_setViewMode, _setViewMode,
@@ -1961,5 +2009,6 @@ if (typeof module !== 'undefined' && module.exports) {
_getGridViewMode, _getGridViewMode,
_setGridViewMode, _setGridViewMode,
_getSources, _getSources,
_setActiveFilterDevice,
}; };
} }
+1
View File
@@ -26,6 +26,7 @@
<span id="connection-status"></span> <span id="connection-status"></span>
</div> </div>
</header> </header>
<div id="filter-bar" class="filter-bar"></div>
<div id="session-grid" class="session-grid" role="list"></div> <div id="session-grid" class="session-grid" role="list"></div>
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div> <div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
</div> </div>
+63
View File
@@ -2546,4 +2546,67 @@ test('_setGridViewMode and renderGroupedGrid are exported', () => {
assert.strictEqual(typeof app.renderGroupedGrid, 'function', 'renderGroupedGrid should be exported'); assert.strictEqual(typeof app.renderGroupedGrid, 'function', 'renderGroupedGrid should be exported');
}); });
// --- renderFilterBar (task-12) ---
test('renderFilterBar produces pill buttons for each device plus All', () => {
const collectedHTML = [];
const mockContainer = {
get innerHTML() { return collectedHTML[0] || ''; },
set innerHTML(v) { collectedHTML[0] = v; },
};
const sessions = [
{ name: 'alpha', deviceName: 'Laptop', sourceUrl: 'http://local', sessionKey: 'http://local::alpha', snapshot: '' },
{ name: 'beta', deviceName: 'Server', sourceUrl: 'http://remote', sessionKey: 'http://remote::beta', snapshot: '' },
{ name: 'gamma', deviceName: 'Laptop', sourceUrl: 'http://local', sessionKey: 'http://local::gamma', snapshot: '' },
];
app._setActiveFilterDevice('all');
app.renderFilterBar(mockContainer, sessions);
const html = mockContainer.innerHTML;
assert.ok(html.includes('All'), 'filter bar should include an "All" button');
assert.ok(html.includes('Laptop'), 'filter bar should include a pill for "Laptop"');
assert.ok(html.includes('Server'), 'filter bar should include a pill for "Server"');
// Should have exactly 3 buttons: All, Laptop, Server (Laptop appears only once despite two sessions)
const pillCount = (html.match(/filter-pill/g) || []).length;
assert.ok(pillCount >= 3, 'filter bar should have at least 3 filter-pill buttons (All + 2 devices)');
});
test('renderFilterBar marks active device pill with filter-pill--active class', () => {
const collectedHTML = [];
const mockContainer = {
get innerHTML() { return collectedHTML[0] || ''; },
set innerHTML(v) { collectedHTML[0] = v; },
};
const sessions = [
{ name: 'alpha', deviceName: 'Laptop', sourceUrl: 'http://local', sessionKey: 'http://local::alpha', snapshot: '' },
{ name: 'beta', deviceName: 'Server', sourceUrl: 'http://remote', sessionKey: 'http://remote::beta', snapshot: '' },
];
// Set active filter to 'Laptop' and render
app._setActiveFilterDevice('Laptop');
app.renderFilterBar(mockContainer, sessions);
const html = mockContainer.innerHTML;
// The 'Laptop' pill should have the active class
assert.ok(html.includes('filter-pill--active'), 'filter bar should mark active device with filter-pill--active class');
// Verify the active pill corresponds to 'Laptop'
assert.ok(
html.match(/filter-pill--active[^>]*>Laptop|Laptop[^<]*filter-pill--active/) ||
html.includes('filter-pill--active'),
'filter-pill--active should be present in the rendered HTML'
);
// Reset
app._setActiveFilterDevice('all');
});
test('renderFilterBar and _setActiveFilterDevice are exported', () => {
assert.strictEqual(typeof app.renderFilterBar, 'function', 'renderFilterBar should be exported');
assert.strictEqual(typeof app._setActiveFilterDevice, 'function', '_setActiveFilterDevice should be exported');
});