@bongos/core 1.19.299 → 1.19.300
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 +37 -17
- package/docs/copy-inventory.md +36 -20
- package/docs/copy-registry.json +196 -22
- package/docs/file-map.md +1 -1
- package/modules/hall-ui/public/idea.css +74 -0
- package/modules/hall-ui/public/idea.html +55 -0
- package/modules/hall-ui/public/idea.js +203 -0
- package/modules/hall-ui/public/ideas.js +4 -1
- package/modules/hall-ui/public/task-detail.js +13 -0
- package/modules/ideas/inbox.js +14 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +7 -1
- package/src/module-api.js +3 -1
- package/tests/idea_detail_page.mjs +271 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// modules/hall-ui/public/idea.js — the idea-detail PAGE (/idea/:id).
|
|
2
|
+
// Task 1002929 / BV1.R153 of goal 1000062 (three grades of idea).
|
|
3
|
+
//
|
|
4
|
+
// A real page rather than a wider modal, following task 751: that task replaced
|
|
5
|
+
// the #/task/N overlay with /task/:id so a record got a shareable URL and working
|
|
6
|
+
// back/forward. A Full Idea is five prose answers — the thing an overlay is worst
|
|
7
|
+
// at — so it earns the same treatment.
|
|
8
|
+
//
|
|
9
|
+
// THE OVERLAY IS NOT REPLACED. task-detail.js keeps owning #/idea/N, because ~320
|
|
10
|
+
// of its ~650 lines are the Metic+ triage panel (promote → POST /tasks, merge,
|
|
11
|
+
// discard, with goal hints and version pickers). Moving that machinery is its own
|
|
12
|
+
// task; breaking a shipped triage flow to make a read prettier would be a bad
|
|
13
|
+
// trade. So the overlay stays the fast peek and links here, and this page stays a
|
|
14
|
+
// READ — the summary→detail relationship the hall already has between the Work
|
|
15
|
+
// board and /task/:id.
|
|
16
|
+
//
|
|
17
|
+
// WHAT THE GRADE DECIDES. The grade is not decoration on this page, it selects
|
|
18
|
+
// what the page contains:
|
|
19
|
+
// full → the five answers, each under its own question, plus the score
|
|
20
|
+
// spark → the body, plus "someone is invited to develop this"
|
|
21
|
+
// quick → the body, and NOTHING about the five fields
|
|
22
|
+
// The done-when names that last case explicitly: a quick idea must render with no
|
|
23
|
+
// empty five-field skeleton. Five headings over five dashes is not an empty
|
|
24
|
+
// state, it is a page telling a builder they did something wrong by filing a
|
|
25
|
+
// quick idea — which is the opposite of what a quick idea is for.
|
|
26
|
+
//
|
|
27
|
+
// The five QUESTIONS come from GET /inbox/template, not from this file, for the
|
|
28
|
+
// same reason the composer's do (R152): the labels live in the R141 declaration,
|
|
29
|
+
// and a copy here would silently ignore a per-project template.
|
|
30
|
+
|
|
31
|
+
(() => {
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const API = '/api/gds';
|
|
35
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
36
|
+
const { escapeHtml, $, fmtDate } = window.OTB;
|
|
37
|
+
|
|
38
|
+
const ideaId = () => {
|
|
39
|
+
const m = /\/idea\/(\d+)/.exec(location.pathname);
|
|
40
|
+
return m ? m[1] : null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// pieces
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// Colour AND word. A badge distinguished only by hue tells a colour-blind
|
|
50
|
+
// reader nothing, and this badge decides how to read the whole page.
|
|
51
|
+
const GRADE_WORD = { full: 'Full Idea', spark: 'Big Idea Spark', quick: 'Quick idea' };
|
|
52
|
+
|
|
53
|
+
function gradeBadge(grade) {
|
|
54
|
+
const g = GRADE_WORD[grade] ? grade : 'quick';
|
|
55
|
+
return `<span class="ig-grade ig-grade--${escapeHtml(g)}">${escapeHtml(GRADE_WORD[g])}</span>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scoreRow(score) {
|
|
59
|
+
if (!Number.isInteger(score)) {
|
|
60
|
+
// A Full Idea with no score is a real state (R142 returns null for
|
|
61
|
+
// anything it cannot score, and that is stored as-is). Say so rather than
|
|
62
|
+
// drawing an empty bar that reads as zero.
|
|
63
|
+
return '<p class="ig-empty">Not scored.</p>';
|
|
64
|
+
}
|
|
65
|
+
const band = score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin';
|
|
66
|
+
return `
|
|
67
|
+
<div class="ig-score">
|
|
68
|
+
<span class="ig-score__track"><span class="ig-score__fill" data-band="${band}" style="width:${score}%"></span></span>
|
|
69
|
+
<span class="ig-score__n">${score}/100 complete</span>
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Who had the thought, and who wrote it out. Two rows when they are two people
|
|
74
|
+
// — R146's whole point is that the author survives being developed by someone
|
|
75
|
+
// else, so this never collapses them into one line.
|
|
76
|
+
function credit(idea) {
|
|
77
|
+
const who = (login, id) => (login ? `@${escapeHtml(login)}` : (id ? `builder ${escapeHtml(String(id))}` : 'someone'));
|
|
78
|
+
const rows = [];
|
|
79
|
+
rows.push(`
|
|
80
|
+
<div class="ig-credit__row">
|
|
81
|
+
<span class="ig-credit__who">${who(idea.filed_by_login, idea.captured_by)}</span>
|
|
82
|
+
<span class="ig-credit__role">had the thought${idea.captured_at ? ` · ${escapeHtml(fmtDate(idea.captured_at))}` : ''}</span>
|
|
83
|
+
</div>`);
|
|
84
|
+
if (idea.developed_by) {
|
|
85
|
+
const same = String(idea.developed_by) === String(idea.captured_by);
|
|
86
|
+
rows.push(`
|
|
87
|
+
<div class="ig-credit__row">
|
|
88
|
+
<span class="ig-credit__who">${who(idea.developed_by_login, idea.developed_by)}</span>
|
|
89
|
+
<span class="ig-credit__role">${same ? 'developed it themselves' : 'developed it into a Full Idea'}${idea.developed_at ? ` · ${escapeHtml(fmtDate(idea.developed_at))}` : ''}</span>
|
|
90
|
+
</div>`);
|
|
91
|
+
}
|
|
92
|
+
return `<div class="ig-credit">${rows.join('')}</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function prose(text) {
|
|
96
|
+
const s = String(text == null ? '' : text).trim();
|
|
97
|
+
return s
|
|
98
|
+
? `<p class="ig-body">${escapeHtml(s)}</p>`
|
|
99
|
+
: '<p class="ig-body" data-empty="1">No description.</p>';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The five answers, in TEMPLATE order, each under its own question. Missing
|
|
103
|
+
// answers are marked rather than hidden: on a Full Idea a blank answer is
|
|
104
|
+
// information (it is why the score is what it is).
|
|
105
|
+
function answers(idea, template) {
|
|
106
|
+
const values = (idea.field_values && typeof idea.field_values === 'object') ? idea.field_values : {};
|
|
107
|
+
return `<div class="ig-answers">${template.fields.map((f) => {
|
|
108
|
+
const v = String(values[f.key] == null ? '' : values[f.key]).trim();
|
|
109
|
+
return `
|
|
110
|
+
<div class="ig-answer">
|
|
111
|
+
<p class="ig-answer__q">${escapeHtml(f.label)}</p>
|
|
112
|
+
<p class="ig-answer__a"${v ? '' : ' data-empty="1"'}>${v ? escapeHtml(v) : 'Not answered.'}</p>
|
|
113
|
+
</div>`;
|
|
114
|
+
}).join('')}</div>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function statusLine(idea) {
|
|
118
|
+
const bits = [];
|
|
119
|
+
bits.push(`<span><strong>${escapeHtml(idea.status || 'open')}</strong></span>`);
|
|
120
|
+
if (idea.kind) bits.push(`<span>${escapeHtml(idea.kind)}</span>`);
|
|
121
|
+
if (idea.promoted_to_task_id) {
|
|
122
|
+
bits.push(`<span>became <a href="${hallBase()}/task/${encodeURIComponent(idea.promoted_to_task_id)}">task ${escapeHtml(String(idea.promoted_to_task_id))}</a></span>`);
|
|
123
|
+
}
|
|
124
|
+
if (idea.merged_into_idea_id) {
|
|
125
|
+
bits.push(`<span>merged into <a href="${hallBase()}/idea/${encodeURIComponent(idea.merged_into_idea_id)}">idea ${escapeHtml(String(idea.merged_into_idea_id))}</a></span>`);
|
|
126
|
+
}
|
|
127
|
+
return `<p class="ig-meta">${bits.join('')}</p>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// render
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function render(idea, template) {
|
|
135
|
+
const grade = GRADE_WORD[idea.grade] ? idea.grade : 'quick';
|
|
136
|
+
const parts = [];
|
|
137
|
+
|
|
138
|
+
parts.push(`<div class="ig-head">${gradeBadge(grade)}</div>`);
|
|
139
|
+
parts.push(`<h1 class="ig-title" id="idea-h">${escapeHtml(idea.title || `Idea ${idea.id}`)}</h1>`);
|
|
140
|
+
parts.push(statusLine(idea));
|
|
141
|
+
parts.push(credit(idea));
|
|
142
|
+
|
|
143
|
+
if (grade === 'full') {
|
|
144
|
+
parts.push(scoreRow(idea.completeness_score));
|
|
145
|
+
if (template) {
|
|
146
|
+
parts.push(answers(idea, template));
|
|
147
|
+
} else {
|
|
148
|
+
// The template read failed. Show the raw answers rather than nothing —
|
|
149
|
+
// but WITHOUT inventing an order, since jsonb key order is not the
|
|
150
|
+
// template's order and pretending otherwise would misreport the idea.
|
|
151
|
+
parts.push('<p class="ig-empty">Could not load the question labels; showing the answers as stored.</p>');
|
|
152
|
+
parts.push(`<div class="ig-answers">${Object.keys(idea.field_values || {}).map((k) =>
|
|
153
|
+
`<div class="ig-answer"><p class="ig-answer__q">${escapeHtml(k)}</p><p class="ig-answer__a">${escapeHtml(String(idea.field_values[k] || ''))}</p></div>`
|
|
154
|
+
).join('')}</div>`);
|
|
155
|
+
}
|
|
156
|
+
// A Full Idea's body_md is the original quick-idea text it grew out of, when
|
|
157
|
+
// it had one. Shown after the answers, subordinate to them.
|
|
158
|
+
if (String(idea.body_md || '').trim()) {
|
|
159
|
+
parts.push(`<div class="ig-sub"><h2 class="ig-sub__h">As first filed</h2>${prose(idea.body_md)}</div>`);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// quick AND spark: the body, and NOT a five-field skeleton.
|
|
163
|
+
parts.push(prose(idea.body_md));
|
|
164
|
+
if (grade === 'spark') {
|
|
165
|
+
parts.push(`<p class="ig-note">Its author marked this as needing development — anyone can turn it into a Full Idea, and they keep the credit for having had it. <a href="${hallBase()}/ideas">Write one</a>.</p>`);
|
|
166
|
+
} else {
|
|
167
|
+
parts.push('<p class="ig-note">A quick idea is a thought filed fast. It is not an unfinished Full Idea — only its author can offer it up for development.</p>');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
$('#idea-body').innerHTML = parts.join('');
|
|
172
|
+
$('#idea-sub').textContent = `Idea ${idea.id}`;
|
|
173
|
+
document.title = `${idea.title || `Idea ${idea.id}`} — idea ${idea.id}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fail(msg) {
|
|
177
|
+
$('#idea-body').innerHTML = `<p class="deeplink-card__title" id="idea-h">${escapeHtml(msg)}</p>`;
|
|
178
|
+
$('#idea-sub').textContent = '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
const id = ideaId();
|
|
183
|
+
if (!id) return fail('No idea id in the address.');
|
|
184
|
+
|
|
185
|
+
const r = await api.inbox.getInboxId({ id });
|
|
186
|
+
if (r.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
187
|
+
if (r.status === 404) return fail(`No idea ${id}.`);
|
|
188
|
+
if (!r.ok || !r.data || !r.data.idea) return fail('Could not load this idea.');
|
|
189
|
+
const idea = r.data.idea;
|
|
190
|
+
|
|
191
|
+
// The labels are only needed for a Full Idea, so a quick idea costs one
|
|
192
|
+
// request, not two — and a template outage cannot stop a quick idea rendering.
|
|
193
|
+
let template = null;
|
|
194
|
+
if (idea.grade === 'full') {
|
|
195
|
+
const key = idea.template_key;
|
|
196
|
+
const t = await api.inbox.getInboxTemplate(key ? { query: { template_key: key } } : {});
|
|
197
|
+
if (t.ok && t.data && t.data.template) template = t.data.template;
|
|
198
|
+
}
|
|
199
|
+
render(idea, template);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
main();
|
|
203
|
+
})();
|
|
@@ -285,7 +285,10 @@
|
|
|
285
285
|
renderScore(null);
|
|
286
286
|
note('');
|
|
287
287
|
if (idea && idea.id) {
|
|
288
|
-
|
|
288
|
+
// The detail PAGE (task 1002929 / R153), not the #/idea/N overlay: the
|
|
289
|
+
// thing just filed has five prose answers, which is what the page is for.
|
|
290
|
+
const base = location.hostname.startsWith('builders.') ? '' : '/builders';
|
|
291
|
+
$('#fi-sub').innerHTML = `Filed <a href="${base}/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
|
|
289
292
|
}
|
|
290
293
|
}
|
|
291
294
|
|
|
@@ -118,6 +118,19 @@
|
|
|
118
118
|
}
|
|
119
119
|
if (r.shipped_at) nodes.push(h('p', 'deeplink-card__note', 'Shipped ' + fmtRel(r.shipped_at)));
|
|
120
120
|
|
|
121
|
+
// task 1002929 (R153): an idea now has a real page, and this card cannot show
|
|
122
|
+
// a Full Idea's five prose answers legibly — so it stops pretending to and
|
|
123
|
+
// points at the page instead. The overlay KEEPS the triage panel below (~320
|
|
124
|
+
// lines of promote/merge/discard); this is the summary→detail relationship the
|
|
125
|
+
// Work board already has with /task/:id, not a replacement.
|
|
126
|
+
if (kind === 'idea') {
|
|
127
|
+
var fv = h('p', 'deeplink-card__note');
|
|
128
|
+
var fl = h('a', 'deeplink-card__link', r.grade === 'full' ? 'Read the full idea →' : 'Open this idea →');
|
|
129
|
+
fl.href = hallBase() + '/idea/' + encodeURIComponent(r.id);
|
|
130
|
+
fv.appendChild(fl);
|
|
131
|
+
nodes.push(fv);
|
|
132
|
+
}
|
|
133
|
+
|
|
121
134
|
// task 759 → task 1002447: Metic+ idea triage, right in the overlay (no
|
|
122
135
|
// /idea-triage skill and no LLM in the loop). 759 shipped Discard alone;
|
|
123
136
|
// 1002447 adds the other two verbs so the whole verdict set is reachable
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -507,11 +507,24 @@ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}
|
|
|
507
507
|
return rows;
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
// The by-id read. It carries the two people's LOGINS as well as their ids
|
|
511
|
+
// (task 1002929 / R153): an idea's attribution is a claim about humans, and
|
|
512
|
+
// "captured_by: 41" is not attribution to anyone reading it. Named to match the
|
|
513
|
+
// R145 spark queue's `filed_by_login` so the two idea surfaces agree.
|
|
514
|
+
//
|
|
515
|
+
// Both joins are LEFT: a builder row can be missing (an FK that went
|
|
516
|
+
// ON DELETE SET NULL), and an idea nobody developed has no developer at all —
|
|
517
|
+
// which is the common case, not an error. A plain JOIN here would drop most of
|
|
518
|
+
// the inbox, the same trap the grade join documents above.
|
|
510
519
|
async function getIdea(id) {
|
|
511
520
|
const { rows } = await pool.query(
|
|
512
|
-
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
|
|
521
|
+
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
522
|
+
ba.github_login AS filed_by_login,
|
|
523
|
+
bd.github_login AS developed_by_login
|
|
513
524
|
FROM idea_inbox i
|
|
514
525
|
${IDEA_JOIN_GRADE}
|
|
526
|
+
LEFT JOIN builders ba ON ba.id = i.captured_by
|
|
527
|
+
LEFT JOIN builders bd ON bd.id = g.developed_by
|
|
515
528
|
WHERE i.id = $1`,
|
|
516
529
|
[id]
|
|
517
530
|
);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.300",
|
|
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.300",
|
|
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.300",
|
|
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",
|
|
@@ -881,9 +881,15 @@ function mountInternalSurfaces(app) {
|
|
|
881
881
|
// needs its own two-segment match ahead of the single-segment resolution
|
|
882
882
|
// below. Same gate tier as Work/Roadmap/Goals (SIGNEDIN_BUILDERS_PAGE_RE).
|
|
883
883
|
const TASK_DETAIL_PAGE_RE = /^\/builders\/task\/(\d+)\/?$/;
|
|
884
|
+
// task 1002929 (R153 of goal 1000062): the idea-detail page is the SECOND
|
|
885
|
+
// two-segment hall page — /builders/idea/:id — for the same reason task 751 gave
|
|
886
|
+
// the task one: a record with real prose in it deserves a shareable URL and
|
|
887
|
+
// working back/forward, not a modal. Same gate tier as the task page.
|
|
888
|
+
const IDEA_DETAIL_PAGE_RE = /^\/builders\/idea\/(\d+)\/?$/;
|
|
884
889
|
function buildersHtmlName(reqPath) {
|
|
885
890
|
if (reqPath === '/builders' || reqPath === '/builders/') return 'index.html';
|
|
886
891
|
if (TASK_DETAIL_PAGE_RE.test(reqPath)) return buildersHtml['task.html'] ? 'task.html' : null;
|
|
892
|
+
if (IDEA_DETAIL_PAGE_RE.test(reqPath)) return buildersHtml['idea.html'] ? 'idea.html' : null;
|
|
887
893
|
// e.g. /builders/ranks or /builders/ranks.html → ranks.html
|
|
888
894
|
const m = reqPath.match(BUILDERS_PAGE_RE);
|
|
889
895
|
if (m) {
|
|
@@ -956,7 +962,7 @@ function mountInternalSurfaces(app) {
|
|
|
956
962
|
// behind the proxy's path normalization.
|
|
957
963
|
const norm = path.posix.normalize(req.path);
|
|
958
964
|
if (SIGNEDIN_BUILDERS_PAGE_RE.test(norm) || SIGNEDIN_BUILDERS_PREFIX_RE.test(norm)
|
|
959
|
-
|| TASK_DETAIL_PAGE_RE.test(norm)) {
|
|
965
|
+
|| TASK_DETAIL_PAGE_RE.test(norm) || IDEA_DETAIL_PAGE_RE.test(norm)) {
|
|
960
966
|
return gdsAuth.requireBuilderPage(req, res, next);
|
|
961
967
|
}
|
|
962
968
|
next();
|
package/src/module-api.js
CHANGED
|
@@ -1025,8 +1025,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1025
1025
|
// landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
|
|
1026
1026
|
// 1.19.299 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1027
1027
|
// landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
|
|
1028
|
+
// 1.19.300 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1029
|
+
// landed since 1.19.299 with no explicit bump. run 32601521458. (task 1002620)
|
|
1028
1030
|
// ---------------------------------------------------------------------------
|
|
1029
|
-
const CORE_VERSION = '1.19.
|
|
1031
|
+
const CORE_VERSION = '1.19.300'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1030
1032
|
|
|
1031
1033
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1032
1034
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// tests/idea_detail_page.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002929 (BV1.R153, goal 1000062) — the idea-detail page (/idea/:id).
|
|
4
|
+
//
|
|
5
|
+
// The done-when, as assertions:
|
|
6
|
+
// 1. a Full Idea is legible end to end — every question and every answer, in
|
|
7
|
+
// TEMPLATE order, plus the score;
|
|
8
|
+
// 2. a QUICK idea renders on the same page with NO empty five-field skeleton —
|
|
9
|
+
// five headings over five dashes is not an empty state, it is a page telling
|
|
10
|
+
// a builder they were wrong to file a quick idea;
|
|
11
|
+
// 3. spark AND developer attribution are both visible — R146's whole point is
|
|
12
|
+
// that the author survives being developed by someone else, so a developed
|
|
13
|
+
// idea must name two people.
|
|
14
|
+
//
|
|
15
|
+
// The page renders by string-building into #idea-body, so this executes the real
|
|
16
|
+
// render path in a minimal DOM stub (the hall_nav.mjs / hall_signout.mjs pattern)
|
|
17
|
+
// and asserts over the HTML a viewer would actually get. Pure vm + file read.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/idea_detail_page.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
31
|
+
const SRC = fs.readFileSync(path.join(HALL, 'idea.js'), 'utf8');
|
|
32
|
+
|
|
33
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
34
|
+
|
|
35
|
+
// The template as GET /inbox/template projects it (R152's shape).
|
|
36
|
+
const TEMPLATE = {
|
|
37
|
+
key: BASE_TEMPLATE.key,
|
|
38
|
+
label: BASE_TEMPLATE.label,
|
|
39
|
+
fields: BASE_TEMPLATE.fields.map((f) => ({ key: f.key, label: f.label, required: !!f.required, substance_min: f.substanceMin || 0 })),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Boot idea.js against a stub DOM + a stub client that answers with `idea`, and
|
|
43
|
+
// return the HTML it wrote into #idea-body.
|
|
44
|
+
function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200 } = {}) {
|
|
45
|
+
const els = new Map();
|
|
46
|
+
const makeEl = () => ({
|
|
47
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
48
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
49
|
+
setAttribute() {}, getAttribute() { return null; }, addEventListener() {},
|
|
50
|
+
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
|
|
51
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const documentObj = {
|
|
55
|
+
title: '',
|
|
56
|
+
body: { dataset: { page: 'idea' } },
|
|
57
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
58
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
59
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
60
|
+
querySelectorAll() { return []; },
|
|
61
|
+
createElement() { return makeEl(); },
|
|
62
|
+
addEventListener() {},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const calls = [];
|
|
66
|
+
const client = {
|
|
67
|
+
inbox: {
|
|
68
|
+
getInboxId: async (args) => { calls.push(['idea', args]); return { status: ideaStatus, ok: ideaStatus === 200, data: { idea } }; },
|
|
69
|
+
getInboxTemplate: async (args) => {
|
|
70
|
+
calls.push(['template', args]);
|
|
71
|
+
if (templateFails) return { status: 500, ok: false, data: null };
|
|
72
|
+
return { status: 200, ok: true, data: { template } };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const windowObj = {
|
|
78
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
79
|
+
BongosClient: { createClient: () => client },
|
|
80
|
+
OTB: {
|
|
81
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
82
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
83
|
+
fmtDate: (iso) => String(iso).slice(0, 10),
|
|
84
|
+
toast() {},
|
|
85
|
+
},
|
|
86
|
+
location: { hostname: 'builders.cloudbongos.com', pathname: `/idea/${idea && idea.id ? idea.id : '1'}`, assign() {} },
|
|
87
|
+
addEventListener() {},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout };
|
|
91
|
+
sandbox.globalThis = sandbox;
|
|
92
|
+
vm.createContext(sandbox);
|
|
93
|
+
vm.runInContext(SRC, sandbox, { filename: 'idea.js' });
|
|
94
|
+
|
|
95
|
+
return { html: () => documentObj.getElementById('idea-body').innerHTML, calls, documentObj };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The render is async (two awaits at most), so let the microtask queue drain.
|
|
99
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
100
|
+
|
|
101
|
+
const FULL_VALUES = Object.fromEntries(BASE_TEMPLATE.fields.map((f, i) => [f.key, `Answer number ${i + 1}, with enough words in it to read like a real answer.`]));
|
|
102
|
+
|
|
103
|
+
const fullIdea = {
|
|
104
|
+
id: '812', title: 'A shared changelog', status: 'open', kind: 'feature',
|
|
105
|
+
body_md: 'the original quick thought', captured_by: '9', captured_at: '2026-08-01T00:00:00Z',
|
|
106
|
+
grade: 'full', template_key: 'base-v1', field_values: FULL_VALUES, completeness_score: 88,
|
|
107
|
+
filed_by_login: 'thinker', developed_by: '42', developed_at: '2026-08-20T00:00:00Z', developed_by_login: 'someone',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let passed = 0;
|
|
111
|
+
let failed = 0;
|
|
112
|
+
async function test(name, fn) {
|
|
113
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
114
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- done-when 1: a Full Idea is legible end to end ---------------------
|
|
118
|
+
|
|
119
|
+
await test('a Full Idea shows every question and every answer, in template order', async () => {
|
|
120
|
+
const r = render(fullIdea);
|
|
121
|
+
await settle();
|
|
122
|
+
const html = r.html();
|
|
123
|
+
let cursor = -1;
|
|
124
|
+
for (const f of TEMPLATE.fields) {
|
|
125
|
+
const qAt = html.indexOf(f.label);
|
|
126
|
+
assert.ok(qAt > -1, `the question "${f.label}" is on the page`);
|
|
127
|
+
assert.ok(qAt > cursor, 'questions appear in TEMPLATE order, not jsonb key order');
|
|
128
|
+
cursor = qAt;
|
|
129
|
+
assert.ok(html.includes(FULL_VALUES[f.key]), `the answer for ${f.key} is on the page`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('the score is shown as a number and a bar', async () => {
|
|
134
|
+
const r = render(fullIdea);
|
|
135
|
+
await settle();
|
|
136
|
+
assert.match(r.html(), /88\/100 complete/);
|
|
137
|
+
assert.match(r.html(), /width:88%/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await test('a Full Idea with no score says so instead of drawing a zero', async () => {
|
|
141
|
+
const r = render({ ...fullIdea, completeness_score: null });
|
|
142
|
+
await settle();
|
|
143
|
+
assert.match(r.html(), /Not scored/);
|
|
144
|
+
assert.doesNotMatch(r.html(), /0\/100/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('a blank answer on a Full Idea is marked, not hidden', async () => {
|
|
148
|
+
const { fit, ...four } = FULL_VALUES;
|
|
149
|
+
const r = render({ ...fullIdea, field_values: four });
|
|
150
|
+
await settle();
|
|
151
|
+
// It is information: it is WHY the score is what it is.
|
|
152
|
+
assert.ok(r.html().includes('Not answered'));
|
|
153
|
+
const label = TEMPLATE.fields.find((f) => f.key === 'fit').label;
|
|
154
|
+
assert.ok(r.html().includes(label), 'the unanswered question is still shown');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// --- done-when 2: a quick idea gets NO five-field skeleton --------------
|
|
158
|
+
|
|
159
|
+
await test('a QUICK idea shows its body and NOT one of the five questions', async () => {
|
|
160
|
+
const r = render({
|
|
161
|
+
id: '5', title: 'a quick thought', status: 'open', body_md: 'just the thought',
|
|
162
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z', grade: 'quick',
|
|
163
|
+
template_key: null, field_values: null, completeness_score: null,
|
|
164
|
+
filed_by_login: 'thinker', developed_by: null, developed_at: null, developed_by_login: null,
|
|
165
|
+
});
|
|
166
|
+
await settle();
|
|
167
|
+
const html = r.html();
|
|
168
|
+
assert.ok(html.includes('just the thought'));
|
|
169
|
+
for (const f of TEMPLATE.fields) {
|
|
170
|
+
assert.ok(!html.includes(f.label), `a quick idea must not show the question "${f.label}"`);
|
|
171
|
+
}
|
|
172
|
+
assert.doesNotMatch(html, /Not answered/, 'and no empty answer rows either');
|
|
173
|
+
assert.doesNotMatch(html, /\/100 complete/, 'nor a completeness bar it has no score for');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('a quick idea costs ONE request — the labels are only needed for a full one', async () => {
|
|
177
|
+
const r = render({ id: '5', title: 't', status: 'open', body_md: 'b', grade: 'quick', captured_by: '9' });
|
|
178
|
+
await settle();
|
|
179
|
+
assert.deepEqual(r.calls.map((c) => c[0]), ['idea'], 'no template fetch for a quick idea');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('a SPARK shows its body, no skeleton, and says it is open for development', async () => {
|
|
183
|
+
const r = render({
|
|
184
|
+
id: '7', title: 'a spark', status: 'open', body_md: 'the seed', grade: 'spark',
|
|
185
|
+
captured_by: '9', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker',
|
|
186
|
+
});
|
|
187
|
+
await settle();
|
|
188
|
+
const html = r.html();
|
|
189
|
+
assert.ok(html.includes('the seed'));
|
|
190
|
+
assert.match(html, /Big Idea Spark/);
|
|
191
|
+
assert.match(html, /needing development/);
|
|
192
|
+
for (const f of TEMPLATE.fields) assert.ok(!html.includes(f.label), 'a spark has no five-field skeleton either');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// --- done-when 3: both attributions visible ----------------------------
|
|
196
|
+
|
|
197
|
+
await test('a developed idea names TWO people, in their two roles', async () => {
|
|
198
|
+
const r = render(fullIdea);
|
|
199
|
+
await settle();
|
|
200
|
+
const html = r.html();
|
|
201
|
+
assert.match(html, /@thinker/);
|
|
202
|
+
assert.match(html, /had the thought/);
|
|
203
|
+
assert.match(html, /@someone/);
|
|
204
|
+
assert.match(html, /developed it into a Full Idea/);
|
|
205
|
+
// The author must come first: the thought precedes the development, and R146
|
|
206
|
+
// exists so being developed does not displace the person who had it.
|
|
207
|
+
assert.ok(html.indexOf('@thinker') < html.indexOf('@someone'));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await test('an UNdeveloped idea names one person and invents no developer', async () => {
|
|
211
|
+
const r = render({ ...fullIdea, developed_by: null, developed_at: null, developed_by_login: null });
|
|
212
|
+
await settle();
|
|
213
|
+
const html = r.html();
|
|
214
|
+
assert.match(html, /had the thought/);
|
|
215
|
+
assert.doesNotMatch(html, /developed it/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await test('self-development reads as one person doing both, not a handoff', async () => {
|
|
219
|
+
const r = render({ ...fullIdea, developed_by: '9', developed_by_login: 'thinker' });
|
|
220
|
+
await settle();
|
|
221
|
+
assert.match(r.html(), /developed it themselves/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('a missing login degrades to the builder id, never "@undefined"', async () => {
|
|
225
|
+
const r = render({ ...fullIdea, filed_by_login: null, developed_by_login: null });
|
|
226
|
+
await settle();
|
|
227
|
+
const html = r.html();
|
|
228
|
+
assert.match(html, /builder 9/);
|
|
229
|
+
assert.match(html, /builder 42/);
|
|
230
|
+
assert.doesNotMatch(html, /@undefined|@null|undefined/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// --- grade badge + plumbing --------------------------------------------
|
|
234
|
+
|
|
235
|
+
await test('the grade is named in WORDS, not only in colour', async () => {
|
|
236
|
+
for (const [grade, word] of [['full', 'Full Idea'], ['spark', 'Big Idea Spark'], ['quick', 'Quick idea']]) {
|
|
237
|
+
const r = render({ ...fullIdea, grade, ...(grade === 'full' ? {} : { field_values: null }) });
|
|
238
|
+
await settle();
|
|
239
|
+
assert.ok(r.html().includes(word), `${grade} is spelled "${word}"`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await test('an unknown grade falls back to quick rather than rendering nothing', async () => {
|
|
244
|
+
const r = render({ id: '1', title: 't', body_md: 'b', grade: 'something-new', captured_by: '9' });
|
|
245
|
+
await settle();
|
|
246
|
+
assert.match(r.html(), /Quick idea/);
|
|
247
|
+
assert.ok(r.html().includes('b'));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await test('a template outage still renders the answers, without inventing an order', async () => {
|
|
251
|
+
const r = render(fullIdea, { templateFails: true });
|
|
252
|
+
await settle();
|
|
253
|
+
const html = r.html();
|
|
254
|
+
assert.match(html, /Could not load the question labels/);
|
|
255
|
+
for (const v of Object.values(FULL_VALUES)) assert.ok(html.includes(v), 'the answers are still readable');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await test('a promoted idea links to the task it became', async () => {
|
|
259
|
+
const r = render({ ...fullIdea, status: 'promoted', promoted_to_task_id: '1234' });
|
|
260
|
+
await settle();
|
|
261
|
+
assert.match(r.html(), /\/task\/1234/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await test('a 404 says which idea is missing rather than failing blank', async () => {
|
|
265
|
+
const r = render({ id: '99' }, { ideaStatus: 404 });
|
|
266
|
+
await settle();
|
|
267
|
+
assert.match(r.html(), /No idea 99/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
|
|
271
|
+
if (failed > 0) process.exit(1);
|