Rebuild the work-package side panel in the style of MS Planner
The auto-hiding drawer was the wrong model — a list you navigate by shouldn't appear and disappear under the pointer, and its vertical text tab read as a stray artifact. Replaced with a persistent side panel following the Planner reference: - Collapse toggle at the top (the panel glyph, arrow flips), remembered across visits. Collapsed leaves a 56px icon rail where the coloured package badges are still clickable, rather than hiding the list entirely. - One primary action: "+ New work package" with a split caret for Duplicate, Split by discipline and Export all. - Icon nav with counts: My packages (owned by you), All packages, Needs attention (on hold or not release-ready), Dashboard. These filter the list below. - Packages as rows with a colour-coded initial badge, number, subject and readiness state, still grouped by status, with a left accent bar on the current package. The badge colour is hashed from the WP number, so a package keeps its swatch instead of shuffling when another is added or deleted. - The panel sits IN the layout: the form and the full-width chrome shift beside it rather than being overlaid. Also, the reason it appeared as loose unstyled widgets in the middle of the form: the panel's markup and its stylesheet are cached independently, so a browser can run new markup against old CSS. Its essential layout (fixed position, width, the row/badge flex, the collapsed rules) is now injected by wp-creation-app.js as a floor, inserted first in <head> so the stylesheet still wins on everything it defines. Same lesson as the iframe: a component whose CSS-missing state is "broken" rather than "plain" must carry its own critical layout. Verified with 25 driven checks in headless Chrome: persistence, the four nav links, badge colours and text, view filtering, collapse/expand, the split menu, row selection and highlighting — and, with wp-creation-styles.css removed from the page entirely, the panel is still a fixed 288px side panel with the form shifted beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1378,123 +1378,216 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
// Every saved package on this project, grouped by status, filterable. Clicking a
|
||||
// row opens it in the form (same path as the Saved table's "edit").
|
||||
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||||
// ── drawer behaviour ─────────────────────────────────────────────────────────
|
||||
// Hover the edge handle to open, move the pointer away to close. Pinning keeps it
|
||||
// open and shifts the form across; the pin is remembered. Touch devices have no
|
||||
// hover, so tapping the handle opens it and it stays until you pick something,
|
||||
// tap outside, or press Escape.
|
||||
let _navCloseTimer = null;
|
||||
// The panel's ESSENTIAL layout ships with this script rather than living only in
|
||||
// wp-creation-styles.css. The two files are cached independently, so a browser can
|
||||
// run new markup against an old stylesheet — and without these rules the panel is
|
||||
// not merely unstyled, it's broken: its title, filter and buttons drop into the
|
||||
// middle of the form as loose widgets (which is exactly what happened once).
|
||||
// Injecting the floor here means the panel is always a positioned side panel; the
|
||||
// stylesheet only refines its appearance.
|
||||
const WP_NAV_CRITICAL_CSS = `
|
||||
body{--nav-w:288px;}
|
||||
body.wp-nav-collapsed{--nav-w:56px;}
|
||||
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
|
||||
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc;
|
||||
border-right:1px solid #e0e0e0;}
|
||||
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
|
||||
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
|
||||
background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
|
||||
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff;
|
||||
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||||
.wp-nav-body{min-width:0;flex:1 1 auto;}
|
||||
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.main{padding-left:calc(var(--nav-w) + 28px);}
|
||||
body.wp-nav-collapsed .wp-nav-body,body.wp-nav-collapsed .wp-nav-state,
|
||||
body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect,
|
||||
body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group,
|
||||
body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;}
|
||||
`;
|
||||
|
||||
function openWpNav(){
|
||||
clearTimeout(_navCloseTimer);
|
||||
document.body.classList.add('wp-nav-open');
|
||||
const h = document.getElementById('wp-nav-handle');
|
||||
if(h) h.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function closeWpNav(force){
|
||||
clearTimeout(_navCloseTimer);
|
||||
if(navPinned() && !force) return; // pinned stays put unless explicitly closed
|
||||
if(force) setNavPinned(false);
|
||||
document.body.classList.remove('wp-nav-open');
|
||||
const h = document.getElementById('wp-nav-handle');
|
||||
if(h) h.setAttribute('aria-expanded', 'false');
|
||||
function injectWpNavCriticalCss(){
|
||||
if(document.getElementById('wp-nav-critical')) return;
|
||||
const st = document.createElement('style');
|
||||
st.id = 'wp-nav-critical';
|
||||
st.textContent = WP_NAV_CRITICAL_CSS;
|
||||
// First in <head> so the real stylesheet (loaded later) still wins on every
|
||||
// property it defines — this is a floor, not an override.
|
||||
const head = document.head || document.documentElement;
|
||||
head.insertBefore(st, head.firstChild);
|
||||
}
|
||||
|
||||
// ── panel behaviour ────────────────────────────────────────────
|
||||
// The panel is always present. The toggle collapses it to a 56px icon rail (badges
|
||||
// only) and back; that choice is remembered. No hover-to-open: a list you navigate
|
||||
// by shouldn't appear and disappear under the pointer.
|
||||
let wpNavView = 'all'; // all | mine | open
|
||||
|
||||
function toggleWpNav(){
|
||||
if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true);
|
||||
else openWpNav();
|
||||
}
|
||||
function navPinned(){ return document.body.classList.contains('wp-nav-pinned'); }
|
||||
function setNavPinned(on){
|
||||
document.body.classList.toggle('wp-nav-pinned', !!on);
|
||||
const btn = document.getElementById('wp-nav-pin');
|
||||
const collapsed = document.body.classList.toggle('wp-nav-collapsed');
|
||||
try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){}
|
||||
const btn = document.getElementById('wp-nav-toggle');
|
||||
if(btn){
|
||||
btn.classList.toggle('is-on', !!on);
|
||||
btn.title = on ? 'Unpin (let it hide again)' : 'Keep this list open';
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
|
||||
}
|
||||
try{ localStorage.setItem('wp_nav_pinned', on ? '1' : ''); }catch(e){}
|
||||
positionSectionNav(); // pinned changes nothing about --rail-top, but keep them in step
|
||||
closeWpNavMore();
|
||||
track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded');
|
||||
}
|
||||
function toggleWpNavPin(){
|
||||
const on = !navPinned();
|
||||
setNavPinned(on);
|
||||
if(on) openWpNav(); else closeWpNav(true);
|
||||
track(on ? 'wp_nav_pinned' : 'wp_nav_unpinned');
|
||||
|
||||
// Kept so older callers (and the suite shell) don't break.
|
||||
function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); }
|
||||
function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); }
|
||||
|
||||
function toggleWpNavMore(ev){
|
||||
if(ev) ev.stopPropagation();
|
||||
const m = document.getElementById('wp-nav-more');
|
||||
const b = document.getElementById('wp-nav-more-btn');
|
||||
if(!m) return;
|
||||
const open = m.hidden;
|
||||
m.hidden = !open;
|
||||
if(b) b.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
function closeWpNavMore(){
|
||||
const m = document.getElementById('wp-nav-more');
|
||||
const b = document.getElementById('wp-nav-more-btn');
|
||||
if(m) m.hidden = true;
|
||||
if(b) b.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if(!e.target.closest || !e.target.closest('.wp-nav-primary')) closeWpNavMore();
|
||||
});
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNavMore(); });
|
||||
|
||||
function wpNavAction(what){
|
||||
closeWpNavMore();
|
||||
if(what === 'duplicate') duplicateWP();
|
||||
else if(what === 'split') splitByDiscipline();
|
||||
else if(what === 'export') exportPackages();
|
||||
}
|
||||
|
||||
// The three views are filters over the same list, like Planner's My tasks / My plans.
|
||||
function setWpNavView(view){
|
||||
wpNavView = view;
|
||||
document.querySelectorAll('.wp-nav-link[data-view]').forEach(b => {
|
||||
b.classList.toggle('is-current', b.getAttribute('data-view') === view);
|
||||
});
|
||||
renderWpNav();
|
||||
track('wp_nav_view', {view});
|
||||
}
|
||||
|
||||
// A stable colour per package so the same one is always the same swatch — the cue
|
||||
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so
|
||||
// it doesn't shuffle when a package is added or deleted.
|
||||
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
|
||||
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
|
||||
function wpBadgeColor(p){
|
||||
const key = (p.number || p.id || '') + '';
|
||||
let h = 0;
|
||||
for(let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 100000;
|
||||
return WP_BADGE_COLORS[h % WP_BADGE_COLORS.length];
|
||||
}
|
||||
// Two characters for the badge: the package's own digits if it has them (WP-25 -> 25),
|
||||
// otherwise the initials of its type, otherwise WP.
|
||||
function wpBadgeText(p){
|
||||
const digits = String(p.number || '').match(/(\d{1,2})(?!.*\d)/);
|
||||
if(digits) return digits[1];
|
||||
const t = String(p.type || '').trim();
|
||||
if(t){
|
||||
const words = t.split(/\s+/).filter(Boolean);
|
||||
return (words.length > 1 ? words[0][0] + words[1][0] : t.slice(0, 2));
|
||||
}
|
||||
return 'WP';
|
||||
}
|
||||
|
||||
function initWpNavDrawer(){
|
||||
const nav = document.getElementById('wp-nav');
|
||||
const handle = document.getElementById('wp-nav-handle');
|
||||
if(!nav || !handle || nav.dataset.bound) return;
|
||||
if(!nav || nav.dataset.bound) return;
|
||||
nav.dataset.bound = '1';
|
||||
|
||||
// Hover in / out. A short close delay stops the drawer snapping shut while the
|
||||
// pointer crosses the gap between handle and panel.
|
||||
const armClose = () => {
|
||||
clearTimeout(_navCloseTimer);
|
||||
_navCloseTimer = setTimeout(() => closeWpNav(false), 350);
|
||||
};
|
||||
handle.addEventListener('mouseenter', openWpNav);
|
||||
handle.addEventListener('mouseleave', armClose);
|
||||
nav.addEventListener('mouseenter', () => clearTimeout(_navCloseTimer));
|
||||
nav.addEventListener('mouseleave', armClose);
|
||||
// Opening from the keyboard should work too.
|
||||
handle.addEventListener('focus', openWpNav);
|
||||
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNav(false); });
|
||||
// Tap/click outside closes it (the touch path, where there's no mouseleave).
|
||||
document.addEventListener('click', e => {
|
||||
if(navPinned()) return;
|
||||
if(!document.body.classList.contains('wp-nav-open')) return;
|
||||
if(nav.contains(e.target) || handle.contains(e.target)) return;
|
||||
closeWpNav(false);
|
||||
});
|
||||
|
||||
try{ if(localStorage.getItem('wp_nav_pinned')) setNavPinned(true); }catch(e){}
|
||||
injectWpNavCriticalCss();
|
||||
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
|
||||
const btn = document.getElementById('wp-nav-toggle');
|
||||
if(btn){
|
||||
const collapsed = document.body.classList.contains('wp-nav-collapsed');
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
|
||||
}
|
||||
}
|
||||
|
||||
function renderWpNav(){
|
||||
const list=document.getElementById('wp-nav-list'); if(!list) return;
|
||||
const cnt=document.getElementById('wp-nav-count');
|
||||
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
|
||||
const badge=document.getElementById('wp-nav-handle-count');
|
||||
if(badge) badge.textContent = savedPackages.length;
|
||||
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
|
||||
const list = document.getElementById('wp-nav-list');
|
||||
if(!list) return;
|
||||
const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase();
|
||||
const me = myUserId();
|
||||
|
||||
// Keep the original index — edit/view act on savedPackages by position.
|
||||
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
|
||||
if(!q) return true;
|
||||
const p=r.p;
|
||||
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
|
||||
});
|
||||
const all = savedPackages.map((p, i) => ({p, i}));
|
||||
const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me);
|
||||
const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p));
|
||||
|
||||
const setN = (id, n) => { const e = document.getElementById(id); if(e) e.textContent = n || ''; };
|
||||
setN('wp-nav-n-all', all.length);
|
||||
setN('wp-nav-n-mine', mine.length);
|
||||
setN('wp-nav-n-open', needs.length);
|
||||
|
||||
let rows = wpNavView === 'mine' ? mine : (wpNavView === 'open' ? needs : all);
|
||||
const label = document.getElementById('wp-nav-sect-label');
|
||||
if(label) label.textContent = wpNavView === 'mine' ? 'My packages'
|
||||
: (wpNavView === 'open' ? 'Needs attention' : 'Work packages');
|
||||
|
||||
if(q){
|
||||
rows = rows.filter(r => {
|
||||
const p = r.p;
|
||||
return [p.number, p.subject, p.type, p.location, p.system]
|
||||
.some(v => String(v || '').toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
|
||||
const cnt = document.getElementById('wp-nav-count');
|
||||
if(cnt) cnt.textContent = rows.length ? '(' + rows.length + ')' : '';
|
||||
|
||||
if(!rows.length){
|
||||
list.innerHTML='<div class="wp-nav-empty">'+(savedPackages.length?'No packages match “'+esc(q)+'”.':'No work packages saved yet. Fill the form and Save Draft.')+'</div>';
|
||||
list.innerHTML = '<div class="wp-nav-empty">' + (
|
||||
savedPackages.length
|
||||
? (q ? 'Nothing matches “' + esc(q) + '”.' : 'No packages in this view.')
|
||||
: 'No work packages yet. Fill the form and Save Draft.'
|
||||
) + '</div>';
|
||||
return;
|
||||
}
|
||||
const groups={};
|
||||
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); });
|
||||
const keys=Object.keys(groups).sort((a,b)=>{
|
||||
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b);
|
||||
return (ia<0?99:ia)-(ib<0?99:ib);
|
||||
|
||||
const groups = {};
|
||||
rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); });
|
||||
const keys = Object.keys(groups).sort((a, b) => {
|
||||
const ia = WPNAV_ORDER.indexOf(a), ib = WPNAV_ORDER.indexOf(b);
|
||||
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
|
||||
});
|
||||
list.innerHTML=keys.map(k=>{
|
||||
const label = k==='Issue' ? 'Issue (Hold)' : k;
|
||||
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{
|
||||
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length;
|
||||
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints.
|
||||
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length;
|
||||
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open');
|
||||
const state = p.status==='Issue' ? 'on hold'
|
||||
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready'));
|
||||
const active = (editingId && p.id===editingId) ? ' active' : '';
|
||||
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+
|
||||
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+
|
||||
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+
|
||||
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+
|
||||
(p.type?' · '+esc(p.type):'')+'</span></button>';
|
||||
}).join('');
|
||||
|
||||
list.innerHTML = keys.map(k => {
|
||||
const heading = k === 'Issue' ? 'Issue (Hold)' : k;
|
||||
return '<div class="wp-nav-group">' + esc(heading) + ' · ' + groups[k].length + '</div>' +
|
||||
groups[k].map(r => {
|
||||
const p = r.p;
|
||||
const open = (p.constraints || []).filter(c => c.status === 'open').length;
|
||||
const waiting = wpWaitingOn(p).length;
|
||||
const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open');
|
||||
const state = p.status === 'Issue' ? 'on hold'
|
||||
: (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
|
||||
const active = (editingId && p.id === editingId) ? ' active' : '';
|
||||
const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
|
||||
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
|
||||
' title="' + esc(title) + '">' +
|
||||
'<span class="wp-nav-badge" style="background:' + wpBadgeColor(p) + '">' + esc(wpBadgeText(p)) + '</span>' +
|
||||
'<span class="wp-nav-body">' +
|
||||
'<span class="wp-nav-num">' + esc(p.number || '(unnumbered)') + '</span>' +
|
||||
'<span class="wp-nav-subj">' + esc(p.subject || 'untitled') + '</span>' +
|
||||
'</span>' +
|
||||
'<span class="wp-nav-state"><span class="wp-nav-dot ' + dot + '"></span>' + esc(state) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function wpNavOpen(i){
|
||||
const p=savedPackages[i]; if(!p) return;
|
||||
closeWpNav(false); // get out of the way once you've chosen (unless pinned)
|
||||
editingId=p.id;
|
||||
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
|
||||
renderWpNav();
|
||||
|
||||
Reference in New Issue
Block a user