@bongos/core 1.19.371 → 1.19.372
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/.bongos-core.json +24 -19
- package/docs/copy-inventory.md +134 -132
- package/docs/copy-registry.json +180 -162
- package/modules/hall-ui/public/dom-utils.js +31 -0
- package/modules/hall-ui/public/goals-page.js +34 -0
- package/modules/hall-ui/public/goals-render.js +21 -7
- package/modules/hall-ui/public/style.css +9 -0
- package/modules/hall-ui/public/task.js +20 -3
- package/modules/hall-ui/public/work.js +16 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/goal_category_ui.mjs +279 -0
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
|
@@ -269,11 +269,42 @@
|
|
|
269
269
|
return (data && typeof data === 'object') ? data : {};
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// task 1003276 (goal 1000072, criterion legibility-categories) — a work
|
|
273
|
+
// CATEGORY as a chip. Lives HERE, not in goals-lib.js, because three surfaces
|
|
274
|
+
// render it — the Goals page, the Tasks board and the task page — and
|
|
275
|
+
// dom-utils.js is the only script all three load. One renderer means the
|
|
276
|
+
// goal's chip and the task's inherited chip cannot drift.
|
|
277
|
+
//
|
|
278
|
+
// A LABEL and nothing more: no href, no pointer affordance. A per-category
|
|
279
|
+
// destination would be a navigation tier, and that is precisely how an
|
|
280
|
+
// advisory word becomes the second hierarchy in practice even when no code
|
|
281
|
+
// gates on it (ADR 0192 — a category authorises nothing).
|
|
282
|
+
//
|
|
283
|
+
// `inherited` marks a TASK's chip. A task stores no category; it reads its
|
|
284
|
+
// goal's through goal_id, and saying so out loud stops anyone hunting for a
|
|
285
|
+
// per-task editor that does not exist.
|
|
286
|
+
//
|
|
287
|
+
// Uncategorised renders NOTHING rather than an "Uncategorised" pill: most
|
|
288
|
+
// goals will carry one, and a board of grey placeholders reads worse than a
|
|
289
|
+
// board with gaps. The Category filter's "not categorised yet" option is how
|
|
290
|
+
// you find them.
|
|
291
|
+
function categoryChip(category, opts) {
|
|
292
|
+
const label = category && category.label ? String(category.label).trim() : '';
|
|
293
|
+
if (!label) return '';
|
|
294
|
+
const inherited = !!(opts && opts.inherited);
|
|
295
|
+
const title = inherited
|
|
296
|
+
? `Category: ${label} — inherited from this task's goal (a task carries no category of its own)`
|
|
297
|
+
: `Category: ${label}`;
|
|
298
|
+
return `<span class="gr-cat${inherited ? ' gr-cat--inherited' : ''}" title="${escapeHtml(title)}">`
|
|
299
|
+
+ `${escapeHtml(label)}${inherited ? '<i class="gr-cat__from" aria-hidden="true">via goal</i>' : ''}</span>`;
|
|
300
|
+
}
|
|
301
|
+
|
|
272
302
|
window.OTB = {
|
|
273
303
|
escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn,
|
|
274
304
|
errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
|
|
275
305
|
contributeSettingsPanel, settingsPanels,
|
|
276
306
|
contributeHallWidget, hallWidgets, hallWidgetIds,
|
|
277
307
|
splitCriterion,
|
|
308
|
+
categoryChip, // task 1003276 — shared by the Goals page, the Tasks board and the task page
|
|
278
309
|
};
|
|
279
310
|
})();
|
|
@@ -228,6 +228,34 @@
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// task 1003276 — the Category dropdown. Unlike Version and Owner (derived from
|
|
232
|
+
// the loaded goals), the options come from the project's SET — GET
|
|
233
|
+
// /work-categories — so a category nobody has used yet is still offered, and a
|
|
234
|
+
// client never hard-codes the labels ("present in every builders hall on any
|
|
235
|
+
// project" only holds if the vocabulary travels over the wire). The 'none'
|
|
236
|
+
// option finds the uncategorised, which is the set this feature exists to
|
|
237
|
+
// shrink. Best-effort: a failed fetch leaves the control with just "— any —".
|
|
238
|
+
async function populateCategoryFilter() {
|
|
239
|
+
const sel = $('#f-goal-category');
|
|
240
|
+
if (!sel) return;
|
|
241
|
+
let cats = [];
|
|
242
|
+
try {
|
|
243
|
+
cats = (await getJSON(`${API}/work-categories`))?.categories || [];
|
|
244
|
+
} catch (_) { return; }
|
|
245
|
+
for (const c of cats) {
|
|
246
|
+
const opt = document.createElement('option');
|
|
247
|
+
opt.value = c.slug;
|
|
248
|
+
opt.textContent = c.label;
|
|
249
|
+
sel.appendChild(opt);
|
|
250
|
+
}
|
|
251
|
+
if (cats.length) {
|
|
252
|
+
const opt = document.createElement('option');
|
|
253
|
+
opt.value = 'none';
|
|
254
|
+
opt.textContent = 'not categorised yet';
|
|
255
|
+
sel.appendChild(opt);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
231
259
|
// Archon check for the detail view's manage/visibility affordances (the
|
|
232
260
|
// server-side rank gates are the real enforcement). The task-1760 drag-reorder
|
|
233
261
|
// affordance this once also served is retired — see the note where
|
|
@@ -797,6 +825,9 @@
|
|
|
797
825
|
// task 1003135 — fire-and-forget: the Owner options fill when the (cached)
|
|
798
826
|
// directory resolves; boot never waits on a label lookup.
|
|
799
827
|
populateOwnerFilter().catch(() => {});
|
|
828
|
+
// task 1003276 — same fire-and-forget posture: boot never waits on the
|
|
829
|
+
// category vocabulary, and the board renders fine before it lands.
|
|
830
|
+
populateCategoryFilter().catch(() => {});
|
|
800
831
|
// Prefetch details (ring + members on the cards). Sequentially-parallel,
|
|
801
832
|
// failure-tolerant: a card without detail still renders.
|
|
802
833
|
// task 1002439 — also fold in per-goal task-readiness rollups (one request
|
|
@@ -859,6 +890,9 @@
|
|
|
859
890
|
{ id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
|
|
860
891
|
{ id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
|
|
861
892
|
{ id: 'f-goal-owner', label: 'Owner', options: [{ value: '', label: '— any —' }] },
|
|
893
|
+
// task 1003276 — filled from GET /work-categories after boot
|
|
894
|
+
// (populateCategoryFilter), the populateOwnerFilter pattern.
|
|
895
|
+
{ id: 'f-goal-category', label: 'Category', options: [{ value: '', label: '— any —' }] },
|
|
862
896
|
{ id: 'f-goal-order',
|
|
863
897
|
label: 'Sort',
|
|
864
898
|
value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
window.OTBGoalsRender = function createGoalsRender(ctx) {
|
|
21
21
|
const { API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
|
|
22
|
-
const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
|
|
22
|
+
const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel, categoryChip } = window.OTB;
|
|
23
23
|
const {
|
|
24
24
|
priorityRank, touchesConflict, rankTagHtml, deHashRefs,
|
|
25
25
|
creditsPerMinute, buildPromptForTask, friendlyBatchReason,
|
|
@@ -81,11 +81,18 @@
|
|
|
81
81
|
const q = ($('#f-goals-search')?.value || '').trim().toLowerCase();
|
|
82
82
|
const needle = q.replace(/^#/, '');
|
|
83
83
|
const owner = $('#f-goal-owner')?.value || '';
|
|
84
|
+
// task 1003276 — the Category dropdown. Matches on the SLUG (the stable
|
|
85
|
+
// machine key) so re-wording a label never breaks a filter, and 'none' is
|
|
86
|
+
// the uncategorised set — which must be findable, because "what has nobody
|
|
87
|
+
// placed yet" is exactly the prompt this feature is built around.
|
|
88
|
+
const cat = $('#f-goal-category')?.value || '';
|
|
84
89
|
const matchesSearch = (g) => !needle
|
|
85
90
|
|| String(g.id).includes(needle)
|
|
86
91
|
|| (g.title || '').toLowerCase().includes(needle);
|
|
87
92
|
const matchesOwner = (g) => !owner
|
|
88
93
|
|| (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
|
|
94
|
+
const matchesCategory = (g) => !cat
|
|
95
|
+
|| (cat === 'none' ? !g.category : (g.category?.slug === cat));
|
|
89
96
|
const myId = selfId();
|
|
90
97
|
const iAmMember = (g) => {
|
|
91
98
|
const d = state.details.get(String(g.id));
|
|
@@ -96,6 +103,7 @@
|
|
|
96
103
|
(!status || g.status === status) &&
|
|
97
104
|
matchesSearch(g) &&
|
|
98
105
|
matchesOwner(g) &&
|
|
106
|
+
matchesCategory(g) &&
|
|
99
107
|
(!mineOnly || iAmMember(g)));
|
|
100
108
|
}
|
|
101
109
|
function goalStampHtml(g) {
|
|
@@ -127,10 +135,13 @@
|
|
|
127
135
|
right = `<span class="gr-quiet">No tasks yet</span>`;
|
|
128
136
|
}
|
|
129
137
|
const metaHtml = `#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}${goalStampHtml(g)}`;
|
|
130
|
-
|
|
138
|
+
// task 1003276 — the category chip. Computed here with the rest of the facts
|
|
139
|
+
// so the row and the card (task 1003136) cannot drift on it.
|
|
140
|
+
const catHtml = categoryChip(g.category);
|
|
141
|
+
return { r, achieved, ringHtml, goalHref, right, metaHtml, catHtml };
|
|
131
142
|
}
|
|
132
143
|
function goalRowHtml(g) {
|
|
133
|
-
const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 44);
|
|
144
|
+
const { r, achieved, ringHtml, goalHref, right, metaHtml, catHtml } = goalRenderFacts(g, 44);
|
|
134
145
|
// NOTE: a rollup row nests real links (title → goal, Claim → task overlay,
|
|
135
146
|
// drill → goal), so the row itself is a <div>, not an <a> — nested anchors
|
|
136
147
|
// are invalid. Only the explicit links navigate.
|
|
@@ -138,20 +149,20 @@
|
|
|
138
149
|
<div class="gr-ringcol">${ringHtml}</div>
|
|
139
150
|
<div class="gr-main">
|
|
140
151
|
<a class="gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
|
|
141
|
-
<div class="gr-meta">${metaHtml}</div>
|
|
152
|
+
<div class="gr-meta">${metaHtml}${catHtml}</div>
|
|
142
153
|
${achieved ? '' : goalRollLine(r)}
|
|
143
154
|
</div>
|
|
144
155
|
<div class="gr-right">${right}</div>
|
|
145
156
|
</div>`;
|
|
146
157
|
}
|
|
147
158
|
function goalCardHtml(g) {
|
|
148
|
-
const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 36);
|
|
159
|
+
const { r, achieved, ringHtml, goalHref, right, metaHtml, catHtml } = goalRenderFacts(g, 36);
|
|
149
160
|
return `<div class="goal-card" data-goal-id="${escapeHtml(String(g.id))}">
|
|
150
161
|
<div class="goal-card__head">
|
|
151
162
|
<div class="gr-ringcol">${ringHtml}</div>
|
|
152
163
|
<a class="goal-card__title gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
|
|
153
164
|
</div>
|
|
154
|
-
<div class="goal-card__meta">${metaHtml}</div>
|
|
165
|
+
<div class="goal-card__meta">${metaHtml}${catHtml}</div>
|
|
155
166
|
${achieved ? '' : goalRollLine(r)}
|
|
156
167
|
<div class="goal-card__foot">${right}</div>
|
|
157
168
|
</div>`;
|
|
@@ -709,7 +720,10 @@
|
|
|
709
720
|
// still never re-derives the protected roster.
|
|
710
721
|
const gateSpan = g.scope_protected
|
|
711
722
|
? '<span class="goal-badge goal-badge--locked" title="Protected module scope — join by request; an Archon admits members">🛡 Archon-mediated</span>' : '';
|
|
712
|
-
|
|
723
|
+
// task 1003276 — the human category, beside the machine-readable module
|
|
724
|
+
// scope so the difference is visible: `scope` is the wall that refuses work
|
|
725
|
+
// (ADR 0086); the chip is a word for a person and gates nothing (ADR 0192).
|
|
726
|
+
meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${categoryChip(g.category)} ${lockSpan} ${gateSpan} ${scope} ${depsSpan}`;
|
|
713
727
|
}
|
|
714
728
|
|
|
715
729
|
// task 1762: the goal's own owner/manager (or an Archon) can invite/manage
|
|
@@ -2172,3 +2172,12 @@ html.palette-open { overflow: hidden; }
|
|
|
2172
2172
|
.palette__panel { margin-top: 6vh; }
|
|
2173
2173
|
.palette__group { min-width: 56px; }
|
|
2174
2174
|
}
|
|
2175
|
+
/* task 1003276 — the human CATEGORY chip. Quiet by design: it sits beside the
|
|
2176
|
+
meta line, not above the title, because it is a label a person scans, never a
|
|
2177
|
+
status or a permission (ADR 0192). No hover/pointer affordance and no href —
|
|
2178
|
+
a per-category destination would be a navigation tier, which is exactly the
|
|
2179
|
+
second hierarchy this feature must not become. On a TASK the chip is the one
|
|
2180
|
+
inherited from its goal, and says so, so nobody hunts for a per-task editor. */
|
|
2181
|
+
.gr-cat { display: inline-flex; align-items: baseline; gap: 5px; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--rule-soft); border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: .01em; color: var(--ink-soft); background: var(--surface-2); vertical-align: baseline; white-space: nowrap; }
|
|
2182
|
+
.gr-cat--inherited { font-weight: 550; color: var(--ink-faint); }
|
|
2183
|
+
.gr-cat__from { font-style: normal; font-weight: 500; font-size: 10px; color: var(--ink-faint); opacity: .85; }
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
`Task description:\n${desc}`;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function renderTask(t, lastClaim, viewer) {
|
|
196
|
+
function renderTask(t, lastClaim, viewer, goal) {
|
|
197
197
|
document.title = `#${t.id} — ${t.title || 'Task'} — ${document.title.split(' — ').pop()}`;
|
|
198
198
|
const sub = $('#task-sub');
|
|
199
199
|
if (sub) sub.textContent = `Task #${t.id}`;
|
|
@@ -222,7 +222,14 @@
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
if (t.goal_id) {
|
|
225
|
-
|
|
225
|
+
// task 1003276 — the goal line names the goal and carries the category this
|
|
226
|
+
// task INHERITS from it. Two legibility fixes in one line: the link used to
|
|
227
|
+
// read "Goal #1000072", which tells a person nothing they didn't already
|
|
228
|
+
// know, and the chip answers "what kind of work is this" without opening
|
|
229
|
+
// the goal. Marked inherited because a task carries no category of its own.
|
|
230
|
+
const goalName = goal && goal.title ? ` — ${escapeHtml(goal.title)}` : '';
|
|
231
|
+
const catChip = window.OTB.categoryChip(goal && goal.category, { inherited: true });
|
|
232
|
+
parts.push(`<p class="td-goal">Goal: <a href="/goals#/goal/${encodeURIComponent(t.goal_id)}">Goal #${escapeHtml(String(t.goal_id))}</a>${goalName}${catChip}</p>`);
|
|
226
233
|
}
|
|
227
234
|
|
|
228
235
|
// Claim history (task 751 — best-effort: the API surfaces the most recent
|
|
@@ -356,7 +363,17 @@
|
|
|
356
363
|
const me = await getJSON(`${API}/me`, { softAuth: true });
|
|
357
364
|
viewer = (me && me.builder) ? me.builder : null;
|
|
358
365
|
} catch (_) { /* cosmetic — the server enforces regardless */ }
|
|
359
|
-
|
|
366
|
+
// task 1003276 — the task's goal, for its name and the category the task
|
|
367
|
+
// inherits. Best-effort and non-blocking on failure: the page has always
|
|
368
|
+
// rendered without it, and a category is a nicety, never a prerequisite.
|
|
369
|
+
let goal = null;
|
|
370
|
+
if (d.task.goal_id != null) {
|
|
371
|
+
try {
|
|
372
|
+
const g = await getJSON(`${API}/goals/${encodeURIComponent(d.task.goal_id)}`, { softAuth: true });
|
|
373
|
+
goal = (g && g.goal) ? g.goal : null;
|
|
374
|
+
} catch (_) { /* cosmetic — the goal link still resolves without it */ }
|
|
375
|
+
}
|
|
376
|
+
renderTask(d.task, d.last_claim || null, viewer, goal);
|
|
360
377
|
} catch (err) {
|
|
361
378
|
if (err && err.code === AUTH_NEEDED) { toAuth(); return; }
|
|
362
379
|
if (err && err.status === 404) { renderNotFound(id); return; }
|
|
@@ -337,6 +337,12 @@
|
|
|
337
337
|
// task 1002955: how old is this task? A ready queue reads as equally fresh
|
|
338
338
|
// without it; the exact date rides the tooltip.
|
|
339
339
|
const ageChip = window.OTBKit.ageChipHtml(t.created_at);
|
|
340
|
+
// task 1003276 — the category this task inherits from its goal, marked as
|
|
341
|
+
// inherited so nobody looks for a per-task editor that does not exist. It
|
|
342
|
+
// is a label only: it never filters or orders this board, because nothing
|
|
343
|
+
// a person may not act on should decide what the queue offers (ADR 0192).
|
|
344
|
+
const catChip = window.OTB.categoryChip(
|
|
345
|
+
(taskState.goalCategories || {})[String(t.goal_id)], { inherited: true });
|
|
340
346
|
const selected = selection.claim.has(String(t.id));
|
|
341
347
|
return `
|
|
342
348
|
<div class="task${selected ? ' task--selected' : ''}" data-task-id="${t.id}">
|
|
@@ -358,6 +364,7 @@
|
|
|
358
364
|
<span>${cpmText}</span>
|
|
359
365
|
${touchesChip}
|
|
360
366
|
${ageChip}
|
|
367
|
+
${catChip}
|
|
361
368
|
</div>
|
|
362
369
|
</div>
|
|
363
370
|
<div class="task__credits">
|
|
@@ -1071,9 +1078,17 @@
|
|
|
1071
1078
|
|
|
1072
1079
|
// task 1755: goal id → title map for the Goal filter (best-effort).
|
|
1073
1080
|
taskState.goalTitles = {};
|
|
1081
|
+
// task 1003276 — a task INHERITS its goal's category (it stores none of its
|
|
1082
|
+
// own), so the board resolves the label the same way the schema does: through
|
|
1083
|
+
// goal_id. The goals fetch below is already made for the titles, so the chip
|
|
1084
|
+
// costs no extra request and no server change.
|
|
1085
|
+
taskState.goalCategories = {};
|
|
1074
1086
|
try {
|
|
1075
1087
|
const gr = await getJSON(`${API}/goals`);
|
|
1076
|
-
(gr.goals || []).forEach((g) => {
|
|
1088
|
+
(gr.goals || []).forEach((g) => {
|
|
1089
|
+
taskState.goalTitles[g.id] = g.title;
|
|
1090
|
+
taskState.goalCategories[g.id] = g.category || null;
|
|
1091
|
+
});
|
|
1077
1092
|
} catch (_) { /* filter labels fall back to 'Goal #id' */ }
|
|
1078
1093
|
|
|
1079
1094
|
// Claimable + active claims in one call. A 403 means the primer gate — show
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.372",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.372",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.372",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -1172,8 +1172,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1172
1172
|
// landed since 1.19.369 with no explicit bump. run 32904231221. (task 1002620)
|
|
1173
1173
|
// 1.19.371 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1174
1174
|
// landed since 1.19.370 with no explicit bump. run 32904582252. (task 1002620)
|
|
1175
|
+
// 1.19.372 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1176
|
+
// landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
|
|
1175
1177
|
// ---------------------------------------------------------------------------
|
|
1176
|
-
const CORE_VERSION = '1.19.
|
|
1178
|
+
const CORE_VERSION = '1.19.372'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1177
1179
|
|
|
1178
1180
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1179
1181
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// tests/goal_category_ui.mjs — the hall shows a goal's category and filters by
|
|
2
|
+
// it, and a task shows the one it inherits (task 1003276, goal 1000072,
|
|
3
|
+
// criterion legibility-categories).
|
|
4
|
+
//
|
|
5
|
+
// This EXECUTES the real goals page in a vm under a minimal DOM/kit stub (the
|
|
6
|
+
// tests/goal_criterion_confirm_ui.mjs precedent — there is no jsdom here), boots
|
|
7
|
+
// it against a fake API serving a categorised goal and an uncategorised one, and
|
|
8
|
+
// asserts on the HTML it actually writes and on what the Category control
|
|
9
|
+
// actually narrows. Source pins are used only for the two properties a harness
|
|
10
|
+
// cannot hold: that the chip is not a navigation destination, and that the other
|
|
11
|
+
// two surfaces render it through the SAME shared helper.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/goal_category_ui.mjs
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import vm from 'node:vm';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
23
|
+
const SRC = read('modules/hall-ui/public/goals-page.js');
|
|
24
|
+
const TASK_LIB_SRC = read('modules/hall-ui/public/task-lib.js');
|
|
25
|
+
const GOALS_LIB_SRC = read('modules/hall-ui/public/goals-lib.js');
|
|
26
|
+
const GOALS_RENDER_SRC = read('modules/hall-ui/public/goals-render.js');
|
|
27
|
+
const DOM_UTILS_SRC = read('modules/hall-ui/public/dom-utils.js');
|
|
28
|
+
const WORK_SRC = read('modules/hall-ui/public/work.js');
|
|
29
|
+
const TASK_PAGE_SRC = read('modules/hall-ui/public/task.js');
|
|
30
|
+
const STYLE_CSS = read('modules/hall-ui/public/style.css');
|
|
31
|
+
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
const SECURITY = { id: '3', slug: 'security', label: 'Security' };
|
|
35
|
+
const ECONOMICS = { id: '6', slug: 'economics', label: 'Economics' };
|
|
36
|
+
const CATEGORIES = [
|
|
37
|
+
{ id: 3, slug: 'security', label: 'Security', sort_order: 50, active: true },
|
|
38
|
+
{ id: 6, slug: 'economics', label: 'Economics', sort_order: 60, active: true },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const GOALS = [
|
|
42
|
+
{ id: 7, version_id: 'V1', title: 'The walls hold', status: 'open', visibility: 'public', created_by: 9, scope_modules: [], category_id: '3', category: SECURITY },
|
|
43
|
+
{ id: 8, version_id: 'V1', title: 'What a credit means', status: 'open', visibility: 'public', created_by: 9, scope_modules: [], category_id: '6', category: ECONOMICS },
|
|
44
|
+
{ id: 9, version_id: 'V1', title: 'Nobody has placed this yet', status: 'open', visibility: 'public', created_by: 9, scope_modules: [], category_id: null, category: null },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Boot goals-page.js on the LIST view and hand back the rendered grid plus a
|
|
48
|
+
// handle on the filter bar's onChange, so a filter can actually be exercised
|
|
49
|
+
// rather than asserted about.
|
|
50
|
+
async function renderList() {
|
|
51
|
+
const calls = { requests: [], appended: [] };
|
|
52
|
+
const els = new Map();
|
|
53
|
+
// The page builds TWO filter bars (the goal list, and the goal-detail To-do
|
|
54
|
+
// list). Keep both and pick by the control we care about, or this harness
|
|
55
|
+
// silently tests the wrong one.
|
|
56
|
+
const bars = [];
|
|
57
|
+
|
|
58
|
+
const makeEl = (key) => ({
|
|
59
|
+
_key: key, dataset: {}, style: {}, title: '', textContent: '', hidden: false, disabled: false, value: '',
|
|
60
|
+
innerHTML: '',
|
|
61
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
62
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
63
|
+
appendChild(child) { calls.appended.push({ into: key, value: child.value, text: child.textContent }); },
|
|
64
|
+
focus() {}, click() {}, querySelectorAll() { return []; }, addEventListener() {},
|
|
65
|
+
});
|
|
66
|
+
const $ = (sel) => {
|
|
67
|
+
if (!sel) return null;
|
|
68
|
+
if (!els.has(sel)) els.set(sel, makeEl(sel));
|
|
69
|
+
return els.get(sel);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const api = {
|
|
73
|
+
request(method, p) {
|
|
74
|
+
calls.requests.push([method, p]);
|
|
75
|
+
const reply = (data) => Promise.resolve({ status: 200, ok: true, data });
|
|
76
|
+
if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: 1, rank: 'archon' }, active_claims: [] });
|
|
77
|
+
if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals: GOALS });
|
|
78
|
+
if (method === 'GET' && /\/work-categories$/.test(p)) return reply({ categories: CATEGORIES });
|
|
79
|
+
if (method === 'GET' && /\/goals\/\d+$/.test(p)) {
|
|
80
|
+
const id = Number(p.match(/(\d+)$/)[1]);
|
|
81
|
+
return reply({ goal: GOALS.find((g) => Number(g.id) === id), members: [], criteria: [], dependencies: [] });
|
|
82
|
+
}
|
|
83
|
+
if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks: [] });
|
|
84
|
+
return reply({});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const windowObj = {
|
|
89
|
+
OTB: null, // filled by dom-utils.js, run for real below
|
|
90
|
+
OTBKit: {
|
|
91
|
+
makePager: (prefix) => ({ prefix, page: 1, perPage: 25 }),
|
|
92
|
+
paginate: (rows) => rows,
|
|
93
|
+
wirePager() {}, pagerBarHtml: () => '',
|
|
94
|
+
makeTabs: () => ({ setCount() {}, select() {}, activate() {} }),
|
|
95
|
+
makeFilterBar(spec) { bars.push(spec); },
|
|
96
|
+
emptyStateHtml: (m) => `<div class="kit-empty">${m}</div>`,
|
|
97
|
+
skeletonRows: () => '', ageChipHtml: () => '',
|
|
98
|
+
},
|
|
99
|
+
OTBBranding: { currencyLabel: () => 'credits', taskSettled: () => false, taskCreditValue: () => 0 },
|
|
100
|
+
BongosClient: { createClient: () => api },
|
|
101
|
+
location: { hash: '', pathname: '/builders/goals', assign() {} },
|
|
102
|
+
addEventListener() {}, scrollTo() {},
|
|
103
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
104
|
+
localStorage: { getItem() { return null; }, setItem() {} },
|
|
105
|
+
};
|
|
106
|
+
const documentObj = {
|
|
107
|
+
body: { dataset: {} },
|
|
108
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
109
|
+
getElementById: (id) => $(`#${id}`),
|
|
110
|
+
querySelector: (sel) => $(sel),
|
|
111
|
+
querySelectorAll: () => [],
|
|
112
|
+
createElement: () => makeEl('created'),
|
|
113
|
+
addEventListener() {},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, Date, encodeURIComponent, Promise, RegExp };
|
|
117
|
+
sandbox.globalThis = sandbox;
|
|
118
|
+
vm.createContext(sandbox);
|
|
119
|
+
vm.runInContext(DOM_UTILS_SRC, sandbox, { filename: 'dom-utils.js' });
|
|
120
|
+
// dom-utils builds its own $ over document; the harness's element map is what
|
|
121
|
+
// the page must actually read, so point OTB.$ at it (the confirm-UI precedent).
|
|
122
|
+
windowObj.OTB.$ = $;
|
|
123
|
+
windowObj.OTB.fmtDate = () => '';
|
|
124
|
+
windowObj.OTB.toast = () => {};
|
|
125
|
+
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
126
|
+
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
127
|
+
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
128
|
+
vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
|
|
129
|
+
for (let i = 0; i < 30; i++) await new Promise((r) => setTimeout(r, 0));
|
|
130
|
+
|
|
131
|
+
const controlIds = (spec) => [...(spec.primary || []), ...(spec.overflow || [])].map((c) => c.id);
|
|
132
|
+
const listBar = bars.find((b) => controlIds(b).includes('f-goal-version'));
|
|
133
|
+
assert.ok(listBar, `the goal-LIST filter bar must exist — saw ${JSON.stringify(bars.map(controlIds))}`);
|
|
134
|
+
|
|
135
|
+
const setFilter = async (id, value) => {
|
|
136
|
+
$(id).value = value;
|
|
137
|
+
assert.ok(listBar.onChange, 'the list filter bar must declare an onChange');
|
|
138
|
+
listBar.onChange();
|
|
139
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
140
|
+
return $('#goal-grid').innerHTML;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return { calls, bars, listBar, controlIds, grid: () => $('#goal-grid').innerHTML, setFilter, categoryChip: windowObj.OTB.categoryChip };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// =========================================================================
|
|
147
|
+
// The chip
|
|
148
|
+
// =========================================================================
|
|
149
|
+
|
|
150
|
+
await test('a categorised goal renders its category; an uncategorised one renders no chip', async () => {
|
|
151
|
+
const { grid } = await renderList();
|
|
152
|
+
const html = grid();
|
|
153
|
+
assert.match(html, /gr-cat[^>]*>Security/, 'the Security goal wears its label');
|
|
154
|
+
assert.match(html, /gr-cat[^>]*>Economics/, 'and so does the Economics one');
|
|
155
|
+
const chips = html.match(/class="gr-cat/g) || [];
|
|
156
|
+
assert.equal(chips.length, 2, `two of three goals are categorised — got ${chips.length} chips`);
|
|
157
|
+
assert.equal(/Uncategorised|Not categorised/i.test(html), false,
|
|
158
|
+
'an uncategorised goal shows NOTHING, not a grey placeholder pill — most goals will carry one, and a board of placeholders reads worse than a board with gaps');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test('the same chip renders in the card view, from the same facts', async () => {
|
|
162
|
+
const { setFilter } = await renderList();
|
|
163
|
+
const html = await setFilter('#f-goal-view', 'cards');
|
|
164
|
+
assert.match(html, /goal-card/, 'the card view is what rendered');
|
|
165
|
+
assert.match(html, /gr-cat[^>]*>Security/, 'and it carries the category too');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// =========================================================================
|
|
169
|
+
// The filter — the half the criterion actually asks for
|
|
170
|
+
// =========================================================================
|
|
171
|
+
|
|
172
|
+
await test('the Category control is declared on the kit filter bar (no bespoke markup)', async () => {
|
|
173
|
+
const { listBar, controlIds } = await renderList();
|
|
174
|
+
const ids = controlIds(listBar);
|
|
175
|
+
assert.ok(ids.includes('f-goal-category'), `Category must be a kit control — got ${JSON.stringify(ids)}`);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('the options come from the project SET, not from the goals on screen', async () => {
|
|
179
|
+
// "Present in every builders hall on any project" only holds if the vocabulary
|
|
180
|
+
// travels over the wire. A category nobody has used yet must still be offered.
|
|
181
|
+
const { calls, appended } = await renderList().then((r) => ({ calls: r.calls, appended: r.calls.appended }));
|
|
182
|
+
assert.ok(calls.requests.some(([m, p]) => m === 'GET' && /\/work-categories$/.test(p)),
|
|
183
|
+
'the page must READ the set rather than derive it from state.goals');
|
|
184
|
+
const into = appended.filter((a) => a.into === '#f-goal-category');
|
|
185
|
+
assert.deepEqual(into.map((a) => a.value), ['security', 'economics', 'none'],
|
|
186
|
+
'every category in the set, plus the uncategorised bucket');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('choosing a category narrows the board to it', async () => {
|
|
190
|
+
const { setFilter } = await renderList();
|
|
191
|
+
const html = await setFilter('#f-goal-category', 'security');
|
|
192
|
+
assert.match(html, /The walls hold/);
|
|
193
|
+
assert.equal(/What a credit means/.test(html), false, 'the Economics goal is filtered out');
|
|
194
|
+
assert.equal(/Nobody has placed this yet/.test(html), false, 'so is the uncategorised one');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await test('"not categorised yet" finds exactly the goals nobody has placed', async () => {
|
|
198
|
+
// This option is the point of the whole feature: it is the queue of goals the
|
|
199
|
+
// human still has to name a category for.
|
|
200
|
+
const { setFilter } = await renderList();
|
|
201
|
+
const html = await setFilter('#f-goal-category', 'none');
|
|
202
|
+
assert.match(html, /Nobody has placed this yet/);
|
|
203
|
+
assert.equal(/The walls hold/.test(html), false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await test('the filter matches on the SLUG, so re-wording a label cannot break it', async () => {
|
|
207
|
+
await renderList();
|
|
208
|
+
const src = GOALS_RENDER_SRC.slice(GOALS_RENDER_SRC.indexOf('function filteredGoals'));
|
|
209
|
+
assert.match(src.slice(0, 1600), /category\?\.slug === cat/,
|
|
210
|
+
'match on slug (the stable key), never on the human label');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// =========================================================================
|
|
214
|
+
// The rule: a chip is a label, not a destination
|
|
215
|
+
// =========================================================================
|
|
216
|
+
|
|
217
|
+
await test('the chip is NOT a link — a per-category page would be a navigation tier', async () => {
|
|
218
|
+
const { grid } = await renderList();
|
|
219
|
+
const html = grid();
|
|
220
|
+
const chip = html.slice(html.indexOf('class="gr-cat'), html.indexOf('class="gr-cat') + 200);
|
|
221
|
+
assert.equal(/<a\b/.test(chip), false, 'no anchor');
|
|
222
|
+
assert.equal(/href=/.test(chip), false, 'no href');
|
|
223
|
+
// And nothing in the CSS gives it a click affordance.
|
|
224
|
+
const rule = STYLE_CSS.slice(STYLE_CSS.indexOf('.gr-cat {'), STYLE_CSS.indexOf('.gr-cat {') + 400);
|
|
225
|
+
assert.equal(/cursor:\s*pointer/.test(rule), false,
|
|
226
|
+
'no pointer cursor — the chip must not even LOOK like a destination (ADR 0192)');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// =========================================================================
|
|
230
|
+
// Inheritance: the task surfaces
|
|
231
|
+
// =========================================================================
|
|
232
|
+
|
|
233
|
+
await test('categoryChip marks an inherited chip and says where it came from', async () => {
|
|
234
|
+
const { categoryChip } = await renderList();
|
|
235
|
+
const own = categoryChip(SECURITY);
|
|
236
|
+
const inherited = categoryChip(SECURITY, { inherited: true });
|
|
237
|
+
assert.equal(/gr-cat--inherited/.test(own), false);
|
|
238
|
+
assert.match(inherited, /gr-cat--inherited/);
|
|
239
|
+
assert.match(inherited, /via goal/, 'a reader must not go hunting for a per-task editor');
|
|
240
|
+
assert.match(inherited, /inherited from this task's goal/, 'and the tooltip says so in full (escaped — it lands in an attribute)');
|
|
241
|
+
assert.equal(categoryChip(null), '', 'no category, no chip');
|
|
242
|
+
assert.equal(categoryChip({ label: ' ' }), '', 'a blank label is no label');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await test('categoryChip escapes what it renders', async () => {
|
|
246
|
+
const { categoryChip } = await renderList();
|
|
247
|
+
const html = categoryChip({ id: '1', slug: 'x', label: '<img src=x onerror=alert(1)>' });
|
|
248
|
+
assert.equal(/<img/.test(html), false);
|
|
249
|
+
assert.match(html, /<img/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('the Tasks board resolves a task\'s category THROUGH its goal', async () => {
|
|
253
|
+
// Inheritance is literal in the schema (no tasks.category column), so the board
|
|
254
|
+
// resolves it the same way — through goal_id, from the goals it already loads.
|
|
255
|
+
assert.match(WORK_SRC, /taskState\.goalCategories\[g\.id\] = g\.category \|\| null;/,
|
|
256
|
+
'the category rides along with the goal title the board already fetches');
|
|
257
|
+
assert.match(WORK_SRC, /categoryChip\(\s*\(taskState\.goalCategories \|\| \{\}\)\[String\(t\.goal_id\)\], \{ inherited: true \}\)/,
|
|
258
|
+
'and renders through the shared helper, marked inherited');
|
|
259
|
+
assert.equal(/taskState\.goalCategories.*filter|f-category/.test(WORK_SRC), false,
|
|
260
|
+
'the board must NOT filter or order on a category — nothing a person may not act on should decide what the queue offers');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await test('the task page names its goal and shows the inherited category', async () => {
|
|
264
|
+
assert.match(TASK_PAGE_SRC, /categoryChip\(goal && goal\.category, \{ inherited: true \}\)/);
|
|
265
|
+
assert.match(TASK_PAGE_SRC, /const goalName = goal && goal\.title/,
|
|
266
|
+
'the goal link used to read "Goal #1000072", which tells a person nothing');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await test('one renderer serves all three surfaces, so they cannot drift', () => {
|
|
270
|
+
// The chip lives in dom-utils.js because that is the only script the Goals
|
|
271
|
+
// page, the Tasks board and the task page all load.
|
|
272
|
+
assert.match(DOM_UTILS_SRC, /function categoryChip\(/);
|
|
273
|
+
for (const [name, src] of [['goals-render', GOALS_RENDER_SRC], ['work', WORK_SRC], ['task page', TASK_PAGE_SRC]]) {
|
|
274
|
+
assert.match(src, /categoryChip\(/, `${name} must render through the shared helper`);
|
|
275
|
+
assert.equal(/function categoryChip\(/.test(src), false, `${name} must not hold its own copy`);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
summary();
|
|
@@ -108,6 +108,12 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
108
108
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])),
|
|
109
109
|
$, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
|
|
110
110
|
toast: (m) => calls.toasts.push(m),
|
|
111
|
+
// task 1003276 — the goal detail head renders the category chip through
|
|
112
|
+
// this shared helper. Stubbed to '' because the chip is not this test's
|
|
113
|
+
// subject (tests/goal_category_ui.mjs runs the real one); it must be
|
|
114
|
+
// PRESENT, though — an undefined helper throws mid-render and takes the
|
|
115
|
+
// whole detail view down, which is how this stub first failed.
|
|
116
|
+
categoryChip: () => '',
|
|
111
117
|
},
|
|
112
118
|
OTBKit: {
|
|
113
119
|
makePager: (prefix) => pager(prefix),
|