T4.2 - S3: the app's state has an address; X1 is unblocked
Wave 0 counted pushState across html/ and found 0. Every page read its query
string once at boot and never wrote one again, so you could not send anyone a
link to WP07, a refresh dropped you back at the default view, and Back left the
app entirely because the app had never added a history entry.
CR-011 and CR-014 both promise an email carrying a direct link to a work package.
That is X1, and it was blocked on this. It is not blocked now.
html/wp-url.js is the whole mechanism, and it is deliberately NOT a router.
Nothing in it intercepts navigation or renders anything; it is the query string
treated as state that can be read, merged, written and subscribed to. Pages keep
their own rendering. Query parameters rather than a hash, because the server
already serves these paths and a hash is never sent to the server - which matters
the day a link has to be resolved before the page boots.
The merge behaviour is the part that earns its place: WPUrl.push({wp:id}) keeps
the active project, and WPUrl.push({wp:''}) clears one key without needing to know
what else is in the URL. Hand-built URLs losing ?project= is the usual way this
goes wrong.
WIRED: the creator (open package, dashboard view), the SOP wizard (tool, step),
the launcher (project). Each records a history entry only when the user chose the
change - restoring from the URL uses replace, or Back would immediately add an
entry and appear to do nothing.
WPUrl.absolute() is what CR-011/CR-014 will paste into an email in wave 8.
TWO BUGS THIS TASK CREATED AND FIXED, both found by the probe rather than by
reading:
- bootSOP() calls newPackage() during boot, and newPackage() cleared ?wp=. A
deep link therefore worked and then erased its own parameter, leaving Back
with nothing to return to. Now guarded on wpCreatorReady.
- goToStep() runs validateStep(), which ends in alert() when a required field
is empty - always true on a freshly loaded page. So restoring ?step=3 from a
shared link opened a modal dialog mid-boot, and hung the browser under CDP.
Restoring a view is not a forward navigation and no longer runs the
forward-navigation guard.
The second one is worth keeping in mind for the rest of wave 4: this app has 79
native dialogs, and any of them firing during a restore path will hang a headless
browser rather than fail visibly.
VERIFICATION. tests/url_state_check.py, 23 checks, all passing, covering every
done-when on the task:
- a URL identifying a work package opens that package
- the same URL for a SIGNED-OUT user goes to login, carries the target through
?next=, and lands on the work package itself after signing in
- refresh preserves project, package, tab and view
- Back and Forward move through states, verified as still-initialised rather
than reloaded, and with the dashboard actually rendered rather than only the
URL changed
- a different user opening the same URL reaches the same view
- nothing credential-shaped appears in the query string
Metric 8, pushState: was 0 at wave 0, now 2 in html/ (one pushState and one
replaceState, both in wp-url.js) behind 6 call sites across 4 files. The raw
count stays low by design - one place writes history, which is the same reason
the token work put one place in charge of colour.
browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, aggregates 16/16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1649,7 +1649,18 @@ function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
|
||||
if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||||
function clearSaved(){ if(!savedPackages.length) return;
|
||||
if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); }
|
||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); }
|
||||
|
||||
// S3: the open package IS the page's state, so it belongs in the URL. This is the
|
||||
// address CR-011 and CR-014 send people to (X1), so it has to survive being pasted
|
||||
// into an email and opened by someone whose browser has never seen this project -
|
||||
// which is why the project id rides along.
|
||||
function urlSyncPackage(id, opts){
|
||||
if(typeof WPUrl === 'undefined') return;
|
||||
const patch = { wp: id || '', view: '' };
|
||||
if(activeProjectId) patch.project = activeProjectId;
|
||||
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
|
||||
}
|
||||
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
||||
function loadPackageIntoForm(p){
|
||||
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
|
||||
@@ -1734,6 +1745,11 @@ function duplicateWP(){
|
||||
}
|
||||
|
||||
function newPackage(){
|
||||
// Only clear the address once the app is up. bootSOP() calls this during boot, so
|
||||
// without the guard a deep link to ?wp=<id> had its own parameter deleted by the
|
||||
// page it was opening - the link worked, then erased itself, and Back had nothing
|
||||
// to return to.
|
||||
if(typeof WPUrl !== 'undefined' && window.wpCreatorReady && WPUrl.get('wp')) urlSyncPackage('', {replace:true});
|
||||
editingId=null;
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
||||
@@ -1844,12 +1860,21 @@ function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todaySt
|
||||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||||
|
||||
function showDashboard(){
|
||||
// Restoring the dashboard because the user pressed Back: same rendering, no new
|
||||
// history entry.
|
||||
function showDashboardFromUrl(){ showDashboard({fromUrl:true}); }
|
||||
function showDashboard(opts){
|
||||
const fromUrl = !!(opts && opts.fromUrl);
|
||||
setFormChrome(false);
|
||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||||
document.getElementById('pkg-output').style.display='none';
|
||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||
currentView='Dashboard'; cmtUpdateCurStep();
|
||||
if(typeof WPUrl !== 'undefined' && !fromUrl){
|
||||
const patch = { view: 'dashboard', wp: '' };
|
||||
if(activeProjectId) patch.project = activeProjectId;
|
||||
WPUrl.push(patch);
|
||||
}
|
||||
// Ask the server every time the dashboard is opened. Counts that were correct
|
||||
// when you last looked are not evidence that they are correct now.
|
||||
dashMetrics=null; loadDashMetrics();
|
||||
@@ -2045,8 +2070,8 @@ function dashIssue(id){
|
||||
WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
|
||||
dashRefreshAfterWrite();
|
||||
}
|
||||
function dashView(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
|
||||
function dashView(i){ if(savedPackages[i]){ renderPackage(savedPackages[i]); urlSyncPackage(savedPackages[i].id); } }
|
||||
function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); urlSyncPackage(p.id); }
|
||||
|
||||
// ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
|
||||
function openSopModal(){
|
||||
@@ -2197,6 +2222,28 @@ function bootData(){
|
||||
else toast('That work package is not on this project (it may have been deleted).');
|
||||
}
|
||||
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
||||
|
||||
// Back / Forward. The URL is the state, so restoring is "read it and show that",
|
||||
// not a bespoke undo stack. Guarded against re-pushing while we restore, or every
|
||||
// Back would immediately add a new entry and the button would appear to do nothing.
|
||||
if(typeof WPUrl !== 'undefined'){
|
||||
WPUrl.onChange(function(state, viaPop){
|
||||
if(!viaPop) return;
|
||||
if(state.view === 'dashboard'){
|
||||
if(currentView !== 'Dashboard') showDashboardFromUrl();
|
||||
return;
|
||||
}
|
||||
const want = state.wp || '';
|
||||
if(want){
|
||||
const ix = savedPackages.findIndex(x => x.id === want);
|
||||
if(ix >= 0){ hideDashboard(); currentView='Form'; editingId=savedPackages[ix].id;
|
||||
loadPackageIntoForm(savedPackages[ix]); renderWpNav(); }
|
||||
} else if(currentView === 'Dashboard'){
|
||||
hideDashboard(); setFormChrome(true); currentView='Form';
|
||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
|
||||
}
|
||||
});
|
||||
}
|
||||
track('app_open');
|
||||
// The embedding shell needs to know when the packages are actually in hand: the
|
||||
// frame's `load` event fires long before pullProject() resolves, so anything that
|
||||
|
||||
Reference in New Issue
Block a user