feat: add grouped view mode to renderGrid with device-group-header sections

- Add renderGroupedGrid(sessions, mobile) that groups sessions by deviceName
  and creates <h3 class="device-group-header"> headers for each group
- Rewrite renderGrid to: apply device filter in 'filtered' mode, apply sort
  order, call renderGroupedGrid when _gridViewMode === 'grouped', render
  filter bar in 'filtered' mode, bind click handlers with sourceUrl
- Add _setGridViewMode(mode) test-only helper
- Export renderGroupedGrid and _setGridViewMode from module.exports
This commit is contained in:
Brian Krabach
2026-03-31 00:00:34 -07:00
parent 300919c7d7
commit 2e8cf6ddee
2 changed files with 125 additions and 13 deletions
+82 -13
View File
@@ -681,39 +681,101 @@ function bindSidebarClickAway() {
* Binds click and keydown handlers on each tile. * Binds click and keydown handlers on each tile.
* @param {object[]} sessions * @param {object[]} sessions
*/ */
function renderGrid(sessions) {
const grid = $('session-grid');
const emptyState = $('empty-state');
const visible = getVisibleSessions(sessions); /**
* Render sessions grouped by device name. Returns HTML string.
* @param {object[]} sessions - sorted, visible sessions
* @param {boolean} mobile
* @returns {string}
*/
function renderGroupedGrid(sessions, mobile) {
// Group by deviceName
var groups = {};
var groupOrder = [];
for (var i = 0; i < sessions.length; i++) {
var dn = sessions[i].deviceName || 'Unknown';
if (!groups[dn]) {
groups[dn] = [];
groupOrder.push(dn);
}
groups[dn].push(sessions[i]);
}
var html = '';
for (var g = 0; g < groupOrder.length; g++) {
var name = groupOrder[g];
html += '<h3 class="device-group-header">' + escapeHtml(name) + '</h3>';
var groupSessions = groups[name];
for (var j = 0; j < groupSessions.length; j++) {
html += buildTileHTML(groupSessions[j], j, mobile);
}
}
return html;
}
function renderGrid(sessions) {
var grid = $('session-grid');
var emptyState = $('empty-state');
var filterBar = $('filter-bar');
var visible = getVisibleSessions(sessions);
// In filtered mode, apply device filter
if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') {
visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; });
}
if (visible.length === 0) { if (visible.length === 0) {
if (grid) grid.innerHTML = ''; if (grid) grid.innerHTML = '';
if (emptyState) emptyState.classList.remove('hidden'); if (emptyState) emptyState.classList.remove('hidden');
// Show filter bar even when filtered to empty (so user can switch back)
if (filterBar) {
if (_gridViewMode === 'filtered') {
renderFilterBar(filterBar, sessions);
} else {
filterBar.innerHTML = '';
}
}
return; return;
} }
if (emptyState) emptyState.classList.add('hidden'); if (emptyState) emptyState.classList.add('hidden');
// Apply sort order from server settings // Apply sort order from server settings
const sortOrder = _serverSettings && _serverSettings.sort_order; var sortOrder = _serverSettings && _serverSettings.sort_order;
const mobile = isMobile(); var mobile = isMobile();
let ordered; var ordered;
if (sortOrder === 'alphabetical') { if (sortOrder === 'alphabetical') {
ordered = visible.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')); ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
} else { } else {
// 'recent', 'manual', and default use server-provided order; priority sort on mobile // 'recent', 'manual', and default use server-provided order; priority sort on mobile
ordered = mobile ? sortByPriority(visible) : visible; ordered = mobile ? sortByPriority(visible) : visible;
} }
const html = ordered.map((session, index) => buildTileHTML(session, index, mobile)).join('');
var html;
if (_gridViewMode === 'grouped') {
html = renderGroupedGrid(ordered, mobile);
} else {
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
}
if (grid) grid.innerHTML = html; if (grid) grid.innerHTML = html;
// Render filter bar
if (filterBar) {
if (_gridViewMode === 'filtered') {
renderFilterBar(filterBar, sessions);
} else {
filterBar.innerHTML = '';
}
}
// Bind interaction handlers on each tile // Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach((tile) => { document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', () => openSession(tile.dataset.session)); on(tile, 'click', function() { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); });
on(tile, 'keydown', (e) => { on(tile, 'keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session); openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl });
} }
}); });
}); });
@@ -1791,6 +1853,11 @@ function _getGridViewMode() {
return _gridViewMode; return _gridViewMode;
} }
/** Test-only: set _gridViewMode directly. */
function _setGridViewMode(mode) {
_gridViewMode = mode;
}
/** Test-only: get _sources. */ /** Test-only: get _sources. */
function _getSources() { function _getSources() {
return _sources; return _sources;
@@ -1839,6 +1906,7 @@ if (typeof module !== 'undefined' && module.exports) {
toggleSidebar, toggleSidebar,
bindSidebarClickAway, bindSidebarClickAway,
renderGrid, renderGrid,
renderGroupedGrid,
requestNotificationPermission, requestNotificationPermission,
handleBellTransitions, handleBellTransitions,
sendHeartbeat, sendHeartbeat,
@@ -1891,6 +1959,7 @@ if (typeof module !== 'undefined' && module.exports) {
_setSources, _setSources,
_setServerSettings, _setServerSettings,
_getGridViewMode, _getGridViewMode,
_setGridViewMode,
_getSources, _getSources,
}; };
} }
+43
View File
@@ -2503,4 +2503,47 @@ test('pollSessions sets unreachable and applies exponential backoff on network e
app._setSources([]); app._setSources([]);
}); });
// --- renderGrid grouped mode (task-11) ---
test('renderGrid in grouped mode produces device-group-header elements', () => {
const collectedHTML = [];
const mockGrid = {
get innerHTML() { return collectedHTML[0] || ''; },
set innerHTML(v) { collectedHTML[0] = v; },
};
const mockEmpty = { style: {}, classList: { add: () => {}, remove: () => {} } };
const origGetById = globalThis.document.getElementById;
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.getElementById = (id) => {
if (id === 'session-grid') return mockGrid;
if (id === 'empty-state') return mockEmpty;
return null;
};
globalThis.document.querySelectorAll = () => [];
// Set up sessions from two different devices
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: '' },
];
app._setGridViewMode('grouped');
app.renderGrid(sessions);
const html = mockGrid.innerHTML;
assert.ok(html.includes('device-group-header'), 'grid HTML should contain device-group-header elements');
assert.ok(html.includes('Laptop'), 'grid HTML should contain device name "Laptop"');
assert.ok(html.includes('Server'), 'grid HTML should contain device name "Server"');
// Reset state
app._setGridViewMode('flat');
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
});
test('_setGridViewMode and renderGroupedGrid are exported', () => {
assert.strictEqual(typeof app._setGridViewMode, 'function', '_setGridViewMode should be exported');
assert.strictEqual(typeof app.renderGroupedGrid, 'function', 'renderGroupedGrid should be exported');
});