@shardworks/clerk-apparatus 0.1.163 → 0.1.165
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/README.md +2 -2
- package/package.json +4 -4
- package/pages/writs/index.html +201 -126
- package/pages/writs/writs-hierarchy.test.js +544 -0
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ const total = await clerk.count({ status: 'ready' });
|
|
|
91
91
|
|
|
92
92
|
### `edit(request): Promise<WritDoc>`
|
|
93
93
|
|
|
94
|
-
Edit a
|
|
94
|
+
Edit a writ, updating one or more fields. Only the provided fields are updated.
|
|
95
95
|
|
|
96
96
|
```typescript
|
|
97
97
|
const edited = await clerk.edit({
|
|
@@ -266,7 +266,7 @@ interface PostCommissionRequest {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
interface EditWritRequest {
|
|
269
|
-
id: string; // writ to edit
|
|
269
|
+
id: string; // writ to edit
|
|
270
270
|
title?: string; // new title
|
|
271
271
|
body?: string; // new body text
|
|
272
272
|
type?: string; // new type (must be valid)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardworks/clerk-apparatus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.165",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"zod": "4.3.6",
|
|
20
|
-
"@shardworks/nexus-core": "0.1.
|
|
21
|
-
"@shardworks/
|
|
22
|
-
"@shardworks/
|
|
20
|
+
"@shardworks/nexus-core": "0.1.165",
|
|
21
|
+
"@shardworks/stacks-apparatus": "0.1.165",
|
|
22
|
+
"@shardworks/tools-apparatus": "0.1.165"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "25.5.0"
|
package/pages/writs/index.html
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
.confirm-section input { padding: 0.3rem 0.5rem; background: var(--bg, #1a1b26); border: 1px solid var(--border, #414868); border-radius: 4px; color: inherit; min-width: 220px; }
|
|
26
26
|
tr.writ-row { cursor: pointer; }
|
|
27
27
|
tr.writ-row:hover { opacity: 0.85; }
|
|
28
|
-
tr.
|
|
28
|
+
tr.child-row { opacity: 0.85; }
|
|
29
29
|
#post-section { margin-bottom: 16px; display: none; }
|
|
30
30
|
#post-section.open { display: block; }
|
|
31
31
|
#post-section h3 { margin: 0 0 0.75rem; }
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
<body>
|
|
48
48
|
<main style="padding: 24px;">
|
|
49
49
|
<h1>Writs</h1>
|
|
50
|
+
|
|
51
|
+
<div id="writ-list-view">
|
|
50
52
|
<div class="card" style="margin-bottom: 16px;">
|
|
51
53
|
<div class="toolbar" id="toolbar">
|
|
52
54
|
<button class="btn btn--primary" id="btn-new-writ">New Writ</button>
|
|
@@ -57,6 +59,7 @@
|
|
|
57
59
|
<button class="btn filter-btn" data-status="new">new</button>
|
|
58
60
|
<button class="btn filter-btn" data-status="ready">ready</button>
|
|
59
61
|
<button class="btn filter-btn" data-status="active">active</button>
|
|
62
|
+
<button class="btn filter-btn" data-status="waiting">waiting</button>
|
|
60
63
|
<button class="btn filter-btn" data-status="completed">completed</button>
|
|
61
64
|
<button class="btn filter-btn" data-status="failed">failed</button>
|
|
62
65
|
<button class="btn filter-btn" data-status="cancelled">cancelled</button>
|
|
@@ -66,6 +69,7 @@
|
|
|
66
69
|
<button class="btn type-filter-btn active-filter" data-type="">All</button>
|
|
67
70
|
</span>
|
|
68
71
|
<input type="text" id="search-input" placeholder="Search title...">
|
|
72
|
+
<button class="btn active-filter" id="btn-toggle-children">Children</button>
|
|
69
73
|
</div>
|
|
70
74
|
</div>
|
|
71
75
|
|
|
@@ -117,6 +121,13 @@
|
|
|
117
121
|
<div id="load-more-row" style="display:none; text-align: center; margin-top: 8px;">
|
|
118
122
|
<button class="btn" id="load-more-btn">Load more</button>
|
|
119
123
|
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div id="writ-detail-view" style="display:none">
|
|
127
|
+
<button id="back-btn" class="btn">← Back to list</button>
|
|
128
|
+
<h2 id="detail-title"></h2>
|
|
129
|
+
<div id="detail-content"></div>
|
|
130
|
+
</div>
|
|
120
131
|
|
|
121
132
|
<datalist id="link-types">
|
|
122
133
|
<option value="retries"></option>
|
|
@@ -131,14 +142,15 @@
|
|
|
131
142
|
<script>
|
|
132
143
|
(() => {
|
|
133
144
|
// ── State ──────────────────────────────────────────────────────────
|
|
134
|
-
let writs = []; //
|
|
145
|
+
let writs = []; // root writs only (no parentId)
|
|
135
146
|
let offset = 0;
|
|
136
147
|
let currentStatus = ''; // '' = all
|
|
137
148
|
let currentType = ''; // '' = all
|
|
138
149
|
let searchText = '';
|
|
139
150
|
let sortCol = 'createdAt';
|
|
140
151
|
let sortDir = 'desc';
|
|
141
|
-
let
|
|
152
|
+
let showChildren = true; // toggle state — children shown by default
|
|
153
|
+
let childrenMap = {}; // { parentId: WritDoc[] } — fetched children keyed by parent id
|
|
142
154
|
let repostSourceId = null;
|
|
143
155
|
|
|
144
156
|
const LIMIT = 20;
|
|
@@ -159,6 +171,7 @@
|
|
|
159
171
|
new: 'badge badge--draft',
|
|
160
172
|
ready: 'badge',
|
|
161
173
|
active: 'badge badge--active',
|
|
174
|
+
waiting: 'badge badge--warning',
|
|
162
175
|
completed: 'badge badge--success',
|
|
163
176
|
failed: 'badge badge--error',
|
|
164
177
|
cancelled: 'badge badge--warning',
|
|
@@ -181,18 +194,37 @@
|
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
function sortedFilteredWrits() {
|
|
184
|
-
let
|
|
185
|
-
|
|
197
|
+
let roots = writs.slice();
|
|
198
|
+
|
|
199
|
+
// Text filter on roots
|
|
186
200
|
if (searchText) {
|
|
187
201
|
const q = searchText.toLowerCase();
|
|
188
|
-
|
|
202
|
+
roots = roots.filter(w => (w.title ?? '').toLowerCase().includes(q));
|
|
189
203
|
}
|
|
190
|
-
|
|
191
|
-
|
|
204
|
+
|
|
205
|
+
// Sort roots by current sort column
|
|
206
|
+
roots.sort((a, b) => {
|
|
192
207
|
const cmp = compareVal(a, b, sortCol);
|
|
193
208
|
return sortDir === 'asc' ? cmp : -cmp;
|
|
194
209
|
});
|
|
195
|
-
|
|
210
|
+
|
|
211
|
+
// Interleave children beneath each root
|
|
212
|
+
const result = [];
|
|
213
|
+
for (const root of roots) {
|
|
214
|
+
result.push({ writ: root, isChild: false });
|
|
215
|
+
if (showChildren) {
|
|
216
|
+
let children = childrenMap[root.id] ?? [];
|
|
217
|
+
// Text filter on children too
|
|
218
|
+
if (searchText) {
|
|
219
|
+
const q = searchText.toLowerCase();
|
|
220
|
+
children = children.filter(w => (w.title ?? '').toLowerCase().includes(q));
|
|
221
|
+
}
|
|
222
|
+
for (const child of children) {
|
|
223
|
+
result.push({ writ: child, isChild: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
196
228
|
}
|
|
197
229
|
|
|
198
230
|
function showError(el, msg) {
|
|
@@ -236,8 +268,10 @@
|
|
|
236
268
|
if (idx >= 0) writs[idx] = writ;
|
|
237
269
|
updateRowStatus(id);
|
|
238
270
|
updateRowActions(id);
|
|
239
|
-
// Refresh
|
|
240
|
-
if (
|
|
271
|
+
// Refresh detail view if open
|
|
272
|
+
if (document.getElementById('writ-detail-view').style.display !== 'none') {
|
|
273
|
+
await refreshDetail(id);
|
|
274
|
+
}
|
|
241
275
|
} catch (e) {
|
|
242
276
|
alert(`Action failed: ${e.message}`);
|
|
243
277
|
btn.disabled = false;
|
|
@@ -273,21 +307,19 @@
|
|
|
273
307
|
return;
|
|
274
308
|
}
|
|
275
309
|
|
|
276
|
-
// Preserve expanded state if row still visible
|
|
277
310
|
tbody.innerHTML = '';
|
|
278
|
-
for (const w of visible) {
|
|
311
|
+
for (const { writ: w, isChild } of visible) {
|
|
279
312
|
const tr = document.createElement('tr');
|
|
280
|
-
tr.className = 'writ-row' + (
|
|
313
|
+
tr.className = 'writ-row' + (isChild ? ' child-row' : '');
|
|
281
314
|
tr.dataset.id = w.id;
|
|
282
|
-
tr.
|
|
283
|
-
|
|
284
|
-
<td
|
|
285
|
-
<td
|
|
286
|
-
<td
|
|
287
|
-
<td
|
|
288
|
-
<td
|
|
289
|
-
|
|
290
|
-
`;
|
|
315
|
+
tr.innerHTML =
|
|
316
|
+
'<td>' + statusBadge(w.status) + '</td>' +
|
|
317
|
+
'<td' + (isChild ? ' style="padding-left:2rem"' : '') + '>' + escHtml(w.title ?? '') + '</td>' +
|
|
318
|
+
'<td>' + (w.type ?? '') + '</td>' +
|
|
319
|
+
'<td><code>' + w.id + '</code></td>' +
|
|
320
|
+
'<td>' + (isChild ? '' : fmtDate(w.createdAt)) + '</td>' +
|
|
321
|
+
'<td class="row-actions" style="white-space:nowrap">' + rowActions(w) + '</td>';
|
|
322
|
+
|
|
291
323
|
// Wire row-action buttons (stop row-click propagation)
|
|
292
324
|
tr.querySelectorAll('.row-action-btn').forEach(btn => {
|
|
293
325
|
btn.addEventListener('click', e => {
|
|
@@ -295,13 +327,9 @@
|
|
|
295
327
|
handleRowAction(btn.dataset.action, btn.dataset.id, btn);
|
|
296
328
|
});
|
|
297
329
|
});
|
|
298
|
-
|
|
330
|
+
// Click row -> show detail view
|
|
331
|
+
tr.addEventListener('click', () => showWritDetail(w.id));
|
|
299
332
|
tbody.appendChild(tr);
|
|
300
|
-
|
|
301
|
-
if (expandedId === w.id) {
|
|
302
|
-
const detailTr = buildDetailRow(w);
|
|
303
|
-
tbody.appendChild(detailTr);
|
|
304
|
-
}
|
|
305
333
|
}
|
|
306
334
|
|
|
307
335
|
updateSortIndicators();
|
|
@@ -324,21 +352,6 @@
|
|
|
324
352
|
|
|
325
353
|
// ── Detail row ─────────────────────────────────────────────────────
|
|
326
354
|
|
|
327
|
-
function buildDetailRow(writ) {
|
|
328
|
-
const tr = document.createElement('tr');
|
|
329
|
-
tr.className = 'detail-row';
|
|
330
|
-
tr.dataset.detailFor = writ.id;
|
|
331
|
-
|
|
332
|
-
const td = document.createElement('td');
|
|
333
|
-
td.colSpan = 6;
|
|
334
|
-
td.innerHTML = renderDetail(writ);
|
|
335
|
-
|
|
336
|
-
// Wire up events after inserting
|
|
337
|
-
tr.appendChild(td);
|
|
338
|
-
requestAnimationFrame(() => wireDetailEvents(tr, writ));
|
|
339
|
-
return tr;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
355
|
function renderDetail(writ) {
|
|
343
356
|
const isTerminal = ['completed', 'failed', 'cancelled'].includes(writ.status);
|
|
344
357
|
const isDraft = writ.status === 'new';
|
|
@@ -366,6 +379,9 @@
|
|
|
366
379
|
// Timestamps + metadata
|
|
367
380
|
html += `<div class="detail-section"><h4>Details</h4><dl class="detail-grid">`;
|
|
368
381
|
if (writ.codex) html += `<dt>Codex</dt><dd>${escHtml(writ.codex)}</dd>`;
|
|
382
|
+
if (writ.parent) {
|
|
383
|
+
html += `<dt>Parent</dt><dd><a href="?writ=${encodeURIComponent(writ.parent.id)}" style="color:var(--blue,#7aa2f7);text-decoration:underline;cursor:pointer">${escHtml(writ.parent.title)}</a> ${statusBadge(writ.parent.status)}</dd>`;
|
|
384
|
+
}
|
|
369
385
|
html += `<dt>Created</dt><dd>${fmtDate(writ.createdAt)}</dd>`;
|
|
370
386
|
html += `<dt>Updated</dt><dd>${fmtDate(writ.updatedAt)}</dd>`;
|
|
371
387
|
if (writ.acceptedAt) html += `<dt>Accepted</dt><dd>${fmtDate(writ.acceptedAt)}</dd>`;
|
|
@@ -402,6 +418,37 @@
|
|
|
402
418
|
html += renderLinksSection(writ);
|
|
403
419
|
html += `</div>`;
|
|
404
420
|
|
|
421
|
+
// Children
|
|
422
|
+
const childItems = writ._fullChildren ?? writ.children?.items ?? [];
|
|
423
|
+
if (childItems.length > 0) {
|
|
424
|
+
html += `<div class="detail-section">`;
|
|
425
|
+
html += `<h4>Children</h4>`;
|
|
426
|
+
|
|
427
|
+
// Summary badges
|
|
428
|
+
if (writ.children?.summary) {
|
|
429
|
+
html += `<div style="margin-bottom:0.5rem">`;
|
|
430
|
+
for (const [status, count] of Object.entries(writ.children.summary)) {
|
|
431
|
+
html += statusBadge(status) + ` <span style="margin-right:0.75rem">${count}</span>`;
|
|
432
|
+
}
|
|
433
|
+
html += `</div>`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Children table: Status, Title, Type, ID, Actions
|
|
437
|
+
html += `<table class="data-table"><thead><tr>`;
|
|
438
|
+
html += `<th>Status</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
|
|
439
|
+
html += `</tr></thead><tbody>`;
|
|
440
|
+
for (const child of childItems) {
|
|
441
|
+
html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
|
|
442
|
+
html += `<td>${statusBadge(child.status)}</td>`;
|
|
443
|
+
html += `<td>${escHtml(child.title ?? '')}</td>`;
|
|
444
|
+
html += `<td>${escHtml(child.type ?? '')}</td>`;
|
|
445
|
+
html += `<td><code>${child.id}</code></td>`;
|
|
446
|
+
html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
|
|
447
|
+
html += `</tr>`;
|
|
448
|
+
}
|
|
449
|
+
html += `</tbody></table></div>`;
|
|
450
|
+
}
|
|
451
|
+
|
|
405
452
|
return html;
|
|
406
453
|
}
|
|
407
454
|
|
|
@@ -444,31 +491,44 @@
|
|
|
444
491
|
return html;
|
|
445
492
|
}
|
|
446
493
|
|
|
447
|
-
function wireDetailEvents(
|
|
448
|
-
const td = tr.querySelector('td');
|
|
449
|
-
if (!td) return;
|
|
450
|
-
|
|
494
|
+
function wireDetailEvents(container, writ) {
|
|
451
495
|
// Populate edit dropdowns for draft writs
|
|
452
496
|
if (writ.status === 'new') {
|
|
453
497
|
populateEditDropdowns(writ);
|
|
454
498
|
}
|
|
455
499
|
|
|
456
|
-
// Transition action buttons
|
|
457
|
-
|
|
500
|
+
// Transition action buttons + other data-action buttons
|
|
501
|
+
container.querySelectorAll('[data-action]').forEach(btn => {
|
|
458
502
|
btn.addEventListener('click', e => {
|
|
459
503
|
e.stopPropagation();
|
|
460
504
|
const action = btn.dataset.action;
|
|
461
505
|
const id = btn.dataset.id;
|
|
462
|
-
handleDetailAction(action, id, btn,
|
|
506
|
+
handleDetailAction(action, id, btn, container, writ);
|
|
463
507
|
});
|
|
464
508
|
});
|
|
465
509
|
|
|
466
|
-
// Link-id clicks
|
|
467
|
-
|
|
510
|
+
// Link-id clicks (links section)
|
|
511
|
+
container.querySelectorAll('.link-id[data-writ-id]').forEach(a => {
|
|
468
512
|
a.addEventListener('click', e => {
|
|
469
513
|
e.stopPropagation();
|
|
470
|
-
|
|
471
|
-
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
showWritDetail(a.dataset.writId);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Child row clicks — navigate to child detail
|
|
520
|
+
container.querySelectorAll('.child-detail-row').forEach(row => {
|
|
521
|
+
row.addEventListener('click', (e) => {
|
|
522
|
+
if (e.target.closest('.row-action-btn')) return;
|
|
523
|
+
showWritDetail(row.dataset.childId);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Wire row-action buttons inside children table
|
|
528
|
+
container.querySelectorAll('.child-detail-row .row-action-btn').forEach(btn => {
|
|
529
|
+
btn.addEventListener('click', e => {
|
|
530
|
+
e.stopPropagation();
|
|
531
|
+
handleRowAction(btn.dataset.action, btn.dataset.id, btn);
|
|
472
532
|
});
|
|
473
533
|
});
|
|
474
534
|
}
|
|
@@ -729,13 +789,26 @@
|
|
|
729
789
|
const idx = writs.findIndex(w => w.id === id);
|
|
730
790
|
if (idx >= 0) writs[idx] = writ;
|
|
731
791
|
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
792
|
+
// Fetch full children if needed
|
|
793
|
+
if (writ.children && writ.children.items.length > 0) {
|
|
794
|
+
try {
|
|
795
|
+
const fullChildren = await api('GET',
|
|
796
|
+
'/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
|
|
797
|
+
fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
798
|
+
writ._fullChildren = fullChildren;
|
|
799
|
+
} catch (e) { writ._fullChildren = null; }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Re-render detail content
|
|
803
|
+
const content = document.getElementById('detail-content');
|
|
804
|
+
if (content) {
|
|
805
|
+
content.innerHTML = renderDetail(writ);
|
|
806
|
+
wireDetailEvents(content, writ);
|
|
738
807
|
}
|
|
808
|
+
|
|
809
|
+
// Also refresh the list view row
|
|
810
|
+
updateRowStatus(id);
|
|
811
|
+
updateRowActions(id);
|
|
739
812
|
}
|
|
740
813
|
|
|
741
814
|
function updateRowStatus(id) {
|
|
@@ -748,63 +821,41 @@
|
|
|
748
821
|
if (cells[0]) cells[0].innerHTML = statusBadge(writ.status);
|
|
749
822
|
}
|
|
750
823
|
|
|
751
|
-
// ──
|
|
824
|
+
// ── Detail view ──────────────────────────────────────────────────
|
|
752
825
|
|
|
753
|
-
async function
|
|
754
|
-
const tbody = document.getElementById('writ-tbody');
|
|
755
|
-
const existing = document.querySelector(`tr[data-detail-for="${id}"]`);
|
|
756
|
-
|
|
757
|
-
if (existing) {
|
|
758
|
-
// Collapse
|
|
759
|
-
existing.remove();
|
|
760
|
-
tr.classList.remove('expanded');
|
|
761
|
-
expandedId = null;
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Collapse any other expanded row
|
|
766
|
-
if (expandedId) {
|
|
767
|
-
const prevDetail = document.querySelector(`tr[data-detail-for="${expandedId}"]`);
|
|
768
|
-
if (prevDetail) prevDetail.remove();
|
|
769
|
-
const prevRow = document.querySelector(`tr.writ-row[data-id="${expandedId}"]`);
|
|
770
|
-
if (prevRow) prevRow.classList.remove('expanded');
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
expandedId = id;
|
|
774
|
-
tr.classList.add('expanded');
|
|
775
|
-
|
|
776
|
-
// Fetch fresh writ data
|
|
826
|
+
async function showWritDetail(id) {
|
|
777
827
|
let writ;
|
|
778
828
|
try {
|
|
779
|
-
writ = await api('GET',
|
|
829
|
+
writ = await api('GET', '/api/writ/show?id=' + encodeURIComponent(id));
|
|
780
830
|
} catch (e) {
|
|
781
|
-
|
|
782
|
-
tr.classList.remove('expanded');
|
|
831
|
+
console.error('Failed to load writ detail:', e);
|
|
783
832
|
return;
|
|
784
833
|
}
|
|
785
834
|
|
|
786
|
-
// Update in
|
|
835
|
+
// Update local data if this writ is already in our arrays
|
|
787
836
|
const idx = writs.findIndex(w => w.id === id);
|
|
788
837
|
if (idx >= 0) writs[idx] = writ;
|
|
789
838
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
839
|
+
// Fetch full children data for the detail children table
|
|
840
|
+
if (writ.children && writ.children.items.length > 0) {
|
|
841
|
+
try {
|
|
842
|
+
const fullChildren = await api('GET',
|
|
843
|
+
'/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
|
|
844
|
+
fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
845
|
+
writ._fullChildren = fullChildren;
|
|
846
|
+
} catch (e) {
|
|
847
|
+
writ._fullChildren = null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
801
850
|
|
|
802
|
-
|
|
851
|
+
// Toggle views
|
|
852
|
+
document.getElementById('writ-list-view').style.display = 'none';
|
|
853
|
+
document.getElementById('writ-detail-view').style.display = '';
|
|
854
|
+
document.getElementById('detail-title').textContent = writ.title ?? writ.id;
|
|
803
855
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
856
|
+
const content = document.getElementById('detail-content');
|
|
857
|
+
content.innerHTML = renderDetail(writ);
|
|
858
|
+
wireDetailEvents(content, writ);
|
|
808
859
|
}
|
|
809
860
|
|
|
810
861
|
// ── Data loading ────────────────────────────────────────────────────
|
|
@@ -813,7 +864,7 @@
|
|
|
813
864
|
if (replace) {
|
|
814
865
|
offset = 0;
|
|
815
866
|
writs = [];
|
|
816
|
-
|
|
867
|
+
childrenMap = {};
|
|
817
868
|
}
|
|
818
869
|
|
|
819
870
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
|
|
@@ -828,13 +879,20 @@
|
|
|
828
879
|
return;
|
|
829
880
|
}
|
|
830
881
|
|
|
882
|
+
// Partition: roots (no parentId) vs children (discarded)
|
|
883
|
+
const roots = result.filter(w => !w.parentId);
|
|
884
|
+
|
|
831
885
|
if (replace) {
|
|
832
|
-
writs =
|
|
886
|
+
writs = roots;
|
|
833
887
|
} else {
|
|
834
|
-
writs = writs.concat(
|
|
888
|
+
writs = writs.concat(roots);
|
|
835
889
|
}
|
|
836
890
|
|
|
837
|
-
offset
|
|
891
|
+
offset += result.length;
|
|
892
|
+
|
|
893
|
+
// Fetch all children for each newly loaded root
|
|
894
|
+
await fetchChildrenForRoots(roots);
|
|
895
|
+
|
|
838
896
|
renderTable();
|
|
839
897
|
|
|
840
898
|
const loadMoreRow = document.getElementById('load-more-row');
|
|
@@ -845,6 +903,23 @@
|
|
|
845
903
|
}
|
|
846
904
|
}
|
|
847
905
|
|
|
906
|
+
async function fetchChildrenForRoots(roots) {
|
|
907
|
+
const fetches = roots.map(async (root) => {
|
|
908
|
+
try {
|
|
909
|
+
const children = await api('GET',
|
|
910
|
+
'/api/writ/list?parentId=' + encodeURIComponent(root.id) + '&limit=1000');
|
|
911
|
+
if (children.length > 0) {
|
|
912
|
+
// Sort children by createdAt ascending — oldest first
|
|
913
|
+
children.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
914
|
+
childrenMap[root.id] = children;
|
|
915
|
+
}
|
|
916
|
+
} catch (e) {
|
|
917
|
+
console.error('Failed to fetch children for ' + root.id, e);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
await Promise.all(fetches);
|
|
921
|
+
}
|
|
922
|
+
|
|
848
923
|
async function loadWritTypes() {
|
|
849
924
|
let types;
|
|
850
925
|
try {
|
|
@@ -990,6 +1065,9 @@
|
|
|
990
1065
|
}
|
|
991
1066
|
|
|
992
1067
|
function openRepost(writ) {
|
|
1068
|
+
// If in detail view, switch back to list view first
|
|
1069
|
+
document.getElementById('writ-detail-view').style.display = 'none';
|
|
1070
|
+
document.getElementById('writ-list-view').style.display = '';
|
|
993
1071
|
openPostForm();
|
|
994
1072
|
document.getElementById('post-title').textContent = 'Repost Writ';
|
|
995
1073
|
document.getElementById('post-title-input').value = `[Repost] ${writ.title ?? ''}`;
|
|
@@ -1101,6 +1179,17 @@
|
|
|
1101
1179
|
|
|
1102
1180
|
document.getElementById('btn-refresh').addEventListener('click', () => loadWrits(true));
|
|
1103
1181
|
|
|
1182
|
+
document.getElementById('back-btn').addEventListener('click', () => {
|
|
1183
|
+
document.getElementById('writ-detail-view').style.display = 'none';
|
|
1184
|
+
document.getElementById('writ-list-view').style.display = '';
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
document.getElementById('btn-toggle-children').addEventListener('click', () => {
|
|
1188
|
+
showChildren = !showChildren;
|
|
1189
|
+
document.getElementById('btn-toggle-children').classList.toggle('active-filter', showChildren);
|
|
1190
|
+
renderTable();
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1104
1193
|
document.getElementById('post-submit-btn').addEventListener('click', submitPostForm);
|
|
1105
1194
|
|
|
1106
1195
|
document.getElementById('load-more-btn').addEventListener('click', () => loadWrits(false));
|
|
@@ -1140,22 +1229,8 @@
|
|
|
1140
1229
|
|
|
1141
1230
|
await loadWrits(true);
|
|
1142
1231
|
|
|
1143
|
-
if (
|
|
1144
|
-
|
|
1145
|
-
// If writ is in loaded list, scroll to it
|
|
1146
|
-
if (writs.find(function (w) { return w.id === writId; })) {
|
|
1147
|
-
scrollAndExpand(writId);
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
// Otherwise, fetch it and prepend to list
|
|
1152
|
-
try {
|
|
1153
|
-
var writ = await api('GET', '/api/writ/show?id=' + encodeURIComponent(writId));
|
|
1154
|
-
writs.unshift(writ);
|
|
1155
|
-
renderTable();
|
|
1156
|
-
scrollAndExpand(writId);
|
|
1157
|
-
} catch (e) {
|
|
1158
|
-
console.error('Deep-link writ not found:', writId);
|
|
1232
|
+
if (writId) {
|
|
1233
|
+
showWritDetail(writId);
|
|
1159
1234
|
}
|
|
1160
1235
|
})();
|
|
1161
1236
|
})();
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for parent/child writ hierarchy in writs/index.html.
|
|
3
|
+
*
|
|
4
|
+
* Extracts and tests the pure logic behind:
|
|
5
|
+
* - sortedFilteredWrits — hierarchical ordering, toggle, search
|
|
6
|
+
* - statusBadge — waiting status
|
|
7
|
+
* - Toggle button state
|
|
8
|
+
* - Children table rendering in detail view
|
|
9
|
+
* - Parent link in detail view
|
|
10
|
+
*
|
|
11
|
+
* Uses a minimal DOM shim (same pattern as writs-type-filter.test.js).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
|
|
17
|
+
// ── Minimal DOM shim ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
class FakeElement {
|
|
20
|
+
constructor(tag) {
|
|
21
|
+
this.tagName = tag.toUpperCase();
|
|
22
|
+
this.className = '';
|
|
23
|
+
this.textContent = '';
|
|
24
|
+
this.title = '';
|
|
25
|
+
this.innerHTML = '';
|
|
26
|
+
this.dataset = {};
|
|
27
|
+
this.children = [];
|
|
28
|
+
this._listeners = {};
|
|
29
|
+
this.style = {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
appendChild(child) {
|
|
33
|
+
this.children.push(child);
|
|
34
|
+
return child;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
querySelectorAll(selector) {
|
|
38
|
+
// Minimal support for class selectors
|
|
39
|
+
if (selector.startsWith('.')) {
|
|
40
|
+
const cls = selector.slice(1);
|
|
41
|
+
return this.children.filter(c =>
|
|
42
|
+
(c.className || '').includes(cls),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
addEventListener(event, fn) {
|
|
49
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
50
|
+
this._listeners[event].push(fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
click() {
|
|
54
|
+
for (const fn of this._listeners.click ?? []) fn();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get classList() {
|
|
58
|
+
const self = this;
|
|
59
|
+
return {
|
|
60
|
+
toggle(cls, force) {
|
|
61
|
+
const classes = self.className.split(/\s+/).filter(Boolean);
|
|
62
|
+
const idx = classes.indexOf(cls);
|
|
63
|
+
if (force && idx === -1) classes.push(cls);
|
|
64
|
+
if (!force && idx !== -1) classes.splice(idx, 1);
|
|
65
|
+
self.className = classes.join(' ');
|
|
66
|
+
},
|
|
67
|
+
contains(cls) {
|
|
68
|
+
return self.className.split(/\s+/).includes(cls);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createElement(tag) {
|
|
75
|
+
return new FakeElement(tag);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Extracted logic (mirrors index.html) ────────────────────────────
|
|
79
|
+
|
|
80
|
+
function statusBadge(status) {
|
|
81
|
+
const map = {
|
|
82
|
+
new: 'badge badge--draft',
|
|
83
|
+
ready: 'badge',
|
|
84
|
+
active: 'badge badge--active',
|
|
85
|
+
waiting: 'badge badge--warning',
|
|
86
|
+
completed: 'badge badge--success',
|
|
87
|
+
failed: 'badge badge--error',
|
|
88
|
+
cancelled: 'badge badge--warning',
|
|
89
|
+
};
|
|
90
|
+
const cls = map[status] ?? 'badge';
|
|
91
|
+
return `<span class="${cls}">${status}</span>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escHtml(s) {
|
|
95
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function escAttr(s) {
|
|
99
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compareVal(a, b, col) {
|
|
103
|
+
const av = a[col] ?? '';
|
|
104
|
+
const bv = b[col] ?? '';
|
|
105
|
+
if (av < bv) return -1;
|
|
106
|
+
if (av > bv) return 1;
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rowActions(w) {
|
|
111
|
+
const isTerminal = ['completed', 'failed', 'cancelled'].includes(w.status);
|
|
112
|
+
if (isTerminal) return '';
|
|
113
|
+
const btns = [];
|
|
114
|
+
if (w.status === 'new') {
|
|
115
|
+
btns.push(`<button class="btn btn--primary row-action-btn" style="padding:0.15rem 0.5rem;font-size:0.8rem" data-action="row-publish" data-id="${w.id}">Start</button>`);
|
|
116
|
+
}
|
|
117
|
+
btns.push(`<button class="btn btn--danger row-action-btn" style="padding:0.15rem 0.5rem;font-size:0.8rem" data-action="row-cancel" data-id="${w.id}">Cancel</button>`);
|
|
118
|
+
return btns.join(' ');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extracted sortedFilteredWrits logic — mirrors index.html.
|
|
123
|
+
*/
|
|
124
|
+
function sortedFilteredWrits(writs, childrenMap, showChildren, searchText, sortCol, sortDir) {
|
|
125
|
+
let roots = writs.slice();
|
|
126
|
+
|
|
127
|
+
// Text filter on roots
|
|
128
|
+
if (searchText) {
|
|
129
|
+
const q = searchText.toLowerCase();
|
|
130
|
+
roots = roots.filter(w => (w.title ?? '').toLowerCase().includes(q));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Sort roots by current sort column
|
|
134
|
+
roots.sort((a, b) => {
|
|
135
|
+
const cmp = compareVal(a, b, sortCol);
|
|
136
|
+
return sortDir === 'asc' ? cmp : -cmp;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Interleave children beneath each root
|
|
140
|
+
const result = [];
|
|
141
|
+
for (const root of roots) {
|
|
142
|
+
result.push({ writ: root, isChild: false });
|
|
143
|
+
if (showChildren) {
|
|
144
|
+
let children = childrenMap[root.id] ?? [];
|
|
145
|
+
// Text filter on children too
|
|
146
|
+
if (searchText) {
|
|
147
|
+
const q = searchText.toLowerCase();
|
|
148
|
+
children = children.filter(w => (w.title ?? '').toLowerCase().includes(q));
|
|
149
|
+
}
|
|
150
|
+
for (const child of children) {
|
|
151
|
+
result.push({ writ: child, isChild: true });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extracted renderDetail logic for parent link and children table.
|
|
160
|
+
* Returns the full HTML string, same as the index.html renderDetail.
|
|
161
|
+
*/
|
|
162
|
+
function renderDetail(writ) {
|
|
163
|
+
const isTerminal = ['completed', 'failed', 'cancelled'].includes(writ.status);
|
|
164
|
+
const isDraft = writ.status === 'new';
|
|
165
|
+
let html = '';
|
|
166
|
+
|
|
167
|
+
// Edit form
|
|
168
|
+
html += `<div class="detail-section" id="edit-section-${writ.id}">`;
|
|
169
|
+
html += `<h4>${isDraft ? 'Edit Draft' : 'Edit'}</h4>`;
|
|
170
|
+
html += `<div class="form-row"><label>Title</label>`;
|
|
171
|
+
html += `<input type="text" id="edit-title-${writ.id}" value="${escAttr(writ.title ?? '')}"></div>`;
|
|
172
|
+
html += `<div class="form-row"><label>Body</label>`;
|
|
173
|
+
html += `<textarea id="edit-body-${writ.id}" rows="8">${escHtml(writ.body ?? '')}</textarea></div>`;
|
|
174
|
+
if (isDraft) {
|
|
175
|
+
html += `<div class="form-row"><label>Type</label><select id="edit-type-${writ.id}"></select></div>`;
|
|
176
|
+
html += `<div class="form-row"><label>Codex</label><select id="edit-codex-${writ.id}"></select></div>`;
|
|
177
|
+
}
|
|
178
|
+
html += `<div class="action-buttons">`;
|
|
179
|
+
html += `<button class="btn btn--primary" data-action="save-edit" data-id="${writ.id}">Save</button>`;
|
|
180
|
+
html += `</div></div>`;
|
|
181
|
+
|
|
182
|
+
// Details grid
|
|
183
|
+
html += `<div class="detail-section"><h4>Details</h4><dl class="detail-grid">`;
|
|
184
|
+
if (writ.codex) html += `<dt>Codex</dt><dd>${escHtml(writ.codex)}</dd>`;
|
|
185
|
+
if (writ.parent) {
|
|
186
|
+
html += `<dt>Parent</dt><dd><a href="?writ=${encodeURIComponent(writ.parent.id)}" style="color:var(--blue,#7aa2f7);text-decoration:underline;cursor:pointer">${escHtml(writ.parent.title)}</a> ${statusBadge(writ.parent.status)}</dd>`;
|
|
187
|
+
}
|
|
188
|
+
html += `<dt>Created</dt><dd></dd>`;
|
|
189
|
+
html += `</dl></div>`;
|
|
190
|
+
|
|
191
|
+
// Transition actions (simplified)
|
|
192
|
+
if (!isTerminal) {
|
|
193
|
+
html += `<div class="detail-section action-buttons" id="actions-${writ.id}"></div>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Repost
|
|
197
|
+
if (writ.status === 'failed' || writ.status === 'cancelled') {
|
|
198
|
+
html += `<div class="detail-section"><button class="btn" data-action="repost" data-id="${writ.id}">Repost</button></div>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Links (simplified)
|
|
202
|
+
html += `<div class="detail-section" id="links-section-${writ.id}"><h4>Links</h4></div>`;
|
|
203
|
+
|
|
204
|
+
// Children
|
|
205
|
+
const childItems = writ._fullChildren ?? writ.children?.items ?? [];
|
|
206
|
+
if (childItems.length > 0) {
|
|
207
|
+
html += `<div class="detail-section">`;
|
|
208
|
+
html += `<h4>Children</h4>`;
|
|
209
|
+
|
|
210
|
+
if (writ.children?.summary) {
|
|
211
|
+
html += `<div style="margin-bottom:0.5rem">`;
|
|
212
|
+
for (const [status, count] of Object.entries(writ.children.summary)) {
|
|
213
|
+
html += statusBadge(status) + ` <span style="margin-right:0.75rem">${count}</span>`;
|
|
214
|
+
}
|
|
215
|
+
html += `</div>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
html += `<table class="data-table"><thead><tr>`;
|
|
219
|
+
html += `<th>Status</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
|
|
220
|
+
html += `</tr></thead><tbody>`;
|
|
221
|
+
for (const child of childItems) {
|
|
222
|
+
html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
|
|
223
|
+
html += `<td>${statusBadge(child.status)}</td>`;
|
|
224
|
+
html += `<td>${escHtml(child.title ?? '')}</td>`;
|
|
225
|
+
html += `<td>${escHtml(child.type ?? '')}</td>`;
|
|
226
|
+
html += `<td><code>${child.id}</code></td>`;
|
|
227
|
+
html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
|
|
228
|
+
html += `</tr>`;
|
|
229
|
+
}
|
|
230
|
+
html += `</tbody></table></div>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return html;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe('sortedFilteredWrits — hierarchy ordering', () => {
|
|
239
|
+
const rootA = { id: 'a', title: 'Alpha Root', type: 'mandate', status: 'active', createdAt: '2025-01-01' };
|
|
240
|
+
const rootB = { id: 'b', title: 'Beta Root', type: 'mandate', status: 'active', createdAt: '2025-01-02' };
|
|
241
|
+
const childA1 = { id: 'a1', title: 'Alpha Child 1', type: 'task', status: 'active', parentId: 'a', createdAt: '2025-01-03' };
|
|
242
|
+
const childA2 = { id: 'a2', title: 'Alpha Child 2', type: 'task', status: 'active', parentId: 'a', createdAt: '2025-01-04' };
|
|
243
|
+
const childB1 = { id: 'b1', title: 'Beta Child 1', type: 'task', status: 'active', parentId: 'b', createdAt: '2025-01-05' };
|
|
244
|
+
|
|
245
|
+
it('happy path — children interleaved beneath parents', () => {
|
|
246
|
+
const writs = [rootA, rootB];
|
|
247
|
+
const childrenMap = { a: [childA1, childA2], b: [childB1] };
|
|
248
|
+
const result = sortedFilteredWrits(writs, childrenMap, true, '', 'createdAt', 'asc');
|
|
249
|
+
|
|
250
|
+
assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
|
|
251
|
+
['a', false],
|
|
252
|
+
['a1', true],
|
|
253
|
+
['a2', true],
|
|
254
|
+
['b', false],
|
|
255
|
+
['b1', true],
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('children hidden — only roots returned', () => {
|
|
260
|
+
const writs = [rootA, rootB];
|
|
261
|
+
const childrenMap = { a: [childA1, childA2], b: [childB1] };
|
|
262
|
+
const result = sortedFilteredWrits(writs, childrenMap, false, '', 'createdAt', 'asc');
|
|
263
|
+
|
|
264
|
+
assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
|
|
265
|
+
['a', false],
|
|
266
|
+
['b', false],
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('sort changes root order only — children stay beneath parent', () => {
|
|
271
|
+
const writs = [rootA, rootB];
|
|
272
|
+
const childrenMap = { a: [childA1, childA2], b: [childB1] };
|
|
273
|
+
// Sort by title ascending: Alpha < Beta
|
|
274
|
+
const result = sortedFilteredWrits(writs, childrenMap, true, '', 'title', 'asc');
|
|
275
|
+
|
|
276
|
+
assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
|
|
277
|
+
['a', false],
|
|
278
|
+
['a1', true],
|
|
279
|
+
['a2', true],
|
|
280
|
+
['b', false],
|
|
281
|
+
['b1', true],
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
// Sort by title descending: Beta > Alpha
|
|
285
|
+
const result2 = sortedFilteredWrits(writs, childrenMap, true, '', 'title', 'desc');
|
|
286
|
+
assert.deepEqual(result2.map(r => [r.writ.id, r.isChild]), [
|
|
287
|
+
['b', false],
|
|
288
|
+
['b1', true],
|
|
289
|
+
['a', false],
|
|
290
|
+
['a1', true],
|
|
291
|
+
['a2', true],
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('search filters both roots and children', () => {
|
|
296
|
+
const writs = [rootA, rootB];
|
|
297
|
+
const childrenMap = { a: [childA1, childA2], b: [childB1] };
|
|
298
|
+
// Search for 'Alpha' — rootA matches, childA1 and childA2 match, rootB doesn't match
|
|
299
|
+
const result = sortedFilteredWrits(writs, childrenMap, true, 'Alpha', 'createdAt', 'asc');
|
|
300
|
+
|
|
301
|
+
assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
|
|
302
|
+
['a', false],
|
|
303
|
+
['a1', true],
|
|
304
|
+
['a2', true],
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('search respects toggle — hidden children not matched', () => {
|
|
309
|
+
// childA1 title contains 'Child 1' but rootA does not contain 'Child'
|
|
310
|
+
const writs = [rootA, rootB];
|
|
311
|
+
const childrenMap = { a: [childA1], b: [childB1] };
|
|
312
|
+
const result = sortedFilteredWrits(writs, childrenMap, false, 'Child 1', 'createdAt', 'asc');
|
|
313
|
+
|
|
314
|
+
// Neither root matches 'Child 1', and children are hidden
|
|
315
|
+
assert.deepEqual(result, []);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('search matches child title but not root when children visible', () => {
|
|
319
|
+
// Search for 'Child 1': rootA title doesn't match, but childA1 does
|
|
320
|
+
// Since rootA doesn't match the root filter, it's excluded entirely
|
|
321
|
+
const writs = [rootA, rootB];
|
|
322
|
+
const childrenMap = { a: [childA1], b: [childB1] };
|
|
323
|
+
const result = sortedFilteredWrits(writs, childrenMap, true, 'Child 1', 'createdAt', 'asc');
|
|
324
|
+
|
|
325
|
+
// Root filter excludes both roots since neither title contains 'Child 1'
|
|
326
|
+
assert.deepEqual(result, []);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('empty children — root appears alone with no child rows', () => {
|
|
330
|
+
const writs = [rootA];
|
|
331
|
+
const childrenMap = {};
|
|
332
|
+
const result = sortedFilteredWrits(writs, childrenMap, true, '', 'createdAt', 'asc');
|
|
333
|
+
|
|
334
|
+
assert.deepEqual(result.map(r => [r.writ.id, r.isChild]), [
|
|
335
|
+
['a', false],
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('empty writs array returns empty', () => {
|
|
340
|
+
const result = sortedFilteredWrits([], {}, true, '', 'createdAt', 'asc');
|
|
341
|
+
assert.deepEqual(result, []);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('statusBadge — waiting status', () => {
|
|
346
|
+
it('maps waiting to badge badge--warning', () => {
|
|
347
|
+
const result = statusBadge('waiting');
|
|
348
|
+
assert.equal(result, '<span class="badge badge--warning">waiting</span>');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('maps cancelled to badge badge--warning', () => {
|
|
352
|
+
const result = statusBadge('cancelled');
|
|
353
|
+
assert.equal(result, '<span class="badge badge--warning">cancelled</span>');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('maps active to badge badge--active', () => {
|
|
357
|
+
const result = statusBadge('active');
|
|
358
|
+
assert.equal(result, '<span class="badge badge--active">active</span>');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('maps unknown status to plain badge', () => {
|
|
362
|
+
const result = statusBadge('unknown');
|
|
363
|
+
assert.equal(result, '<span class="badge">unknown</span>');
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('Toggle button state', () => {
|
|
368
|
+
it('initial state: showChildren true, button has active-filter', () => {
|
|
369
|
+
let showChildren = true;
|
|
370
|
+
const btn = createElement('button');
|
|
371
|
+
btn.className = 'btn active-filter';
|
|
372
|
+
assert.equal(showChildren, true);
|
|
373
|
+
assert.ok(btn.classList.contains('active-filter'));
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('after first click: showChildren false, button loses active-filter', () => {
|
|
377
|
+
let showChildren = true;
|
|
378
|
+
const btn = createElement('button');
|
|
379
|
+
btn.className = 'btn active-filter';
|
|
380
|
+
|
|
381
|
+
// Simulate click
|
|
382
|
+
showChildren = !showChildren;
|
|
383
|
+
btn.classList.toggle('active-filter', showChildren);
|
|
384
|
+
|
|
385
|
+
assert.equal(showChildren, false);
|
|
386
|
+
assert.ok(!btn.classList.contains('active-filter'));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('after second click: showChildren true, button regains active-filter', () => {
|
|
390
|
+
let showChildren = true;
|
|
391
|
+
const btn = createElement('button');
|
|
392
|
+
btn.className = 'btn active-filter';
|
|
393
|
+
|
|
394
|
+
// First click
|
|
395
|
+
showChildren = !showChildren;
|
|
396
|
+
btn.classList.toggle('active-filter', showChildren);
|
|
397
|
+
|
|
398
|
+
// Second click
|
|
399
|
+
showChildren = !showChildren;
|
|
400
|
+
btn.classList.toggle('active-filter', showChildren);
|
|
401
|
+
|
|
402
|
+
assert.equal(showChildren, true);
|
|
403
|
+
assert.ok(btn.classList.contains('active-filter'));
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('Children table rendering in detail view', () => {
|
|
408
|
+
it('renders children table with 3 rows and correct columns', () => {
|
|
409
|
+
const writ = {
|
|
410
|
+
id: 'parent-1',
|
|
411
|
+
title: 'Parent Writ',
|
|
412
|
+
status: 'active',
|
|
413
|
+
body: 'body text',
|
|
414
|
+
_fullChildren: [
|
|
415
|
+
{ id: 'c1', title: 'Child 1', type: 'task', status: 'active', createdAt: '2025-01-01' },
|
|
416
|
+
{ id: 'c2', title: 'Child 2', type: 'task', status: 'completed', createdAt: '2025-01-02' },
|
|
417
|
+
{ id: 'c3', title: 'Child 3', type: 'bug', status: 'new', createdAt: '2025-01-03' },
|
|
418
|
+
],
|
|
419
|
+
children: {
|
|
420
|
+
summary: { active: 1, completed: 1, new: 1 },
|
|
421
|
+
items: [],
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const html = renderDetail(writ);
|
|
426
|
+
|
|
427
|
+
// Should contain children section
|
|
428
|
+
assert.ok(html.includes('<h4>Children</h4>'), 'Children header should exist');
|
|
429
|
+
|
|
430
|
+
// Should have 3 child-detail-row entries
|
|
431
|
+
const rowMatches = html.match(/child-detail-row/g);
|
|
432
|
+
assert.equal(rowMatches.length, 3, 'Should have 3 child rows');
|
|
433
|
+
|
|
434
|
+
// Should have Status, Title, Type, ID, Actions columns (no Created)
|
|
435
|
+
assert.ok(html.includes('<th>Status</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>'));
|
|
436
|
+
|
|
437
|
+
// Verify child data appears
|
|
438
|
+
assert.ok(html.includes('Child 1'));
|
|
439
|
+
assert.ok(html.includes('Child 2'));
|
|
440
|
+
assert.ok(html.includes('Child 3'));
|
|
441
|
+
assert.ok(html.includes('data-child-id="c1"'));
|
|
442
|
+
assert.ok(html.includes('data-child-id="c2"'));
|
|
443
|
+
assert.ok(html.includes('data-child-id="c3"'));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('does not render children section when no children', () => {
|
|
447
|
+
const writ = {
|
|
448
|
+
id: 'parent-2',
|
|
449
|
+
title: 'No Children',
|
|
450
|
+
status: 'active',
|
|
451
|
+
body: '',
|
|
452
|
+
children: { summary: {}, items: [] },
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const html = renderDetail(writ);
|
|
456
|
+
assert.ok(!html.includes('<h4>Children</h4>'), 'Children header should not exist');
|
|
457
|
+
assert.ok(!html.includes('child-detail-row'), 'No child rows');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('children in detail table ordered by createdAt ascending', () => {
|
|
461
|
+
const writ = {
|
|
462
|
+
id: 'parent-3',
|
|
463
|
+
title: 'Parent',
|
|
464
|
+
status: 'active',
|
|
465
|
+
body: '',
|
|
466
|
+
_fullChildren: [
|
|
467
|
+
{ id: 'c-early', title: 'Early', type: 'task', status: 'active', createdAt: '2025-01-01' },
|
|
468
|
+
{ id: 'c-late', title: 'Late', type: 'task', status: 'active', createdAt: '2025-01-10' },
|
|
469
|
+
{ id: 'c-mid', title: 'Mid', type: 'task', status: 'active', createdAt: '2025-01-05' },
|
|
470
|
+
],
|
|
471
|
+
children: { summary: {}, items: [] },
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const html = renderDetail(writ);
|
|
475
|
+
const earlyIdx = html.indexOf('c-early');
|
|
476
|
+
const midIdx = html.indexOf('c-mid');
|
|
477
|
+
const lateIdx = html.indexOf('c-late');
|
|
478
|
+
|
|
479
|
+
// _fullChildren is pre-sorted by the caller, but we test that the rendering
|
|
480
|
+
// preserves the order they appear in. In real code fetchChildrenForRoots sorts them.
|
|
481
|
+
assert.ok(earlyIdx < lateIdx, 'Early should appear before Late');
|
|
482
|
+
assert.ok(earlyIdx < midIdx, 'Early should appear before Mid');
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe('Parent link in detail view', () => {
|
|
487
|
+
it('renders parent row with link and status badge when parent exists', () => {
|
|
488
|
+
const writ = {
|
|
489
|
+
id: 'child-w',
|
|
490
|
+
title: 'Child Writ',
|
|
491
|
+
status: 'active',
|
|
492
|
+
body: '',
|
|
493
|
+
parent: { id: 'w-parent', title: 'Parent Writ', status: 'active' },
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const html = renderDetail(writ);
|
|
497
|
+
|
|
498
|
+
// Should contain Parent dt/dd
|
|
499
|
+
assert.ok(html.includes('<dt>Parent</dt>'), 'Parent label should exist');
|
|
500
|
+
// Should contain link to parent
|
|
501
|
+
assert.ok(html.includes('href="?writ=w-parent"'), 'Parent link should use ?writ= param');
|
|
502
|
+
assert.ok(html.includes('Parent Writ'), 'Parent title should appear');
|
|
503
|
+
// Should have status badge for parent
|
|
504
|
+
assert.ok(html.includes('badge badge--active'), 'Parent status badge should appear');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('does not render parent row when parent is null', () => {
|
|
508
|
+
const writ = {
|
|
509
|
+
id: 'root-w',
|
|
510
|
+
title: 'Root Writ',
|
|
511
|
+
status: 'active',
|
|
512
|
+
body: '',
|
|
513
|
+
parent: null,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const html = renderDetail(writ);
|
|
517
|
+
assert.ok(!html.includes('<dt>Parent</dt>'), 'Parent label should not exist');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('does not render parent row when parent is undefined', () => {
|
|
521
|
+
const writ = {
|
|
522
|
+
id: 'root-w2',
|
|
523
|
+
title: 'Root Writ 2',
|
|
524
|
+
status: 'active',
|
|
525
|
+
body: '',
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const html = renderDetail(writ);
|
|
529
|
+
assert.ok(!html.includes('<dt>Parent</dt>'), 'Parent label should not exist');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('encodes parent id in URL', () => {
|
|
533
|
+
const writ = {
|
|
534
|
+
id: 'child-w2',
|
|
535
|
+
title: 'Child',
|
|
536
|
+
status: 'active',
|
|
537
|
+
body: '',
|
|
538
|
+
parent: { id: 'w-parent with spaces', title: 'Parent', status: 'ready' },
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const html = renderDetail(writ);
|
|
542
|
+
assert.ok(html.includes('href="?writ=w-parent%20with%20spaces"'), 'Parent id should be URL-encoded');
|
|
543
|
+
});
|
|
544
|
+
});
|