@bongos/core 1.19.864 → 1.19.865
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 +25 -20
- package/docs/copy-inventory.md +40 -18
- package/docs/copy-registry.json +260 -18
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/blockers.html +4 -1
- package/modules/hall-ui/public/blockers.js +283 -18
- package/modules/hall-ui/public/goal-inbox.js +6 -0
- package/modules/hall-ui/public/task-detail.js +92 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +92 -0
- package/tests/blockers_page_ui.mjs +325 -0
- package/tests/helpers.mjs +13 -0
- package/tests/task_detail_ui.mjs +79 -1
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
// blockers.js — the Blockers page: the full open-blocker queue (task
|
|
2
|
-
// goal 1000089).
|
|
1
|
+
// blockers.js — the Blockers page: the full open-blocker queue, and now (task
|
|
2
|
+
// 1003456, goal 1000089) the write half — file, resolve, link.
|
|
3
3
|
//
|
|
4
4
|
// GET /blockers (modules/ideas/routes/blockers.js) is PUBLIC and had ZERO hall
|
|
5
|
-
// consumers before
|
|
6
|
-
// #/blocker/N overlay task-detail.js already renders, reachable only by
|
|
7
|
-
// the id into the address bar. Owner decision 2026-08-30: BOTH surfaces,
|
|
8
|
-
// ONE code path — this page is the full queue (a long list has nowhere
|
|
9
|
-
// live), and goal-inbox.js's attention-inbox slice is the capped
|
|
10
|
-
// render from blockers-lib.js's blockerFacts(), so the two
|
|
11
|
-
// the way ~10 hand-rolled pagers did before the kit
|
|
5
|
+
// consumers before task 1003455 — the only blocker a builder could ever see was
|
|
6
|
+
// the #/blocker/N overlay task-detail.js already renders, reachable only by
|
|
7
|
+
// typing the id into the address bar. Owner decision 2026-08-30: BOTH surfaces,
|
|
8
|
+
// from ONE code path — this page is the full queue (a long list has nowhere
|
|
9
|
+
// else to live), and goal-inbox.js's attention-inbox slice is the capped
|
|
10
|
+
// summary. Both render from blockers-lib.js's blockerFacts(), so the two
|
|
11
|
+
// cannot drift apart the way ~10 hand-rolled pagers did before the kit.
|
|
12
|
+
//
|
|
13
|
+
// task 1003456 — the write half. POST /blockers, POST /blockers/:id/resolve
|
|
14
|
+
// and POST /blockers/:id/link already carry their own rank floors (any-builder
|
|
15
|
+
// for filing, blocker.review — metic+archon — for resolve/link; proven by the
|
|
16
|
+
// fitness route-rank-check gate, not re-proven here). AUTHORITY IS UNCHANGED:
|
|
17
|
+
// this file adds no floor of its own, only a COSMETIC mirror of the real one —
|
|
18
|
+
// the exact idea-triage-panel precedent in task-detail.js ("The panel only
|
|
19
|
+
// renders for metic/archon; the server enforces the same gate regardless").
|
|
20
|
+
// The one control open to EVERY signed-in viewer, including a non-reviewer, is
|
|
21
|
+
// filing — and the file form's own "blocking task #" field is deliberately
|
|
22
|
+
// NOT hidden from a non-reviewer, because a builder who is themselves stuck is
|
|
23
|
+
// exactly who most needs to say what it blocks, even though completing the
|
|
24
|
+
// LINK half needs a Metic+. That is the case "every API refusal renders as
|
|
25
|
+
// honest copy rather than a silent no-op" (the task's own done-when) exists
|
|
26
|
+
// for: filing still succeeds, and the honest message says a Metic+ has to
|
|
27
|
+
// finish the link — never a swallowed failure.
|
|
12
28
|
//
|
|
13
|
-
// Read-only: filing, resolving and linking a blocker to a task are task 1003456.
|
|
14
29
|
// Deliberately UNGATED (tests/hall_page_gate_map.mjs) — its one data source is
|
|
15
30
|
// public, the roster/drachmae precedent: a page must not be gated tighter than
|
|
16
|
-
// the API it fronts.
|
|
31
|
+
// the API it fronts. A signed-out visitor sees the queue and the File button;
|
|
32
|
+
// clicking it redirects to sign-in, same as every other hall write.
|
|
17
33
|
(function () {
|
|
18
34
|
'use strict';
|
|
19
35
|
var kit = window.OTBKit;
|
|
@@ -22,6 +38,45 @@
|
|
|
22
38
|
var body = document.getElementById('blockers-body');
|
|
23
39
|
var countPill = document.getElementById('blockers-count');
|
|
24
40
|
var sub = document.getElementById('blockers-sub');
|
|
41
|
+
var fileArea = document.getElementById('blockers-file-area');
|
|
42
|
+
|
|
43
|
+
// The generated typed client (task-detail.js's own pattern), result-mode so
|
|
44
|
+
// the status-inspecting control flow below (401 → sign-in, 403 → honest
|
|
45
|
+
// copy) reads 1:1.
|
|
46
|
+
var api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
47
|
+
var toast = function (m) { if (window.OTB && window.OTB.toast) window.OTB.toast(m, { duration: 3200 }); };
|
|
48
|
+
|
|
49
|
+
async function writeJSON(method, path, payload) {
|
|
50
|
+
var res = await api.request(method, path, { body: payload || {} });
|
|
51
|
+
if (res.status === 401) { window.location.assign(API + '/auth/web/start'); throw new Error('redirecting to auth'); }
|
|
52
|
+
var data = null;
|
|
53
|
+
try { data = res.data; } catch (_) { /* empty / non-JSON */ }
|
|
54
|
+
if (!res.ok) { var err = new Error(path + ' → ' + res.status); err.status = res.status; err.data = data || {}; throw err; }
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The R01/ADR-0116 envelope is `{ error: { code, message } }` — nested, the
|
|
59
|
+
// same trap task-detail.js's errMessage() guards against.
|
|
60
|
+
function errMessage(err) {
|
|
61
|
+
var d = err && err.data;
|
|
62
|
+
var e = d && d.error;
|
|
63
|
+
if (e && typeof e === 'object' && e.message) return e.message;
|
|
64
|
+
return (d && d.message) || '';
|
|
65
|
+
}
|
|
66
|
+
// Honest copy for a refusal — never a silent no-op (the done-when, verbatim).
|
|
67
|
+
// Generic: prefers the server's own message, falling back to the caller's.
|
|
68
|
+
// Filing has NO rank floor, so a 403 there is never "you need Metic+" — that
|
|
69
|
+
// reading belongs only to the two calls that actually gate on blocker.review.
|
|
70
|
+
function refusalMessage(err, fallback) {
|
|
71
|
+
return errMessage(err) || fallback;
|
|
72
|
+
}
|
|
73
|
+
// The reviewer-gated calls' version: a 403 on resolve/link IS the
|
|
74
|
+
// blocker.review floor, so it gets the specific message rather than
|
|
75
|
+
// whatever (or nothing) the server's envelope happened to say.
|
|
76
|
+
function reviewerRefusalMessage(err, fallback) {
|
|
77
|
+
if (err && err.status === 403) return 'You need Metic+ access to do that.';
|
|
78
|
+
return refusalMessage(err, fallback);
|
|
79
|
+
}
|
|
25
80
|
|
|
26
81
|
var taskHref = function (id) {
|
|
27
82
|
return window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : ('/task/' + encodeURIComponent(id));
|
|
@@ -29,6 +84,17 @@
|
|
|
29
84
|
var blockerHref = function (id) { return '#/blocker/' + encodeURIComponent(id); };
|
|
30
85
|
|
|
31
86
|
var all = [];
|
|
87
|
+
// Which rank a reviewer action (Resolve / Link) needs — mirrors the server's
|
|
88
|
+
// blocker.review floor (metic+archon), cosmetically only; the server holds
|
|
89
|
+
// the actual gate regardless of what this variable says.
|
|
90
|
+
var viewer = { rank: null };
|
|
91
|
+
var isReviewer = function () { return viewer.rank === 'metic' || viewer.rank === 'archon'; };
|
|
92
|
+
|
|
93
|
+
// Which row (if any) has an inline action form open, and which form.
|
|
94
|
+
// { id, mode: 'resolve' | 'link' } | null — a row's OWN form, not a global
|
|
95
|
+
// one, so opening a second row's form closes whichever was open (one at a
|
|
96
|
+
// time keeps the list from growing a form per row on a long queue).
|
|
97
|
+
var openRow = null;
|
|
32
98
|
|
|
33
99
|
function rowHtml(b) {
|
|
34
100
|
var facts = lib.blockerFacts(b, {
|
|
@@ -38,12 +104,46 @@
|
|
|
38
104
|
blockerHref: blockerHref,
|
|
39
105
|
taskHref: taskHref,
|
|
40
106
|
});
|
|
107
|
+
var right = '';
|
|
108
|
+
if (isReviewer()) {
|
|
109
|
+
var id = String(b.id);
|
|
110
|
+
if (openRow && String(openRow.id) === id && openRow.mode === 'resolve') {
|
|
111
|
+
right = '<div class="ov-row__right"><form class="td-form" data-resolve-form="' + lib.esc(id) + '">'
|
|
112
|
+
+ '<label class="td-field td-field--wide"><span>Resolution note (optional)</span>'
|
|
113
|
+
+ '<input type="text" name="note" maxlength="1000" placeholder="what got un-stuck"></label>'
|
|
114
|
+
+ '<div class="td-actions">'
|
|
115
|
+
+ '<button type="submit" class="td-btn td-btn--primary" data-submit>Resolve</button>'
|
|
116
|
+
+ '<button type="button" class="td-btn" data-row-cancel>Cancel</button>'
|
|
117
|
+
+ '</div><p class="td-action-note" data-err></p></form></div>';
|
|
118
|
+
} else if (openRow && String(openRow.id) === id && openRow.mode === 'link') {
|
|
119
|
+
right = '<div class="ov-row__right"><form class="td-form" data-link-form="' + lib.esc(id) + '">'
|
|
120
|
+
+ '<label class="td-field td-field--wide"><span>Task #</span>'
|
|
121
|
+
+ '<input type="number" name="taskId" min="1" required placeholder="e.g. 1002980"></label>'
|
|
122
|
+
+ '<div class="td-actions">'
|
|
123
|
+
+ '<button type="submit" class="td-btn td-btn--primary" data-submit>Link</button>'
|
|
124
|
+
+ '<button type="button" class="td-btn" data-row-cancel>Cancel</button>'
|
|
125
|
+
+ '</div><p class="td-action-note" data-err></p></form></div>';
|
|
126
|
+
} else {
|
|
127
|
+
right = '<div class="ov-row__right"><div class="ov-row__actions">'
|
|
128
|
+
+ '<button type="button" class="btn-ghost" data-resolve-open="' + lib.esc(id) + '">Resolve</button>'
|
|
129
|
+
+ '<button type="button" class="btn-ghost" data-link-open="' + lib.esc(id) + '">Link a task</button>'
|
|
130
|
+
+ '</div></div>';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Plain .ov-row, never --lead: this row has no LEAD column (a face, as
|
|
134
|
+
// collab's rows have) — it is .ov-row__main + .ov-row__right, exactly the
|
|
135
|
+
// shape .ov-row's own grid (minmax(0,1fr) auto) already handles correctly,
|
|
136
|
+
// both at full width and under the family's ≤640px collapse. --lead's
|
|
137
|
+
// three-column grid (auto minmax(0,1fr) auto) would misread the actions
|
|
138
|
+
// column as the row's middle content and pin it beside the text on a
|
|
139
|
+
// phone instead of dropping it beneath.
|
|
41
140
|
return '<li class="ov-row" data-id="' + lib.esc(String(b.id)) + '">'
|
|
42
141
|
+ '<div class="ov-row__main">'
|
|
43
142
|
+ facts.titleHtml
|
|
44
143
|
+ facts.noteHtml
|
|
45
144
|
+ '<div class="ov-row__facts">' + facts.factsHtml + '</div>'
|
|
46
145
|
+ '</div>'
|
|
146
|
+
+ right
|
|
47
147
|
+ '</li>';
|
|
48
148
|
}
|
|
49
149
|
|
|
@@ -57,6 +157,10 @@
|
|
|
57
157
|
: kit.emptyStateHtml('Nothing is stuck on the owner right now.');
|
|
58
158
|
}
|
|
59
159
|
|
|
160
|
+
function reload() {
|
|
161
|
+
return fetchBlockers().catch(function () { toast('Couldn’t refresh the queue — try reloading the page.'); });
|
|
162
|
+
}
|
|
163
|
+
|
|
60
164
|
function mountBlockers(rows) {
|
|
61
165
|
all = Array.isArray(rows) ? rows : [];
|
|
62
166
|
if (countPill) { countPill.textContent = String(all.length); countPill.hidden = all.length === 0; }
|
|
@@ -78,11 +182,172 @@
|
|
|
78
182
|
body.textContent = msg;
|
|
79
183
|
}
|
|
80
184
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
185
|
+
function fetchBlockers() {
|
|
186
|
+
return fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
|
|
187
|
+
.then(function (r) {
|
|
188
|
+
if (!r.ok) throw new Error('http ' + r.status);
|
|
189
|
+
return r.json();
|
|
190
|
+
})
|
|
191
|
+
.then(function (data) { mountBlockers(data && data.blockers); });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- filing (any signed-in builder) ----------------------------------------
|
|
195
|
+
|
|
196
|
+
function fileFormHtml() {
|
|
197
|
+
return '<div class="td-actions" id="blockers-file-toggle">'
|
|
198
|
+
+ '<button type="button" class="btn-ghost" id="blockers-file-open">+ File a blocker</button>'
|
|
199
|
+
+ '</div>'
|
|
200
|
+
+ '<form class="td-form" id="blockers-file-form" hidden>'
|
|
201
|
+
+ '<label class="td-field td-field--wide"><span>Title</span>'
|
|
202
|
+
+ '<input type="text" id="bf-title" maxlength="200" required placeholder="what needs the owner’s decision"></label>'
|
|
203
|
+
+ '<label class="td-field td-field--wide"><span>What’s stuck (optional)</span>'
|
|
204
|
+
+ '<input type="text" id="bf-note" maxlength="2000" placeholder="a sentence of context"></label>'
|
|
205
|
+
+ '<label class="td-field"><span>Blocking task # (optional)</span>'
|
|
206
|
+
+ '<input type="number" id="bf-task-id" min="1" placeholder="e.g. 1002980"></label>'
|
|
207
|
+
+ '<div class="td-actions">'
|
|
208
|
+
+ '<button type="submit" class="td-btn td-btn--primary" id="bf-submit">File it</button>'
|
|
209
|
+
+ '<button type="button" class="td-btn" id="bf-cancel">Cancel</button>'
|
|
210
|
+
+ '</div><p class="td-action-note" id="bf-err"></p>'
|
|
211
|
+
// The one control open to a non-reviewer that the review floor still
|
|
212
|
+
// touches: filing succeeds regardless, but linking needs blocker.review,
|
|
213
|
+
// and a viewer below it is told so BEFORE they submit — not left to
|
|
214
|
+
// discover it from a refusal.
|
|
215
|
+
+ (isReviewer() ? '' : '<p class="td-form-hint">Filing is open to anyone. Linking to a task needs Metic+ — if you name one and aren’t, the blocker still files and a Metic+ can link it.</p>')
|
|
216
|
+
+ '</form>';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function wireFileForm() {
|
|
220
|
+
var openBtn = document.getElementById('blockers-file-open');
|
|
221
|
+
var form = document.getElementById('blockers-file-form');
|
|
222
|
+
var cancelBtn = document.getElementById('bf-cancel');
|
|
223
|
+
var errEl = document.getElementById('bf-err');
|
|
224
|
+
if (!openBtn || !form) return;
|
|
225
|
+
openBtn.addEventListener('click', function () {
|
|
226
|
+
openBtn.parentElement.hidden = true;
|
|
227
|
+
form.hidden = false;
|
|
228
|
+
document.getElementById('bf-title').focus();
|
|
229
|
+
});
|
|
230
|
+
cancelBtn.addEventListener('click', function () {
|
|
231
|
+
form.hidden = true;
|
|
232
|
+
openBtn.parentElement.hidden = false;
|
|
233
|
+
form.reset();
|
|
234
|
+
errEl.textContent = '';
|
|
235
|
+
});
|
|
236
|
+
form.addEventListener('submit', async function (ev) {
|
|
237
|
+
ev.preventDefault();
|
|
238
|
+
errEl.textContent = '';
|
|
239
|
+
var title = document.getElementById('bf-title').value.trim();
|
|
240
|
+
var note = document.getElementById('bf-note').value.trim();
|
|
241
|
+
var taskIdRaw = document.getElementById('bf-task-id').value.trim();
|
|
242
|
+
if (!title) { errEl.textContent = 'A title is required.'; return; }
|
|
243
|
+
var submitBtn = document.getElementById('bf-submit');
|
|
244
|
+
submitBtn.disabled = true;
|
|
245
|
+
try {
|
|
246
|
+
var res = await writeJSON('POST', API + '/blockers', { title: title, body_md: note });
|
|
247
|
+
var blocker = res && res.blocker;
|
|
248
|
+
if (res && res.skipped) {
|
|
249
|
+
// A duplicate (source, source_ref) skip returns no blocker id at
|
|
250
|
+
// all, so a typed task # was NEVER attempted — say so rather than
|
|
251
|
+
// silently dropping half the request the builder asked for.
|
|
252
|
+
toast(taskIdRaw
|
|
253
|
+
? 'That blocker was already filed. It was NOT linked to task #' + taskIdRaw + ' — open the existing blocker to link it.'
|
|
254
|
+
: 'That blocker was already filed.');
|
|
255
|
+
} else if (blocker && taskIdRaw) {
|
|
256
|
+
var taskId = Number(taskIdRaw);
|
|
257
|
+
try {
|
|
258
|
+
await writeJSON('POST', API + '/blockers/' + encodeURIComponent(blocker.id) + '/link', { task_id: taskId });
|
|
259
|
+
toast('Blocker #' + blocker.id + ' filed and linked to task #' + taskId + '.');
|
|
260
|
+
} catch (linkErr) {
|
|
261
|
+
// Honest copy, not a silent no-op: the filing DID succeed, and the
|
|
262
|
+
// reader is told exactly what still needs a Metic+.
|
|
263
|
+
toast('Blocker #' + blocker.id + ' filed. ' + reviewerRefusalMessage(linkErr, 'Could not link it to task #' + taskId + ' — a Metic+ can do that from this page.'));
|
|
264
|
+
}
|
|
265
|
+
} else if (blocker) {
|
|
266
|
+
toast('Blocker #' + blocker.id + ' filed.');
|
|
267
|
+
} else {
|
|
268
|
+
toast('Filed.');
|
|
269
|
+
}
|
|
270
|
+
form.reset();
|
|
271
|
+
form.hidden = true;
|
|
272
|
+
document.getElementById('blockers-file-toggle').hidden = false;
|
|
273
|
+
await reload();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
errEl.textContent = refusalMessage(err, 'Could not file that blocker. Try again.');
|
|
276
|
+
} finally {
|
|
277
|
+
submitBtn.disabled = false;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- resolve / link (blocker.review — metic+archon; cosmetic mirror only) --
|
|
283
|
+
|
|
284
|
+
function closeRowForms() { openRow = null; rerender(); }
|
|
285
|
+
|
|
286
|
+
body.addEventListener('click', function (e) {
|
|
287
|
+
var resolveOpen = e.target.closest('[data-resolve-open]');
|
|
288
|
+
if (resolveOpen) { openRow = { id: resolveOpen.dataset.resolveOpen, mode: 'resolve' }; rerender(); return; }
|
|
289
|
+
var linkOpen = e.target.closest('[data-link-open]');
|
|
290
|
+
if (linkOpen) { openRow = { id: linkOpen.dataset.linkOpen, mode: 'link' }; rerender(); return; }
|
|
291
|
+
if (e.target.closest('[data-row-cancel]')) { closeRowForms(); }
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
body.addEventListener('submit', async function (e) {
|
|
295
|
+
var resolveForm = e.target.closest('[data-resolve-form]');
|
|
296
|
+
var linkForm = e.target.closest('[data-link-form]');
|
|
297
|
+
if (!resolveForm && !linkForm) return;
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
var form = resolveForm || linkForm;
|
|
300
|
+
var id = (resolveForm || linkForm).getAttribute(resolveForm ? 'data-resolve-form' : 'data-link-form');
|
|
301
|
+
var errEl = form.querySelector('[data-err]');
|
|
302
|
+
var submitBtn = form.querySelector('[data-submit]');
|
|
303
|
+
submitBtn.disabled = true;
|
|
304
|
+
try {
|
|
305
|
+
if (resolveForm) {
|
|
306
|
+
var note = form.querySelector('[name="note"]').value.trim();
|
|
307
|
+
await writeJSON('POST', API + '/blockers/' + encodeURIComponent(id) + '/resolve', { resolution_note: note || null });
|
|
308
|
+
toast('Blocker #' + id + ' resolved.');
|
|
309
|
+
} else {
|
|
310
|
+
var taskId = Number(form.querySelector('[name="taskId"]').value);
|
|
311
|
+
await writeJSON('POST', API + '/blockers/' + encodeURIComponent(id) + '/link', { task_id: taskId });
|
|
312
|
+
toast('Blocker #' + id + ' linked to task #' + taskId + '.');
|
|
313
|
+
}
|
|
314
|
+
openRow = null;
|
|
315
|
+
await reload();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
errEl.textContent = reviewerRefusalMessage(err, 'Could not do that. Try again.');
|
|
318
|
+
submitBtn.disabled = false;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---- boot -------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
async function loadViewer() {
|
|
325
|
+
try {
|
|
326
|
+
var res = await api.request('GET', API + '/me');
|
|
327
|
+
if (res.ok && res.data && res.data.builder) {
|
|
328
|
+
viewer = { rank: String(res.data.builder.rank || '').toLowerCase() };
|
|
329
|
+
}
|
|
330
|
+
} catch (_) { /* signed out or offline — viewer stays { rank: null }, isReviewer() false */ }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
loadViewer().then(function () {
|
|
334
|
+
if (fileArea) { fileArea.innerHTML = fileFormHtml(); wireFileForm(); }
|
|
335
|
+
// Boot race: GET /me and GET /blockers fire together, and rowHtml() reads
|
|
336
|
+
// isReviewer() at RENDER time. If the queue rendered first (the common
|
|
337
|
+
// case — /me is Metic+-gated work the server does more of), it rendered
|
|
338
|
+
// with isReviewer() still false and nothing re-draws it once the real
|
|
339
|
+
// rank lands — a Metic/Archon would see no Resolve/Link buttons until an
|
|
340
|
+
// unrelated re-render (a pager click, a file, an otb:blocker-resolved).
|
|
341
|
+
// Re-render now that the viewer is known, but only if the list actually
|
|
342
|
+
// exists yet (fetchBlockers may still be in flight — it will render
|
|
343
|
+
// correctly on its own the moment it resolves).
|
|
344
|
+
if (listEl) rerender();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
fetchBlockers().catch(function () { notice('Couldn’t load the blocker queue right now.'); });
|
|
348
|
+
|
|
349
|
+
// A row's title opens the #/blocker/N overlay ON TOP of this page
|
|
350
|
+
// (task-detail.js), whose own Resolve panel fires this — refresh the queue
|
|
351
|
+
// in place rather than leaving a resolved row sitting there.
|
|
352
|
+
window.addEventListener('otb:blocker-resolved', reload);
|
|
88
353
|
}());
|
|
@@ -530,4 +530,10 @@
|
|
|
530
530
|
id: 'attention-scroll', label: 'Needs your attention', order: 20, mount, core: true,
|
|
531
531
|
labelledby: 'attention-h', view: 'home', zone: 'home-main', hiddenByDefault: true,
|
|
532
532
|
});
|
|
533
|
+
|
|
534
|
+
// task 1003456: a blocker resolved through the #/blocker/N overlay (which
|
|
535
|
+
// this slice's own row titles open into) should not sit in the inbox until
|
|
536
|
+
// the next full load — the otb:idea-triaged precedent, applied to the
|
|
537
|
+
// sibling event task-detail.js's blockerPanel fires.
|
|
538
|
+
window.addEventListener('otb:blocker-resolved', loadBlockers);
|
|
533
539
|
})();
|
|
@@ -153,9 +153,97 @@
|
|
|
153
153
|
if (kind === 'idea' && r.triage_note) {
|
|
154
154
|
nodes.push(h('p', 'deeplink-card__note', 'Triage note: ' + r.triage_note));
|
|
155
155
|
}
|
|
156
|
+
|
|
157
|
+
// task 1003456 (goal 1000089): the write half of the blocker queue. The
|
|
158
|
+
// overlay is the SAME summary→detail relationship the idea triage panel
|
|
159
|
+
// above already has with the /idea page — a compact card, not the full
|
|
160
|
+
// queue — so it carries only Resolve (the one-tap "this is done" verb);
|
|
161
|
+
// Link a task lives on the fuller /blockers page, which has room for the
|
|
162
|
+
// task-id field and the queue to confirm against. Rendered only for
|
|
163
|
+
// metic/archon, the COSMETIC mirror of the real blocker.review floor —
|
|
164
|
+
// "AUTHORITY IS UNCHANGED... the hall must add none of its own" — the
|
|
165
|
+
// server enforces the same gate regardless, exactly the idea panel's rule.
|
|
166
|
+
if (kind === 'blocker' && r.status === 'open' && (rank === 'metic' || rank === 'archon')) {
|
|
167
|
+
nodes.push(blockerPanel(r));
|
|
168
|
+
}
|
|
169
|
+
// A resolved blocker says what closed it — otherwise the status word has
|
|
170
|
+
// no story behind it (the idea triage_note precedent, two blocks up).
|
|
171
|
+
if (kind === 'blocker' && r.status === 'resolved') {
|
|
172
|
+
nodes.push(h('p', 'deeplink-card__note', 'Resolved' + (r.resolution_note ? ': ' + r.resolution_note : '.')));
|
|
173
|
+
}
|
|
156
174
|
return nodes;
|
|
157
175
|
}
|
|
158
176
|
|
|
177
|
+
// ---- the blocker resolve panel (task 1003456) ------------------------------
|
|
178
|
+
//
|
|
179
|
+
// One verb, one form — the renderDiscard shape below, at blocker scale.
|
|
180
|
+
function blockerPanel(blocker) {
|
|
181
|
+
var box = h('div', 'td-triage');
|
|
182
|
+
var mode = 'idle';
|
|
183
|
+
|
|
184
|
+
function note(text) {
|
|
185
|
+
var p = box.querySelector('[data-err]');
|
|
186
|
+
if (p) p.textContent = text || '';
|
|
187
|
+
}
|
|
188
|
+
function busy(on, submitLabel) {
|
|
189
|
+
box.querySelectorAll('button, input').forEach(function (el) { el.disabled = on; });
|
|
190
|
+
var submit = box.querySelector('[data-submit]');
|
|
191
|
+
if (submit) submit.textContent = on ? '…' : submitLabel;
|
|
192
|
+
}
|
|
193
|
+
function go(next) { mode = next; render(); }
|
|
194
|
+
|
|
195
|
+
function render() {
|
|
196
|
+
if (mode === 'idle') return renderIdle();
|
|
197
|
+
return renderResolve();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderIdle() {
|
|
201
|
+
box.innerHTML = '<div class="td-actions"><button type="button" class="td-btn td-btn--primary" data-go="resolve">Resolve</button></div>';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderResolve() {
|
|
205
|
+
box.innerHTML =
|
|
206
|
+
'<form class="td-form" data-form="resolve">'
|
|
207
|
+
+ '<label class="td-field td-field--wide"><span>Resolution note (optional)</span>'
|
|
208
|
+
+ '<input type="text" name="note" maxlength="1000" placeholder="what got un-stuck"></label>'
|
|
209
|
+
+ '<div class="td-actions">'
|
|
210
|
+
+ '<button type="submit" class="td-btn td-btn--primary" data-submit>Resolve</button>'
|
|
211
|
+
+ '<button type="button" class="td-btn" data-go="idle">Cancel</button>'
|
|
212
|
+
+ '</div><p class="td-action-note" data-err></p></form>';
|
|
213
|
+
box.querySelector('[data-form="resolve"]').addEventListener('submit', function (ev) {
|
|
214
|
+
ev.preventDefault();
|
|
215
|
+
submitResolve(ev.target);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function submitResolve(form) {
|
|
220
|
+
note('');
|
|
221
|
+
busy(true, 'Resolve');
|
|
222
|
+
try {
|
|
223
|
+
await postJSON(API + '/blockers/' + encodeURIComponent(blocker.id) + '/resolve', {
|
|
224
|
+
resolution_note: String(form.note.value || '').trim() || null,
|
|
225
|
+
});
|
|
226
|
+
toast('Blocker #' + blocker.id + ' resolved.');
|
|
227
|
+
try {
|
|
228
|
+
window.dispatchEvent(new CustomEvent('otb:blocker-resolved', { detail: { id: blocker.id } }));
|
|
229
|
+
} catch (_) { /* CustomEvent unsupported — the refresh is a nicety */ }
|
|
230
|
+
clearLink();
|
|
231
|
+
} catch (err) {
|
|
232
|
+
// Honest copy, never a silent no-op — the done-when, verbatim.
|
|
233
|
+
var msg = (err && err.status === 403) ? 'You need Metic+ access to resolve blockers.' : (errMessage(err) || 'Could not resolve that blocker. Try again.');
|
|
234
|
+
busy(false, 'Resolve');
|
|
235
|
+
note(msg);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
box.addEventListener('click', function (e) {
|
|
240
|
+
var btn = e.target.closest('[data-go]');
|
|
241
|
+
if (btn) go(btn.dataset.go);
|
|
242
|
+
});
|
|
243
|
+
render();
|
|
244
|
+
return box;
|
|
245
|
+
}
|
|
246
|
+
|
|
159
247
|
// ---- the triage panel (task 1002447) ---------------------------------------
|
|
160
248
|
//
|
|
161
249
|
// Promote → POST /tasks, then PATCH the idea promoted + linked to it.
|
|
@@ -633,10 +721,11 @@
|
|
|
633
721
|
render({ loading: true, kind: kind, id: id });
|
|
634
722
|
try {
|
|
635
723
|
var ref = await fetchRef(kind, id);
|
|
636
|
-
// task 759: probe the viewer's rank
|
|
637
|
-
//
|
|
724
|
+
// task 759: probe the viewer's rank for an idea OR a blocker (task
|
|
725
|
+
// 1003456 added the latter) so the overlay can offer Metic+ triage /
|
|
726
|
+
// resolve. Best-effort — a failure just hides the button.
|
|
638
727
|
var viewer = null;
|
|
639
|
-
if (kind === 'idea' && ref) {
|
|
728
|
+
if ((kind === 'idea' || kind === 'blocker') && ref) {
|
|
640
729
|
try {
|
|
641
730
|
var me = await getJSON(API + '/me', { softAuth: true });
|
|
642
731
|
viewer = (me && me.builder) ? me.builder : null;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.865",
|
|
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.865",
|
|
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.865",
|
|
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
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.865'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/blockers_hall.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const GOAL_INBOX_SRC = readFileSync(join(HALL, 'goal-inbox.js'), 'utf8');
|
|
|
27
27
|
const BLOCKERS_JS_SRC = readFileSync(join(HALL, 'blockers.js'), 'utf8');
|
|
28
28
|
const BLOCKERS_HTML_SRC = readFileSync(join(HALL, 'blockers.html'), 'utf8');
|
|
29
29
|
const INDEX_HTML_SRC = readFileSync(join(HALL, 'index.html'), 'utf8');
|
|
30
|
+
const TASK_DETAIL_SRC = readFileSync(join(HALL, 'task-detail.js'), 'utf8');
|
|
30
31
|
|
|
31
32
|
// ---- the attention-inbox slice (goal-inbox.js) -----------------------------
|
|
32
33
|
|
|
@@ -105,3 +106,94 @@ test('every title link opens the SAME #/blocker/N overlay task-detail.js already
|
|
|
105
106
|
assert.match(GOAL_INBOX_SRC, /`#\/blocker\/\$\{encodeURIComponent\(id\)\}`/);
|
|
106
107
|
assert.match(BLOCKERS_JS_SRC, /'#\/blocker\/' \+ encodeURIComponent\(id\)/);
|
|
107
108
|
});
|
|
109
|
+
|
|
110
|
+
// ---- task 1003456: the write half — file, resolve, link -------------------
|
|
111
|
+
//
|
|
112
|
+
// POST /blockers, POST /blockers/:id/resolve and POST /blockers/:id/link
|
|
113
|
+
// already carry their own server-side rank floors (any-builder / blocker.review
|
|
114
|
+
// respectively), proven by the fitness route-rank-check gate — not re-proven
|
|
115
|
+
// here. What this pins is the CLIENT side of the done-when: filing/resolving/
|
|
116
|
+
// linking are actually wired to those routes, resolve/link mirror the real
|
|
117
|
+
// floor cosmetically (never invent a stricter or looser one), filing itself
|
|
118
|
+
// carries no such gate, and a refusal renders as honest copy rather than a
|
|
119
|
+
// silent no-op.
|
|
120
|
+
|
|
121
|
+
test('blockers.js files a blocker against the real POST /blockers route', () => {
|
|
122
|
+
assert.match(BLOCKERS_JS_SRC, /writeJSON\('POST', API \+ '\/blockers', \{ title: title, body_md: note \}\)/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('blockers.js resolves against the real POST /blockers/:id/resolve route', () => {
|
|
126
|
+
assert.match(BLOCKERS_JS_SRC, /API \+ '\/blockers\/' \+ encodeURIComponent\(id\) \+ '\/resolve'/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('blockers.js links against the real POST /blockers/:id/link route, from both the file form and a row', () => {
|
|
130
|
+
const matches = BLOCKERS_JS_SRC.match(/\/blockers\/'\s*\+\s*encodeURIComponent\([^)]+\)\s*\+\s*'\/link'/g) || [];
|
|
131
|
+
assert.ok(matches.length >= 2, `expected the file-form link attempt AND the per-row link form, found ${matches.length}`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('filing has no rank gate — any signed-in builder sees the File button, mirroring the any-builder route', () => {
|
|
135
|
+
assert.match(BLOCKERS_JS_SRC, /fileArea\.innerHTML = fileFormHtml\(\); wireFileForm\(\);/,
|
|
136
|
+
'the file form is mounted unconditionally, not behind an isReviewer()/rank check');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('resolve/link controls mirror the SAME metic-or-archon floor the idea triage panel already uses — no floor of its own', () => {
|
|
140
|
+
assert.match(BLOCKERS_JS_SRC, /isReviewer\s*=\s*function\s*\(\)\s*\{\s*return viewer\.rank === 'metic' \|\| viewer\.rank === 'archon';\s*\}/);
|
|
141
|
+
assert.match(TASK_DETAIL_SRC, /kind === 'blocker' && r\.status === 'open' && \(rank === 'metic' \|\| rank === 'archon'\)/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a 403 on resolve/link/file-link renders HONEST copy, not a silent no-op (the done-when, verbatim)', () => {
|
|
145
|
+
assert.match(BLOCKERS_JS_SRC, /err && err\.status === 403.*You need Metic\+ access to do that\./);
|
|
146
|
+
// The file-then-link flow specifically: a filer without blocker.review still
|
|
147
|
+
// gets told the filing succeeded AND that linking needs a Metic+ — never a
|
|
148
|
+
// swallowed catch that leaves them guessing whether anything happened.
|
|
149
|
+
assert.match(BLOCKERS_JS_SRC, /catch \(linkErr\) \{[\s\S]*?reviewerRefusalMessage\(linkErr,/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the reviewer-only Metic+ message is scoped to resolve/link, never applied to filing itself (which has no rank floor)', () => {
|
|
153
|
+
// Two distinct helpers: a bare 403 on POST /blockers (filing) is NOT a
|
|
154
|
+
// blocker.review refusal and must not be read as one.
|
|
155
|
+
assert.match(BLOCKERS_JS_SRC, /function refusalMessage\(err, fallback\) \{\s*return errMessage\(err\) \|\| fallback;\s*\}/);
|
|
156
|
+
assert.match(BLOCKERS_JS_SRC, /function reviewerRefusalMessage\(err, fallback\)/);
|
|
157
|
+
assert.match(BLOCKERS_JS_SRC, /errEl\.textContent = refusalMessage\(err, 'Could not file that blocker\. Try again\.'\);/,
|
|
158
|
+
'the FILE form\'s own catch uses the generic helper, not the reviewer-specific one');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a deduped file with a task # typed says the link was NOT attempted, rather than a silent no-op', () => {
|
|
162
|
+
assert.match(BLOCKERS_JS_SRC, /taskIdRaw\s*\n\s*\?\s*'That blocker was already filed\. It was NOT linked/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a 401 on any blockers.js write redirects to sign-in, same as every other hall write', () => {
|
|
166
|
+
assert.match(BLOCKERS_JS_SRC, /res\.status === 401.*window\.location\.assign\(API \+ '\/auth\/web\/start'\)/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('task-detail.js\'s blocker resolve panel posts to the real route and reports a refusal honestly', () => {
|
|
170
|
+
assert.match(TASK_DETAIL_SRC, /function blockerPanel\(blocker\)/);
|
|
171
|
+
assert.match(TASK_DETAIL_SRC, /postJSON\(API \+ '\/blockers\/' \+ encodeURIComponent\(blocker\.id\) \+ '\/resolve'/);
|
|
172
|
+
assert.match(TASK_DETAIL_SRC, /function submitResolve[\s\S]*?err && err\.status === 403[\s\S]*?You need Metic\+ access to resolve blockers\./);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('resolving through the overlay tells the two OTHER blocker surfaces to refresh, so a resolved row does not sit there stale', () => {
|
|
176
|
+
assert.match(TASK_DETAIL_SRC, /new CustomEvent\('otb:blocker-resolved', \{ detail: \{ id: blocker\.id \} \}\)/);
|
|
177
|
+
assert.match(GOAL_INBOX_SRC, /window\.addEventListener\('otb:blocker-resolved', loadBlockers\)/);
|
|
178
|
+
assert.match(BLOCKERS_JS_SRC, /window\.addEventListener\('otb:blocker-resolved', reload\)/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('blockers.html carries the file-form mount, above the list', () => {
|
|
182
|
+
const fileAt = BLOCKERS_HTML_SRC.indexOf('id="blockers-file-area"');
|
|
183
|
+
const bodyAt = BLOCKERS_HTML_SRC.indexOf('id="blockers-body"');
|
|
184
|
+
assert.ok(fileAt > -1 && bodyAt > -1);
|
|
185
|
+
assert.ok(fileAt < bodyAt, 'filing sits above the queue it adds to');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a row never carries .ov-row--lead — this row has no lead/face column, and oversight.css\'s --lead grid would misplace the actions column on a phone', () => {
|
|
189
|
+
assert.doesNotMatch(BLOCKERS_JS_SRC, /ov-row--lead/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('viewer carries no unread field — dead state is not left lying around', () => {
|
|
193
|
+
assert.doesNotMatch(BLOCKERS_JS_SRC, /\bauthed\b/, 'viewer.authed was written and never read');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a resolved blocker on the overlay shows what closed it, the idea triage_note precedent', () => {
|
|
197
|
+
assert.match(TASK_DETAIL_SRC, /kind === 'blocker' && r\.status === 'resolved'/);
|
|
198
|
+
assert.match(TASK_DETAIL_SRC, /'Resolved' \+ \(r\.resolution_note \? ': ' \+ r\.resolution_note : '\.'\)/);
|
|
199
|
+
});
|