From d0929a2aa90e5a2f75d1ee99cddccd2160acb715 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 1 May 2026 15:23:42 -0500 Subject: [PATCH] feat(landing): groups + click-to-open redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the caret-dropdown preset menu with two stacked cards: Groups (top) — saved bundles of projects; click to open the archive with that group's project set; per-row edit/delete buttons. Projects — filterable table; in default mode no checkboxes, click any row to open just that project. "+ New group" or a row's edit button enters select-mode: checkboxes appear on each project row, an action bar shows above the projects card with Save group / Open visible-checked / Cancel. "Open visible-checked" intentionally excludes filter-hidden checked projects so users can scope to a subset they're currently looking at. Storage migrates from old zddc_landing_presets to zddc_landing_groups (simpler shape: {name, projects: [...]}). One-shot migration runs on first load. Adds the new favicon SVG to the landing header alongside the title. Drops the ?projects= URL state since selection is no longer the page's primary state in click-to-open mode. Updates Playwright suite: 9 new test cases covering click-to-open, group crud, edit pre-population, "open selected visible" scoping, and legacy preset migration. Adds a LandingApp._setNavigate test hook since window.location.href cannot be reliably patched in modern engines. Co-Authored-By: Claude Opus 4.7 (1M context) --- landing/css/landing.css | 248 ++++++++--------- landing/js/landing.js | 589 +++++++++++++++++++++------------------- landing/template.html | 55 +++- tests/landing.spec.js | 205 ++++++++++---- 4 files changed, 612 insertions(+), 485 deletions(-) diff --git a/landing/css/landing.css b/landing/css/landing.css index 94b7eab..0c3dc2b 100644 --- a/landing/css/landing.css +++ b/landing/css/landing.css @@ -57,14 +57,16 @@ body { flex-shrink: 0; } -/* Main card */ +/* Cards (groups + projects) */ .landing-card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: visible; box-shadow: 0 1px 2px rgba(0,0,0,0.04); + margin-bottom: 16px; } +.landing-card:last-child { margin-bottom: 0; } .landing-card-header { display: flex; @@ -98,7 +100,109 @@ body { flex-wrap: wrap; } -/* Project list container */ +/* ── Groups card ─────────────────────────────────────────────────────────── */ +.groups-container { min-height: 0; } +.groups-empty { + padding: 16px 24px; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.5; +} +.groups-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +.groups-row { + cursor: pointer; + transition: background 0.08s; +} +.groups-row:hover { background: var(--bg-hover); } +.groups-row td { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.groups-row:last-child td { border-bottom: none; } +.groups-row-name { + font-weight: 500; + color: var(--text); +} +.groups-row-count { + color: var(--text-muted); + font-size: 0.85rem; + width: 8em; + white-space: nowrap; +} +.groups-row-actions { + width: 4.5em; + text-align: right; + white-space: nowrap; +} +.groups-btn-edit, .groups-btn-delete { + background: none; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; + color: var(--text-muted); + font-size: 0.95rem; + padding: 2px 6px; + line-height: 1; +} +.groups-btn-edit:hover { + background: var(--bg-hover); + color: var(--text); +} +.groups-btn-delete:hover { + background: var(--bg-hover); + color: var(--danger, #c0392b); +} + +/* ── Select-mode action bar ──────────────────────────────────────────────── */ +.select-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.select-action-bar.hidden { display: none; } +.select-action-bar__label { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 240px; + min-width: 0; +} +.select-action-bar__label > span { + font-weight: 500; + color: var(--text); + flex-shrink: 0; +} +.group-name-input { + flex: 1 1 0; + min-width: 120px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 3px; + font-size: 0.875rem; + background: var(--bg); + color: var(--text); +} +.group-name-input:focus { + outline: none; + border-color: var(--primary); +} +.select-action-bar__buttons { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* ── Projects card ───────────────────────────────────────────────────────── */ .project-list-container { min-height: 80px; } @@ -238,143 +342,3 @@ body { text-align: center; color: var(--text-muted); } - -/* Footer */ -.landing-card-footer { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - border-top: 1px solid var(--border); - gap: 8px; -} -.landing-selection-summary { - color: var(--text-muted); - font-size: 0.875rem; -} - -/* Open Archive split button (footer): primary action + preset dropdown caret */ -.open-archive-split { - position: relative; - display: inline-flex; - align-items: stretch; -} -.open-archive-main { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.open-archive-caret { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-left: 1px solid rgba(255,255,255,0.25); - padding-left: 8px; - padding-right: 8px; -} -.open-archive-main:disabled + .open-archive-caret { - /* Caret stays usable even when no projects are checked, so the user can - still open the menu to load a preset. */ -} -.open-archive-menu { - position: absolute; - bottom: 100%; - right: 0; - margin-bottom: 4px; - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: 0 4px 12px rgba(0,0,0,0.12); - min-width: 280px; - z-index: 100; - padding: 4px 0; -} -.open-archive-menu.hidden { display: none; } -.preset-menu-header { - padding: 8px 12px 4px; - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; -} -.preset-menu-list { - max-height: 240px; - overflow-y: auto; -} -.preset-menu-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - cursor: pointer; - font-size: 0.875rem; - gap: 8px; -} -.preset-menu-item:hover { background: var(--bg-hover); } -.preset-menu-item-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.preset-load-btn { - background: none; - border: 1px solid var(--border); - border-radius: 3px; - cursor: pointer; - color: var(--text-muted); - font-size: 0.75rem; - padding: 2px 8px; - flex-shrink: 0; -} -.preset-load-btn:hover { - background: var(--bg-hover); - color: var(--text); - border-color: var(--border-dark); -} -.preset-delete-btn { - background: none; - border: none; - cursor: pointer; - color: var(--text-muted); - font-size: 0.875rem; - padding: 0 2px; - flex-shrink: 0; -} -.preset-delete-btn:hover { color: var(--danger); } -.preset-menu-empty { - padding: 8px 12px; - color: var(--text-muted); - font-size: 0.875rem; -} -.preset-menu-divider { - border: none; - border-top: 1px solid var(--border); - margin: 4px 0; -} -.preset-menu-actions { - padding: 6px 12px 8px; - display: flex; - justify-content: stretch; -} -.preset-menu-actions .btn { - width: 100%; -} -.preset-menu-saving { - padding: 6px 12px 8px; - display: flex; - gap: 6px; - flex-wrap: wrap; -} -.preset-name-input { - flex: 1 1 100%; - padding: 5px 8px; - border: 1px solid var(--border); - border-radius: 3px; - font-size: 0.875rem; - background: var(--bg); - color: var(--text); -} -.preset-name-input:focus { - outline: none; - border-color: var(--primary); -} diff --git a/landing/js/landing.js b/landing/js/landing.js index b0b2703..32870df 100644 --- a/landing/js/landing.js +++ b/landing/js/landing.js @@ -2,36 +2,50 @@ 'use strict'; // ZDDC landing page — project picker. // - // Pulls the ACL-filtered project list from zddc-server (GET / - // Accept: application/json → ProjectInfo[]), renders a sortable/filterable - // table, persists view state in the URL (so links are shareable), and lets - // the user save named presets in localStorage. Pressing "Open Archive" - // navigates to archive.html?projects=. + // Two stacked sections: + // 1. Groups (saved bundles of projects) — click row to open, edit, or delete + // 2. Projects (live list from the server) — click row to open one project directly + // + // "Select-mode" (entered via "+ New group" or a group's edit ✏ button) shows + // checkboxes on each project row, a name input, and an action bar with + // Save / Open visible-checked / Cancel. In default (rest) mode there are no + // checkboxes; clicking anything just opens the archive. + // + // Storage: groups persist in localStorage under `zddc_landing_groups` as + // an array of { name: string, projects: string[] }. Old `zddc_landing_presets` + // entries are migrated once on init (project list only — filter/sort state + // from the old preset model is dropped). // ── State ──────────────────────────────────────────────────────────────── var allProjects = []; // [{name, title, url}] from server - var selected = new Set(); // selected project names + var groups = []; // [{name, projects: [name,...]}] from localStorage + var selected = new Set(); // checked project names (only used in select-mode) var columnFilters = { pn: '', pt: '' }; var columnFilterASTs = { pn: null, pt: null }; var sortField = 'name'; // 'name' | 'title' var sortDirection = 'asc'; - var presets = []; // [{name, state}] from localStorage var loadError = null; // user-facing error string var loadErrorKind = null; // 'static' | 'auth' | 'non-json' | 'network' - var presetSavingMode = false; // when true, dropdown shows naming input - var PRESETS_KEY = 'zddc_landing_presets'; + // selectMode === null → default mode (click to open) + // selectMode === { kind: 'create' } + // selectMode === { kind: 'edit', originalName: '...' } + var selectMode = null; + + var GROUPS_KEY = 'zddc_landing_groups'; + var LEGACY_PRESETS_KEY = 'zddc_landing_presets'; var DEFAULT_SORT_FIELD = 'name'; var DEFAULT_SORT_DIRECTION = 'asc'; // ── URL state ──────────────────────────────────────────────────────────── + // Only filters and sort persist in the URL. Selection (`?projects=`) used + // to live here for save-as-preset workflows; with click-to-open + named + // groups it adds noise and isn't shareable in any useful way (groups are + // localStorage-only per user). function urlSerialize() { var p = new URLSearchParams(); - if (selected.size > 0) { - p.set('projects', Array.from(selected).sort().join(',')); - } if (columnFilters.pn) p.set('pn', columnFilters.pn); if (columnFilters.pt) p.set('pt', columnFilters.pt); if (sortField !== DEFAULT_SORT_FIELD) p.set('sort', sortField); @@ -53,10 +67,6 @@ function urlRestore() { var p = new URLSearchParams(location.search); - if (p.has('projects')) { - var names = p.get('projects').split(',').map(function(s) { return s.trim(); }).filter(Boolean); - selected = new Set(names); - } if (p.has('pn')) { columnFilters.pn = p.get('pn'); columnFilterASTs.pn = parseFilterAST(columnFilters.pn); @@ -92,11 +102,6 @@ }); if (!resp.ok) throw new Error('HTTP ' + resp.status); - // Read the body as text first so we can give a useful error when - // the server returns HTML (zddc-server too old to honor - // Accept: application/json on /, or a proxy in the way that - // strips the header). resp.json() in that case throws an opaque - // "Unexpected token <" SyntaxError. var ctype = resp.headers.get('Content-Type') || ''; var body = await resp.text(); var trimmed = body.trim(); @@ -113,10 +118,6 @@ loadErrorKind = 'auth'; throw new Error("The request was redirected to " + resp.url + ' — likely to an auth/login page. Sign in and reload.'); } - // Heuristic: the level-2 bootstrap stub identifies itself with - // its loading title. When the server returns it for our JSON - // request, we're on a plain static deployment (no zddc-server - // backend with the project-list API). if (/\s*Loading\s+ZDDC/i.test(trimmed) || /<title>\s*Loading\s+Archive/i.test(trimmed)) { loadErrorKind = 'static'; throw new Error("This deployment doesn't expose a project list. The server is serving static stubs without a zddc-server backend."); @@ -154,7 +155,6 @@ rows.sort(function(a, b) { var av = (a[sortField] || '').toString(); var bv = (b[sortField] || '').toString(); - // Empty titles sort last regardless of direction. if (sortField === 'title') { if (!av && bv) return 1; if (av && !bv) return -1; @@ -168,13 +168,65 @@ // ── Rendering ──────────────────────────────────────────────────────────── function render() { - renderTable(); - renderPresetMenu(); - renderSelectionSummary(); + renderActionBar(); + renderGroups(); + renderProjects(); renderProjectCount(); + renderGroupCount(); } - function renderTable() { + function renderActionBar() { + var bar = document.getElementById('selectActionBar'); + var title = document.getElementById('selectModeTitle'); + var input = document.getElementById('groupNameInput'); + var newBtn = document.getElementById('newGroupBtn'); + if (!bar) return; + + if (!selectMode) { + bar.classList.add('hidden'); + if (newBtn) newBtn.disabled = false; + return; + } + + bar.classList.remove('hidden'); + if (newBtn) newBtn.disabled = true; + + if (selectMode.kind === 'create') { + title.textContent = 'New group:'; + if (document.activeElement !== input) input.value = ''; + } else { + title.textContent = 'Editing:'; + if (document.activeElement !== input) input.value = selectMode.originalName || ''; + } + } + + function renderGroups() { + var container = document.getElementById('groupsContainer'); + if (!container) return; + if (groups.length === 0) { + container.innerHTML = '<div class="groups-empty">No saved groups yet. Use <strong>+ New group</strong> to bundle a set of projects.</div>'; + return; + } + var html = '<table class="groups-table">'; + html += '<tbody>'; + for (var i = 0; i < groups.length; i++) { + var g = groups[i]; + var n = escapeHtml(g.name); + var count = g.projects.length; + html += '<tr class="groups-row" data-name="' + n + '" onclick="LandingApp.openGroup(event)">'; + html += '<td class="groups-row-name">' + n + '</td>'; + html += '<td class="groups-row-count">' + count + ' project' + (count === 1 ? '' : 's') + '</td>'; + html += '<td class="groups-row-actions">'; + html += '<button class="groups-btn-edit" data-name="' + n + '" onclick="event.stopPropagation(); LandingApp.startEditGroup(event)" title="Edit group">✎</button>'; + html += '<button class="groups-btn-delete" data-name="' + n + '" onclick="event.stopPropagation(); LandingApp.deleteGroup(event)" title="Delete group">×</button>'; + html += '</td>'; + html += '</tr>'; + } + html += '</tbody></table>'; + container.innerHTML = html; + } + + function renderProjects() { var container = document.getElementById('projectListContainer'); if (loadError) { var heading, help; @@ -209,19 +261,23 @@ var rows = visibleProjects(); var anyTitles = allProjects.some(function(p) { return p.title; }); - var visibleSelected = rows.filter(function(r) { return selected.has(r.name); }).length; - var headerCheckedState = visibleSelected === 0 ? 'unchecked' + var inSelect = !!selectMode; + var visibleSelected = inSelect ? rows.filter(function(r) { return selected.has(r.name); }).length : 0; + var headerCheckedState = !inSelect ? 'unchecked' + : visibleSelected === 0 ? 'unchecked' : visibleSelected === rows.length ? 'checked' : 'indeterminate'; - var html = '<table class="project-table">'; + var html = '<table class="project-table' + (inSelect ? ' is-select-mode' : '') + '">'; html += '<thead>'; html += '<tr class="project-table-headers">'; - html += '<th class="project-table-checkbox-col">' - + '<input type="checkbox" id="headerCheckbox" ' - + (headerCheckedState === 'checked' ? 'checked ' : '') - + 'onclick="LandingApp.toggleHeaderCheckbox()" ' - + 'title="Check / uncheck all visible projects">' - + '</th>'; + if (inSelect) { + html += '<th class="project-table-checkbox-col">' + + '<input type="checkbox" id="headerCheckbox" ' + + (headerCheckedState === 'checked' ? 'checked ' : '') + + 'onclick="LandingApp.toggleHeaderCheckbox()" ' + + 'title="Check / uncheck all visible projects">' + + '</th>'; + } html += '<th class="project-table-name-col" data-sort="name" onclick="LandingApp.toggleSort(\'name\')">' + 'Project number ' + sortIndicator('name') + '</th>'; @@ -232,7 +288,7 @@ } html += '</tr>'; html += '<tr class="project-table-filters">'; - html += '<th></th>'; + if (inSelect) html += '<th></th>'; html += '<th><input type="text" class="column-filter ' + (columnFilters.pn ? 'filter-active' : '') + '" ' + 'data-column="pn" placeholder="filter…" ' + 'value="' + escapeHtml(columnFilters.pn) + '" ' @@ -246,17 +302,23 @@ html += '</tr>'; html += '</thead>'; + var colspan = (inSelect ? 1 : 0) + 1 + (anyTitles ? 1 : 0); if (rows.length === 0) { - html += '<tbody><tr><td colspan="' + (anyTitles ? 3 : 2) + '" class="project-table-no-match">' + html += '<tbody><tr><td colspan="' + colspan + '" class="project-table-no-match">' + 'No projects match the current filters.' + '</td></tr></tbody>'; } else { html += '<tbody>'; for (var i = 0; i < rows.length; i++) { var r = rows[i]; - var checked = selected.has(r.name) ? ' checked' : ''; - html += '<tr class="project-table-row' + (selected.has(r.name) ? ' is-selected' : '') + '" data-name="' + escapeHtml(r.name) + '" onclick="LandingApp.toggleRow(event)">'; - html += '<td class="project-table-checkbox-col"><input type="checkbox" value="' + escapeHtml(r.name) + '"' + checked + ' onclick="event.stopPropagation(); LandingApp.toggleByCheckbox(event)"></td>'; + var isSel = inSelect && selected.has(r.name); + html += '<tr class="project-table-row' + (isSel ? ' is-selected' : '') + '" ' + + 'data-name="' + escapeHtml(r.name) + '" onclick="LandingApp.onProjectRowClick(event)">'; + if (inSelect) { + html += '<td class="project-table-checkbox-col"><input type="checkbox" value="' + escapeHtml(r.name) + '"' + + (isSel ? ' checked' : '') + + ' onclick="event.stopPropagation(); LandingApp.toggleByCheckbox(event)"></td>'; + } html += '<td class="project-table-name-col">' + escapeHtml(r.name) + '</td>'; if (anyTitles) { html += '<td class="project-table-title-col">' + (r.title ? escapeHtml(r.title) : '<span class="project-table-no-title">—</span>') + '</td>'; @@ -277,71 +339,24 @@ return '<span class="sort-indicator active">' + (sortDirection === 'asc' ? '▲' : '▼') + '</span>'; } - function renderPresetMenu() { - var menu = document.getElementById('openArchiveMenu'); - if (!menu) return; - - // Each row's primary click action is "apply preset and open the archive" - // — the most common path. The smaller "Load" button on the right just - // applies the preset to the page so the user can edit the selection - // before opening. × deletes. - var listHtml = presets.length === 0 - ? '<div class="preset-menu-empty"><i>No saved presets</i></div>' - : presets.map(function(p) { - var n = escapeHtml(p.name); - return '<div class="preset-menu-item" data-action="apply-open" data-name="' + n + '" title="Apply preset and open archive">' - + '<span class="preset-menu-item-name">' + n + '</span>' - + '<button class="preset-load-btn" data-action="apply-stay" data-name="' + n + '" title="Load preset (stay on this page)">Load</button>' - + '<button class="preset-delete-btn" data-action="delete" data-name="' + n + '" title="Delete preset">×</button>' - + '</div>'; - }).join(''); - - var footerHtml; - if (presetSavingMode) { - footerHtml = '<div class="preset-menu-saving">' - + '<input type="text" id="presetNameInput" class="preset-name-input" placeholder="Preset name" autoFocus>' - + '<button class="btn btn-primary btn-sm" data-action="confirm-save">Save</button>' - + '<button class="btn btn-secondary btn-sm" data-action="cancel-save">Cancel</button>' - + '</div>'; - } else { - var anySelectedOrFiltered = selected.size > 0 || columnFilters.pn || columnFilters.pt - || sortField !== DEFAULT_SORT_FIELD || sortDirection !== DEFAULT_SORT_DIRECTION; - footerHtml = '<div class="preset-menu-actions">' - + '<button class="btn btn-primary btn-sm" ' - + (anySelectedOrFiltered ? '' : 'disabled ') - + 'data-action="start-save">Save current as preset…</button>' - + '</div>'; - } - - menu.innerHTML = '<div class="preset-menu-header">Saved presets</div>' - + '<div class="preset-menu-list">' + listHtml + '</div>' - + '<div class="preset-menu-divider"></div>' - + footerHtml; - } - - function renderSelectionSummary() { - var el = document.getElementById('selectionSummary'); - var btn = document.getElementById('openArchiveBtn'); - if (!el || !btn) return; - if (selected.size === 0) { - el.textContent = ''; - btn.disabled = true; - } else { - el.textContent = selected.size + (selected.size === 1 ? ' project selected' : ' projects selected'); - btn.disabled = false; - } - } - function renderProjectCount() { var el = document.getElementById('projectCount'); if (!el) return; if (loadError || allProjects.length === 0) { el.textContent = ''; return; } var rows = visibleProjects(); - if (rows.length === allProjects.length) { - el.textContent = '(' + allProjects.length + ')'; - } else { - el.textContent = '(' + rows.length + ' of ' + allProjects.length + ')'; + var base = rows.length === allProjects.length + ? '(' + allProjects.length + ')' + : '(' + rows.length + ' of ' + allProjects.length + ')'; + if (selectMode) { + base = base + ' — ' + selected.size + ' checked'; } + el.textContent = base; + } + + function renderGroupCount() { + var el = document.getElementById('groupCount'); + if (!el) return; + el.textContent = groups.length === 0 ? '' : '(' + groups.length + ')'; } // ── Events / actions ───────────────────────────────────────────────────── @@ -363,10 +378,8 @@ columnFilters[col] = val; columnFilterASTs[col] = parseFilterAST(val); urlPush(); - // Re-render table only — don't lose input focus by re-rendering preset menu. - renderTable(); + renderProjects(); renderProjectCount(); - // Refocus the input we typed into. var sel = document.querySelector('.column-filter[data-column="' + col + '"]'); if (sel) { sel.focus(); @@ -374,46 +387,73 @@ } } - function toggleByCheckbox(e) { - var cb = e.target; - var name = cb.value; - if (cb.checked) selected.add(name); - else selected.delete(name); - urlPush(); - render(); - } - - function toggleRow(e) { + function onProjectRowClick(e) { var row = e.target.closest('.project-table-row'); if (!row) return; var name = row.getAttribute('data-name'); if (!name) return; - if (selected.has(name)) selected.delete(name); - else selected.add(name); - urlPush(); + if (selectMode) { + // In select-mode the row toggles its checkbox. + if (selected.has(name)) selected.delete(name); + else selected.add(name); + render(); + } else { + // Default mode: click opens that single project directly. + openArchiveWith([name]); + } + } + + function toggleByCheckbox(e) { + if (!selectMode) return; + var cb = e.target; + var name = cb.value; + if (cb.checked) selected.add(name); + else selected.delete(name); render(); } function toggleHeaderCheckbox() { + if (!selectMode) return; var cb = document.getElementById('headerCheckbox'); if (!cb) return; var rows = visibleProjects(); - if (cb.checked) { - rows.forEach(function(r) { selected.add(r.name); }); - } else { - rows.forEach(function(r) { selected.delete(r.name); }); - } - urlPush(); + if (cb.checked) rows.forEach(function(r) { selected.add(r.name); }); + else rows.forEach(function(r) { selected.delete(r.name); }); render(); } - function openArchive() { - if (selected.size === 0) return; + // Navigation hook — tests replace this via LandingApp._setNavigate. + // (Patching window.location.href is unreliable in modern engines.) + var navigate = function(url) { location.href = url; }; + + function openArchiveWith(names) { + if (!names || names.length === 0) return; var base = location.pathname.replace(/\/[^\/]*$/, '/'); - var params = ['projects=' + Array.from(selected).map(encodeURIComponent).join(',')]; + var params = ['projects=' + names.map(encodeURIComponent).join(',')]; var v = new URLSearchParams(location.search).get('v'); if (v) params.push('v=' + encodeURIComponent(v)); - location.href = base + 'archive.html?' + params.join('&'); + navigate(base + 'archive.html?' + params.join('&')); + } + + function openGroup(e) { + var row = e.target.closest('.groups-row'); + if (!row) return; + var name = row.getAttribute('data-name'); + var g = groups.find(function(x) { return x.name === name; }); + if (!g) return; + // Drop projects the user no longer has access to (server-side ACL may + // have changed since the group was saved). + var accessible = new Set(allProjects.map(function(p) { return p.name; })); + var openable = g.projects.filter(function(p) { return accessible.has(p); }); + if (openable.length === 0) { + showWarning('Group "' + name + '" has no projects you currently have access to.'); + return; + } + if (openable.length < g.projects.length) { + // Open with what we can; warn but don't block. + console.warn('Skipping inaccessible projects in group', name, g.projects.filter(function(p) { return !accessible.has(p); })); + } + openArchiveWith(openable); } function dismissWarning() { @@ -429,178 +469,166 @@ el.classList.remove('hidden'); } - // ── Presets ────────────────────────────────────────────────────────────── + // ── Select-mode (create / edit groups) ─────────────────────────────────── - function loadPresets() { - try { - var raw = localStorage.getItem(PRESETS_KEY); - var parsed = raw ? JSON.parse(raw) : []; - presets = Array.isArray(parsed) ? parsed : []; - } catch (e) { - presets = []; - } + function startCreateGroup() { + selectMode = { kind: 'create' }; + selected = new Set(); + render(); + var input = document.getElementById('groupNameInput'); + if (input) input.focus(); } - function persistPresets() { - try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); } - catch (e) { /* private mode / quota */ } + function startEditGroup(e) { + var btn = e.target.closest('.groups-btn-edit'); + if (!btn) return; + var name = btn.getAttribute('data-name'); + var g = groups.find(function(x) { return x.name === name; }); + if (!g) return; + selectMode = { kind: 'edit', originalName: g.name }; + selected = new Set(g.projects); + render(); + var input = document.getElementById('groupNameInput'); + if (input) input.focus(); } - function snapshotState() { - return { - projects: Array.from(selected).sort(), - pn: columnFilters.pn || '', - pt: columnFilters.pt || '', - sort: sortField, - dir: sortDirection - }; + function deleteGroup(e) { + var btn = e.target.closest('.groups-btn-delete'); + if (!btn) return; + var name = btn.getAttribute('data-name'); + if (!confirm('Delete group "' + name + '"?')) return; + groups = groups.filter(function(g) { return g.name !== name; }); + persistGroups(); + render(); } - function applyState(s) { - selected = new Set(Array.isArray(s.projects) ? s.projects : []); - columnFilters.pn = s.pn || ''; - columnFilters.pt = s.pt || ''; - columnFilterASTs.pn = parseFilterAST(columnFilters.pn); - columnFilterASTs.pt = parseFilterAST(columnFilters.pt); - sortField = s.sort === 'title' ? 'title' : 'name'; - sortDirection = s.dir === 'desc' ? 'desc' : 'asc'; + function cancelSelect() { + selectMode = null; + selected = new Set(); + render(); } - function toggleOpenMenu() { - var menu = document.getElementById('openArchiveMenu'); - var btn = document.getElementById('openArchiveMenuBtn'); - if (!menu) return; - var hidden = menu.classList.contains('hidden'); - if (hidden) { - renderPresetMenu(); - menu.classList.remove('hidden'); - if (btn) btn.setAttribute('aria-expanded', 'true'); - // Attach delegation once. Stops bubbling (so the document-level - // outside-click handler doesn't close us) and dispatches actions - // by data-action without inline onclick attribute quoting issues. - if (!menu.dataset.delegationAttached) { - menu.addEventListener('click', handlePresetMenuClick); - menu.dataset.delegationAttached = '1'; - } - } else { - menu.classList.add('hidden'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - presetSavingMode = false; - } - } - - function closeOpenMenu() { - var menu = document.getElementById('openArchiveMenu'); - var btn = document.getElementById('openArchiveMenuBtn'); - if (menu) menu.classList.add('hidden'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - presetSavingMode = false; - } - - function handlePresetMenuClick(e) { - e.stopPropagation(); - var item = e.target.closest('[data-action]'); - if (!item) return; - var action = item.getAttribute('data-action'); - var name = item.getAttribute('data-name') || ''; - if (action === 'apply-open') { - applyPreset(name, true); - } else if (action === 'apply-stay') { - applyPreset(name, false); - } else if (action === 'delete') { - deletePreset(name); - } else if (action === 'start-save') { - startSavePreset(); - } else if (action === 'confirm-save') { - confirmSavePreset(); - } else if (action === 'cancel-save') { - cancelSavePreset(); - } - } - - function startSavePreset() { - presetSavingMode = true; - renderPresetMenu(); - var input = document.getElementById('presetNameInput'); - if (input) { - input.focus(); - input.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { e.preventDefault(); confirmSavePreset(); } - else if (e.key === 'Escape') { e.preventDefault(); cancelSavePreset(); } - }); - } - } - - function cancelSavePreset() { - presetSavingMode = false; - renderPresetMenu(); - } - - function confirmSavePreset() { - var input = document.getElementById('presetNameInput'); + function saveGroup() { + if (!selectMode) return; + var input = document.getElementById('groupNameInput'); if (!input) return; var name = (input.value || '').trim(); - if (!name) return; - presets = presets.filter(function(p) { return p.name !== name; }); - presets.push({ name: name, state: snapshotState() }); - persistPresets(); - // Close the menu — the user just completed an action; they can re-open - // via the caret if they want to apply the just-saved preset. - closeOpenMenu(); - } - - function applyPreset(name, openAfter) { - var preset = presets.find(function(p) { return p.name === name; }); - if (!preset || !preset.state) return; - applyState(preset.state); - urlPush(); - render(); - if (openAfter) { - openArchive(); + if (!name) { + input.focus(); return; } - closeOpenMenu(); + var projects = Array.from(selected).sort(); + if (projects.length === 0) { + alert('Select at least one project before saving the group.'); + return; + } + if (selectMode.kind === 'create') { + // Reject duplicate names so two groups can't share an identity. + if (groups.some(function(g) { return g.name === name; })) { + alert('A group named "' + name + '" already exists. Pick a different name or edit that group instead.'); + return; + } + groups.push({ name: name, projects: projects }); + } else { + // Editing: rename if name changed (and the new name doesn't collide). + if (name !== selectMode.originalName && groups.some(function(g) { return g.name === name; })) { + alert('A group named "' + name + '" already exists. Pick a different name.'); + return; + } + groups = groups.map(function(g) { + return g.name === selectMode.originalName + ? { name: name, projects: projects } + : g; + }); + } + persistGroups(); + selectMode = null; + selected = new Set(); + render(); } - function deletePreset(name) { - presets = presets.filter(function(p) { return p.name !== name; }); - persistPresets(); - renderPresetMenu(); + function openSelectedVisible() { + if (!selectMode) return; + // Rule: open only those that are currently visible (filtered in) AND + // checked. Filter-hidden but checked items are intentionally left out. + var visibleNames = new Set(visibleProjects().map(function(r) { return r.name; })); + var openable = Array.from(selected).filter(function(n) { return visibleNames.has(n); }); + if (openable.length === 0) { + alert('No checked projects are currently visible. Adjust filters or check more projects to open.'); + return; + } + openArchiveWith(openable); + } + + // ── Persistence ────────────────────────────────────────────────────────── + + function loadGroups() { + var raw; + try { raw = localStorage.getItem(GROUPS_KEY); } + catch (e) { raw = null; } + if (raw) { + try { + var parsed = JSON.parse(raw); + groups = Array.isArray(parsed) ? parsed.filter(isValidGroup) : []; + return; + } catch (e) { /* fall through to legacy */ } + } + // One-shot migration: convert old `zddc_landing_presets` (which carried + // filter+sort state alongside the project list) to plain groups. + try { + var legacy = localStorage.getItem(LEGACY_PRESETS_KEY); + if (!legacy) return; + var legacyParsed = JSON.parse(legacy); + if (!Array.isArray(legacyParsed)) return; + groups = legacyParsed.map(function(p) { + var projects = (p && p.state && Array.isArray(p.state.projects)) ? p.state.projects : []; + return { name: String(p && p.name || ''), projects: projects }; + }).filter(isValidGroup); + persistGroups(); + } catch (e) { groups = []; } + } + + function isValidGroup(g) { + return g && typeof g.name === 'string' && g.name.length > 0 + && Array.isArray(g.projects); + } + + function persistGroups() { + try { localStorage.setItem(GROUPS_KEY, JSON.stringify(groups)); } + catch (e) { /* private mode / quota */ } } // ── Bootstrap ──────────────────────────────────────────────────────────── async function init() { - loadPresets(); + loadGroups(); urlRestore(); var ok = await fetchProjects(); if (ok) { - // Drop any URL-restored selections that don't exist on the server. + // No URL-restored selection in the new model, but warn about + // groups that reference inaccessible projects so the user knows + // why their group is shorter than expected when opened. var accessibleNames = new Set(allProjects.map(function(p) { return p.name; })); - var missing = []; - var pruned = new Set(); - selected.forEach(function(n) { - if (accessibleNames.has(n)) pruned.add(n); - else missing.push(n); + var ghostlyGroups = groups.filter(function(g) { + return g.projects.some(function(p) { return !accessibleNames.has(p); }); }); - selected = pruned; - if (missing.length > 0) { - showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', ')); + if (ghostlyGroups.length > 0) { + console.info('Some saved groups reference projects you no longer have access to; they will open with the accessible subset only.', ghostlyGroups.map(function(g) { return g.name; })); } } render(); - // Close preset menu on outside click. - document.addEventListener('click', function(e) { - var menu = document.getElementById('openArchiveMenu'); - var btn = document.getElementById('openArchiveMenuBtn'); - if (!menu || menu.classList.contains('hidden')) return; - if (!menu.contains(e.target) && e.target !== btn) { - closeOpenMenu(); - } - }); + // Wire up keyboard shortcuts in the action-bar input: Enter saves, + // Escape cancels. + var input = document.getElementById('groupNameInput'); + if (input) { + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { e.preventDefault(); saveGroup(); } + else if (e.key === 'Escape') { e.preventDefault(); cancelSelect(); } + }); + } } function escapeHtml(text) { @@ -615,18 +643,21 @@ window.LandingApp = { init: init, toggleByCheckbox: toggleByCheckbox, - toggleRow: toggleRow, toggleHeaderCheckbox: toggleHeaderCheckbox, toggleSort: toggleSort, onColumnFilterInput: onColumnFilterInput, - openArchive: openArchive, - toggleOpenMenu: toggleOpenMenu, - startSavePreset: startSavePreset, - cancelSavePreset: cancelSavePreset, - confirmSavePreset: confirmSavePreset, - applyPreset: applyPreset, - deletePreset: deletePreset, - dismissWarning: dismissWarning + onProjectRowClick: onProjectRowClick, + openGroup: openGroup, + startCreateGroup: startCreateGroup, + startEditGroup: startEditGroup, + deleteGroup: deleteGroup, + cancelSelect: cancelSelect, + saveGroup: saveGroup, + openSelectedVisible: openSelectedVisible, + dismissWarning: dismissWarning, + // Test-only: override the navigation function (avoids the messy + // browser-locked-down state of window.location). + _setNavigate: function(fn) { navigate = fn; } }; })(); diff --git a/landing/template.html b/landing/template.html index f588ce7..3fd2c3b 100644 --- a/landing/template.html +++ b/landing/template.html @@ -12,6 +12,14 @@ <body> <header class="app-header"> <div class="header-left"> + <svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true"> + <rect width="64" height="64" rx="12" fill="#1e3a5f"/> + <g fill="#fff"> + <rect x="14" y="18" width="36" height="7"/> + <polygon points="43,25 50,25 21,43 14,43"/> + <rect x="14" y="43" width="36" height="7"/> + </g> + </svg> <span class="app-header__title">ZDDC Archive</span> <span class="build-timestamp">{{BUILD_LABEL}}</span> </div> @@ -25,9 +33,8 @@ <section class="landing-hero"> <h1>Welcome to the ZDDC Archive</h1> <p class="landing-hero-sub"> - Pick the projects you want to view, then open the archive. Filter by - project number or title, and save your selection as a preset to - share or come back to later. + Click a group or project below to open the archive. Use + <strong>+ New group</strong> to bundle a set of projects you open together. </p> </section> @@ -37,8 +44,37 @@ <button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">×</button> </div> - <!-- Project picker card --> + <!-- Groups card --> <div class="landing-card"> + <div class="landing-card-header"> + <div class="landing-card-title"> + <h2>Groups</h2> + <span id="groupCount" class="landing-count"></span> + </div> + <div class="landing-header-actions"> + <button id="newGroupBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.startCreateGroup()">+ New group</button> + </div> + </div> + <div id="groupsContainer" class="groups-container"> + <!-- Populated by JS --> + </div> + </div> + + <!-- Projects card --> + <div class="landing-card"> + <!-- Action bar (only visible in select-mode) --> + <div id="selectActionBar" class="select-action-bar hidden"> + <div class="select-action-bar__label"> + <span id="selectModeTitle"></span> + <input id="groupNameInput" type="text" class="group-name-input" placeholder="Group name"> + </div> + <div class="select-action-bar__buttons"> + <button id="cancelSelectBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.cancelSelect()">Cancel</button> + <button id="openSelectedBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.openSelectedVisible()">Open selected</button> + <button id="saveGroupBtn" class="btn btn-primary btn-sm" onclick="LandingApp.saveGroup()">Save group</button> + </div> + </div> + <div class="landing-card-header"> <div class="landing-card-title"> <h2>Projects</h2> @@ -47,18 +83,9 @@ </div> <div id="projectListContainer" class="project-list-container"> - <!-- Populated by JS: either a table, a friendly empty state, or a loading message. --> + <!-- Populated by JS --> <div class="project-list-loading">Loading projects…</div> </div> - - <div class="landing-card-footer"> - <span id="selectionSummary" class="landing-selection-summary"></span> - <div class="open-archive-split"> - <button id="openArchiveBtn" class="btn btn-primary open-archive-main" onclick="LandingApp.openArchive()" disabled>Open Archive →</button> - <button id="openArchiveMenuBtn" class="btn btn-primary open-archive-caret" onclick="LandingApp.toggleOpenMenu()" aria-haspopup="menu" aria-expanded="false" aria-label="Presets" title="Presets">▾</button> - <div id="openArchiveMenu" class="open-archive-menu hidden"></div> - </div> - </div> </div> </main> diff --git a/tests/landing.spec.js b/tests/landing.spec.js index ceb73f7..a5bc044 100644 --- a/tests/landing.spec.js +++ b/tests/landing.spec.js @@ -38,22 +38,38 @@ async function loadLandingWithProjects(page, projects) { test.describe('Landing page', () => { - test('renders the welcome hero and a project table when projects come back', async ({ page }) => { + test('renders welcome hero and a project table when projects come back', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); await expect(page.locator('.landing-hero h1')).toContainText(/Welcome/i); const rowCount = await page.locator('.project-table tbody tr').count(); expect(rowCount).toBe(3); - // Title column is shown because at least one project has a title. await expect(page.locator('th.project-table-title-col')).toBeVisible(); await expect(page.locator('.project-table tbody')).toContainText('Greenfield Substation'); - await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap'); - // 210045 has no title — should render a dash placeholder, not the empty string. await expect(page.locator('.project-table-no-title')).toHaveCount(1); }); - test('column filters narrow the table; URL is updated', async ({ page }) => { + test('default mode has no project checkbox column; click row opens that single project', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + // Default mode: no checkbox column on rows. + await expect(page.locator('.project-table tbody td.project-table-checkbox-col')).toHaveCount(0); + + // Stub navigation so the click doesn't navigate the test page. + await page.evaluate(() => { + window.__navTo = null; + window.LandingApp._setNavigate(url => { window.__navTo = url; }); + }); + + await page.locator('.project-table-row[data-name="197072"]').click(); + + const navTo = await page.evaluate(() => window.__navTo); + expect(navTo).toContain('archive.html?projects=197072'); + }); + + test('column filters narrow the table; filters persist in URL', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); @@ -69,71 +85,160 @@ test.describe('Landing page', () => { await page.locator('input.column-filter[data-column="pn"]').fill(''); await page.locator('input.column-filter[data-column="pt"]').fill('Brown'); await page.waitForTimeout(150); - const rowsAfterPt = await page.locator('.project-table tbody tr').count(); - expect(rowsAfterPt).toBe(1); await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap'); }); - test('selecting projects enables Open Archive and writes ?projects=', async ({ page }) => { - await loadLandingWithProjects(page, SAMPLE_PROJECTS); - await page.waitForSelector('.project-table', { timeout: 5000 }); - - await expect(page.locator('#openArchiveBtn')).toBeDisabled(); - - await page.locator('.project-table-row[data-name="176109"]').click(); - await expect(page.locator('#openArchiveBtn')).toBeEnabled(); - await expect(page.locator('#selectionSummary')).toContainText('1 project selected'); - - const search = await page.evaluate(() => location.search); - expect(search).toContain('projects=176109'); - }); - test('shows a friendly empty state when the server returns no projects', async ({ page }) => { await loadLandingWithProjects(page, []); await page.waitForSelector('.project-list-empty', { timeout: 5000 }); await expect(page.locator('.project-list-empty')).toContainText(/No projects to show/); await expect(page.locator('.project-list-empty')).toContainText(/access/i); - await expect(page.locator('#openArchiveBtn')).toBeDisabled(); }); - test('save / load named preset round-trips selection + filters', async ({ page }) => { + test('"+ New group" enters select-mode: checkboxes appear, action bar shows', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); - // Set up state: pick 176109 and filter title by "Green". + await expect(page.locator('#selectActionBar')).toBeHidden(); + await expect(page.locator('.project-table tbody td.project-table-checkbox-col')).toHaveCount(0); + + await page.locator('#newGroupBtn').click(); + + await expect(page.locator('#selectActionBar')).toBeVisible(); + await expect(page.locator('.project-table tbody td.project-table-checkbox-col')).toHaveCount(3); + await expect(page.locator('#groupNameInput')).toBeFocused(); + }); + + test('Save group writes to localStorage and renders in the groups table', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + await page.locator('#newGroupBtn').click(); + // Click two project rows to check them. await page.locator('.project-table-row[data-name="176109"]').click(); - await page.locator('input.column-filter[data-column="pt"]').fill('Green'); - await page.waitForTimeout(150); + await page.locator('.project-table-row[data-name="197072"]').click(); + await page.locator('#groupNameInput').fill('Greenfield Sites'); + await page.locator('#saveGroupBtn').click(); - // Save preset (open the split-button caret menu). - await page.locator('#openArchiveMenuBtn').click(); - await page.locator('button:has-text("Save current as preset")').click(); - await page.locator('#presetNameInput').fill('My View'); - await page.locator('button:has-text("Save")').click(); + // Action bar closed, group rendered in groups table. + await expect(page.locator('#selectActionBar')).toBeHidden(); + await expect(page.locator('.groups-table')).toContainText('Greenfield Sites'); + await expect(page.locator('.groups-row[data-name="Greenfield Sites"] .groups-row-count')).toContainText('2 projects'); - // Clear state so we can verify preset application restores it. - // (Manual clear: unclick the row and empty the title filter.) + const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups')); + expect(stored).toContain('Greenfield Sites'); + expect(stored).toContain('176109'); + expect(stored).toContain('197072'); + }); + + test('clicking a group row opens archive with that group\'s projects', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + // Seed a group via storage and reload. + await page.evaluate(() => { + localStorage.setItem('zddc_landing_groups', JSON.stringify([ + { name: 'Both', projects: ['176109', '197072'] } + ])); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.groups-row', { timeout: 5000 }); + + await page.evaluate(() => { + window.__navTo = null; + window.LandingApp._setNavigate(url => { window.__navTo = url; }); + }); + + await page.locator('.groups-row[data-name="Both"]').click(); + const navTo = await page.evaluate(() => window.__navTo); + expect(navTo).toMatch(/archive\.html\?projects=176109,197072|archive\.html\?projects=197072,176109/); + }); + + test('delete button removes a group', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.evaluate(() => { + localStorage.setItem('zddc_landing_groups', JSON.stringify([ + { name: 'ToGo', projects: ['176109'] } + ])); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.groups-row', { timeout: 5000 }); + + // Auto-confirm the confirm() dialog. + page.once('dialog', d => d.accept()); + await page.locator('.groups-row[data-name="ToGo"] .groups-btn-delete').click(); + + await expect(page.locator('.groups-row[data-name="ToGo"]')).toHaveCount(0); + const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups')); + expect(stored).not.toContain('ToGo'); + }); + + test('edit button enters select-mode pre-populated with that group\'s projects', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.evaluate(() => { + localStorage.setItem('zddc_landing_groups', JSON.stringify([ + { name: 'OnlyOne', projects: ['176109'] } + ])); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.groups-row', { timeout: 5000 }); + + await page.locator('.groups-row[data-name="OnlyOne"] .groups-btn-edit').click(); + + await expect(page.locator('#selectActionBar')).toBeVisible(); + await expect(page.locator('#groupNameInput')).toHaveValue('OnlyOne'); + // Pre-checked: 176109 only. + const checked = page.locator('.project-table tbody input[type="checkbox"]:checked'); + await expect(checked).toHaveCount(1); + const checkedValue = await checked.getAttribute('value'); + expect(checkedValue).toBe('176109'); + }); + + test('"Open selected" excludes filtered-out checked projects', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + await page.waitForSelector('.project-table', { timeout: 5000 }); + + await page.locator('#newGroupBtn').click(); + // Check two: 176109 and 197072. await page.locator('.project-table-row[data-name="176109"]').click(); - await page.locator('input.column-filter[data-column="pt"]').fill(''); - await page.waitForTimeout(150); - // Sanity: nothing selected, no filter. - const cleared = await page.evaluate(() => ({ - selectedRows: document.querySelectorAll('.project-table-row.is-selected').length, - ptValue: document.querySelector('input.column-filter[data-column="pt"]').value, - })); - expect(cleared.selectedRows).toBe(0); - expect(cleared.ptValue).toBe(''); + await page.locator('.project-table-row[data-name="197072"]').click(); - // Open the menu and click "Load" on the preset (apply-stay variant — - // clicking the preset name itself would navigate to archive.html). - await page.locator('#openArchiveMenuBtn').click(); - await page.waitForTimeout(100); - await page.locator('.preset-menu-item:has(.preset-menu-item-name:has-text("My View")) .preset-load-btn').click(); + // Filter to hide 197072 (its name doesn't contain "176"). + await page.locator('input.column-filter[data-column="pn"]').fill('176'); await page.waitForTimeout(150); - await expect(page.locator('.project-table-row[data-name="176109"]')).toHaveClass(/is-selected/); - const ptVal = await page.locator('input.column-filter[data-column="pt"]').inputValue(); - expect(ptVal).toBe('Green'); + // Stub navigation. + await page.evaluate(() => { + window.__navTo = null; + window.LandingApp._setNavigate(url => { window.__navTo = url; }); + }); + + await page.locator('#openSelectedBtn').click(); + + const navTo = await page.evaluate(() => window.__navTo); + expect(navTo).toContain('archive.html?projects=176109'); + expect(navTo).not.toContain('197072'); + }); + + test('legacy presets are migrated to groups on first load', async ({ page }) => { + await loadLandingWithProjects(page, SAMPLE_PROJECTS); + // Seed legacy presets and clear the new key. + await page.evaluate(() => { + localStorage.removeItem('zddc_landing_groups'); + localStorage.setItem('zddc_landing_presets', JSON.stringify([ + { name: 'Old Preset', state: { projects: ['176109', '210045'], pn: 'foo', sort: 'name' } } + ])); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.groups-row', { timeout: 5000 }); + + await expect(page.locator('.groups-row[data-name="Old Preset"]')).toBeVisible(); + const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups')); + expect(stored).toContain('Old Preset'); + expect(stored).toContain('176109'); + expect(stored).toContain('210045'); + // Filter/sort metadata should NOT carry over. + expect(stored).not.toContain('foo'); }); });