@shardworks/clerk-apparatus 0.1.279 → 0.1.281
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/pages/writs/index.html +225 -41
- package/pages/writs/writs-hierarchy.test.js +257 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardworks/clerk-apparatus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.281",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"zod": "4.3.6",
|
|
27
|
-
"@shardworks/stacks-apparatus": "0.1.
|
|
28
|
-
"@shardworks/tools-apparatus": "0.1.
|
|
27
|
+
"@shardworks/stacks-apparatus": "0.1.281",
|
|
28
|
+
"@shardworks/tools-apparatus": "0.1.281"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "25.5.0",
|
|
32
|
-
"@shardworks/nexus-core": "0.1.
|
|
32
|
+
"@shardworks/nexus-core": "0.1.281"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
package/pages/writs/index.html
CHANGED
|
@@ -77,6 +77,14 @@
|
|
|
77
77
|
<h1>Writs</h1>
|
|
78
78
|
|
|
79
79
|
<div id="writ-list-view">
|
|
80
|
+
<!--
|
|
81
|
+
URL-state validation banner. Surfaced when the page is loaded with a
|
|
82
|
+
URL filter value that does not match any known classification, type,
|
|
83
|
+
sort column, or sort direction (D6 fail-loud). Pages must not
|
|
84
|
+
silently fall back to a default — the operator copy-pasting a URL
|
|
85
|
+
needs honest feedback so they can recover.
|
|
86
|
+
-->
|
|
87
|
+
<div id="url-error-banner" class="error-msg" style="display:none;margin-bottom:12px;padding:0.6rem 0.8rem;background:var(--bg2,#1a1b26);border:1px solid var(--red,#f7768e);border-radius:6px;font-size:0.9rem;color:var(--red,#f7768e)"></div>
|
|
80
88
|
<div class="card" style="margin-bottom: 16px;">
|
|
81
89
|
<div class="toolbar" id="toolbar">
|
|
82
90
|
<button class="btn btn--primary" id="btn-new-writ">New Writ</button>
|
|
@@ -221,33 +229,188 @@
|
|
|
221
229
|
const DEPTH_CAP = 8;
|
|
222
230
|
|
|
223
231
|
// ── URL handling ───────────────────────────────────────────────────
|
|
232
|
+
//
|
|
233
|
+
// All deep-linkable view state for this page rides on
|
|
234
|
+
// `window.NexusUrl` — the shared helper auto-injected by oculus's
|
|
235
|
+
// chrome pass. The earlier inline `currentUrlParams` / `updateUrl`
|
|
236
|
+
// copies are gone (commission moix23w5).
|
|
237
|
+
//
|
|
238
|
+
// URL keys, all client-side except `?writ=`, `?classification=`,
|
|
239
|
+
// and `?type=` which are also accepted by /api/writ/* endpoints:
|
|
240
|
+
// ?classification= one of '' | 'initial' | 'active' | 'terminal'.
|
|
241
|
+
// Default '' (All); omitted when default (D4).
|
|
242
|
+
// ?type=A&type=B Repeated keys (D3). Omitted when every known
|
|
243
|
+
// type is selected (the default). Subset → emit
|
|
244
|
+
// each currently-selected type. The empty-set
|
|
245
|
+
// edge case collapses to default on round-trip
|
|
246
|
+
// (operators don't share empty list states).
|
|
247
|
+
// ?q= Search text. Omitted when empty.
|
|
248
|
+
// ?sort= One of phase | title | type | id | createdAt.
|
|
249
|
+
// Default 'createdAt'; omitted when default.
|
|
250
|
+
// ?dir= 'asc' | 'desc'. Default 'desc'; omitted when
|
|
251
|
+
// default.
|
|
252
|
+
// ?children= 'true' | 'false' (D7). Default true; omitted
|
|
253
|
+
// when default.
|
|
254
|
+
// ?cancelled= 'true' | 'false'. Default false; omitted when
|
|
255
|
+
// default.
|
|
256
|
+
// ?writ=ID Detail deep-link; opening pushes (D5).
|
|
257
|
+
|
|
258
|
+
const SORT_COLS = new Set(['phase', 'title', 'type', 'id', 'createdAt']);
|
|
259
|
+
const SORT_DIRS = new Set(['asc', 'desc']);
|
|
260
|
+
const CLASSIFICATIONS = new Set(['', 'initial', 'active', 'terminal']);
|
|
261
|
+
|
|
262
|
+
/** Append a fail-loud message to the URL-error banner (D6). */
|
|
263
|
+
function showUrlError(msg) {
|
|
264
|
+
const el = document.getElementById('url-error-banner');
|
|
265
|
+
if (!el) return;
|
|
266
|
+
// Append rather than replace so multiple invalid params each get
|
|
267
|
+
// their own line — operators see every recovery hint at once.
|
|
268
|
+
const line = document.createElement('div');
|
|
269
|
+
line.textContent = msg;
|
|
270
|
+
el.appendChild(line);
|
|
271
|
+
el.style.display = 'block';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Clear the URL-error banner — used before a fresh validation pass. */
|
|
275
|
+
function clearUrlErrors() {
|
|
276
|
+
const el = document.getElementById('url-error-banner');
|
|
277
|
+
if (!el) return;
|
|
278
|
+
el.innerHTML = '';
|
|
279
|
+
el.style.display = 'none';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Apply current filter state to the URL via NexusUrl.update. Always
|
|
284
|
+
* a replace (D5 default) — filter changes do not push history
|
|
285
|
+
* entries. When a filter is at its declared default the
|
|
286
|
+
* corresponding key is omitted (D4). Type[] is omitted when every
|
|
287
|
+
* known type is selected (the operator-default).
|
|
288
|
+
*/
|
|
289
|
+
function writeFiltersToUrl() {
|
|
290
|
+
const allKnown = knownTypes.length > 0 && knownTypes.every(t => currentType.has(t));
|
|
291
|
+
const noneSelected = currentType.size === 0;
|
|
292
|
+
const typeArray = (allKnown || noneSelected) ? null : [...currentType];
|
|
293
|
+
window.NexusUrl.update({
|
|
294
|
+
classification: currentClassification === '' ? null : currentClassification,
|
|
295
|
+
type: typeArray,
|
|
296
|
+
q: searchText === '' ? null : searchText,
|
|
297
|
+
sort: sortCol === 'createdAt' ? null : sortCol,
|
|
298
|
+
dir: sortDir === 'desc' ? null : sortDir,
|
|
299
|
+
children: showChildren === true ? null : false,
|
|
300
|
+
cancelled: showCancelled === false ? null : true,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
224
303
|
|
|
225
304
|
/**
|
|
226
|
-
* Read the
|
|
227
|
-
*
|
|
305
|
+
* Read the URL filter state into module-level variables. Validates
|
|
306
|
+
* each value against its known set and surfaces an error banner
|
|
307
|
+
* for any unknown value (D6 fail-loud). Returns the writ id, if
|
|
308
|
+
* any, so the caller can route into the detail view.
|
|
309
|
+
*
|
|
310
|
+
* Must be called after loadWritTypes() so knownTypes is populated
|
|
311
|
+
* and the type[] validation can fire.
|
|
228
312
|
*/
|
|
229
|
-
function
|
|
230
|
-
|
|
313
|
+
function readFiltersFromUrl() {
|
|
314
|
+
clearUrlErrors();
|
|
315
|
+
const params = window.NexusUrl.read();
|
|
316
|
+
|
|
317
|
+
// ── classification ────────────────────────────────────────────
|
|
318
|
+
const classification = params.get('classification');
|
|
319
|
+
if (classification !== null) {
|
|
320
|
+
if (CLASSIFICATIONS.has(classification)) {
|
|
321
|
+
currentClassification = classification;
|
|
322
|
+
} else {
|
|
323
|
+
showUrlError(`Unknown classification "${classification}". Expected one of: initial, active, terminal.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── type[] ─────────────────────────────────────────────────────
|
|
328
|
+
const typeValues = params.getAll('type');
|
|
329
|
+
if (typeValues.length > 0) {
|
|
330
|
+
const knownSet = new Set(knownTypes);
|
|
331
|
+
const unknown = typeValues.filter(t => !knownSet.has(t));
|
|
332
|
+
if (unknown.length > 0) {
|
|
333
|
+
for (const u of unknown) {
|
|
334
|
+
showUrlError(`Unknown writ type "${u}". The current type registry does not declare this name.`);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
currentType = new Set(typeValues);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Otherwise leave currentType at its default (set by buildTypeFilterBar to all known types).
|
|
341
|
+
|
|
342
|
+
// ── search text ────────────────────────────────────────────────
|
|
343
|
+
const q = params.get('q');
|
|
344
|
+
if (q !== null) searchText = q;
|
|
345
|
+
|
|
346
|
+
// ── sort ───────────────────────────────────────────────────────
|
|
347
|
+
const sort = params.get('sort');
|
|
348
|
+
if (sort !== null) {
|
|
349
|
+
if (SORT_COLS.has(sort)) {
|
|
350
|
+
sortCol = sort;
|
|
351
|
+
} else {
|
|
352
|
+
showUrlError(`Unknown sort column "${sort}". Expected one of: ${[...SORT_COLS].join(', ')}.`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const dir = params.get('dir');
|
|
356
|
+
if (dir !== null) {
|
|
357
|
+
if (SORT_DIRS.has(dir)) {
|
|
358
|
+
sortDir = dir;
|
|
359
|
+
} else {
|
|
360
|
+
showUrlError(`Unknown sort direction "${dir}". Expected "asc" or "desc".`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── boolean toggles ───────────────────────────────────────────
|
|
365
|
+
const childrenRaw = params.get('children');
|
|
366
|
+
if (childrenRaw !== null) {
|
|
367
|
+
if (childrenRaw === 'true') showChildren = true;
|
|
368
|
+
else if (childrenRaw === 'false') showChildren = false;
|
|
369
|
+
else showUrlError(`Invalid children value "${childrenRaw}". Expected "true" or "false".`);
|
|
370
|
+
}
|
|
371
|
+
const cancelledRaw = params.get('cancelled');
|
|
372
|
+
if (cancelledRaw !== null) {
|
|
373
|
+
if (cancelledRaw === 'true') showCancelled = true;
|
|
374
|
+
else if (cancelledRaw === 'false') showCancelled = false;
|
|
375
|
+
else showUrlError(`Invalid cancelled value "${cancelledRaw}". Expected "true" or "false".`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return params.get('writ');
|
|
231
379
|
}
|
|
232
380
|
|
|
233
381
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* Ratchet's `updateUrl` (D9). Always pushes — `back-to-list` does
|
|
238
|
-
* `updateUrl({writ: null})` so the operator's Back button returns to
|
|
239
|
-
* whatever page they came from rather than collapsing inside the
|
|
240
|
-
* page's own history (D11).
|
|
382
|
+
* Sync the toolbar UI to current filter state. Called after URL
|
|
383
|
+
* restore (init / popstate) so visible button fills, the search
|
|
384
|
+
* input, and the type-filter bar match what the URL describes.
|
|
241
385
|
*/
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
386
|
+
function syncFilterUiFromState() {
|
|
387
|
+
// Classification buttons
|
|
388
|
+
document.querySelectorAll('.filter-btn[data-classification]').forEach(btn => {
|
|
389
|
+
btn.classList.toggle('active-filter', btn.dataset.classification === currentClassification);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Type-filter pills
|
|
393
|
+
const bar = document.getElementById('type-filter-bar');
|
|
394
|
+
if (bar) {
|
|
395
|
+
const allBtn = bar.querySelector('.type-filter-btn[data-type=""]');
|
|
396
|
+
const typeBtns = [...bar.querySelectorAll('.type-filter-btn:not([data-type=""])')];
|
|
397
|
+
const allTypeNames = typeBtns.map(b => b.dataset.type);
|
|
398
|
+
const allActive = allTypeNames.length > 0 && allTypeNames.every(t => currentType.has(t));
|
|
399
|
+
if (allBtn) allBtn.classList.toggle('active-filter', allActive);
|
|
400
|
+
typeBtns.forEach(btn => {
|
|
401
|
+
btn.classList.toggle('active-filter', currentType.has(btn.dataset.type));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Search input
|
|
406
|
+
const searchInput = document.getElementById('search-input');
|
|
407
|
+
if (searchInput && searchInput.value !== searchText) searchInput.value = searchText;
|
|
408
|
+
|
|
409
|
+
// Toggles
|
|
410
|
+
const childrenBtn = document.getElementById('btn-toggle-children');
|
|
411
|
+
if (childrenBtn) childrenBtn.classList.toggle('active-filter', showChildren);
|
|
412
|
+
const cancelledBtn = document.getElementById('btn-toggle-cancelled');
|
|
413
|
+
if (cancelledBtn) cancelledBtn.classList.toggle('active-filter', showCancelled);
|
|
251
414
|
}
|
|
252
415
|
|
|
253
416
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
@@ -1451,7 +1614,7 @@
|
|
|
1451
1614
|
writ = await api('GET', '/api/writ/show?id=' + encodeURIComponent(id));
|
|
1452
1615
|
} catch (e) {
|
|
1453
1616
|
console.error('Failed to load writ detail:', e);
|
|
1454
|
-
if (!skipUrlPush)
|
|
1617
|
+
if (!skipUrlPush) window.NexusUrl.update({ writ: id }, { push: true });
|
|
1455
1618
|
renderDetailNotFound(id);
|
|
1456
1619
|
return;
|
|
1457
1620
|
}
|
|
@@ -1472,8 +1635,9 @@
|
|
|
1472
1635
|
|
|
1473
1636
|
// Centralised URL push — every callsite into showWritDetail emits
|
|
1474
1637
|
// ?writ=ID for free, except the popstate-driven path which would
|
|
1475
|
-
// otherwise re-push the current URL.
|
|
1476
|
-
|
|
1638
|
+
// otherwise re-push the current URL. Detail open is a navigation
|
|
1639
|
+
// event (pushes), per the D5 push-vs-replace split.
|
|
1640
|
+
if (!skipUrlPush) window.NexusUrl.update({ writ: id }, { push: true });
|
|
1477
1641
|
|
|
1478
1642
|
// Toggle views
|
|
1479
1643
|
document.getElementById('writ-list-view').style.display = 'none';
|
|
@@ -1505,15 +1669,15 @@
|
|
|
1505
1669
|
}
|
|
1506
1670
|
|
|
1507
1671
|
/** Switch back from the detail view to the list view. Centralised so
|
|
1508
|
-
* the URL clear (`
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1672
|
+
* the URL clear (`{ writ: null }`) and the DOM toggle stay paired.
|
|
1673
|
+
* Per D11 we never call `history.back()` — clicking "Back to list"
|
|
1674
|
+
* pushes a clean URL so the operator's forward button keeps doing
|
|
1675
|
+
* what they expect. */
|
|
1512
1676
|
function showWritList(opts) {
|
|
1513
1677
|
const skipUrlPush = !!(opts && opts.skipUrlPush);
|
|
1514
1678
|
document.getElementById('writ-detail-view').style.display = 'none';
|
|
1515
1679
|
document.getElementById('writ-list-view').style.display = '';
|
|
1516
|
-
if (!skipUrlPush)
|
|
1680
|
+
if (!skipUrlPush) window.NexusUrl.update({ writ: null }, { push: true });
|
|
1517
1681
|
}
|
|
1518
1682
|
|
|
1519
1683
|
// ── Data loading ────────────────────────────────────────────────────
|
|
@@ -1848,6 +2012,7 @@
|
|
|
1848
2012
|
document.querySelectorAll('.filter-btn[data-classification]').forEach(btn => {
|
|
1849
2013
|
btn.classList.toggle('active-filter', btn.dataset.classification === classification);
|
|
1850
2014
|
});
|
|
2015
|
+
writeFiltersToUrl();
|
|
1851
2016
|
loadWrits(true);
|
|
1852
2017
|
}
|
|
1853
2018
|
|
|
@@ -1884,6 +2049,7 @@
|
|
|
1884
2049
|
btn.classList.toggle('active-filter', currentType.has(btn.dataset.type));
|
|
1885
2050
|
});
|
|
1886
2051
|
|
|
2052
|
+
writeFiltersToUrl();
|
|
1887
2053
|
loadWrits(true);
|
|
1888
2054
|
}
|
|
1889
2055
|
|
|
@@ -1901,16 +2067,17 @@
|
|
|
1901
2067
|
document.getElementById('btn-refresh').addEventListener('click', () => loadWrits(true));
|
|
1902
2068
|
|
|
1903
2069
|
document.getElementById('back-btn').addEventListener('click', () => {
|
|
1904
|
-
// D11: push a clean URL via showWritList →
|
|
1905
|
-
// history.back(). Survives deep-link entries (the operator
|
|
1906
|
-
// arrived directly at ?writ=ID with no prior list-view
|
|
1907
|
-
// pop back to), and keeps Forward navigation predictable.
|
|
2070
|
+
// D11: push a clean URL via showWritList → NexusUrl.update, never
|
|
2071
|
+
// call history.back(). Survives deep-link entries (the operator
|
|
2072
|
+
// may have arrived directly at ?writ=ID with no prior list-view
|
|
2073
|
+
// entry to pop back to), and keeps Forward navigation predictable.
|
|
1908
2074
|
showWritList();
|
|
1909
2075
|
});
|
|
1910
2076
|
|
|
1911
2077
|
document.getElementById('btn-toggle-children').addEventListener('click', () => {
|
|
1912
2078
|
showChildren = !showChildren;
|
|
1913
2079
|
document.getElementById('btn-toggle-children').classList.toggle('active-filter', showChildren);
|
|
2080
|
+
writeFiltersToUrl();
|
|
1914
2081
|
renderTable();
|
|
1915
2082
|
});
|
|
1916
2083
|
|
|
@@ -1920,6 +2087,7 @@
|
|
|
1920
2087
|
// cancelled rows when the toggle is off.
|
|
1921
2088
|
showCancelled = !showCancelled;
|
|
1922
2089
|
document.getElementById('btn-toggle-cancelled').classList.toggle('active-filter', showCancelled);
|
|
2090
|
+
writeFiltersToUrl();
|
|
1923
2091
|
renderTable();
|
|
1924
2092
|
});
|
|
1925
2093
|
|
|
@@ -1937,6 +2105,7 @@
|
|
|
1937
2105
|
|
|
1938
2106
|
document.getElementById('search-input').addEventListener('input', e => {
|
|
1939
2107
|
searchText = e.target.value;
|
|
2108
|
+
writeFiltersToUrl();
|
|
1940
2109
|
renderTable();
|
|
1941
2110
|
});
|
|
1942
2111
|
|
|
@@ -1949,6 +2118,7 @@
|
|
|
1949
2118
|
sortCol = col;
|
|
1950
2119
|
sortDir = 'asc';
|
|
1951
2120
|
}
|
|
2121
|
+
writeFiltersToUrl();
|
|
1952
2122
|
renderTable();
|
|
1953
2123
|
});
|
|
1954
2124
|
});
|
|
@@ -1957,18 +2127,25 @@
|
|
|
1957
2127
|
|
|
1958
2128
|
/**
|
|
1959
2129
|
* Reflect browser navigation (Back / Forward) into the page's view
|
|
1960
|
-
* state. The popstate handler
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
2130
|
+
* state. The popstate handler now restores the full filter state in
|
|
2131
|
+
* addition to the detail-id state — Back from a deep-linked
|
|
2132
|
+
* filtered URL must reproduce the original filtered view, not just
|
|
2133
|
+
* its detail open/close axis (commission moix23w5).
|
|
2134
|
+
*
|
|
2135
|
+
* The browser has already updated the URL by the time popstate
|
|
2136
|
+
* fires, so showWritDetail / showWritList run with skipUrlPush; the
|
|
2137
|
+
* filter restore reads from `window.location.search` directly via
|
|
2138
|
+
* readFiltersFromUrl and never writes back.
|
|
1965
2139
|
*/
|
|
1966
2140
|
window.addEventListener('popstate', () => {
|
|
1967
|
-
const writId =
|
|
2141
|
+
const writId = readFiltersFromUrl();
|
|
2142
|
+
syncFilterUiFromState();
|
|
1968
2143
|
if (writId) {
|
|
1969
2144
|
showWritDetail(writId, { skipUrlPush: true });
|
|
1970
2145
|
} else {
|
|
1971
2146
|
showWritList({ skipUrlPush: true });
|
|
2147
|
+
// Refresh the list with the restored filter state.
|
|
2148
|
+
loadWrits(true);
|
|
1972
2149
|
}
|
|
1973
2150
|
});
|
|
1974
2151
|
|
|
@@ -1976,12 +2153,19 @@
|
|
|
1976
2153
|
loadCodexes();
|
|
1977
2154
|
loadLinkKinds();
|
|
1978
2155
|
|
|
1979
|
-
// Deep-link: ?writ=ID
|
|
2156
|
+
// Deep-link: ?writ=ID and filter state
|
|
1980
2157
|
(async function () {
|
|
1981
|
-
// Load writ types first so currentType default is set
|
|
2158
|
+
// Load writ types first so currentType default is set and known
|
|
2159
|
+
// type names are populated — the URL-restore step needs them to
|
|
2160
|
+
// validate ?type= against the registry.
|
|
1982
2161
|
await loadWritTypes();
|
|
1983
2162
|
|
|
1984
|
-
|
|
2163
|
+
// Read URL filter state AFTER buildTypeFilterBar (inside
|
|
2164
|
+
// loadWritTypes) so default-fill does not clobber URL-restored
|
|
2165
|
+
// selections. readFiltersFromUrl validates each value and routes
|
|
2166
|
+
// unknowns to the fail-loud banner per D6.
|
|
2167
|
+
var writId = readFiltersFromUrl();
|
|
2168
|
+
syncFilterUiFromState();
|
|
1985
2169
|
|
|
1986
2170
|
await loadWrits(true);
|
|
1987
2171
|
|
|
@@ -1405,17 +1405,16 @@ describe('Deep descendant rendering in detail view', () => {
|
|
|
1405
1405
|
});
|
|
1406
1406
|
});
|
|
1407
1407
|
|
|
1408
|
-
describe('URL deep-link
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
|
|
1418
|
-
// into showWritDetail (skipUrlPush) or showWritList (skipUrlPush)
|
|
1408
|
+
describe('URL deep-link contract (window.NexusUrl + filter state + popstate cycle)', () => {
|
|
1409
|
+
// The page now relies on the shared `window.NexusUrl` helper that
|
|
1410
|
+
// oculus auto-injects. The inline `currentUrlParams` / `updateUrl`
|
|
1411
|
+
// copies were removed in commission moix23w5; the source of truth
|
|
1412
|
+
// for these tests is `packages/plugins/oculus/src/static/nexus-url.js`.
|
|
1413
|
+
//
|
|
1414
|
+
// We model the page's contract directly: detail open pushes a new
|
|
1415
|
+
// history entry, filter changes replace, and the popstate-driven
|
|
1416
|
+
// path uses skipUrlPush so it never re-pushes.
|
|
1417
|
+
|
|
1419
1418
|
function makeWindow(initialSearch) {
|
|
1420
1419
|
const w = {
|
|
1421
1420
|
location: {
|
|
@@ -1424,10 +1423,15 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
|
|
|
1424
1423
|
},
|
|
1425
1424
|
history: {
|
|
1426
1425
|
pushed: [],
|
|
1427
|
-
|
|
1426
|
+
replaced: [],
|
|
1427
|
+
pushState(_state, _title, url) {
|
|
1428
1428
|
this.pushed.push(url);
|
|
1429
|
-
|
|
1430
|
-
|
|
1429
|
+
const idx = url.indexOf('?');
|
|
1430
|
+
w.location.search = idx === -1 ? '' : url.slice(idx);
|
|
1431
|
+
w.location.pathname = idx === -1 ? url : url.slice(0, idx);
|
|
1432
|
+
},
|
|
1433
|
+
replaceState(_state, _title, url) {
|
|
1434
|
+
this.replaced.push(url);
|
|
1431
1435
|
const idx = url.indexOf('?');
|
|
1432
1436
|
w.location.search = idx === -1 ? '' : url.slice(idx);
|
|
1433
1437
|
w.location.pathname = idx === -1 ? url : url.slice(0, idx);
|
|
@@ -1435,62 +1439,107 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
|
|
|
1435
1439
|
},
|
|
1436
1440
|
_popstate: null,
|
|
1437
1441
|
addEventListener(name, fn) { if (name === 'popstate') this._popstate = fn; },
|
|
1442
|
+
removeEventListener() {},
|
|
1438
1443
|
};
|
|
1439
1444
|
return w;
|
|
1440
1445
|
}
|
|
1441
1446
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1447
|
+
/** Mount NexusUrl into a fake window by inlining its IIFE shape. */
|
|
1448
|
+
function mountNexusUrl(w) {
|
|
1449
|
+
function read() { return new URLSearchParams(w.location.search); }
|
|
1450
|
+
function isEmpty(v) {
|
|
1451
|
+
if (v === null || v === undefined) return true;
|
|
1452
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
1453
|
+
if (v === '') return true;
|
|
1454
|
+
return false;
|
|
1445
1455
|
}
|
|
1446
|
-
function
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1456
|
+
function applyChange(p, k, v) {
|
|
1457
|
+
if (isEmpty(v)) { p.delete(k); return; }
|
|
1458
|
+
if (Array.isArray(v)) {
|
|
1459
|
+
p.delete(k);
|
|
1460
|
+
for (const x of v) {
|
|
1461
|
+
if (x === null || x === undefined || x === '') continue;
|
|
1462
|
+
p.append(k, String(x));
|
|
1463
|
+
}
|
|
1464
|
+
return;
|
|
1451
1465
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
w.history.pushState({}, '', next);
|
|
1466
|
+
if (typeof v === 'boolean') { p.set(k, v ? 'true' : 'false'); return; }
|
|
1467
|
+
p.set(k, String(v));
|
|
1455
1468
|
}
|
|
1456
|
-
|
|
1469
|
+
w.NexusUrl = {
|
|
1470
|
+
read,
|
|
1471
|
+
update(changes, opts) {
|
|
1472
|
+
const p = read();
|
|
1473
|
+
for (const k of Object.keys(changes ?? {})) applyChange(p, k, changes[k]);
|
|
1474
|
+
const qs = p.toString();
|
|
1475
|
+
const next = w.location.pathname + (qs ? '?' + qs : '');
|
|
1476
|
+
if (opts && opts.push) w.history.pushState({}, '', next);
|
|
1477
|
+
else w.history.replaceState({}, '', next);
|
|
1478
|
+
return p;
|
|
1479
|
+
},
|
|
1480
|
+
};
|
|
1457
1481
|
}
|
|
1458
1482
|
|
|
1459
|
-
it('
|
|
1483
|
+
it('detail open pushes ?writ=ID (push: true)', () => {
|
|
1460
1484
|
const w = makeWindow();
|
|
1461
|
-
|
|
1462
|
-
|
|
1485
|
+
mountNexusUrl(w);
|
|
1486
|
+
w.NexusUrl.update({ writ: 'w-abc' }, { push: true });
|
|
1463
1487
|
assert.deepEqual(w.history.pushed, ['/pages/writs/?writ=w-abc']);
|
|
1488
|
+
assert.equal(w.history.replaced.length, 0);
|
|
1464
1489
|
});
|
|
1465
1490
|
|
|
1466
|
-
it('
|
|
1491
|
+
it('back-to-list pushes a clean URL (writ: null clears the key)', () => {
|
|
1467
1492
|
const w = makeWindow('?writ=w-old&keep=me');
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
assert.
|
|
1471
|
-
|
|
1493
|
+
mountNexusUrl(w);
|
|
1494
|
+
w.NexusUrl.update({ writ: null }, { push: true });
|
|
1495
|
+
assert.deepEqual(w.history.pushed, ['/pages/writs/?keep=me']);
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
it('filter changes replace, never push (D5)', () => {
|
|
1499
|
+
const w = makeWindow();
|
|
1500
|
+
mountNexusUrl(w);
|
|
1501
|
+
w.NexusUrl.update({ classification: 'active' });
|
|
1502
|
+
w.NexusUrl.update({ q: 'hello' });
|
|
1503
|
+
w.NexusUrl.update({ children: false });
|
|
1504
|
+
assert.equal(w.history.pushed.length, 0, 'filter changes must not push');
|
|
1505
|
+
assert.equal(w.history.replaced.length, 3);
|
|
1506
|
+
assert.equal(w.location.search, '?classification=active&q=hello&children=false');
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('omit-defaults clears keys when set to default (D4)', () => {
|
|
1510
|
+
const w = makeWindow('?classification=active&q=hello&children=false');
|
|
1511
|
+
mountNexusUrl(w);
|
|
1512
|
+
w.NexusUrl.update({ classification: null, q: null, children: null });
|
|
1513
|
+
assert.equal(w.location.search, '');
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
it('repeated-keys array encoding for type filter (D3)', () => {
|
|
1517
|
+
const w = makeWindow();
|
|
1518
|
+
mountNexusUrl(w);
|
|
1519
|
+
w.NexusUrl.update({ type: ['mandate', 'bug'] });
|
|
1520
|
+
const params = new URLSearchParams(w.location.search);
|
|
1521
|
+
assert.deepEqual(params.getAll('type'), ['mandate', 'bug']);
|
|
1472
1522
|
});
|
|
1473
1523
|
|
|
1474
|
-
it('
|
|
1524
|
+
it('boolean encoding uses true/false strings (D7)', () => {
|
|
1475
1525
|
const w = makeWindow();
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
assert.equal(w.history.pushed[0], '/pages/writs/?writ=w+with+spaces');
|
|
1526
|
+
mountNexusUrl(w);
|
|
1527
|
+
w.NexusUrl.update({ children: false, cancelled: true });
|
|
1528
|
+
const params = new URLSearchParams(w.location.search);
|
|
1529
|
+
assert.equal(params.get('children'), 'false');
|
|
1530
|
+
assert.equal(params.get('cancelled'), 'true');
|
|
1482
1531
|
});
|
|
1483
1532
|
|
|
1484
1533
|
it('a full select → back-to-list → select cycle pushes the right URL sequence', () => {
|
|
1485
1534
|
const w = makeWindow();
|
|
1486
|
-
|
|
1535
|
+
mountNexusUrl(w);
|
|
1487
1536
|
|
|
1488
1537
|
// Operator clicks into a writ → showWritDetail pushes ?writ=
|
|
1489
|
-
|
|
1538
|
+
w.NexusUrl.update({ writ: 'w-1' }, { push: true });
|
|
1490
1539
|
// Back to list → showWritList clears ?writ=
|
|
1491
|
-
|
|
1540
|
+
w.NexusUrl.update({ writ: null }, { push: true });
|
|
1492
1541
|
// Operator opens another writ
|
|
1493
|
-
|
|
1542
|
+
w.NexusUrl.update({ writ: 'w-2' }, { push: true });
|
|
1494
1543
|
|
|
1495
1544
|
assert.deepEqual(w.history.pushed, [
|
|
1496
1545
|
'/pages/writs/?writ=w-1',
|
|
@@ -1501,18 +1550,13 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
|
|
|
1501
1550
|
|
|
1502
1551
|
it('popstate-driven path does not push (the browser already updated the URL)', () => {
|
|
1503
1552
|
const w = makeWindow('?writ=w-1');
|
|
1504
|
-
|
|
1553
|
+
mountNexusUrl(w);
|
|
1505
1554
|
|
|
1506
|
-
// Simulate the popstate dispatch: the page reads ?writ from the
|
|
1507
|
-
// current URL and calls showWritDetail with skipUrlPush=true. We
|
|
1508
|
-
// model showWritDetail's URL-push branch here as "if not
|
|
1509
|
-
// skipUrlPush, updateUrl({writ: id})" — see the index.html
|
|
1510
|
-
// implementation. The expected behaviour is zero pushes.
|
|
1511
1555
|
function simulateShowWritDetail(id, skipUrlPush) {
|
|
1512
|
-
if (!skipUrlPush)
|
|
1556
|
+
if (!skipUrlPush) w.NexusUrl.update({ writ: id }, { push: true });
|
|
1513
1557
|
}
|
|
1514
1558
|
function simulateShowWritList(skipUrlPush) {
|
|
1515
|
-
if (!skipUrlPush)
|
|
1559
|
+
if (!skipUrlPush) w.NexusUrl.update({ writ: null }, { push: true });
|
|
1516
1560
|
}
|
|
1517
1561
|
|
|
1518
1562
|
// Forward to detail (programmatic, normal) — pushes once.
|
|
@@ -1533,25 +1577,33 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
|
|
|
1533
1577
|
assert.equal(w.history.pushed[1], '/pages/writs/');
|
|
1534
1578
|
});
|
|
1535
1579
|
|
|
1536
|
-
it('popstate handler reads ?writ from the new URL
|
|
1580
|
+
it('popstate handler reads ?writ from the new URL and restores filter state', () => {
|
|
1537
1581
|
const w = makeWindow();
|
|
1538
|
-
|
|
1582
|
+
mountNexusUrl(w);
|
|
1539
1583
|
|
|
1540
|
-
// Track which view the page would be showing.
|
|
1584
|
+
// Track which view the page would be showing AND the filter state.
|
|
1541
1585
|
let view = 'list';
|
|
1542
1586
|
let detailId = null;
|
|
1587
|
+
let appliedClassification = '';
|
|
1588
|
+
let appliedSearch = '';
|
|
1543
1589
|
function showWritDetail(id, opts) {
|
|
1544
1590
|
view = 'detail';
|
|
1545
1591
|
detailId = id;
|
|
1546
|
-
if (!(opts && opts.skipUrlPush))
|
|
1592
|
+
if (!(opts && opts.skipUrlPush)) w.NexusUrl.update({ writ: id }, { push: true });
|
|
1547
1593
|
}
|
|
1548
1594
|
function showWritList(opts) {
|
|
1549
1595
|
view = 'list';
|
|
1550
1596
|
detailId = null;
|
|
1551
|
-
if (!(opts && opts.skipUrlPush))
|
|
1597
|
+
if (!(opts && opts.skipUrlPush)) w.NexusUrl.update({ writ: null }, { push: true });
|
|
1598
|
+
}
|
|
1599
|
+
function readFilters() {
|
|
1600
|
+
const p = w.NexusUrl.read();
|
|
1601
|
+
appliedClassification = p.get('classification') ?? '';
|
|
1602
|
+
appliedSearch = p.get('q') ?? '';
|
|
1603
|
+
return p.get('writ');
|
|
1552
1604
|
}
|
|
1553
1605
|
function popstate() {
|
|
1554
|
-
const id =
|
|
1606
|
+
const id = readFilters();
|
|
1555
1607
|
if (id) showWritDetail(id, { skipUrlPush: true });
|
|
1556
1608
|
else showWritList({ skipUrlPush: true });
|
|
1557
1609
|
}
|
|
@@ -1563,21 +1615,158 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
|
|
|
1563
1615
|
assert.equal(detailId, 'w-1');
|
|
1564
1616
|
assert.equal(w.location.search, '?writ=w-1');
|
|
1565
1617
|
|
|
1566
|
-
// Browser Back
|
|
1567
|
-
|
|
1568
|
-
w.location.search = '';
|
|
1618
|
+
// Browser Back to a filtered list view (?classification=active&q=foo)
|
|
1619
|
+
w.location.search = '?classification=active&q=foo';
|
|
1569
1620
|
w._popstate({});
|
|
1570
1621
|
assert.equal(view, 'list', 'popstate to list-view');
|
|
1571
1622
|
assert.equal(detailId, null);
|
|
1623
|
+
assert.equal(appliedClassification, 'active', 'classification filter restored from URL');
|
|
1624
|
+
assert.equal(appliedSearch, 'foo', 'search text restored from URL');
|
|
1572
1625
|
// No additional pushes from the popstate-driven render.
|
|
1573
1626
|
assert.equal(w.history.pushed.length, 1);
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1574
1629
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1630
|
+
describe('Clerk writs URL state — filter restore from initial URL', () => {
|
|
1631
|
+
// Mirrors the page's readFiltersFromUrl logic. Asserts:
|
|
1632
|
+
// (a) changing a filter writes the expected URL key with the
|
|
1633
|
+
// expected value (covered by the URL-helper tests above),
|
|
1634
|
+
// (b) initial load with the URL key present applies the filter,
|
|
1635
|
+
// (c) popstate restoration re-applies the filter (covered above).
|
|
1636
|
+
// This block focuses on the (b) case for each filter key.
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Distilled mirror of readFiltersFromUrl in index.html. Returns the
|
|
1640
|
+
* filter state derived from a query string. Validation errors (D6
|
|
1641
|
+
* fail-loud) are returned as a `errors` list — the page surfaces
|
|
1642
|
+
* them in a top-of-page banner, but here we just assert the shape.
|
|
1643
|
+
*/
|
|
1644
|
+
function readFiltersFromUrl(search, knownTypes) {
|
|
1645
|
+
const SORT_COLS = new Set(['phase', 'title', 'type', 'id', 'createdAt']);
|
|
1646
|
+
const SORT_DIRS = new Set(['asc', 'desc']);
|
|
1647
|
+
const CLASSIFICATIONS = new Set(['', 'initial', 'active', 'terminal']);
|
|
1648
|
+
const params = new URLSearchParams(search);
|
|
1649
|
+
|
|
1650
|
+
const errors = [];
|
|
1651
|
+
let classification = '';
|
|
1652
|
+
let currentType = new Set(knownTypes);
|
|
1653
|
+
let q = '';
|
|
1654
|
+
let sortCol = 'createdAt';
|
|
1655
|
+
let sortDir = 'desc';
|
|
1656
|
+
let showChildren = true;
|
|
1657
|
+
let showCancelled = false;
|
|
1658
|
+
|
|
1659
|
+
const c = params.get('classification');
|
|
1660
|
+
if (c !== null) {
|
|
1661
|
+
if (CLASSIFICATIONS.has(c)) classification = c;
|
|
1662
|
+
else errors.push(`classification: "${c}"`);
|
|
1663
|
+
}
|
|
1664
|
+
const types = params.getAll('type');
|
|
1665
|
+
if (types.length > 0) {
|
|
1666
|
+
const known = new Set(knownTypes);
|
|
1667
|
+
const unknown = types.filter(t => !known.has(t));
|
|
1668
|
+
if (unknown.length > 0) errors.push(...unknown.map(u => `type: "${u}"`));
|
|
1669
|
+
else currentType = new Set(types);
|
|
1670
|
+
}
|
|
1671
|
+
const queryText = params.get('q');
|
|
1672
|
+
if (queryText !== null) q = queryText;
|
|
1673
|
+
const sort = params.get('sort');
|
|
1674
|
+
if (sort !== null) {
|
|
1675
|
+
if (SORT_COLS.has(sort)) sortCol = sort;
|
|
1676
|
+
else errors.push(`sort: "${sort}"`);
|
|
1677
|
+
}
|
|
1678
|
+
const dir = params.get('dir');
|
|
1679
|
+
if (dir !== null) {
|
|
1680
|
+
if (SORT_DIRS.has(dir)) sortDir = dir;
|
|
1681
|
+
else errors.push(`dir: "${dir}"`);
|
|
1682
|
+
}
|
|
1683
|
+
const ch = params.get('children');
|
|
1684
|
+
if (ch !== null) {
|
|
1685
|
+
if (ch === 'true') showChildren = true;
|
|
1686
|
+
else if (ch === 'false') showChildren = false;
|
|
1687
|
+
else errors.push(`children: "${ch}"`);
|
|
1688
|
+
}
|
|
1689
|
+
const cn = params.get('cancelled');
|
|
1690
|
+
if (cn !== null) {
|
|
1691
|
+
if (cn === 'true') showCancelled = true;
|
|
1692
|
+
else if (cn === 'false') showCancelled = false;
|
|
1693
|
+
else errors.push(`cancelled: "${cn}"`);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
return { classification, currentType, q, sortCol, sortDir, showChildren, showCancelled, errors };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
it('initial load with ?classification=active applies the classification filter', () => {
|
|
1700
|
+
const s = readFiltersFromUrl('?classification=active', ['mandate', 'bug']);
|
|
1701
|
+
assert.equal(s.classification, 'active');
|
|
1702
|
+
assert.equal(s.errors.length, 0);
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
it('initial load with ?type=mandate&type=bug applies the type filter', () => {
|
|
1706
|
+
const s = readFiltersFromUrl('?type=mandate&type=bug', ['mandate', 'bug', 'task']);
|
|
1707
|
+
assert.deepEqual(s.currentType, new Set(['mandate', 'bug']));
|
|
1708
|
+
assert.equal(s.errors.length, 0);
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
it('initial load with ?q=hello applies the search text', () => {
|
|
1712
|
+
const s = readFiltersFromUrl('?q=hello', ['mandate']);
|
|
1713
|
+
assert.equal(s.q, 'hello');
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it('initial load with ?sort=title&dir=asc applies sort col + direction', () => {
|
|
1717
|
+
const s = readFiltersFromUrl('?sort=title&dir=asc', ['mandate']);
|
|
1718
|
+
assert.equal(s.sortCol, 'title');
|
|
1719
|
+
assert.equal(s.sortDir, 'asc');
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
it('initial load with ?children=false applies the children toggle', () => {
|
|
1723
|
+
const s = readFiltersFromUrl('?children=false', ['mandate']);
|
|
1724
|
+
assert.equal(s.showChildren, false);
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
it('initial load with ?cancelled=true applies the cancelled toggle', () => {
|
|
1728
|
+
const s = readFiltersFromUrl('?cancelled=true', ['mandate']);
|
|
1729
|
+
assert.equal(s.showCancelled, true);
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it('absent keys leave each filter at its declared default (D4)', () => {
|
|
1733
|
+
const s = readFiltersFromUrl('', ['mandate', 'bug']);
|
|
1734
|
+
assert.equal(s.classification, '');
|
|
1735
|
+
assert.deepEqual(s.currentType, new Set(['mandate', 'bug']));
|
|
1736
|
+
assert.equal(s.q, '');
|
|
1737
|
+
assert.equal(s.sortCol, 'createdAt');
|
|
1738
|
+
assert.equal(s.sortDir, 'desc');
|
|
1739
|
+
assert.equal(s.showChildren, true);
|
|
1740
|
+
assert.equal(s.showCancelled, false);
|
|
1741
|
+
assert.equal(s.errors.length, 0);
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
it('fail-loud — invalid classification surfaces an error and does not fall back silently (D6)', () => {
|
|
1745
|
+
const s = readFiltersFromUrl('?classification=bogus', ['mandate']);
|
|
1746
|
+
assert.equal(s.errors.length, 1);
|
|
1747
|
+
assert.match(s.errors[0], /classification.*bogus/);
|
|
1748
|
+
// The default value stays at '' — but the error must be surfaced.
|
|
1749
|
+
assert.equal(s.classification, '');
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
it('fail-loud — unknown type surfaces an error', () => {
|
|
1753
|
+
const s = readFiltersFromUrl('?type=bogus', ['mandate']);
|
|
1754
|
+
assert.equal(s.errors.length, 1);
|
|
1755
|
+
assert.match(s.errors[0], /type.*bogus/);
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
it('fail-loud — invalid sort column / direction', () => {
|
|
1759
|
+
const s = readFiltersFromUrl('?sort=banana&dir=sideways', ['mandate']);
|
|
1760
|
+
assert.equal(s.errors.length, 2);
|
|
1761
|
+
assert.ok(s.errors.some(e => /sort.*banana/.test(e)));
|
|
1762
|
+
assert.ok(s.errors.some(e => /dir.*sideways/.test(e)));
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it('fail-loud — invalid boolean values', () => {
|
|
1766
|
+
const s = readFiltersFromUrl('?children=maybe&cancelled=perhaps', ['mandate']);
|
|
1767
|
+
assert.equal(s.errors.length, 2);
|
|
1768
|
+
assert.ok(s.errors.some(e => /children.*maybe/.test(e)));
|
|
1769
|
+
assert.ok(s.errors.some(e => /cancelled.*perhaps/.test(e)));
|
|
1581
1770
|
});
|
|
1582
1771
|
});
|
|
1583
1772
|
|