@bongos/core 1.19.805 → 1.19.806
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 +21 -16
- package/docs/copy-inventory.md +47 -34
- package/docs/copy-registry.json +200 -72
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/goals-page.js +1 -1
- package/modules/hall-ui/public/goals-render.js +144 -22
- package/modules/hall-ui/public/goals.css +16 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_invite_consent.mjs +307 -0
|
@@ -883,7 +883,7 @@
|
|
|
883
883
|
// this client gate deliberately doesn't re-derive the protected-module list
|
|
884
884
|
// (that roster lives server-side only, module-scope-map.js) — a non-Archon
|
|
885
885
|
// owner/manager sees the control on a protected-scope goal and gets a clear
|
|
886
|
-
// toast if the server 403s (see
|
|
886
|
+
// toast if the server 403s (see sendInvitation's error handling below).
|
|
887
887
|
const isOwner = sameId(g.created_by, selfId());
|
|
888
888
|
const canManage = g.status === 'open' && (isArchon() || isOwner || iAmLead);
|
|
889
889
|
renderActions(g, { joined, canManage });
|
|
@@ -1122,6 +1122,49 @@
|
|
|
1122
1122
|
}
|
|
1123
1123
|
return state.directory;
|
|
1124
1124
|
}
|
|
1125
|
+
// WHO IS STILL INVITABLE. Two exclusions, both of which exist to stop the page
|
|
1126
|
+
// offering a name that can only fail:
|
|
1127
|
+
// - members, which POST /invitations 409s as `already_a_member`;
|
|
1128
|
+
// - anyone holding a PENDING row, which it 409s as `invite_pending`.
|
|
1129
|
+
// The second is the one that matters, because a pending invitation is INVISIBLE
|
|
1130
|
+
// from the member list — it is not a membership — so without this the user picks
|
|
1131
|
+
// a perfectly ordinary-looking name and is handed an error they had no way to
|
|
1132
|
+
// predict. `state.pendingRequests` carries BOTH kinds (invites the goal sent and
|
|
1133
|
+
// join-requests it received) and the server refuses on either, so neither is
|
|
1134
|
+
// filtered out here.
|
|
1135
|
+
function invitableBuilders(members) {
|
|
1136
|
+
const excluded = new Set((members || []).map((m) => String(m.builder_id)));
|
|
1137
|
+
for (const r of state.pendingRequests || []) excluded.add(String(r.builder_id));
|
|
1138
|
+
return (state.directory || []).filter((b) => !excluded.has(String(b.id)));
|
|
1139
|
+
}
|
|
1140
|
+
function builderOptionsHtml(list, selectedId) {
|
|
1141
|
+
return list.map((b) => {
|
|
1142
|
+
const id = String(b.id);
|
|
1143
|
+
const sel = selectedId && id === String(selectedId) ? ' selected' : '';
|
|
1144
|
+
return `<option value="${escapeHtml(id)}"${sel}>${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`;
|
|
1145
|
+
}).join('');
|
|
1146
|
+
}
|
|
1147
|
+
// THE INVITE BOX SENDS AN INVITATION; IT DOES NOT ADD ANYONE (task 1002886).
|
|
1148
|
+
//
|
|
1149
|
+
// This control used to POST /goals/:id/members — the immediate force-add — and
|
|
1150
|
+
// then toast 'Invited.', which was simply untrue: the person was already in the
|
|
1151
|
+
// goal and had never been asked. Meanwhile the consented flow ADR 0112 is built
|
|
1152
|
+
// around (POST /goals/:id/invitations -> a pending row -> the invitee accepts)
|
|
1153
|
+
// had its receiving half fully wired in goal-inbox.js and NO sender anywhere in
|
|
1154
|
+
// the hall, so nothing in the product ever created an invitation.
|
|
1155
|
+
//
|
|
1156
|
+
// Now: the primary control invites, and the force-add survives as its own
|
|
1157
|
+
// explicitly-labelled affordance (owner ruling, 2026-09-16) so nobody loses a
|
|
1158
|
+
// capability they had — seeding a goal with a team who already agreed offline is
|
|
1159
|
+
// a real thing people do. The rule the owner set is that BOTH paths say what
|
|
1160
|
+
// they actually do; relocating the lie would have been worse than leaving it.
|
|
1161
|
+
//
|
|
1162
|
+
// NO MEMBERSHIP-KIND PICKER ON THE INVITE HALF, and that is not an oversight:
|
|
1163
|
+
// `goal_membership_requests` has no membership_kind column, so an accepted
|
|
1164
|
+
// invitation lands through addGoalMember's default 'member'. A picker here would
|
|
1165
|
+
// be a choice silently dropped on the floor — the same class of defect as the
|
|
1166
|
+
// toast it replaces. The form says so instead, and the picker stays on the
|
|
1167
|
+
// add-directly path, where it IS honoured.
|
|
1125
1168
|
function renderInviteBox(goalId, canInvite, members) {
|
|
1126
1169
|
const box = $('#goal-invite');
|
|
1127
1170
|
if (!box) return;
|
|
@@ -1129,6 +1172,7 @@
|
|
|
1129
1172
|
box.hidden = true;
|
|
1130
1173
|
box.innerHTML = '';
|
|
1131
1174
|
state.inviteOpen = false;
|
|
1175
|
+
state.addDirectOpen = false;
|
|
1132
1176
|
return;
|
|
1133
1177
|
}
|
|
1134
1178
|
box.hidden = false;
|
|
@@ -1141,42 +1185,111 @@
|
|
|
1141
1185
|
});
|
|
1142
1186
|
return;
|
|
1143
1187
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1188
|
+
// This function rebuilds the box from scratch on every call, and it is called
|
|
1189
|
+
// again when the pending-request list lands (see loadGoalMembership). Carry the
|
|
1190
|
+
// half-filled form across that redraw, or a note someone is part-way through
|
|
1191
|
+
// typing vanishes under them.
|
|
1192
|
+
const keep = {
|
|
1193
|
+
builder: $('#goal-invite-builder')?.value || '',
|
|
1194
|
+
note: $('#goal-invite-note')?.value || '',
|
|
1195
|
+
direct: $('#goal-direct-builder')?.value || '',
|
|
1196
|
+
kind: $('#goal-direct-kind')?.value || 'member',
|
|
1197
|
+
};
|
|
1198
|
+
const invitable = invitableBuilders(members);
|
|
1199
|
+
const none = !invitable.length;
|
|
1200
|
+
const emptyLabel = 'Nobody left to invite';
|
|
1149
1201
|
box.innerHTML = `
|
|
1150
1202
|
<form class="goal-invite__form" id="goal-invite-form">
|
|
1151
|
-
<select id="goal-invite-builder" required ${
|
|
1152
|
-
${
|
|
1153
|
-
:
|
|
1154
|
-
</select>
|
|
1155
|
-
<select id="goal-invite-kind">
|
|
1156
|
-
<option value="member">Member</option>
|
|
1157
|
-
<option value="lead">Manager</option>
|
|
1203
|
+
<select id="goal-invite-builder" required ${none ? 'disabled' : ''}>
|
|
1204
|
+
${none ? `<option value="">${emptyLabel}</option>`
|
|
1205
|
+
: `<option value="" disabled${keep.builder ? '' : ' selected'}>Choose a builder…</option>${builderOptionsHtml(invitable, keep.builder)}`}
|
|
1158
1206
|
</select>
|
|
1159
|
-
<
|
|
1207
|
+
<input id="goal-invite-note" type="text" maxlength="500" placeholder="Note (optional) — why you're asking" value="${escapeHtml(keep.note)}" ${none ? 'disabled' : ''}>
|
|
1208
|
+
<button class="landing__btn" type="submit" ${none ? 'disabled' : ''}>Send invitation</button>
|
|
1160
1209
|
<button class="btn-ghost" id="goal-invite-cancel" type="button">Cancel</button>
|
|
1161
|
-
</form
|
|
1210
|
+
</form>
|
|
1211
|
+
<p class="goal-invite__hint">They join as a member once they accept — you can change that afterwards. Nobody is added until they do.</p>
|
|
1212
|
+
${state.addDirectOpen ? `
|
|
1213
|
+
<form class="goal-invite__form goal-invite__direct" id="goal-direct-form">
|
|
1214
|
+
<select id="goal-direct-builder" required ${none ? 'disabled' : ''}>
|
|
1215
|
+
${none ? `<option value="">${emptyLabel}</option>`
|
|
1216
|
+
: `<option value="" disabled${keep.direct ? '' : ' selected'}>Choose a builder…</option>${builderOptionsHtml(invitable, keep.direct)}`}
|
|
1217
|
+
</select>
|
|
1218
|
+
<select id="goal-direct-kind" ${none ? 'disabled' : ''}>
|
|
1219
|
+
<option value="member"${keep.kind === 'member' ? ' selected' : ''}>Member</option>
|
|
1220
|
+
<option value="lead"${keep.kind === 'lead' ? ' selected' : ''}>Manager</option>
|
|
1221
|
+
</select>
|
|
1222
|
+
<button class="btn-ghost" type="submit" ${none ? 'disabled' : ''}>Add without asking</button>
|
|
1223
|
+
<button class="btn-ghost" id="goal-direct-cancel" type="button">Cancel</button>
|
|
1224
|
+
</form>
|
|
1225
|
+
<p class="goal-invite__hint">They are added straight away and never asked. Use this only for someone who has already agreed.</p>`
|
|
1226
|
+
: '<button class="goal-invite__more" id="goal-direct-open" type="button">Or add someone without asking…</button>'}`;
|
|
1162
1227
|
$('#goal-invite-cancel')?.addEventListener('click', () => {
|
|
1163
1228
|
state.inviteOpen = false;
|
|
1229
|
+
state.addDirectOpen = false;
|
|
1164
1230
|
renderInviteBox(goalId, canInvite, members);
|
|
1165
1231
|
});
|
|
1166
1232
|
$('#goal-invite-form')?.addEventListener('submit', (ev) => {
|
|
1167
1233
|
ev.preventDefault();
|
|
1168
1234
|
const builderId = $('#goal-invite-builder')?.value;
|
|
1169
|
-
const
|
|
1170
|
-
if (builderId)
|
|
1235
|
+
const note = $('#goal-invite-note')?.value || '';
|
|
1236
|
+
if (builderId) sendInvitation(goalId, builderId, note);
|
|
1237
|
+
});
|
|
1238
|
+
$('#goal-direct-open')?.addEventListener('click', () => {
|
|
1239
|
+
state.addDirectOpen = true;
|
|
1240
|
+
renderInviteBox(goalId, canInvite, members);
|
|
1241
|
+
});
|
|
1242
|
+
$('#goal-direct-cancel')?.addEventListener('click', () => {
|
|
1243
|
+
state.addDirectOpen = false;
|
|
1244
|
+
renderInviteBox(goalId, canInvite, members);
|
|
1245
|
+
});
|
|
1246
|
+
$('#goal-direct-form')?.addEventListener('submit', (ev) => {
|
|
1247
|
+
ev.preventDefault();
|
|
1248
|
+
const builderId = $('#goal-direct-builder')?.value;
|
|
1249
|
+
const membershipKind = $('#goal-direct-kind')?.value || 'member';
|
|
1250
|
+
if (builderId) addMemberDirectly(goalId, builderId, membershipKind);
|
|
1171
1251
|
});
|
|
1172
1252
|
}
|
|
1173
|
-
|
|
1253
|
+
// The ADR 0112 sender. 201 means a PENDING row exists — not a membership — so
|
|
1254
|
+
// the toast may not claim the person is in the goal.
|
|
1255
|
+
async function sendInvitation(goalId, builderId, note) {
|
|
1256
|
+
try {
|
|
1257
|
+
const body = { builder_id: Number(builderId) };
|
|
1258
|
+
const trimmed = String(note || '').trim();
|
|
1259
|
+
if (trimmed) body.note = trimmed;
|
|
1260
|
+
await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/invitations`, body);
|
|
1261
|
+
toast('Invitation sent — they join once they accept.');
|
|
1262
|
+
state.inviteOpen = false;
|
|
1263
|
+
state.addDirectOpen = false;
|
|
1264
|
+
await loadDetail(goalId, { force: true });
|
|
1265
|
+
renderDetail(goalId);
|
|
1266
|
+
renderList();
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
const code = err?.data?.error;
|
|
1269
|
+
// `invite_pending` and `already_a_member` are both filtered out of the
|
|
1270
|
+
// dropdown above, so reaching either means the goal changed under this form
|
|
1271
|
+
// — say what happened rather than a generic failure, because the name really
|
|
1272
|
+
// was offered a moment ago.
|
|
1273
|
+
if (code === 'invite_pending') toast('They already have an invitation or a join-request pending on this goal.');
|
|
1274
|
+
else if (code === 'already_a_member') toast('They joined this goal already.');
|
|
1275
|
+
else if (code === 'builder_not_found') toast('That builder no longer exists.');
|
|
1276
|
+
else if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may bring someone in.');
|
|
1277
|
+
else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
|
|
1278
|
+
else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may invite.');
|
|
1279
|
+
else if (code === 'goal_not_open') toast('This goal is not open.');
|
|
1280
|
+
else toast('Could not send that invitation just now.');
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// The force-add, kept on purpose and labelled for what it is. Same endpoint the
|
|
1284
|
+
// '+ Invite' button used to call behind the word 'Invited.'
|
|
1285
|
+
async function addMemberDirectly(goalId, builderId, membershipKind) {
|
|
1174
1286
|
try {
|
|
1175
1287
|
// `membership_kind`, not `role` — ADR 0174 reserves that word for the three
|
|
1176
1288
|
// crafts (task 1003013).
|
|
1177
1289
|
await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
|
|
1178
|
-
toast('
|
|
1290
|
+
toast('Added — they are in the goal now, without being asked.');
|
|
1179
1291
|
state.inviteOpen = false;
|
|
1292
|
+
state.addDirectOpen = false;
|
|
1180
1293
|
await loadDetail(goalId, { force: true });
|
|
1181
1294
|
renderDetail(goalId);
|
|
1182
1295
|
renderList();
|
|
@@ -1184,9 +1297,9 @@
|
|
|
1184
1297
|
const code = err?.data?.error;
|
|
1185
1298
|
if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may add members.');
|
|
1186
1299
|
else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
|
|
1187
|
-
else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may
|
|
1300
|
+
else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may add members.');
|
|
1188
1301
|
else if (code === 'goal_not_open') toast('This goal is not open.');
|
|
1189
|
-
else toast('Could not
|
|
1302
|
+
else toast('Could not add that builder just now.');
|
|
1190
1303
|
}
|
|
1191
1304
|
}
|
|
1192
1305
|
async function joinLeave(goalId, action) {
|
|
@@ -1324,7 +1437,16 @@
|
|
|
1324
1437
|
if (!sameId(state.current, goalId)) return;
|
|
1325
1438
|
renderPendingRequests();
|
|
1326
1439
|
const d = currentDetail();
|
|
1327
|
-
if (d)
|
|
1440
|
+
if (d) {
|
|
1441
|
+
renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
|
|
1442
|
+
// The invite dropdown's exclusion set reads state.pendingRequests, which
|
|
1443
|
+
// lands HERE — after renderDetail has already drawn the box. Without this
|
|
1444
|
+
// redraw a form opened before this resolved keeps offering a builder whose
|
|
1445
|
+
// invitation is already pending, which is the exact invisible 409 the
|
|
1446
|
+
// filter exists to prevent. renderInviteBox carries the half-filled form
|
|
1447
|
+
// across the redraw.
|
|
1448
|
+
if (state.inviteOpen) renderInviteBox(goalId, canManage, d.members || []);
|
|
1449
|
+
}
|
|
1328
1450
|
}
|
|
1329
1451
|
async function loadDetail(goalId, { force = false } = {}) {
|
|
1330
1452
|
if (!force && state.details.has(String(goalId))) return state.details.get(String(goalId));
|
|
@@ -200,7 +200,22 @@
|
|
|
200
200
|
collapsed behind "+ Invite" (disclosure, closed by default — task 2171). */
|
|
201
201
|
.goal-invite { margin: 4px 0 10px; }
|
|
202
202
|
.goal-invite__form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
203
|
-
.goal-invite__form select#goal-invite-builder
|
|
203
|
+
.goal-invite__form select#goal-invite-builder,
|
|
204
|
+
.goal-invite__form select#goal-direct-builder { flex: 1; min-width: 180px; }
|
|
205
|
+
.goal-invite__form input#goal-invite-note { flex: 1; min-width: 180px; }
|
|
206
|
+
|
|
207
|
+
/* task 1002886 — BOTH admission paths now say what they do, so both carry a
|
|
208
|
+
line of their own: the invite half promises nobody is added until they
|
|
209
|
+
accept, the force-add half admits nobody is asked. Muted, because this is
|
|
210
|
+
the consequence of the button beside it, not a warning. */
|
|
211
|
+
.goal-invite__hint { margin: 4px 0 8px; font-size: 12.5px; color: var(--ink-faint); max-width: var(--measure); }
|
|
212
|
+
/* The force-add is deliberately QUIETER than the invite it sits under — same
|
|
213
|
+
authority, but it is the path that skips consent, so it does not get equal
|
|
214
|
+
visual weight. It stays one click away, never hidden. */
|
|
215
|
+
.goal-invite__more { background: none; border: 0; padding: 0; margin: 0 0 8px; cursor: pointer;
|
|
216
|
+
font: inherit; font-size: 12.5px; color: var(--ink-soft); text-decoration: underline; }
|
|
217
|
+
.goal-invite__more:hover { color: var(--ink); }
|
|
218
|
+
.goal-invite__direct { padding-top: 8px; border-top: var(--line) solid var(--rule-soft); }
|
|
204
219
|
|
|
205
220
|
/* task 1953 — private goals: the lock badge (meta line) + the goal's own
|
|
206
221
|
pending invite/join-request queue (manager view, next to the invite box). */
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.806",
|
|
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.806",
|
|
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.806",
|
|
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.806'; // 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');
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// tests/goal_invite_consent.mjs — the goals page INVITES; it does not force-add
|
|
2
|
+
// behind the word "Invited." (task 1002886, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. The '+ Invite' control posted to POST /goals/:id/members
|
|
5
|
+
// — the immediate force-add — and then toasted 'Invited.' The person was already
|
|
6
|
+
// in the goal and had never been asked. Meanwhile ADR 0112's consented flow
|
|
7
|
+
// (POST /goals/:id/invitations -> a pending row -> the invitee accepts) had its
|
|
8
|
+
// RECEIVING half fully wired in goal-inbox.js and no sender anywhere in the hall,
|
|
9
|
+
// so nothing in the product ever created an invitation.
|
|
10
|
+
//
|
|
11
|
+
// Nothing in tests/ touched the invite box before this file — which is how a
|
|
12
|
+
// control could claim one thing and do the opposite for as long as it did. The
|
|
13
|
+
// lesson generalises past this button: assert the REQUEST a control issues and
|
|
14
|
+
// the SENTENCE it then shows, because those two drifting apart is invisible to
|
|
15
|
+
// any test that only checks the control renders.
|
|
16
|
+
//
|
|
17
|
+
// It runs the REAL goals-render.js (plus the real goals-lib, task-lib and kit)
|
|
18
|
+
// in a vm against a stub DOM and a recording postJSON, and drives renderDetail —
|
|
19
|
+
// the actual entry point, not the private renderer — so what is asserted is the
|
|
20
|
+
// call the page really makes.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/goal_invite_consent.mjs
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import vm from 'node:vm';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { makeRunner } from './helpers.mjs';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
32
|
+
const P = 'modules/hall-ui/public/';
|
|
33
|
+
const RENDER_JS = read(`${P}goals-render.js`);
|
|
34
|
+
|
|
35
|
+
const { test, summary } = makeRunner();
|
|
36
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
37
|
+
|
|
38
|
+
const SELF = 7001; // the viewer: the goal's creator, so canManage is true
|
|
39
|
+
const MEMBER_ID = 7002; // already in the goal
|
|
40
|
+
const PENDING_ID = 7003; // holds a pending invitation the goal sent
|
|
41
|
+
const FREE_ID = 7004; // neither — the only genuinely invitable builder
|
|
42
|
+
|
|
43
|
+
function makeEl(id) {
|
|
44
|
+
const el = {
|
|
45
|
+
id,
|
|
46
|
+
innerHTML: '', textContent: '', value: '', className: '',
|
|
47
|
+
hidden: false, disabled: false, checked: false,
|
|
48
|
+
options: [], style: {}, dataset: {},
|
|
49
|
+
children: [],
|
|
50
|
+
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
|
51
|
+
appendChild(c) { el.children.push(c); return c; },
|
|
52
|
+
setAttribute() {}, removeAttribute() {}, focus() {}, scrollIntoView() {},
|
|
53
|
+
_on: new Map(),
|
|
54
|
+
addEventListener(ev, fn) {
|
|
55
|
+
if (!el._on.has(ev)) el._on.set(ev, []);
|
|
56
|
+
el._on.get(ev).push(fn);
|
|
57
|
+
},
|
|
58
|
+
fire(ev, arg) {
|
|
59
|
+
for (const fn of el._on.get(ev) || []) fn(arg || { preventDefault() {} });
|
|
60
|
+
},
|
|
61
|
+
querySelector() { return null; },
|
|
62
|
+
querySelectorAll() { return []; },
|
|
63
|
+
};
|
|
64
|
+
return el;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function harness({ pendingRequests = [] } = {}) {
|
|
68
|
+
const els = new Map();
|
|
69
|
+
// The box is re-rendered by replacing innerHTML, and the stub DOM does not
|
|
70
|
+
// parse HTML — so the controls the page writes into #goal-invite are modelled
|
|
71
|
+
// by their ids, which is exactly how the page reaches them ($('#id')).
|
|
72
|
+
// Ids the invite box writes INTO itself. Listed rather than inferred because
|
|
73
|
+
// the stub does not parse HTML — this is the seam where it has to imitate the
|
|
74
|
+
// one thing real innerHTML does that matters here: destroy what was there.
|
|
75
|
+
const INVITE_CHILD_IDS = [
|
|
76
|
+
'goal-invite-open', 'goal-invite-form', 'goal-invite-builder', 'goal-invite-note',
|
|
77
|
+
'goal-invite-cancel', 'goal-direct-open', 'goal-direct-form', 'goal-direct-builder',
|
|
78
|
+
'goal-direct-kind', 'goal-direct-cancel',
|
|
79
|
+
];
|
|
80
|
+
const getEl = (id) => {
|
|
81
|
+
const key = String(id).replace(/^#/, '');
|
|
82
|
+
if (!els.has(key)) {
|
|
83
|
+
const el = makeEl(key);
|
|
84
|
+
if (key === 'goal-invite') {
|
|
85
|
+
// THE BUG THIS MODELS IS REAL: renderInviteBox rebuilds the box on every
|
|
86
|
+
// call, and a stub that kept the old nodes alive would keep their click
|
|
87
|
+
// handlers too — so a single click would re-enter the renderer once per
|
|
88
|
+
// previous render. That is a defect in the STUB, not the page, but only
|
|
89
|
+
// because the page really does replace these nodes. Keep them in step.
|
|
90
|
+
let html = '';
|
|
91
|
+
Object.defineProperty(el, 'innerHTML', {
|
|
92
|
+
get: () => html,
|
|
93
|
+
set: (v) => { html = String(v); for (const cid of INVITE_CHILD_IDS) els.delete(cid); },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
els.set(key, el);
|
|
97
|
+
}
|
|
98
|
+
return els.get(key);
|
|
99
|
+
};
|
|
100
|
+
const documentObj = {
|
|
101
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
102
|
+
body: makeEl('body'),
|
|
103
|
+
getElementById: getEl,
|
|
104
|
+
querySelector: (sel) => getEl(sel),
|
|
105
|
+
querySelectorAll: () => [],
|
|
106
|
+
createElement: () => makeEl(),
|
|
107
|
+
addEventListener() {},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const posts = [];
|
|
111
|
+
const toasts = [];
|
|
112
|
+
const state = {
|
|
113
|
+
current: 1,
|
|
114
|
+
view: 'detail',
|
|
115
|
+
details: new Map(),
|
|
116
|
+
directory: [
|
|
117
|
+
{ id: MEMBER_ID, display_name: 'Ada Member', github_login: 'ada' },
|
|
118
|
+
{ id: PENDING_ID, display_name: 'Ben Pending', github_login: 'ben' },
|
|
119
|
+
{ id: FREE_ID, display_name: 'Cleo Free', github_login: 'cleo' },
|
|
120
|
+
],
|
|
121
|
+
pendingRequests,
|
|
122
|
+
myPendingRequest: null,
|
|
123
|
+
inviteOpen: false,
|
|
124
|
+
addDirectOpen: false,
|
|
125
|
+
// The rest of the page state renderDetail walks through on its way to the
|
|
126
|
+
// invite box. Shapes copied from goals-page.js's initial state — the
|
|
127
|
+
// renderers read several of these unguarded, so a partial stub throws
|
|
128
|
+
// before execution ever reaches what is under test here.
|
|
129
|
+
workSort: { todo: { by: null, dir: 'desc' }, done: { by: null, dir: 'desc' } },
|
|
130
|
+
work: { todo: [], done: [] },
|
|
131
|
+
workLoaded: false,
|
|
132
|
+
claimsByTask: new Map(),
|
|
133
|
+
conflictPairs: null,
|
|
134
|
+
conflictSource: null,
|
|
135
|
+
rollups: new Map(),
|
|
136
|
+
activity: new Map(),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const windowObj = {
|
|
140
|
+
location: { assign() {}, href: '/goals' },
|
|
141
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
142
|
+
scrollTo() {}, addEventListener() {},
|
|
143
|
+
matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
|
|
144
|
+
OTB: {
|
|
145
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
146
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
147
|
+
fmtNum: (n) => String(n ?? ''),
|
|
148
|
+
fmtInt: (n) => String(n ?? ''),
|
|
149
|
+
fmtDate: (d) => String(d ?? '').slice(0, 10),
|
|
150
|
+
toast: (m) => toasts.push(String(m)),
|
|
151
|
+
rankLabel: (r) => String(r ?? ''),
|
|
152
|
+
categoryChip: () => '',
|
|
153
|
+
num: (n) => Number(n) || 0,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const sandbox = {
|
|
158
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
159
|
+
console: { error() {}, warn() {}, log() {} },
|
|
160
|
+
setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON, Set, Map,
|
|
161
|
+
fetch: () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }),
|
|
162
|
+
};
|
|
163
|
+
sandbox.globalThis = sandbox;
|
|
164
|
+
vm.createContext(sandbox);
|
|
165
|
+
for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goals-render.js']) {
|
|
166
|
+
vm.runInContext(read(P + f), sandbox, { filename: f });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const goal = {
|
|
170
|
+
id: 1, version_id: 'BONGOS-V2', status: 'open', visibility: 'public',
|
|
171
|
+
created_by: SELF, title: 'A goal', scope_modules: [], accepting_requests: true,
|
|
172
|
+
};
|
|
173
|
+
const members = [{ builder_id: MEMBER_ID, display_name: 'Ada Member', membership_kind: 'member' }];
|
|
174
|
+
const detail = { goal, members, criteria: [], satisfied: [], tasks: [] };
|
|
175
|
+
state.details.set('1', detail);
|
|
176
|
+
|
|
177
|
+
const api = sandbox.window.OTBGoalsRender({
|
|
178
|
+
API: '/api/bongos',
|
|
179
|
+
curLabel: () => 'Credits',
|
|
180
|
+
// URL-aware, because the renderers walk several feeds on the way to the
|
|
181
|
+
// invite box and a one-shape-fits-all reply feeds the wrong array into the
|
|
182
|
+
// wrong pager.
|
|
183
|
+
getJSON: async (url) => {
|
|
184
|
+
if (/\/goals\/\d+\/requests$/.test(url)) return { requests: pendingRequests };
|
|
185
|
+
if (/\/goals\/inbox$/.test(url)) return { myPendingRequests: [] };
|
|
186
|
+
if (/\/builders\/directory/.test(url)) return { builders: state.directory };
|
|
187
|
+
if (/\/tasks/.test(url)) return { tasks: [] };
|
|
188
|
+
if (/\/claims/.test(url)) return { claims: [] };
|
|
189
|
+
return {};
|
|
190
|
+
},
|
|
191
|
+
patchJSON: async () => ({}),
|
|
192
|
+
postJSON: async (url, body) => { posts.push({ url, body }); return { ok: true }; },
|
|
193
|
+
sameId: (a, b) => String(a ?? '') === String(b ?? ''),
|
|
194
|
+
selfId: () => SELF,
|
|
195
|
+
state,
|
|
196
|
+
get workTabs() { return { activate() {} }; },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return { api, state, posts, toasts, getEl, els };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Open the invite box the way a user does: render the detail, then click
|
|
203
|
+
// "+ Invite". Returns the harness with the form on screen.
|
|
204
|
+
async function openInviteForm(h) {
|
|
205
|
+
h.api.renderDetail(1);
|
|
206
|
+
await tick();
|
|
207
|
+
h.getEl('goal-invite-open').fire('click');
|
|
208
|
+
await tick(); await tick();
|
|
209
|
+
return h;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
await test('the source no longer names the force-add as an "invite"', () => {
|
|
215
|
+
assert.ok(!/async function inviteMember\b/.test(RENDER_JS),
|
|
216
|
+
'inviteMember() was the force-add wearing the word invite — it is split in two');
|
|
217
|
+
assert.match(RENDER_JS, /async function sendInvitation\(/, 'the consented sender exists');
|
|
218
|
+
assert.match(RENDER_JS, /async function addMemberDirectly\(/, 'the force-add is named for what it does');
|
|
219
|
+
assert.ok(!/toast\('Invited\.'\)/.test(RENDER_JS), "the 'Invited.' toast on a force-add is gone");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await test('the invite form posts to /invitations and never claims they joined', async () => {
|
|
223
|
+
const h = await openInviteForm(harness());
|
|
224
|
+
h.getEl('goal-invite-builder').value = String(FREE_ID);
|
|
225
|
+
h.getEl('goal-invite-note').value = ' we could use your eye on this ';
|
|
226
|
+
h.getEl('goal-invite-form').fire('submit');
|
|
227
|
+
await tick(); await tick();
|
|
228
|
+
|
|
229
|
+
assert.equal(h.posts.length, 1, 'exactly one write');
|
|
230
|
+
const { url, body } = h.posts[0];
|
|
231
|
+
assert.match(url, /\/goals\/1\/invitations$/, 'the CONSENTED endpoint, not /members');
|
|
232
|
+
assert.ok(!/\/members$/.test(url), 'the force-add endpoint is not what Invite calls');
|
|
233
|
+
assert.equal(body.builder_id, FREE_ID);
|
|
234
|
+
assert.equal(body.note, 'we could use your eye on this', 'the note is sent, trimmed');
|
|
235
|
+
assert.ok(!('membership_kind' in body),
|
|
236
|
+
'the invitations route carries no membership_kind — sending one would be a choice dropped in silence');
|
|
237
|
+
|
|
238
|
+
const said = h.toasts.join(' ');
|
|
239
|
+
assert.match(said, /accept/i, 'the toast turns on their acceptance');
|
|
240
|
+
assert.ok(!/^Invited\.$/.test(h.toasts[0]), 'the old lie is not relocated');
|
|
241
|
+
assert.ok(!/\b(added|joined|is now a member)\b/i.test(said),
|
|
242
|
+
`nobody joined anything yet — got: ${JSON.stringify(h.toasts)}`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await test('an empty note is omitted rather than sent as an empty string', async () => {
|
|
246
|
+
const h = await openInviteForm(harness());
|
|
247
|
+
h.getEl('goal-invite-builder').value = String(FREE_ID);
|
|
248
|
+
h.getEl('goal-invite-note').value = ' ';
|
|
249
|
+
h.getEl('goal-invite-form').fire('submit');
|
|
250
|
+
await tick(); await tick();
|
|
251
|
+
assert.ok(!('note' in h.posts[0].body), 'a blank note is absent, not ""');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await test('the invite form offers no membership-kind picker', async () => {
|
|
255
|
+
const h = await openInviteForm(harness());
|
|
256
|
+
const html = h.getEl('goal-invite').innerHTML;
|
|
257
|
+
assert.ok(!/id="goal-invite-kind"/.test(html),
|
|
258
|
+
'the picker cannot be honoured by an invitation — goal_membership_requests has no such column');
|
|
259
|
+
assert.match(html, /join as a member/i, 'and the form says what they will join as instead');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await test('the dropdown excludes members AND anyone already pending', async () => {
|
|
263
|
+
const h = await openInviteForm(harness({
|
|
264
|
+
pendingRequests: [{ id: 55, kind: 'invite', builder_id: PENDING_ID, display_name: 'Ben Pending' }],
|
|
265
|
+
}));
|
|
266
|
+
const html = h.getEl('goal-invite').innerHTML;
|
|
267
|
+
assert.ok(html.includes(`value="${FREE_ID}"`), 'the invitable builder is offered');
|
|
268
|
+
assert.ok(!html.includes(`value="${MEMBER_ID}"`), 'a member would 409 already_a_member');
|
|
269
|
+
// The one that matters: a pending invitation is INVISIBLE in the member list,
|
|
270
|
+
// so without this filter the user picks an ordinary-looking name and is handed
|
|
271
|
+
// an error they had no way to predict.
|
|
272
|
+
assert.ok(!html.includes(`value="${PENDING_ID}"`), 'a pending invitee would 409 invite_pending');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await test('a pending JOIN-REQUEST is excluded too, not just an invitation', async () => {
|
|
276
|
+
const h = await openInviteForm(harness({
|
|
277
|
+
pendingRequests: [{ id: 56, kind: 'request', builder_id: PENDING_ID }],
|
|
278
|
+
}));
|
|
279
|
+
const html = h.getEl('goal-invite').innerHTML;
|
|
280
|
+
assert.ok(!html.includes(`value="${PENDING_ID}"`),
|
|
281
|
+
'the server refuses on EITHER kind, so the filter may not look only at invites');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await test('the force-add survives, one click away, and says nobody was asked', async () => {
|
|
285
|
+
const h = await openInviteForm(harness());
|
|
286
|
+
const collapsed = h.getEl('goal-invite').innerHTML;
|
|
287
|
+
assert.match(collapsed, /without asking/i, 'the path is discoverable, not removed');
|
|
288
|
+
assert.ok(!/id="goal-direct-form"/.test(collapsed), 'but it is not the default control');
|
|
289
|
+
|
|
290
|
+
h.getEl('goal-direct-open').fire('click');
|
|
291
|
+
await tick();
|
|
292
|
+
const open = h.getEl('goal-invite').innerHTML;
|
|
293
|
+
assert.match(open, /id="goal-direct-kind"/, 'the membership kind IS honoured on this path, so it is offered here');
|
|
294
|
+
|
|
295
|
+
h.getEl('goal-direct-builder').value = String(FREE_ID);
|
|
296
|
+
h.getEl('goal-direct-kind').value = 'lead';
|
|
297
|
+
h.getEl('goal-direct-form').fire('submit');
|
|
298
|
+
await tick(); await tick();
|
|
299
|
+
|
|
300
|
+
assert.equal(h.posts.length, 1);
|
|
301
|
+
assert.match(h.posts[0].url, /\/goals\/1\/members$/, 'the force-add endpoint');
|
|
302
|
+
assert.equal(h.posts[0].body.membership_kind, 'lead', 'and the chosen kind travels');
|
|
303
|
+
const said = h.toasts.join(' ');
|
|
304
|
+
assert.match(said, /without being asked/i, 'the consequence is stated, not hidden behind "Invited."');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
summary();
|