@shardworks/clerk-apparatus 0.1.249 → 0.1.250
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 +5 -5
- package/pages/writs/index.html +99 -20
- package/pages/writs/writs-hierarchy.test.js +268 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardworks/clerk-apparatus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.250",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"zod": "4.3.6",
|
|
23
|
-
"@shardworks/
|
|
24
|
-
"@shardworks/
|
|
23
|
+
"@shardworks/tools-apparatus": "0.1.250",
|
|
24
|
+
"@shardworks/stacks-apparatus": "0.1.250"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "25.5.0",
|
|
28
|
-
"@shardworks/nexus-core": "0.1.
|
|
28
|
+
"@shardworks/nexus-core": "0.1.250"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc",
|
|
36
|
-
"test": "node --disable-warning=ExperimentalWarning --experimental-transform-types --test 'src/**/*.test.ts'",
|
|
36
|
+
"test": "node --disable-warning=ExperimentalWarning --experimental-transform-types --test 'src/**/*.test.ts' 'pages/**/*.test.js'",
|
|
37
37
|
"typecheck": "tsc --noEmit"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/pages/writs/index.html
CHANGED
|
@@ -235,6 +235,73 @@
|
|
|
235
235
|
el.style.display = 'none';
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// ── Descendant tree helpers ────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* WritTree node shape: { writ: WritDoc, children: WritTreeNode[] }.
|
|
242
|
+
*
|
|
243
|
+
* Recursively fetches descendants of `rootId` via /api/writ/list, producing a
|
|
244
|
+
* tree of `WritTreeNode` entries. Guards against cycles with `visited` and
|
|
245
|
+
* bounds recursion at `maxDepth` (default 10) to keep the UI responsive on
|
|
246
|
+
* pathological data. Children at each level are sorted by createdAt ascending
|
|
247
|
+
* (oldest first), matching fetchChildrenForRoots behavior.
|
|
248
|
+
*
|
|
249
|
+
* Returns `WritTreeNode[]` — the direct children of `rootId`, each with their
|
|
250
|
+
* own nested `children` list.
|
|
251
|
+
*/
|
|
252
|
+
async function buildDescendantTree(rootId, maxDepth = 10, visited = null) {
|
|
253
|
+
if (visited === null) visited = new Set();
|
|
254
|
+
if (maxDepth <= 0) return [];
|
|
255
|
+
if (visited.has(rootId)) return [];
|
|
256
|
+
visited.add(rootId);
|
|
257
|
+
|
|
258
|
+
let children;
|
|
259
|
+
try {
|
|
260
|
+
children = await api('GET',
|
|
261
|
+
'/api/writ/list?parentId=' + encodeURIComponent(rootId) + '&limit=1000');
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.error('Failed to fetch children for ' + rootId, e);
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
children.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
268
|
+
|
|
269
|
+
// Fetch grandchildren in parallel for each child
|
|
270
|
+
const nodes = await Promise.all(children.map(async (child) => {
|
|
271
|
+
const subChildren = await buildDescendantTree(child.id, maxDepth - 1, visited);
|
|
272
|
+
return { writ: child, children: subChildren };
|
|
273
|
+
}));
|
|
274
|
+
return nodes;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Flattens a WritTree forest into a depth-first list of `{ writ, depth }`.
|
|
279
|
+
* `depth` is 0-based — direct children of the root get depth 0, their
|
|
280
|
+
* children get depth 1, etc. Order preserves the pre-order traversal
|
|
281
|
+
* of the tree (parent before children, children in createdAt order).
|
|
282
|
+
*/
|
|
283
|
+
function flattenTree(nodes, depth = 0, acc = null) {
|
|
284
|
+
if (acc === null) acc = [];
|
|
285
|
+
for (const node of nodes) {
|
|
286
|
+
acc.push({ writ: node.writ, depth });
|
|
287
|
+
if (node.children && node.children.length > 0) {
|
|
288
|
+
flattenTree(node.children, depth + 1, acc);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return acc;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Returns inline style for indenting a depth-aware row's title cell.
|
|
296
|
+
* Depth 0 is the first indent step, matching the existing child-row
|
|
297
|
+
* padding-left: 2rem in the main list; each additional depth adds
|
|
298
|
+
* 1.5rem so nested descendants are visually distinguishable.
|
|
299
|
+
*/
|
|
300
|
+
function depthIndentStyle(depth) {
|
|
301
|
+
const rem = 2 + depth * 1.5;
|
|
302
|
+
return `padding-left:${rem}rem`;
|
|
303
|
+
}
|
|
304
|
+
|
|
238
305
|
// ── Row actions ────────────────────────────────────────────────────
|
|
239
306
|
|
|
240
307
|
/** Returns the HTML for the context-aware Actions cell for a writ row. */
|
|
@@ -439,13 +506,27 @@
|
|
|
439
506
|
html += renderLinksSection(writ);
|
|
440
507
|
html += `</div>`;
|
|
441
508
|
|
|
442
|
-
// Children
|
|
443
|
-
|
|
444
|
-
|
|
509
|
+
// Children — deep descendant rendering
|
|
510
|
+
//
|
|
511
|
+
// Prefer the descendant tree populated by showWritDetail / refreshDetail
|
|
512
|
+
// (writ._descendantTree: WritTreeNode[]). If the tree hasn't been populated
|
|
513
|
+
// yet (older code path or fetch failure), fall back to the flat children
|
|
514
|
+
// list shape so we still render something. The summary-badges block is
|
|
515
|
+
// driven by writ.children.summary, which is the direct-child summary
|
|
516
|
+
// returned by writ-show — it intentionally covers depth 1 only.
|
|
517
|
+
let rows;
|
|
518
|
+
if (writ._descendantTree && writ._descendantTree.length > 0) {
|
|
519
|
+
rows = flattenTree(writ._descendantTree);
|
|
520
|
+
} else {
|
|
521
|
+
const childItems = writ._fullChildren ?? writ.children?.items ?? [];
|
|
522
|
+
rows = childItems.map(c => ({ writ: c, depth: 0 }));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (rows.length > 0) {
|
|
445
526
|
html += `<div class="detail-section">`;
|
|
446
527
|
html += `<h4>Children</h4>`;
|
|
447
528
|
|
|
448
|
-
// Summary badges
|
|
529
|
+
// Summary badges — direct children only, per writ-show contract.
|
|
449
530
|
if (writ.children?.summary) {
|
|
450
531
|
html += `<div style="margin-bottom:0.5rem">`;
|
|
451
532
|
for (const [phase, count] of Object.entries(writ.children.summary)) {
|
|
@@ -454,14 +535,14 @@
|
|
|
454
535
|
html += `</div>`;
|
|
455
536
|
}
|
|
456
537
|
|
|
457
|
-
// Children table: Phase, Title, Type, ID, Actions
|
|
538
|
+
// Children table: Phase, Title, Type, ID, Actions — rows indent by depth.
|
|
458
539
|
html += `<table class="data-table"><thead><tr>`;
|
|
459
540
|
html += `<th>Phase</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
|
|
460
541
|
html += `</tr></thead><tbody>`;
|
|
461
|
-
for (const child of
|
|
462
|
-
html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
|
|
542
|
+
for (const { writ: child, depth } of rows) {
|
|
543
|
+
html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" data-depth="${depth}" style="cursor:pointer">`;
|
|
463
544
|
html += `<td>${phaseBadge(child.phase)}</td>`;
|
|
464
|
-
html += `<td>${escHtml(child.title ?? '')}</td>`;
|
|
545
|
+
html += `<td style="${depthIndentStyle(depth)}">${escHtml(child.title ?? '')}</td>`;
|
|
465
546
|
html += `<td>${escHtml(child.type ?? '')}</td>`;
|
|
466
547
|
html += `<td><code>${child.id}</code></td>`;
|
|
467
548
|
html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
|
|
@@ -800,14 +881,13 @@
|
|
|
800
881
|
const idx = writs.findIndex(w => w.id === id);
|
|
801
882
|
if (idx >= 0) writs[idx] = writ;
|
|
802
883
|
|
|
803
|
-
// Fetch full
|
|
884
|
+
// Fetch full descendant tree if there are any children.
|
|
804
885
|
if (writ.children && writ.children.items.length > 0) {
|
|
805
886
|
try {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
} catch (e) { writ._fullChildren = null; }
|
|
887
|
+
writ._descendantTree = await buildDescendantTree(id);
|
|
888
|
+
} catch (e) {
|
|
889
|
+
writ._descendantTree = null;
|
|
890
|
+
}
|
|
811
891
|
}
|
|
812
892
|
|
|
813
893
|
// Re-render detail content
|
|
@@ -847,15 +927,14 @@
|
|
|
847
927
|
const idx = writs.findIndex(w => w.id === id);
|
|
848
928
|
if (idx >= 0) writs[idx] = writ;
|
|
849
929
|
|
|
850
|
-
// Fetch full
|
|
930
|
+
// Fetch full descendant tree for the detail children table. The detail
|
|
931
|
+
// view renders deep descendants (not just direct children) so clicking
|
|
932
|
+
// into a writ does not collapse visible depth back to 1.
|
|
851
933
|
if (writ.children && writ.children.items.length > 0) {
|
|
852
934
|
try {
|
|
853
|
-
|
|
854
|
-
'/api/writ/list?parentId=' + encodeURIComponent(id) + '&limit=1000');
|
|
855
|
-
fullChildren.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
856
|
-
writ._fullChildren = fullChildren;
|
|
935
|
+
writ._descendantTree = await buildDescendantTree(id);
|
|
857
936
|
} catch (e) {
|
|
858
|
-
writ.
|
|
937
|
+
writ._descendantTree = null;
|
|
859
938
|
}
|
|
860
939
|
}
|
|
861
940
|
|
|
@@ -155,6 +155,27 @@ function sortedFilteredWrits(writs, childrenMap, showChildren, searchText, sortC
|
|
|
155
155
|
return result;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Flattens a WritTree forest into a depth-first list of `{ writ, depth }`.
|
|
160
|
+
* Mirrors the flattenTree helper in index.html.
|
|
161
|
+
*/
|
|
162
|
+
function flattenTree(nodes, depth = 0, acc = null) {
|
|
163
|
+
if (acc === null) acc = [];
|
|
164
|
+
for (const node of nodes) {
|
|
165
|
+
acc.push({ writ: node.writ, depth });
|
|
166
|
+
if (node.children && node.children.length > 0) {
|
|
167
|
+
flattenTree(node.children, depth + 1, acc);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return acc;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Mirrors depthIndentStyle in index.html. */
|
|
174
|
+
function depthIndentStyle(depth) {
|
|
175
|
+
const rem = 2 + depth * 1.5;
|
|
176
|
+
return `padding-left:${rem}rem`;
|
|
177
|
+
}
|
|
178
|
+
|
|
158
179
|
/**
|
|
159
180
|
* Extracted renderDetail logic for parent link and children table.
|
|
160
181
|
* Returns the full HTML string, same as the index.html renderDetail.
|
|
@@ -202,9 +223,16 @@ function renderDetail(writ) {
|
|
|
202
223
|
// Links (simplified)
|
|
203
224
|
html += `<div class="detail-section" id="links-section-${writ.id}"><h4>Links</h4></div>`;
|
|
204
225
|
|
|
205
|
-
// Children
|
|
206
|
-
|
|
207
|
-
if (
|
|
226
|
+
// Children — deep descendant rendering.
|
|
227
|
+
let rows;
|
|
228
|
+
if (writ._descendantTree && writ._descendantTree.length > 0) {
|
|
229
|
+
rows = flattenTree(writ._descendantTree);
|
|
230
|
+
} else {
|
|
231
|
+
const childItems = writ._fullChildren ?? writ.children?.items ?? [];
|
|
232
|
+
rows = childItems.map(c => ({ writ: c, depth: 0 }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (rows.length > 0) {
|
|
208
236
|
html += `<div class="detail-section">`;
|
|
209
237
|
html += `<h4>Children</h4>`;
|
|
210
238
|
|
|
@@ -219,10 +247,10 @@ function renderDetail(writ) {
|
|
|
219
247
|
html += `<table class="data-table"><thead><tr>`;
|
|
220
248
|
html += `<th>Phase</th><th>Title</th><th>Type</th><th>ID</th><th>Actions</th>`;
|
|
221
249
|
html += `</tr></thead><tbody>`;
|
|
222
|
-
for (const child of
|
|
223
|
-
html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" style="cursor:pointer">`;
|
|
250
|
+
for (const { writ: child, depth } of rows) {
|
|
251
|
+
html += `<tr class="writ-row child-detail-row" data-child-id="${child.id}" data-depth="${depth}" style="cursor:pointer">`;
|
|
224
252
|
html += `<td>${phaseBadge(child.phase)}</td>`;
|
|
225
|
-
html += `<td>${escHtml(child.title ?? '')}</td>`;
|
|
253
|
+
html += `<td style="${depthIndentStyle(depth)}">${escHtml(child.title ?? '')}</td>`;
|
|
226
254
|
html += `<td>${escHtml(child.type ?? '')}</td>`;
|
|
227
255
|
html += `<td><code>${child.id}</code></td>`;
|
|
228
256
|
html += `<td class="row-actions" style="white-space:nowrap">${rowActions(child)}</td>`;
|
|
@@ -538,3 +566,237 @@ describe('Parent link in detail view', () => {
|
|
|
538
566
|
assert.ok(html.includes('href="?writ=w-parent%20with%20spaces"'), 'Parent id should be URL-encoded');
|
|
539
567
|
});
|
|
540
568
|
});
|
|
569
|
+
|
|
570
|
+
describe('flattenTree — depth-first pre-order traversal', () => {
|
|
571
|
+
it('flattens a single-level forest at depth 0', () => {
|
|
572
|
+
const nodes = [
|
|
573
|
+
{ writ: { id: 'a', title: 'A' }, children: [] },
|
|
574
|
+
{ writ: { id: 'b', title: 'B' }, children: [] },
|
|
575
|
+
];
|
|
576
|
+
const rows = flattenTree(nodes);
|
|
577
|
+
assert.deepEqual(rows, [
|
|
578
|
+
{ writ: { id: 'a', title: 'A' }, depth: 0 },
|
|
579
|
+
{ writ: { id: 'b', title: 'B' }, depth: 0 },
|
|
580
|
+
]);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('flattens a two-level tree — children follow parent with depth+1', () => {
|
|
584
|
+
const nodes = [
|
|
585
|
+
{
|
|
586
|
+
writ: { id: 'a' },
|
|
587
|
+
children: [
|
|
588
|
+
{ writ: { id: 'a1' }, children: [] },
|
|
589
|
+
{ writ: { id: 'a2' }, children: [] },
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
{ writ: { id: 'b' }, children: [] },
|
|
593
|
+
];
|
|
594
|
+
const rows = flattenTree(nodes);
|
|
595
|
+
assert.deepEqual(rows.map(r => [r.writ.id, r.depth]), [
|
|
596
|
+
['a', 0],
|
|
597
|
+
['a1', 1],
|
|
598
|
+
['a2', 1],
|
|
599
|
+
['b', 0],
|
|
600
|
+
]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('flattens a three-level tree preserving depth per level', () => {
|
|
604
|
+
const nodes = [
|
|
605
|
+
{
|
|
606
|
+
writ: { id: 'a' },
|
|
607
|
+
children: [
|
|
608
|
+
{
|
|
609
|
+
writ: { id: 'a1' },
|
|
610
|
+
children: [
|
|
611
|
+
{ writ: { id: 'a1x' }, children: [] },
|
|
612
|
+
{ writ: { id: 'a1y' }, children: [] },
|
|
613
|
+
],
|
|
614
|
+
},
|
|
615
|
+
{ writ: { id: 'a2' }, children: [] },
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
const rows = flattenTree(nodes);
|
|
620
|
+
assert.deepEqual(rows.map(r => [r.writ.id, r.depth]), [
|
|
621
|
+
['a', 0],
|
|
622
|
+
['a1', 1],
|
|
623
|
+
['a1x', 2],
|
|
624
|
+
['a1y', 2],
|
|
625
|
+
['a2', 1],
|
|
626
|
+
]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('empty forest returns empty', () => {
|
|
630
|
+
assert.deepEqual(flattenTree([]), []);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('accepts an explicit start depth (for sub-tree rendering)', () => {
|
|
634
|
+
const nodes = [
|
|
635
|
+
{ writ: { id: 'x' }, children: [{ writ: { id: 'xx' }, children: [] }] },
|
|
636
|
+
];
|
|
637
|
+
const rows = flattenTree(nodes, 5);
|
|
638
|
+
assert.deepEqual(rows.map(r => [r.writ.id, r.depth]), [
|
|
639
|
+
['x', 5],
|
|
640
|
+
['xx', 6],
|
|
641
|
+
]);
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
describe('depthIndentStyle — depth → padding-left mapping', () => {
|
|
646
|
+
it('depth 0 matches the existing 2rem main-list indent', () => {
|
|
647
|
+
assert.equal(depthIndentStyle(0), 'padding-left:2rem');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('depth 1 adds 1.5rem', () => {
|
|
651
|
+
assert.equal(depthIndentStyle(1), 'padding-left:3.5rem');
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('depth 2 adds another 1.5rem', () => {
|
|
655
|
+
assert.equal(depthIndentStyle(2), 'padding-left:5rem');
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe('Deep descendant rendering in detail view', () => {
|
|
660
|
+
it('renders all descendants when _descendantTree is populated', () => {
|
|
661
|
+
const writ = {
|
|
662
|
+
id: 'parent-1',
|
|
663
|
+
title: 'Parent',
|
|
664
|
+
phase: 'open',
|
|
665
|
+
body: '',
|
|
666
|
+
children: {
|
|
667
|
+
summary: { open: 1 },
|
|
668
|
+
items: [{ id: 'c1', title: 'Child 1', phase: 'open' }],
|
|
669
|
+
},
|
|
670
|
+
_descendantTree: [
|
|
671
|
+
{
|
|
672
|
+
writ: { id: 'c1', title: 'Child 1', type: 'task', phase: 'open' },
|
|
673
|
+
children: [
|
|
674
|
+
{
|
|
675
|
+
writ: { id: 'g1', title: 'Grandchild 1', type: 'task', phase: 'open' },
|
|
676
|
+
children: [
|
|
677
|
+
{
|
|
678
|
+
writ: { id: 'gg1', title: 'Great-grandchild', type: 'task', phase: 'open' },
|
|
679
|
+
children: [],
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const html = renderDetail(writ);
|
|
689
|
+
|
|
690
|
+
// All three descendants appear as rows
|
|
691
|
+
assert.ok(html.includes('data-child-id="c1"'), 'Direct child row present');
|
|
692
|
+
assert.ok(html.includes('data-child-id="g1"'), 'Grandchild row present');
|
|
693
|
+
assert.ok(html.includes('data-child-id="gg1"'), 'Great-grandchild row present');
|
|
694
|
+
// Pre-order: parent appears before child
|
|
695
|
+
assert.ok(html.indexOf('data-child-id="c1"') < html.indexOf('data-child-id="g1"'));
|
|
696
|
+
assert.ok(html.indexOf('data-child-id="g1"') < html.indexOf('data-child-id="gg1"'));
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('rows carry depth data attribute and depth-based indent', () => {
|
|
700
|
+
const writ = {
|
|
701
|
+
id: 'p',
|
|
702
|
+
title: 'P',
|
|
703
|
+
phase: 'open',
|
|
704
|
+
body: '',
|
|
705
|
+
children: { summary: {}, items: [{ id: 'c', title: 'C', phase: 'open' }] },
|
|
706
|
+
_descendantTree: [
|
|
707
|
+
{
|
|
708
|
+
writ: { id: 'c', title: 'Child', type: 'task', phase: 'open' },
|
|
709
|
+
children: [
|
|
710
|
+
{ writ: { id: 'g', title: 'Grand', type: 'task', phase: 'open' }, children: [] },
|
|
711
|
+
],
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const html = renderDetail(writ);
|
|
717
|
+
assert.ok(html.includes('data-child-id="c" data-depth="0"'), 'Direct child has depth 0');
|
|
718
|
+
assert.ok(html.includes('data-child-id="g" data-depth="1"'), 'Grandchild has depth 1');
|
|
719
|
+
assert.ok(html.includes('style="padding-left:2rem"'), 'Depth 0 title cell indents 2rem');
|
|
720
|
+
assert.ok(html.includes('style="padding-left:3.5rem"'), 'Depth 1 title cell indents 3.5rem');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('falls back to flat children when _descendantTree is missing', () => {
|
|
724
|
+
const writ = {
|
|
725
|
+
id: 'p',
|
|
726
|
+
title: 'P',
|
|
727
|
+
phase: 'open',
|
|
728
|
+
body: '',
|
|
729
|
+
_fullChildren: [
|
|
730
|
+
{ id: 'c1', title: 'Child 1', type: 'task', phase: 'open' },
|
|
731
|
+
{ id: 'c2', title: 'Child 2', type: 'task', phase: 'open' },
|
|
732
|
+
],
|
|
733
|
+
children: { summary: { open: 2 }, items: [] },
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const html = renderDetail(writ);
|
|
737
|
+
assert.ok(html.includes('data-child-id="c1" data-depth="0"'));
|
|
738
|
+
assert.ok(html.includes('data-child-id="c2" data-depth="0"'));
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('summary badges reflect direct children only (writ.children.summary)', () => {
|
|
742
|
+
const writ = {
|
|
743
|
+
id: 'p',
|
|
744
|
+
title: 'P',
|
|
745
|
+
phase: 'open',
|
|
746
|
+
body: '',
|
|
747
|
+
children: {
|
|
748
|
+
// Direct-child summary from writ-show: 2 open direct children.
|
|
749
|
+
// Grandchildren are NOT counted here by design.
|
|
750
|
+
summary: { open: 2 },
|
|
751
|
+
items: [
|
|
752
|
+
{ id: 'c1', title: 'C1', phase: 'open' },
|
|
753
|
+
{ id: 'c2', title: 'C2', phase: 'open' },
|
|
754
|
+
],
|
|
755
|
+
},
|
|
756
|
+
_descendantTree: [
|
|
757
|
+
{
|
|
758
|
+
writ: { id: 'c1', title: 'C1', type: 'task', phase: 'open' },
|
|
759
|
+
children: [
|
|
760
|
+
{ writ: { id: 'g1', title: 'G1', type: 'task', phase: 'completed' }, children: [] },
|
|
761
|
+
],
|
|
762
|
+
},
|
|
763
|
+
{ writ: { id: 'c2', title: 'C2', type: 'task', phase: 'open' }, children: [] },
|
|
764
|
+
],
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const html = renderDetail(writ);
|
|
768
|
+
// Summary badges section shows "open 2" — not the grandchild's completed phase.
|
|
769
|
+
const summaryOpenMatch = html.match(/badge badge--active">open<\/span>\s*<span[^>]*>2</);
|
|
770
|
+
assert.ok(summaryOpenMatch, 'Summary shows 2 open direct children');
|
|
771
|
+
assert.ok(!html.match(/badge badge--success">completed<\/span>\s*<span[^>]*>1</),
|
|
772
|
+
'Summary does NOT count the grandchild');
|
|
773
|
+
// But the table still renders the grandchild row.
|
|
774
|
+
assert.ok(html.includes('data-child-id="g1"'), 'Grandchild row is present in the table');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('preserves rowActions on every depth row', () => {
|
|
778
|
+
const writ = {
|
|
779
|
+
id: 'p',
|
|
780
|
+
title: 'P',
|
|
781
|
+
phase: 'open',
|
|
782
|
+
body: '',
|
|
783
|
+
children: { summary: {}, items: [{ id: 'c', title: 'C', phase: 'new' }] },
|
|
784
|
+
_descendantTree: [
|
|
785
|
+
{
|
|
786
|
+
writ: { id: 'c', title: 'C', type: 'task', phase: 'new' },
|
|
787
|
+
children: [
|
|
788
|
+
{ writ: { id: 'g', title: 'G', type: 'task', phase: 'open' }, children: [] },
|
|
789
|
+
],
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const html = renderDetail(writ);
|
|
795
|
+
// Depth 0 (new phase) gets Start + Cancel
|
|
796
|
+
assert.ok(html.match(/data-action="row-publish"[^>]*data-id="c"/));
|
|
797
|
+
assert.ok(html.match(/data-action="row-cancel"[^>]*data-id="c"/));
|
|
798
|
+
// Depth 1 (open phase) gets Cancel only
|
|
799
|
+
assert.ok(html.match(/data-action="row-cancel"[^>]*data-id="g"/));
|
|
800
|
+
assert.ok(!html.match(/data-action="row-publish"[^>]*data-id="g"/));
|
|
801
|
+
});
|
|
802
|
+
});
|