Completing T1.1. My first verification primed localStorage before loading each
page, which made both sources of truth agree and hid two remaining cases. Re-run
with genuinely cold storage, the app bar still showed "(unnamed)" on the field
view and "Select a project" on the console pages.
Two causes, both the same F1 shape - a page holding a copy the shared store
does not have:
1. field.js could only write {id} at boot (it needs the id synchronously, for
the per-project storage namespace), then resolved the full record into a
local PROJECT variable, rendered "Project: Job A" from it, and never
published it. The store kept the stub, so the bar read "(unnamed)".
setActive now fills a nameless record in from the cached project list, or
from the API when the cache has not loaded yet, and re-checks the id before
applying a slow response so it cannot overwrite a project the user has since
switched to. That fixes every caller of this shape rather than the one that
was caught - work-package-suite-app.js and wp-creation-app.js write the same
stub. field.js also publishes the record it already fetched, so the common
path costs no extra request.
2. admin.html and users.html have no project-resolution logic of their own, so
nothing read ?project= and a deep link left the bar on whatever was last
stored. The bar is the one component every chromed page has, so it resolves
the parameter once in wp-chrome.js rather than being taught to five pages.
Verified with localStorage cleared before every navigation: a cold deep link
now shows the project on field, SOP wizard, launcher, admin and users, and the
stored record carries the name rather than a stub.
The creator remains the one page with no app bar - it loads no chrome because
it renders as the iframe child. T7.1.
browser_check 71/71. f_items F1 FIXED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
416 lines
19 KiB
JavaScript
416 lines
19 KiB
JavaScript
/* Shared app chrome for the Work Package Suite: a project switcher beside the
|
|
Prime logo and a global search centered in the top bar.
|
|
|
|
One script for every page because there are two generations of top bar — the
|
|
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
|
|
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
|
|
flip a colour set based on how dark the host bar is.
|
|
|
|
Search hits GET /api/search, which scopes results to the projects the signed-in
|
|
user may access — so this is a convenience, never a way to see another job.
|
|
|
|
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
|
|
second bar inside the frame would be nonsense. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
|
if (inIframe) return;
|
|
|
|
var SEARCH_MIN = 2; // characters before we ask the server
|
|
var DEBOUNCE_MS = 180;
|
|
|
|
function el(tag, cls, html) {
|
|
var n = document.createElement(tag);
|
|
if (cls) n.className = cls;
|
|
if (html != null) n.innerHTML = html;
|
|
return n;
|
|
}
|
|
function esc(v) {
|
|
return String(v == null ? '' : v)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
function isDark(node) {
|
|
try {
|
|
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
|
if (!m) return false;
|
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
// ── where to put the chrome ────────────────────────────────────────────────
|
|
// Returns {host, insertBefore} or null. The insertion point matters: on the
|
|
// dark bar we sit before the spacer (so search takes the middle); on the light
|
|
// bars we sit between the left block and the right-hand buttons.
|
|
function findMount() {
|
|
var appbar = document.querySelector('.wp-appbar');
|
|
if (appbar) {
|
|
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
|
|
}
|
|
var header = document.querySelector('.header');
|
|
if (header) {
|
|
// The suite page wraps its own left/right groups; the creator's bar is a
|
|
// flat row of buttons whose first button carries margin-left:auto.
|
|
var right = header.querySelector('.header-right');
|
|
if (right) return { host: header, before: right };
|
|
var firstBtn = header.querySelector('.btn, button');
|
|
return { host: header, before: firstBtn };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── project switcher ───────────────────────────────────────────────────────
|
|
var projects = [];
|
|
|
|
function activeProject() {
|
|
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
|
|
}
|
|
|
|
function projectLabel(p) {
|
|
if (!p) return 'Select a project';
|
|
var n = p.name || '(unnamed)';
|
|
return p.number ? (p.number + ' — ' + n) : n;
|
|
}
|
|
|
|
// Switching project reloads the current page with ?project=<id>. Every page
|
|
// already resolves its project from that param (falling back to the stored
|
|
// active id), so a reload is both the simplest and the safest route — no page
|
|
// has to re-hydrate half its state in place.
|
|
function switchProject(p) {
|
|
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
|
|
var url = new URL(location.href);
|
|
url.searchParams.set('project', p.id);
|
|
url.hash = '';
|
|
location.assign(url.toString());
|
|
}
|
|
|
|
function buildProjectSwitcher() {
|
|
var wrap = el('div', 'wpc-proj');
|
|
var btn = el('button', 'wpc-proj-btn');
|
|
btn.type = 'button';
|
|
btn.setAttribute('aria-haspopup', 'listbox');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.title = 'Switch project';
|
|
var cur = activeProject();
|
|
btn.innerHTML =
|
|
'<span class="wpc-proj-labels">' +
|
|
'<span class="wpc-proj-kicker">Project</span>' +
|
|
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
|
|
'</span><span class="wpc-caret">▾</span>';
|
|
var pop = el('div', 'wpc-pop');
|
|
pop.hidden = true;
|
|
wrap.appendChild(btn);
|
|
wrap.appendChild(pop);
|
|
|
|
function render() {
|
|
var curId = (activeProject() || {}).id || '';
|
|
var rows = projects.map(function (p) {
|
|
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
|
|
'" data-pid="' + esc(p.id) + '">' +
|
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
|
|
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
|
|
'</button>';
|
|
}).join('');
|
|
pop.innerHTML =
|
|
'<div class="wpc-pop-head">Switch project</div>' +
|
|
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
|
|
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
|
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
|
|
item.addEventListener('click', function () {
|
|
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
|
|
if (p) switchProject(p);
|
|
});
|
|
});
|
|
}
|
|
|
|
function open() {
|
|
render();
|
|
pop.hidden = false;
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
}
|
|
function close() {
|
|
pop.hidden = true;
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
if (pop.hidden) open(); else close();
|
|
});
|
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
|
|
|
// Refresh the label once the project list (and any active project) is known.
|
|
wrap.wpcRefresh = function () {
|
|
var c = activeProject();
|
|
var nameEl = btn.querySelector('.wpc-proj-name');
|
|
if (nameEl) nameEl.textContent = projectLabel(c);
|
|
if (!pop.hidden) render();
|
|
};
|
|
return wrap;
|
|
}
|
|
|
|
function loadProjects(switcher) {
|
|
// ProjectData.list() already hits the API and falls back to its local cache
|
|
// when offline, so there's no second request to make here.
|
|
var p;
|
|
try {
|
|
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
|
|
} catch (e) { p = null; }
|
|
if (!p) {
|
|
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : []; });
|
|
}
|
|
Promise.resolve(p)
|
|
.then(function (list) {
|
|
projects = Array.isArray(list) ? list : [];
|
|
// A deep link names the project, and the bar is the one component every
|
|
// page carrying chrome has. The launcher, SOP wizard, creator and field
|
|
// view each resolve ?project= themselves; admin.html and users.html have
|
|
// no project logic at all, so without this their bar shows whatever was
|
|
// last stored — or "Select a project" on a cold browser — while the URL
|
|
// says otherwise. Resolving it here covers every page once.
|
|
try {
|
|
var wanted = new URLSearchParams(location.search).get('project');
|
|
if (wanted && window.ProjectData && ProjectData.setActive) {
|
|
var hit = projects.filter(function (x) { return x.id === wanted; })[0];
|
|
var cur = activeProject();
|
|
if (hit && (!cur || cur.id !== hit.id || !cur.name)) ProjectData.setActive(hit);
|
|
}
|
|
} catch (e) {}
|
|
switcher.wpcRefresh();
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
// ── archived-project banner ────────────────────────────────────────────────
|
|
// An archived project is still readable and still deep-linkable (?project=<id>),
|
|
// but every write now 409s. With nothing on the page to say so, that reads as a
|
|
// silent failure — so the bar, the one thing every page has, carries the warning.
|
|
//
|
|
// The state comes from GET /api/projects/<id>, never from "it's missing from the
|
|
// switcher": absence also means "you have no access to it", which is a different
|
|
// message. Fails closed and silent — an error means no banner, not a broken page.
|
|
function activeProjectId() {
|
|
try {
|
|
var q = new URLSearchParams(location.search).get('project');
|
|
if (q) return q;
|
|
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
|
} catch (e) { return ''; }
|
|
}
|
|
|
|
function buildArchivedBanner() {
|
|
var bar = el('div', 'wpc-archived');
|
|
bar.setAttribute('role', 'status');
|
|
bar.innerHTML =
|
|
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
|
|
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
|
|
'Unarchive it from the Admin Console to make changes.</span>';
|
|
return bar;
|
|
}
|
|
|
|
function checkArchived(host) {
|
|
var id = activeProjectId();
|
|
if (!id || !host || !host.parentNode) return;
|
|
var pinned = false;
|
|
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
|
|
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (p) {
|
|
if (!p || !p.archived) return;
|
|
// The home page reconciles the stored active project against the (now
|
|
// archive-filtered) list while this request is in flight, and drops it. If
|
|
// that happened, the id we asked about is nobody's context any more —
|
|
// banner-ing it would contradict the picker one line below. A ?project=
|
|
// deep link is pinned to this page and can't be cleared out from under us.
|
|
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
|
|
ProjectData.getActiveId() !== id) return;
|
|
// Also on the document element, so the two big apps can gate their own UI
|
|
// from CSS or a boot check without a second round trip. The server stays
|
|
// the real gate; this is only there so the UI can agree with it.
|
|
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
|
|
if (document.querySelector('.wpc-archived')) return;
|
|
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
// ── global search ──────────────────────────────────────────────────────────
|
|
function buildSearch() {
|
|
var wrap = el('div', 'wpc-search');
|
|
var box = el('div', 'wpc-search-box');
|
|
box.innerHTML =
|
|
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
|
|
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
|
|
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
|
|
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
|
|
'<span class="wpc-kbd">Ctrl K</span>';
|
|
var pop = el('div', 'wpc-pop');
|
|
pop.hidden = true;
|
|
wrap.appendChild(box);
|
|
wrap.appendChild(pop);
|
|
|
|
var input = box.querySelector('.wpc-search-input');
|
|
var clear = box.querySelector('.wpc-clear');
|
|
var timer = null, seq = 0, items = [], activeIx = -1;
|
|
|
|
function close() { pop.hidden = true; activeIx = -1; }
|
|
|
|
function highlight() {
|
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
|
|
n.classList.toggle('is-active', i === activeIx);
|
|
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
// A work package lives inside the suite's Creator tab, so open the suite on
|
|
// that project with the package requested; a SOP opens the SOP tab.
|
|
function hrefFor(hit) {
|
|
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
|
|
if (hit.kind === 'wp') {
|
|
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
|
|
'&wp=' + encodeURIComponent(hit.id);
|
|
}
|
|
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
|
|
}
|
|
|
|
function go(hit) {
|
|
if (!hit) return;
|
|
if (hit.kind === 'project') {
|
|
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
|
|
if (p) { switchProject(p); return; }
|
|
}
|
|
// Set the active project only from a full record — writing a stub would
|
|
// clobber the cached project (name, number, client) other pages read. The
|
|
// ?project= param in the URL is what actually switches context.
|
|
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
|
|
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
|
|
location.assign(hrefFor(hit));
|
|
}
|
|
|
|
function renderResults(data) {
|
|
items = [];
|
|
var html = '';
|
|
function group(title, rows) {
|
|
if (!rows.length) return;
|
|
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
|
|
}
|
|
group('Work packages', (data.wps || []).map(function (w) {
|
|
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
|
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
|
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
|
|
esc(w.subject || '') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
|
'</button>';
|
|
}));
|
|
group('Projects', (data.projects || []).map(function (p) {
|
|
items.push({ kind: 'project', id: p.id });
|
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
|
|
'</button>';
|
|
}));
|
|
group('SOPs', (data.sops || []).map(function (s) {
|
|
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
|
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
|
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
|
'</button>';
|
|
}));
|
|
if (!items.length) {
|
|
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
|
|
}
|
|
pop.innerHTML = html;
|
|
pop.hidden = false;
|
|
activeIx = items.length ? 0 : -1;
|
|
highlight();
|
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
|
|
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
|
|
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
|
|
});
|
|
}
|
|
|
|
function run(q) {
|
|
var mine = ++seq;
|
|
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (data) {
|
|
if (mine !== seq) return; // a newer keystroke already won
|
|
if (!data) { close(); return; }
|
|
renderResults(data);
|
|
})
|
|
.catch(function () {
|
|
if (mine !== seq) return;
|
|
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
|
|
pop.hidden = false;
|
|
});
|
|
}
|
|
|
|
input.addEventListener('input', function () {
|
|
var q = input.value.trim();
|
|
clear.hidden = !q;
|
|
clearTimeout(timer);
|
|
if (q.length < SEARCH_MIN) { close(); return; }
|
|
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
|
|
});
|
|
input.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { close(); input.blur(); return; }
|
|
if (pop.hidden || !items.length) return;
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
|
|
});
|
|
input.addEventListener('focus', function () {
|
|
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
|
|
});
|
|
clear.addEventListener('click', function () {
|
|
input.value = ''; clear.hidden = true; close(); input.focus();
|
|
});
|
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
|
|
|
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
|
|
// use). Ignored while typing in another field so it can't steal a shortcut.
|
|
document.addEventListener('keydown', function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
|
e.preventDefault();
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
});
|
|
return wrap;
|
|
}
|
|
|
|
// ── mount ──────────────────────────────────────────────────────────────────
|
|
function mount() {
|
|
if (document.querySelector('.wp-chrome')) return;
|
|
var m = findMount();
|
|
if (!m) return;
|
|
var chrome = el('div', 'wp-chrome');
|
|
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
|
|
var switcher = buildProjectSwitcher();
|
|
chrome.appendChild(switcher);
|
|
chrome.appendChild(buildSearch());
|
|
if (m.before) m.host.insertBefore(chrome, m.before);
|
|
else m.host.appendChild(chrome);
|
|
loadProjects(switcher);
|
|
checkArchived(m.host);
|
|
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
|
|
|
// Subscribe rather than keep our own copy of the value. Before this, the label
|
|
// was rendered once at build time and refreshed only when /api/projects came
|
|
// back, so selecting a project on the launcher updated the hero and left the bar
|
|
// reading "Select a project" — that was F1.
|
|
try {
|
|
if (window.ProjectData && ProjectData.onActiveChange) {
|
|
ProjectData.onActiveChange(function () { switcher.wpcRefresh(); });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Wait for the auth guard: an unauthenticated page is about to redirect, and
|
|
// /api/search would 401 anyway.
|
|
if (window.WP_USER) mount();
|
|
else document.addEventListener('wp-auth-ready', mount);
|
|
})();
|