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:
@@ -8,6 +8,9 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
@@ -440,6 +443,13 @@
|
||||
}
|
||||
// Reconcile the active project against the list; clear if it's gone.
|
||||
const active = ProjectData.getActive();
|
||||
// S3: if a project is active but the URL does not say so, make the URL say
|
||||
// so — with replace, not push, because the user did not navigate here. This
|
||||
// is what makes "copy the address bar" produce a link that lands somebody
|
||||
// else on the same project rather than on whatever they last had open.
|
||||
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
||||
WPUrl.replace({ project: active.id });
|
||||
}
|
||||
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
||||
if(dropped) ProjectData.setActive(null);
|
||||
renderProjectPicker();
|
||||
@@ -527,10 +537,17 @@
|
||||
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
// S3: which project you are in is addressable, so a launcher link carries it
|
||||
// and Back returns to the project you were looking at before.
|
||||
function urlSyncProject(id, replace){
|
||||
if(typeof WPUrl === 'undefined') return;
|
||||
(replace ? WPUrl.replace : WPUrl.push).call(WPUrl, { project: id || '' });
|
||||
}
|
||||
|
||||
function selectProject(id){
|
||||
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||
if(!id){ ProjectData.setActive(null); urlSyncProject('', false); applyActiveProject(); return; }
|
||||
const p = _projects.find(x => x.id === id);
|
||||
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||
if(p){ ProjectData.setActive(p); urlSyncProject(p.id, false); applyActiveProject(); }
|
||||
}
|
||||
|
||||
function afterProjectChosen(p){
|
||||
@@ -577,7 +594,22 @@
|
||||
reflectSOPStatus(active);
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectPicker(); applyActiveProject(); }
|
||||
|
||||
// Back / Forward between projects.
|
||||
if(typeof WPUrl !== 'undefined'){
|
||||
WPUrl.onChange(function(state, viaPop){
|
||||
if(!viaPop) return;
|
||||
const want = state.project || '';
|
||||
if(want === (ProjectData.getActiveId() || '')) return;
|
||||
if(!want){ ProjectData.setActive(null); }
|
||||
else {
|
||||
const p = (_projects||[]).find(x=>x.id===want);
|
||||
ProjectData.setActive(p || { id: want });
|
||||
}
|
||||
renderProjectPicker(); applyActiveProject();
|
||||
});
|
||||
}
|
||||
|
||||
// Reflect SOP completion on the tool cards (B4).
|
||||
//
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
|
||||
@@ -291,15 +291,25 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
updateProjectDisplay();
|
||||
loadProjectUsers(); // team pickers: who's on this project
|
||||
applyBimFlag(); // hide the BIM section unless an admin enabled it
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE —
|
||||
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab
|
||||
// keep opening the dashboard for the rest of the session.
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id> | ?step=N.
|
||||
//
|
||||
// These used to be consumed ONCE, because a leftover ?view=dashboard made the
|
||||
// Work Package Creation tab keep reopening the dashboard for the rest of the
|
||||
// session. Since T4.2 the URL is not a one-shot instruction — it TRACKS the
|
||||
// current state and switchTool() clears `view` when you leave the dashboard —
|
||||
// so the param can be honoured every time and a refresh lands where you were.
|
||||
//
|
||||
// fromUrl on every call: the URL already says this, so restoring it must not
|
||||
// add a history entry. Without that, the first Back after loading a deep link
|
||||
// would just return you to the same view.
|
||||
const tab = params.get('tab');
|
||||
_deepLinkWp = params.get('wp') || '';
|
||||
const wantDashboard = params.get('view') === 'dashboard';
|
||||
if(wantDashboard) switchTool('dashboard');
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
else if(_deepLinkWp) switchTool('wp');
|
||||
if(wantDashboard) switchTool('dashboard', {fromUrl:true});
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
|
||||
else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
|
||||
const bootStep = parseInt(params.get('step'), 10);
|
||||
if(bootStep >= 1 && bootStep <= 10 && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
|
||||
}
|
||||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||
@@ -464,8 +474,15 @@ function repopulateForm(){
|
||||
}
|
||||
|
||||
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
||||
function switchTool(tool){
|
||||
function switchTool(tool, opts){
|
||||
currentTool = tool;
|
||||
// S3: which tool is open is addressable state. `fromUrl` is set when we are
|
||||
// restoring because the user pressed Back - recording that as a new entry would
|
||||
// make Back appear to do nothing.
|
||||
if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){
|
||||
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
|
||||
: { tab: tool, view: '' });
|
||||
}
|
||||
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||||
// creator) but opens it straight to the dashboard view.
|
||||
const isDash = (tool === 'dashboard');
|
||||
@@ -1148,12 +1165,31 @@ function addSource(){
|
||||
}
|
||||
|
||||
// ── STEP NAVIGATION ────────────────────────────────────────────────────────────
|
||||
function goToStep(n){
|
||||
if(!validateStep(currentStep)) return;
|
||||
function goToStep(n, opts){
|
||||
const fromUrl = !!(opts && opts.fromUrl);
|
||||
// Restoring a step from the URL is not a forward navigation, so it must not run
|
||||
// the forward-navigation guard. validateStep() ends in alert() when a required
|
||||
// field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared
|
||||
// link to step 3 opened a modal dialog before the page had finished booting.
|
||||
if(!fromUrl && !validateStep(currentStep)) return;
|
||||
currentStep = n;
|
||||
// S3: the step you are on survives a refresh and a shared link.
|
||||
if(typeof WPUrl !== 'undefined' && !fromUrl) WPUrl.push({ step: n > 1 ? n : '' });
|
||||
updateStepUI();
|
||||
}
|
||||
|
||||
// S3: Back / Forward across tools and steps. The URL is the state, so restoring is
|
||||
// "read it and show that" rather than a bespoke undo stack.
|
||||
if(typeof WPUrl !== 'undefined'){
|
||||
WPUrl.onChange(function(state, viaPop){
|
||||
if(!viaPop) return;
|
||||
const wantTool = state.view === 'dashboard' ? 'dashboard' : (state.tab || 'sop');
|
||||
if(wantTool !== currentTool) switchTool(wantTool, {fromUrl:true});
|
||||
const step = parseInt(state.step, 10);
|
||||
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
|
||||
});
|
||||
}
|
||||
|
||||
function nextStep(){
|
||||
if(!validateStep(currentStep)) return;
|
||||
if(currentStep < 10){
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
|
||||
126
html/wp-url.js
Normal file
126
html/wp-url.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/* Addressable state — S3 / T4.2.
|
||||
---------------------------------------------------------------------------
|
||||
Before this file there was no pushState anywhere in the suite. Every page read
|
||||
its query string once at boot and never wrote one again, so:
|
||||
|
||||
• you could not send anyone a link to WP07 — the URL said the same thing
|
||||
whatever you were looking at;
|
||||
• a refresh dropped you back at the default view;
|
||||
• Back left the app entirely, because the app had never added a history entry.
|
||||
|
||||
CR-011 and CR-014 both promise an email containing a direct link to a work
|
||||
package (X1). Those emails cannot exist until a work package has an address,
|
||||
which is what this provides.
|
||||
|
||||
WHAT IT IS NOT: a router. Nothing here 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, not a hash: the server serves these paths already, so a hash
|
||||
would be a workaround for a problem this app does not have, and hashes are not
|
||||
sent to the server — which matters the day a link needs to be resolved before
|
||||
the page boots.
|
||||
|
||||
Nothing secret goes in the URL. It is copied into emails, chat and tickets.
|
||||
*/
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var listeners = [];
|
||||
var LAST = serialize(current());
|
||||
|
||||
function current() {
|
||||
var out = {};
|
||||
try {
|
||||
new URLSearchParams(window.location.search).forEach(function (v, k) { out[k] = v; });
|
||||
} catch (e) {}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serialize(state) {
|
||||
var keys = Object.keys(state).filter(function (k) {
|
||||
return state[k] !== '' && state[k] != null && state[k] !== false;
|
||||
}).sort();
|
||||
var sp = new URLSearchParams();
|
||||
keys.forEach(function (k) { sp.set(k, String(state[k])); });
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
// Merge a patch over the current state. Undefined/null/'' removes a key, so a
|
||||
// caller can clear `wp` without having to know what else is in the URL — the
|
||||
// usual reason ad-hoc URL building loses the active project.
|
||||
function merge(patch) {
|
||||
var next = current();
|
||||
Object.keys(patch || {}).forEach(function (k) {
|
||||
var v = patch[k];
|
||||
if (v === undefined || v === null || v === '' || v === false) delete next[k];
|
||||
else next[k] = v;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function href(patch) {
|
||||
var qs = serialize(merge(patch));
|
||||
return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
|
||||
}
|
||||
|
||||
function apply(patch, opts) {
|
||||
opts = opts || {};
|
||||
var next = merge(patch);
|
||||
var qs = serialize(next);
|
||||
if (qs === LAST && !opts.force) return false; // nothing to record
|
||||
var url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
|
||||
try {
|
||||
if (opts.replace) window.history.replaceState({ wpurl: qs }, '', url);
|
||||
else window.history.pushState({ wpurl: qs }, '', url);
|
||||
} catch (e) {
|
||||
return false; // file:// and the like
|
||||
}
|
||||
LAST = qs;
|
||||
return true;
|
||||
}
|
||||
|
||||
function notify(state, viaPop) {
|
||||
listeners.forEach(function (fn) {
|
||||
try { fn(state, viaPop); } catch (e) { /* one bad subscriber must not stop the rest */ }
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
LAST = serialize(current());
|
||||
notify(current(), true);
|
||||
});
|
||||
|
||||
window.WPUrl = {
|
||||
// Read one parameter, or everything.
|
||||
get: function (name) { var s = current(); return name == null ? s : (s[name] || ''); },
|
||||
all: current,
|
||||
|
||||
/* Record a state change in history. Merges over what is already there.
|
||||
WPUrl.push({ wp: id }) -> new history entry, Back returns
|
||||
WPUrl.push({ wp: '' }) -> clears it
|
||||
WPUrl.replace({ view: 'form' }) -> corrects the URL without a new entry
|
||||
replace() is for normalising on load or for a change the user did not ask
|
||||
for; push() is for one they did, because Back should undo exactly the
|
||||
things they chose to do. */
|
||||
push: function (patch) { return apply(patch, { replace: false }); },
|
||||
replace: function (patch) { return apply(patch, { replace: true }); },
|
||||
|
||||
// A URL string for the same merge, without navigating. For hrefs and for the
|
||||
// links that go into CR-011 / CR-014 emails.
|
||||
href: href,
|
||||
absolute: function (patch) {
|
||||
return window.location.origin + href(patch);
|
||||
},
|
||||
|
||||
/* Subscribe to state changes. Called on Back/Forward with viaPop === true.
|
||||
Returns an unsubscribe function. */
|
||||
onChange: function (fn) {
|
||||
listeners.push(fn);
|
||||
return function () {
|
||||
var i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
})(window, document);
|
||||
Reference in New Issue
Block a user