feat(landing): groups + click-to-open redesign
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) <noreply@anthropic.com>
This commit is contained in:
parent
f01a177b73
commit
d0929a2aa9
4 changed files with 612 additions and 485 deletions
|
|
@ -57,14 +57,16 @@ body {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main card */
|
/* Cards (groups + projects) */
|
||||||
.landing-card {
|
.landing-card {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
.landing-card:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
.landing-card-header {
|
.landing-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -98,7 +100,109 @@ body {
|
||||||
flex-wrap: wrap;
|
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 {
|
.project-list-container {
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
@ -238,143 +342,3 @@ body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,50 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
// ZDDC landing page — project picker.
|
// ZDDC landing page — project picker.
|
||||||
//
|
//
|
||||||
// Pulls the ACL-filtered project list from zddc-server (GET /
|
// Two stacked sections:
|
||||||
// Accept: application/json → ProjectInfo[]), renders a sortable/filterable
|
// 1. Groups (saved bundles of projects) — click row to open, edit, or delete
|
||||||
// table, persists view state in the URL (so links are shareable), and lets
|
// 2. Projects (live list from the server) — click row to open one project directly
|
||||||
// the user save named presets in localStorage. Pressing "Open Archive"
|
//
|
||||||
// navigates to archive.html?projects=<selected>.
|
// "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 ────────────────────────────────────────────────────────────────
|
// ── State ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var allProjects = []; // [{name, title, url}] from server
|
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 columnFilters = { pn: '', pt: '' };
|
||||||
var columnFilterASTs = { pn: null, pt: null };
|
var columnFilterASTs = { pn: null, pt: null };
|
||||||
var sortField = 'name'; // 'name' | 'title'
|
var sortField = 'name'; // 'name' | 'title'
|
||||||
var sortDirection = 'asc';
|
var sortDirection = 'asc';
|
||||||
var presets = []; // [{name, state}] from localStorage
|
|
||||||
var loadError = null; // user-facing error string
|
var loadError = null; // user-facing error string
|
||||||
var loadErrorKind = null; // 'static' | 'auth' | 'non-json' | 'network'
|
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_FIELD = 'name';
|
||||||
var DEFAULT_SORT_DIRECTION = 'asc';
|
var DEFAULT_SORT_DIRECTION = 'asc';
|
||||||
|
|
||||||
// ── URL state ────────────────────────────────────────────────────────────
|
// ── 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() {
|
function urlSerialize() {
|
||||||
var p = new URLSearchParams();
|
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.pn) p.set('pn', columnFilters.pn);
|
||||||
if (columnFilters.pt) p.set('pt', columnFilters.pt);
|
if (columnFilters.pt) p.set('pt', columnFilters.pt);
|
||||||
if (sortField !== DEFAULT_SORT_FIELD) p.set('sort', sortField);
|
if (sortField !== DEFAULT_SORT_FIELD) p.set('sort', sortField);
|
||||||
|
|
@ -53,10 +67,6 @@
|
||||||
|
|
||||||
function urlRestore() {
|
function urlRestore() {
|
||||||
var p = new URLSearchParams(location.search);
|
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')) {
|
if (p.has('pn')) {
|
||||||
columnFilters.pn = p.get('pn');
|
columnFilters.pn = p.get('pn');
|
||||||
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
|
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
|
||||||
|
|
@ -92,11 +102,6 @@
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
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 ctype = resp.headers.get('Content-Type') || '';
|
||||||
var body = await resp.text();
|
var body = await resp.text();
|
||||||
var trimmed = body.trim();
|
var trimmed = body.trim();
|
||||||
|
|
@ -113,10 +118,6 @@
|
||||||
loadErrorKind = 'auth';
|
loadErrorKind = 'auth';
|
||||||
throw new Error("The request was redirected to " + resp.url + ' — likely to an auth/login page. Sign in and reload.');
|
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 (/<title>\s*Loading\s+ZDDC/i.test(trimmed) || /<title>\s*Loading\s+Archive/i.test(trimmed)) {
|
if (/<title>\s*Loading\s+ZDDC/i.test(trimmed) || /<title>\s*Loading\s+Archive/i.test(trimmed)) {
|
||||||
loadErrorKind = 'static';
|
loadErrorKind = 'static';
|
||||||
throw new Error("This deployment doesn't expose a project list. The server is serving static stubs without a zddc-server backend.");
|
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) {
|
rows.sort(function(a, b) {
|
||||||
var av = (a[sortField] || '').toString();
|
var av = (a[sortField] || '').toString();
|
||||||
var bv = (b[sortField] || '').toString();
|
var bv = (b[sortField] || '').toString();
|
||||||
// Empty titles sort last regardless of direction.
|
|
||||||
if (sortField === 'title') {
|
if (sortField === 'title') {
|
||||||
if (!av && bv) return 1;
|
if (!av && bv) return 1;
|
||||||
if (av && !bv) return -1;
|
if (av && !bv) return -1;
|
||||||
|
|
@ -168,13 +168,65 @@
|
||||||
// ── Rendering ────────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
renderTable();
|
renderActionBar();
|
||||||
renderPresetMenu();
|
renderGroups();
|
||||||
renderSelectionSummary();
|
renderProjects();
|
||||||
renderProjectCount();
|
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');
|
var container = document.getElementById('projectListContainer');
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
var heading, help;
|
var heading, help;
|
||||||
|
|
@ -209,19 +261,23 @@
|
||||||
|
|
||||||
var rows = visibleProjects();
|
var rows = visibleProjects();
|
||||||
var anyTitles = allProjects.some(function(p) { return p.title; });
|
var anyTitles = allProjects.some(function(p) { return p.title; });
|
||||||
var visibleSelected = rows.filter(function(r) { return selected.has(r.name); }).length;
|
var inSelect = !!selectMode;
|
||||||
var headerCheckedState = visibleSelected === 0 ? 'unchecked'
|
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';
|
: visibleSelected === rows.length ? 'checked' : 'indeterminate';
|
||||||
|
|
||||||
var html = '<table class="project-table">';
|
var html = '<table class="project-table' + (inSelect ? ' is-select-mode' : '') + '">';
|
||||||
html += '<thead>';
|
html += '<thead>';
|
||||||
html += '<tr class="project-table-headers">';
|
html += '<tr class="project-table-headers">';
|
||||||
|
if (inSelect) {
|
||||||
html += '<th class="project-table-checkbox-col">'
|
html += '<th class="project-table-checkbox-col">'
|
||||||
+ '<input type="checkbox" id="headerCheckbox" '
|
+ '<input type="checkbox" id="headerCheckbox" '
|
||||||
+ (headerCheckedState === 'checked' ? 'checked ' : '')
|
+ (headerCheckedState === 'checked' ? 'checked ' : '')
|
||||||
+ 'onclick="LandingApp.toggleHeaderCheckbox()" '
|
+ 'onclick="LandingApp.toggleHeaderCheckbox()" '
|
||||||
+ 'title="Check / uncheck all visible projects">'
|
+ 'title="Check / uncheck all visible projects">'
|
||||||
+ '</th>';
|
+ '</th>';
|
||||||
|
}
|
||||||
html += '<th class="project-table-name-col" data-sort="name" onclick="LandingApp.toggleSort(\'name\')">'
|
html += '<th class="project-table-name-col" data-sort="name" onclick="LandingApp.toggleSort(\'name\')">'
|
||||||
+ 'Project number ' + sortIndicator('name')
|
+ 'Project number ' + sortIndicator('name')
|
||||||
+ '</th>';
|
+ '</th>';
|
||||||
|
|
@ -232,7 +288,7 @@
|
||||||
}
|
}
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '<tr class="project-table-filters">';
|
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' : '') + '" '
|
html += '<th><input type="text" class="column-filter ' + (columnFilters.pn ? 'filter-active' : '') + '" '
|
||||||
+ 'data-column="pn" placeholder="filter…" '
|
+ 'data-column="pn" placeholder="filter…" '
|
||||||
+ 'value="' + escapeHtml(columnFilters.pn) + '" '
|
+ 'value="' + escapeHtml(columnFilters.pn) + '" '
|
||||||
|
|
@ -246,17 +302,23 @@
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '</thead>';
|
html += '</thead>';
|
||||||
|
|
||||||
|
var colspan = (inSelect ? 1 : 0) + 1 + (anyTitles ? 1 : 0);
|
||||||
if (rows.length === 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.'
|
+ 'No projects match the current filters.'
|
||||||
+ '</td></tr></tbody>';
|
+ '</td></tr></tbody>';
|
||||||
} else {
|
} else {
|
||||||
html += '<tbody>';
|
html += '<tbody>';
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
var r = rows[i];
|
var r = rows[i];
|
||||||
var checked = selected.has(r.name) ? ' checked' : '';
|
var isSel = inSelect && selected.has(r.name);
|
||||||
html += '<tr class="project-table-row' + (selected.has(r.name) ? ' is-selected' : '') + '" data-name="' + escapeHtml(r.name) + '" onclick="LandingApp.toggleRow(event)">';
|
html += '<tr class="project-table-row' + (isSel ? ' is-selected' : '') + '" '
|
||||||
html += '<td class="project-table-checkbox-col"><input type="checkbox" value="' + escapeHtml(r.name) + '"' + checked + ' onclick="event.stopPropagation(); LandingApp.toggleByCheckbox(event)"></td>';
|
+ '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>';
|
html += '<td class="project-table-name-col">' + escapeHtml(r.name) + '</td>';
|
||||||
if (anyTitles) {
|
if (anyTitles) {
|
||||||
html += '<td class="project-table-title-col">' + (r.title ? escapeHtml(r.title) : '<span class="project-table-no-title">—</span>') + '</td>';
|
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>';
|
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() {
|
function renderProjectCount() {
|
||||||
var el = document.getElementById('projectCount');
|
var el = document.getElementById('projectCount');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (loadError || allProjects.length === 0) { el.textContent = ''; return; }
|
if (loadError || allProjects.length === 0) { el.textContent = ''; return; }
|
||||||
var rows = visibleProjects();
|
var rows = visibleProjects();
|
||||||
if (rows.length === allProjects.length) {
|
var base = rows.length === allProjects.length
|
||||||
el.textContent = '(' + allProjects.length + ')';
|
? '(' + allProjects.length + ')'
|
||||||
} else {
|
: '(' + rows.length + ' of ' + allProjects.length + ')';
|
||||||
el.textContent = '(' + 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 ─────────────────────────────────────────────────────
|
// ── Events / actions ─────────────────────────────────────────────────────
|
||||||
|
|
@ -363,10 +378,8 @@
|
||||||
columnFilters[col] = val;
|
columnFilters[col] = val;
|
||||||
columnFilterASTs[col] = parseFilterAST(val);
|
columnFilterASTs[col] = parseFilterAST(val);
|
||||||
urlPush();
|
urlPush();
|
||||||
// Re-render table only — don't lose input focus by re-rendering preset menu.
|
renderProjects();
|
||||||
renderTable();
|
|
||||||
renderProjectCount();
|
renderProjectCount();
|
||||||
// Refocus the input we typed into.
|
|
||||||
var sel = document.querySelector('.column-filter[data-column="' + col + '"]');
|
var sel = document.querySelector('.column-filter[data-column="' + col + '"]');
|
||||||
if (sel) {
|
if (sel) {
|
||||||
sel.focus();
|
sel.focus();
|
||||||
|
|
@ -374,46 +387,73 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleByCheckbox(e) {
|
function onProjectRowClick(e) {
|
||||||
var cb = e.target;
|
|
||||||
var name = cb.value;
|
|
||||||
if (cb.checked) selected.add(name);
|
|
||||||
else selected.delete(name);
|
|
||||||
urlPush();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRow(e) {
|
|
||||||
var row = e.target.closest('.project-table-row');
|
var row = e.target.closest('.project-table-row');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
var name = row.getAttribute('data-name');
|
var name = row.getAttribute('data-name');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
if (selectMode) {
|
||||||
|
// In select-mode the row toggles its checkbox.
|
||||||
if (selected.has(name)) selected.delete(name);
|
if (selected.has(name)) selected.delete(name);
|
||||||
else selected.add(name);
|
else selected.add(name);
|
||||||
urlPush();
|
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();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHeaderCheckbox() {
|
function toggleHeaderCheckbox() {
|
||||||
|
if (!selectMode) return;
|
||||||
var cb = document.getElementById('headerCheckbox');
|
var cb = document.getElementById('headerCheckbox');
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
var rows = visibleProjects();
|
var rows = visibleProjects();
|
||||||
if (cb.checked) {
|
if (cb.checked) rows.forEach(function(r) { selected.add(r.name); });
|
||||||
rows.forEach(function(r) { selected.add(r.name); });
|
else rows.forEach(function(r) { selected.delete(r.name); });
|
||||||
} else {
|
|
||||||
rows.forEach(function(r) { selected.delete(r.name); });
|
|
||||||
}
|
|
||||||
urlPush();
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openArchive() {
|
// Navigation hook — tests replace this via LandingApp._setNavigate.
|
||||||
if (selected.size === 0) return;
|
// (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 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');
|
var v = new URLSearchParams(location.search).get('v');
|
||||||
if (v) params.push('v=' + encodeURIComponent(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() {
|
function dismissWarning() {
|
||||||
|
|
@ -429,179 +469,167 @@
|
||||||
el.classList.remove('hidden');
|
el.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Presets ──────────────────────────────────────────────────────────────
|
// ── Select-mode (create / edit groups) ───────────────────────────────────
|
||||||
|
|
||||||
function loadPresets() {
|
function startCreateGroup() {
|
||||||
try {
|
selectMode = { kind: 'create' };
|
||||||
var raw = localStorage.getItem(PRESETS_KEY);
|
selected = new Set();
|
||||||
var parsed = raw ? JSON.parse(raw) : [];
|
render();
|
||||||
presets = Array.isArray(parsed) ? parsed : [];
|
var input = document.getElementById('groupNameInput');
|
||||||
} catch (e) {
|
if (input) input.focus();
|
||||||
presets = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistPresets() {
|
function startEditGroup(e) {
|
||||||
try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); }
|
var btn = e.target.closest('.groups-btn-edit');
|
||||||
catch (e) { /* private mode / quota */ }
|
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() {
|
function deleteGroup(e) {
|
||||||
return {
|
var btn = e.target.closest('.groups-btn-delete');
|
||||||
projects: Array.from(selected).sort(),
|
if (!btn) return;
|
||||||
pn: columnFilters.pn || '',
|
var name = btn.getAttribute('data-name');
|
||||||
pt: columnFilters.pt || '',
|
if (!confirm('Delete group "' + name + '"?')) return;
|
||||||
sort: sortField,
|
groups = groups.filter(function(g) { return g.name !== name; });
|
||||||
dir: sortDirection
|
persistGroups();
|
||||||
};
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyState(s) {
|
function cancelSelect() {
|
||||||
selected = new Set(Array.isArray(s.projects) ? s.projects : []);
|
selectMode = null;
|
||||||
columnFilters.pn = s.pn || '';
|
selected = new Set();
|
||||||
columnFilters.pt = s.pt || '';
|
render();
|
||||||
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
|
|
||||||
columnFilterASTs.pt = parseFilterAST(columnFilters.pt);
|
|
||||||
sortField = s.sort === 'title' ? 'title' : 'name';
|
|
||||||
sortDirection = s.dir === 'desc' ? 'desc' : 'asc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOpenMenu() {
|
function saveGroup() {
|
||||||
var menu = document.getElementById('openArchiveMenu');
|
if (!selectMode) return;
|
||||||
var btn = document.getElementById('openArchiveMenuBtn');
|
var input = document.getElementById('groupNameInput');
|
||||||
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');
|
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
var name = (input.value || '').trim();
|
var name = (input.value || '').trim();
|
||||||
if (!name) return;
|
if (!name) {
|
||||||
presets = presets.filter(function(p) { return p.name !== name; });
|
input.focus();
|
||||||
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();
|
|
||||||
return;
|
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) {
|
function openSelectedVisible() {
|
||||||
presets = presets.filter(function(p) { return p.name !== name; });
|
if (!selectMode) return;
|
||||||
persistPresets();
|
// Rule: open only those that are currently visible (filtered in) AND
|
||||||
renderPresetMenu();
|
// 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 ────────────────────────────────────────────────────────────
|
// ── Bootstrap ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
loadPresets();
|
loadGroups();
|
||||||
urlRestore();
|
urlRestore();
|
||||||
|
|
||||||
var ok = await fetchProjects();
|
var ok = await fetchProjects();
|
||||||
if (ok) {
|
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 accessibleNames = new Set(allProjects.map(function(p) { return p.name; }));
|
||||||
var missing = [];
|
var ghostlyGroups = groups.filter(function(g) {
|
||||||
var pruned = new Set();
|
return g.projects.some(function(p) { return !accessibleNames.has(p); });
|
||||||
selected.forEach(function(n) {
|
|
||||||
if (accessibleNames.has(n)) pruned.add(n);
|
|
||||||
else missing.push(n);
|
|
||||||
});
|
});
|
||||||
selected = pruned;
|
if (ghostlyGroups.length > 0) {
|
||||||
if (missing.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; }));
|
||||||
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
// Close preset menu on outside click.
|
// Wire up keyboard shortcuts in the action-bar input: Enter saves,
|
||||||
document.addEventListener('click', function(e) {
|
// Escape cancels.
|
||||||
var menu = document.getElementById('openArchiveMenu');
|
var input = document.getElementById('groupNameInput');
|
||||||
var btn = document.getElementById('openArchiveMenuBtn');
|
if (input) {
|
||||||
if (!menu || menu.classList.contains('hidden')) return;
|
input.addEventListener('keydown', function(e) {
|
||||||
if (!menu.contains(e.target) && e.target !== btn) {
|
if (e.key === 'Enter') { e.preventDefault(); saveGroup(); }
|
||||||
closeOpenMenu();
|
else if (e.key === 'Escape') { e.preventDefault(); cancelSelect(); }
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
|
|
@ -615,18 +643,21 @@
|
||||||
window.LandingApp = {
|
window.LandingApp = {
|
||||||
init: init,
|
init: init,
|
||||||
toggleByCheckbox: toggleByCheckbox,
|
toggleByCheckbox: toggleByCheckbox,
|
||||||
toggleRow: toggleRow,
|
|
||||||
toggleHeaderCheckbox: toggleHeaderCheckbox,
|
toggleHeaderCheckbox: toggleHeaderCheckbox,
|
||||||
toggleSort: toggleSort,
|
toggleSort: toggleSort,
|
||||||
onColumnFilterInput: onColumnFilterInput,
|
onColumnFilterInput: onColumnFilterInput,
|
||||||
openArchive: openArchive,
|
onProjectRowClick: onProjectRowClick,
|
||||||
toggleOpenMenu: toggleOpenMenu,
|
openGroup: openGroup,
|
||||||
startSavePreset: startSavePreset,
|
startCreateGroup: startCreateGroup,
|
||||||
cancelSavePreset: cancelSavePreset,
|
startEditGroup: startEditGroup,
|
||||||
confirmSavePreset: confirmSavePreset,
|
deleteGroup: deleteGroup,
|
||||||
applyPreset: applyPreset,
|
cancelSelect: cancelSelect,
|
||||||
deletePreset: deletePreset,
|
saveGroup: saveGroup,
|
||||||
dismissWarning: dismissWarning
|
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; }
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
<body>
|
<body>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<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="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,9 +33,8 @@
|
||||||
<section class="landing-hero">
|
<section class="landing-hero">
|
||||||
<h1>Welcome to the ZDDC Archive</h1>
|
<h1>Welcome to the ZDDC Archive</h1>
|
||||||
<p class="landing-hero-sub">
|
<p class="landing-hero-sub">
|
||||||
Pick the projects you want to view, then open the archive. Filter by
|
Click a group or project below to open the archive. Use
|
||||||
project number or title, and save your selection as a preset to
|
<strong>+ New group</strong> to bundle a set of projects you open together.
|
||||||
share or come back to later.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -37,8 +44,37 @@
|
||||||
<button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">×</button>
|
<button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project picker card -->
|
<!-- Groups card -->
|
||||||
<div class="landing-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-header">
|
||||||
<div class="landing-card-title">
|
<div class="landing-card-title">
|
||||||
<h2>Projects</h2>
|
<h2>Projects</h2>
|
||||||
|
|
@ -47,18 +83,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="projectListContainer" class="project-list-container">
|
<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 class="project-list-loading">Loading projects…</div>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,22 +38,38 @@ async function loadLandingWithProjects(page, projects) {
|
||||||
|
|
||||||
test.describe('Landing page', () => {
|
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 loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
await page.waitForSelector('.project-table', { timeout: 5000 });
|
||||||
|
|
||||||
await expect(page.locator('.landing-hero h1')).toContainText(/Welcome/i);
|
await expect(page.locator('.landing-hero h1')).toContainText(/Welcome/i);
|
||||||
const rowCount = await page.locator('.project-table tbody tr').count();
|
const rowCount = await page.locator('.project-table tbody tr').count();
|
||||||
expect(rowCount).toBe(3);
|
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('th.project-table-title-col')).toBeVisible();
|
||||||
await expect(page.locator('.project-table tbody')).toContainText('Greenfield Substation');
|
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);
|
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 loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
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="pn"]').fill('');
|
||||||
await page.locator('input.column-filter[data-column="pt"]').fill('Brown');
|
await page.locator('input.column-filter[data-column="pt"]').fill('Brown');
|
||||||
await page.waitForTimeout(150);
|
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');
|
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 }) => {
|
test('shows a friendly empty state when the server returns no projects', async ({ page }) => {
|
||||||
await loadLandingWithProjects(page, []);
|
await loadLandingWithProjects(page, []);
|
||||||
await page.waitForSelector('.project-list-empty', { timeout: 5000 });
|
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(/No projects to show/);
|
||||||
await expect(page.locator('.project-list-empty')).toContainText(/access/i);
|
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 loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
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('.project-table-row[data-name="176109"]').click();
|
||||||
await page.locator('input.column-filter[data-column="pt"]').fill('Green');
|
await page.locator('.project-table-row[data-name="197072"]').click();
|
||||||
await page.waitForTimeout(150);
|
await page.locator('#groupNameInput').fill('Greenfield Sites');
|
||||||
|
await page.locator('#saveGroupBtn').click();
|
||||||
|
|
||||||
// Save preset (open the split-button caret menu).
|
// Action bar closed, group rendered in groups table.
|
||||||
await page.locator('#openArchiveMenuBtn').click();
|
await expect(page.locator('#selectActionBar')).toBeHidden();
|
||||||
await page.locator('button:has-text("Save current as preset")').click();
|
await expect(page.locator('.groups-table')).toContainText('Greenfield Sites');
|
||||||
await page.locator('#presetNameInput').fill('My View');
|
await expect(page.locator('.groups-row[data-name="Greenfield Sites"] .groups-row-count')).toContainText('2 projects');
|
||||||
await page.locator('button:has-text("Save")').click();
|
|
||||||
|
|
||||||
// Clear state so we can verify preset application restores it.
|
const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups'));
|
||||||
// (Manual clear: unclick the row and empty the title filter.)
|
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('.project-table-row[data-name="176109"]').click();
|
||||||
await page.locator('input.column-filter[data-column="pt"]').fill('');
|
await page.locator('.project-table-row[data-name="197072"]').click();
|
||||||
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('');
|
|
||||||
|
|
||||||
// Open the menu and click "Load" on the preset (apply-stay variant —
|
// Filter to hide 197072 (its name doesn't contain "176").
|
||||||
// clicking the preset name itself would navigate to archive.html).
|
await page.locator('input.column-filter[data-column="pn"]').fill('176');
|
||||||
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();
|
|
||||||
await page.waitForTimeout(150);
|
await page.waitForTimeout(150);
|
||||||
|
|
||||||
await expect(page.locator('.project-table-row[data-name="176109"]')).toHaveClass(/is-selected/);
|
// Stub navigation.
|
||||||
const ptVal = await page.locator('input.column-filter[data-column="pt"]').inputValue();
|
await page.evaluate(() => {
|
||||||
expect(ptVal).toBe('Green');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue