@bongos/core 1.21.67 → 1.21.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +24 -24
- package/docs/copy-inventory.md +371 -366
- package/docs/copy-registry.json +457 -412
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +718 -714
- package/modules/public-landing/public/account.html +88 -1
- package/modules/public-landing/public/account.probes.json +9 -0
- package/modules/public-landing/public/account.states.json +2 -1
- package/modules/public-landing/public/assets/cosmos.css +3 -0
- package/modules/public-landing/public/invitations.html +16 -7
- package/modules/public-landing/public/projects.html +14 -81
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/card_apply_ui.mjs +0 -1
- package/tests/hub_account_page.mjs +132 -6
- package/tests/hub_invitations_page.mjs +15 -0
- package/tests/public_landing_projects.mjs +12 -13
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
once, and every project's hall links out to it instead of keeping a copy.
|
|
11
11
|
Hub-only: platform-server.js serves /account only where platform-identity
|
|
12
12
|
is on, because every read and write below is one of that module's own-scoped
|
|
13
|
-
routes (GET/PATCH /me/cross-project, PATCH /me/handle, PATCH /me/profile
|
|
13
|
+
routes (GET/PATCH /me/cross-project, PATCH /me/handle, PATCH /me/profile,
|
|
14
|
+
GET /my-projects and POST /my-projects/:clientId/leave).
|
|
14
15
|
Server-stamped like the footer doors: {{…}} tokens filled by landingPage(). -->
|
|
15
16
|
<title>{{productName}} — Your account</title>
|
|
16
17
|
<meta name="description" content="Your {{productName}} account: your handle, your name, your bio and links, and who can find you.">
|
|
@@ -245,6 +246,24 @@
|
|
|
245
246
|
<p class="projMsg" id="recordMsg" role="status" aria-live="polite"></p>
|
|
246
247
|
</form>
|
|
247
248
|
</section>
|
|
249
|
+
|
|
250
|
+
<!-- PROJECTS YOU BUILD ON — GET /my-projects building[], Leave → POST
|
|
251
|
+
/my-projects/:clientId/leave (task 1003774). Moved here from
|
|
252
|
+
/projects?view=mine by task 1004277: the owner's call of 2026-09-25 put it
|
|
253
|
+
on the builder's own profile, own view only, and with no hub profile page
|
|
254
|
+
built (/u/<handle> was abandoned) this page is that own view (the owner's
|
|
255
|
+
pick, 2026-10-06). Cross-project data, so never on a project hall's seat
|
|
256
|
+
profile. The rows are cosmos.css's .mineRow, as on /invitations. -->
|
|
257
|
+
<section class="aSec" id="aBuilding" aria-labelledby="aBuilding-h">
|
|
258
|
+
<h2 id="aBuilding-h">projects you build on</h2>
|
|
259
|
+
<p class="aSide">The projects you have joined as a builder. Only you see this list.</p>
|
|
260
|
+
<div class="aBody">
|
|
261
|
+
<p class="aWhat" id="buildingLoading">Reading the projects you build on…</p>
|
|
262
|
+
<p class="aWhat" id="buildingEmpty" hidden>You’re not building on any other projects yet.</p>
|
|
263
|
+
<ul class="mineRows" id="buildingList"></ul>
|
|
264
|
+
<p class="projMsg" id="buildingMsg" role="status" aria-live="polite"></p>
|
|
265
|
+
</div>
|
|
266
|
+
</section>
|
|
248
267
|
</div>
|
|
249
268
|
</main>
|
|
250
269
|
|
|
@@ -323,6 +342,12 @@
|
|
|
323
342
|
$('loading').hidden = true;
|
|
324
343
|
$('door').hidden = true;
|
|
325
344
|
$('acctBody').hidden = false;
|
|
345
|
+
// a link to one section (/projects' "Open your account" names #aBuilding)
|
|
346
|
+
// lands on it: the browser looked for the anchor while the body was hidden
|
|
347
|
+
var hash = window.location && window.location.hash;
|
|
348
|
+
var target = hash && document.getElementById(hash.slice(1));
|
|
349
|
+
if (target && typeof target.scrollIntoView === 'function') target.scrollIntoView();
|
|
350
|
+
loadBuilding();
|
|
326
351
|
}, function () { showAlert('Couldn’t reach the server — check your connection and refresh.'); });
|
|
327
352
|
}
|
|
328
353
|
|
|
@@ -530,6 +555,68 @@
|
|
|
530
555
|
: 'Cross-project record shown on your public profile.');
|
|
531
556
|
});
|
|
532
557
|
|
|
558
|
+
// ---- projects you build on (task 1004277; the rows and Leave came from
|
|
559
|
+
// /projects?view=mine, task 1003774). Read once the account is shown, on its
|
|
560
|
+
// own: a failed read says so and never paints a false "not building on
|
|
561
|
+
// anything". The map's mineOrigins clean-up stayed behind with the map —
|
|
562
|
+
// /projects re-reads GET /my-projects whenever it is opened. ----
|
|
563
|
+
function hostOf(origin) {
|
|
564
|
+
return String(origin || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
|
|
565
|
+
}
|
|
566
|
+
function rowEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
567
|
+
function loadBuilding() {
|
|
568
|
+
var msg = $('buildingMsg');
|
|
569
|
+
return request('GET', '/my-projects').then(function (res) {
|
|
570
|
+
$('buildingLoading').hidden = true;
|
|
571
|
+
if (!res.ok || !res.data) { say(msg, 'Couldn’t read the projects you build on — refresh in a moment.', 'err'); return; }
|
|
572
|
+
paintBuilding(res.data.building || []);
|
|
573
|
+
}, function () {
|
|
574
|
+
$('buildingLoading').hidden = true;
|
|
575
|
+
say(msg, 'Couldn’t reach the server — check your connection and refresh.', 'err');
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
function paintBuilding(rows) {
|
|
579
|
+
var ul = $('buildingList');
|
|
580
|
+
ul.textContent = '';
|
|
581
|
+
rows.forEach(function (m) { ul.appendChild(buildingRow(m)); });
|
|
582
|
+
$('buildingEmpty').hidden = rows.length > 0;
|
|
583
|
+
}
|
|
584
|
+
function buildingRow(m) {
|
|
585
|
+
var label = m.name || m.client_id;
|
|
586
|
+
var row = rowEl('li', 'mineRow');
|
|
587
|
+
var main = rowEl('div', 'mineRow__main');
|
|
588
|
+
var name = rowEl('div', 'mineRow__name');
|
|
589
|
+
name.textContent = label;
|
|
590
|
+
main.appendChild(name);
|
|
591
|
+
var host = hostOf(m.origin);
|
|
592
|
+
if (host) { var h = rowEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
|
|
593
|
+
row.appendChild(main);
|
|
594
|
+
var actions = rowEl('div', 'mineRow__actions');
|
|
595
|
+
var btn = document.createElement('button');
|
|
596
|
+
btn.type = 'button';
|
|
597
|
+
btn.className = 'btn small ghost';
|
|
598
|
+
btn.textContent = 'Leave';
|
|
599
|
+
// every row's button reads Leave; the name says which project it leaves
|
|
600
|
+
btn.setAttribute('aria-label', 'Leave ' + label);
|
|
601
|
+
btn.addEventListener('click', function () { leave(m.client_id, label, row, btn); });
|
|
602
|
+
actions.appendChild(btn);
|
|
603
|
+
row.appendChild(actions);
|
|
604
|
+
return row;
|
|
605
|
+
}
|
|
606
|
+
function leave(clientId, label, row, btn) {
|
|
607
|
+
var msg = $('buildingMsg');
|
|
608
|
+
function restore() { btn.disabled = false; btn.textContent = 'Leave'; }
|
|
609
|
+
btn.disabled = true;
|
|
610
|
+
btn.textContent = 'Leaving…';
|
|
611
|
+
request('POST', '/my-projects/' + encodeURIComponent(clientId) + '/leave').then(function (res) {
|
|
612
|
+
if (res.status === 401) { restore(); sessionGone(msg); return; }
|
|
613
|
+
if (!res.ok) { restore(); say(msg, 'Couldn’t leave ' + label + '. Try again.', 'err'); return; }
|
|
614
|
+
row.remove();
|
|
615
|
+
say(msg, 'You left ' + label + '.', 'ok');
|
|
616
|
+
if (!$('buildingList').children.length) $('buildingEmpty').hidden = false;
|
|
617
|
+
}, function () { restore(); say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
|
|
618
|
+
}
|
|
619
|
+
|
|
533
620
|
// The identity read failing is not a sign-out. If the account itself was
|
|
534
621
|
// read, the chip names it from that answer, so the way out is never lost.
|
|
535
622
|
load().then(function () { return bar.ready; }).then(function () {
|
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
{ "name": "hiding the record saves hide_stats alone", "continue": true,
|
|
31
31
|
"steps": [["click", "#aRecord .aOpt:nth-child(2)"], ["click", "#recordSave"], ["wait", 400]],
|
|
32
32
|
"expect": { "text": [["#recordMsg", "Cross-project record hidden — removed from your profile and directories immediately."]], "checked": "input[name=record][value=hide]" } },
|
|
33
|
+
{ "name": "projects you build on (task 1004277): one row per project, each with its own Leave", "auth": true, "width": 1440, "url": "/account#aBuilding",
|
|
34
|
+
"steps": [["wait", 500]],
|
|
35
|
+
"expect": { "count": [["#buildingList .mineRow", 2]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"], ["#buildingList .mineRow:last-child button", "aria-label", "Leave Emersonian Circles"]], "hidden": ["#buildingEmpty"] } },
|
|
36
|
+
{ "name": "Leave drops the row and says so", "continue": true,
|
|
37
|
+
"steps": [["click", "#buildingList .mineRow:first-child button"], ["wait", 300]],
|
|
38
|
+
"expect": { "count": [["#buildingList .mineRow", 1]], "text": [["#buildingMsg", "You left Example World."]], "hidden": ["#buildingEmpty"] } },
|
|
39
|
+
{ "name": "leaving the last project shows the empty line", "continue": true,
|
|
40
|
+
"steps": [["click", "#buildingList .mineRow:first-child button"], ["wait", 300]],
|
|
41
|
+
"expect": { "count": [["#buildingList .mineRow", 0]], "text": [["#buildingMsg", "You left Emersonian Circles."], ["#buildingEmpty", "You’re not building on any other projects yet."]], "visible": ["#buildingEmpty"] } },
|
|
33
42
|
{ "name": "the hub's bar: the chip names the person, both bands closed, the page starts under the bar (task 1004455)", "auth": true, "width": 1440,
|
|
34
43
|
"steps": [["wait", 400]],
|
|
35
44
|
"expect": { "expanded": false, "top": [["#acct", 64]], "visible": ["#projTrig", "#who"], "hidden": ["#signinTop", "#menuProjects", "#menuProfile"], "text": [["#whoLogin", "builder"]], "count": [["#mapQ", 0]] } },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the
|
|
2
|
+
"_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the five sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links), the fifth being the projects you build on, from fixtures/my-projects.json (task 1004277; in-building lands on it through /account#aBuilding). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
|
|
3
3
|
"page": "/account",
|
|
4
4
|
"surface": "public-landing",
|
|
5
5
|
"modes": ["dark"],
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#who"], "text": [["#whoLogin", "builder"], ["#recordLine", "Your record: 3 projects · 1000 credits · 19 works shipped · 10 karma."]], "hidden": ["#door", "#loading", "#signinTop", "#menuProjects", "#menuProfile"], "value": [["#fHandle", "builder"], ["#fName", "A Builder"], ["#fLinkX", "https://x.com/builder"]] } },
|
|
13
13
|
"in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
|
|
14
14
|
"in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
|
|
15
|
+
"in-building": { "auth": true, "url": "/account#aBuilding", "actions": [["wait", 500]], "expect": { "visible": ["#aBuilding", "#buildingList"], "hidden": ["#buildingEmpty", "#buildingLoading"], "count": [["#buildingList .mineRow", 2]], "text": [["#buildingList .mineRow:first-child .mineRow__name", "Example World"], ["#buildingList .mineRow:first-child .mineRow__host", "example.com"]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"]] } },
|
|
15
16
|
"in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "text": [["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
|
|
16
17
|
},
|
|
17
18
|
"reducedMotion": { "auth": true, "actions": [["wait", 300]] }
|
|
@@ -1305,6 +1305,9 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1305
1305
|
(this content is a stacked list, not a label/value pair). ── */
|
|
1306
1306
|
.mineExtra section{margin-top:30px;padding-top:26px;border-top:1px solid var(--rule-soft);}
|
|
1307
1307
|
.mineExtra h2{font-family:var(--font-display);font-weight:300;font-size:clamp(20px,2.2vw,24px);letter-spacing:-.02em;line-height:1.15;text-transform:lowercase;color:var(--ink);margin:0 0 14px;}
|
|
1308
|
+
/* the mine view's two ways out (to /invitations and /account) are links in a
|
|
1309
|
+
sentence; inline-block lets each clear the kit's 24px target floor */
|
|
1310
|
+
#view-mine .mineExtra .lede a{display:inline-block;min-height:24px;}
|
|
1308
1311
|
.mineRows{list-style:none;display:flex;flex-direction:column;gap:0;margin:0;padding:0;}
|
|
1309
1312
|
.mineRow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid var(--rule-soft);}
|
|
1310
1313
|
.mineRows .mineRow:first-child{border-top:0;}
|
|
@@ -197,6 +197,12 @@
|
|
|
197
197
|
return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : '';
|
|
198
198
|
} catch (e) { return ''; }
|
|
199
199
|
}
|
|
200
|
+
/* hostOf is for DISPLAY: it tolerates a bare or half-typed address and drops
|
|
201
|
+
a leading www, so a row reads "example.com". pickHost (the join picker,
|
|
202
|
+
below) is for MATCHING: new URL().host keeps www and any port, so it agrees
|
|
203
|
+
exactly with the origins the server stores, and the picker's mine/applied
|
|
204
|
+
maps and the "Applied" echo key on that. Folding one into the other would
|
|
205
|
+
either mislabel rows or miss matches. */
|
|
200
206
|
function hostOf(origin) {
|
|
201
207
|
return String(origin || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
|
|
202
208
|
}
|
|
@@ -222,16 +228,16 @@
|
|
|
222
228
|
return row;
|
|
223
229
|
}
|
|
224
230
|
|
|
225
|
-
/* the
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
/* the decline shape: disable, POST, drop the row on success, re-enable the
|
|
232
|
+
button on failure. `onRemoved` carries whatever else the caller needs after
|
|
233
|
+
the row is gone (the empty-line flip). The busy/idle label swap the /projects
|
|
234
|
+
copy carries is for its Leave button, which never moved here. */
|
|
235
|
+
function postThenRemoveRow(url, row, btn, onRemoved) {
|
|
229
236
|
btn.disabled = true;
|
|
230
|
-
if (idleLabel) btn.textContent = idleLabel.busy;
|
|
231
237
|
fetch(url, { method: 'POST', credentials: 'same-origin' })
|
|
232
238
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
|
233
239
|
.then(function () { row.remove(); if (onRemoved) onRemoved(); })
|
|
234
|
-
.catch(function () { btn.disabled = false;
|
|
240
|
+
.catch(function () { btn.disabled = false; });
|
|
235
241
|
}
|
|
236
242
|
|
|
237
243
|
/* Each invitation renders as a way IN, never an Accept button: the hub's own
|
|
@@ -266,7 +272,7 @@
|
|
|
266
272
|
decline.textContent = 'Decline';
|
|
267
273
|
decline.addEventListener('click', function () {
|
|
268
274
|
postThenRemoveRow(
|
|
269
|
-
API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline,
|
|
275
|
+
API + '/my-projects/invites/' + encodeURIComponent(inv.client_id) + '/decline', row, decline,
|
|
270
276
|
paintInvitesSection
|
|
271
277
|
);
|
|
272
278
|
});
|
|
@@ -856,8 +862,11 @@
|
|
|
856
862
|
list.hidden = false;
|
|
857
863
|
input.setAttribute('aria-expanded', 'true');
|
|
858
864
|
}
|
|
865
|
+
/* once the feed is cached a keystroke renders once; only the first open
|
|
866
|
+
paints "Loading projects…" and then re-renders when the reads settle */
|
|
859
867
|
function openPicker() {
|
|
860
868
|
renderPicker();
|
|
869
|
+
if (picker.projects) return;
|
|
861
870
|
loadPicker().then(function () {
|
|
862
871
|
if (document.activeElement === $('joinOrigin')) renderPicker();
|
|
863
872
|
});
|
|
@@ -955,20 +955,22 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
955
955
|
<ul class="projects" id="projectsList"></ul>
|
|
956
956
|
<p class="finePrint">Buttons here ask the platform to act; each row reports only what was verified, and when. A standup keeps going even with this tab closed.</p>
|
|
957
957
|
|
|
958
|
-
<!-- ══
|
|
958
|
+
<!-- ══ the ways to INVITATIONS and to PROJECTS YOU BUILD ON ══
|
|
959
959
|
The invitations (a project's own, a guild's, a guild's engagements, the
|
|
960
960
|
requests to a guild you own) and the join-by-address field have their own
|
|
961
|
-
page since task 1004276 (the owner's call, ADR 0337 D5): /invitations.
|
|
961
|
+
page since task 1004276 (the owner's call, ADR 0337 D5): /invitations.
|
|
962
|
+
The projects you build on, with Leave (task 1003774), moved to the
|
|
963
|
+
account page with task 1004277: the owner put them on the builder's own
|
|
964
|
+
profile, and /account is the hub's own view of it. ══ -->
|
|
962
965
|
<div class="mineExtra">
|
|
963
966
|
<section aria-labelledby="mineToInvitesH">
|
|
964
967
|
<h2 id="mineToInvitesH">Invitations</h2>
|
|
965
968
|
<p class="lede">Invitations from projects and guilds, and applying to a project by its address, have their own page. <a href="/invitations">Open your invitations</a></p>
|
|
966
969
|
</section>
|
|
967
970
|
|
|
968
|
-
<section aria-labelledby="
|
|
969
|
-
<h2 id="
|
|
970
|
-
<p class="
|
|
971
|
-
<ul class="mineRows" id="buildingList"></ul>
|
|
971
|
+
<section aria-labelledby="mineToAccountH">
|
|
972
|
+
<h2 id="mineToAccountH">Projects you build on</h2>
|
|
973
|
+
<p class="lede">The projects you build on, and leaving one, are on your account page. <a href="/account#aBuilding">Open your account</a></p>
|
|
972
974
|
</section>
|
|
973
975
|
</div>
|
|
974
976
|
</section>
|
|
@@ -2522,10 +2524,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2522
2524
|
: '%3Fview%3D' + v + idPart.replace('&', '%26').replace('=', '%3D');
|
|
2523
2525
|
$('signinTop').href = '/api/bongos/auth/web/start?return=/projects' + backPart + '&repos=1';
|
|
2524
2526
|
/* the mine view is the only one that polls; it re-reads on entry so a
|
|
2525
|
-
card that changed while you were on the map is not stale
|
|
2526
|
-
the
|
|
2527
|
-
|
|
2528
|
-
if (v === 'mine' && me)
|
|
2527
|
+
card that changed while you were on the map is not stale. (It re-read
|
|
2528
|
+
the memberships too while it listed the projects you build on; that
|
|
2529
|
+
list is /account's since task 1004277.) */
|
|
2530
|
+
if (v === 'mine' && me) refreshList();
|
|
2529
2531
|
/* manage re-reads its project on entry — no timer lives here (C8) */
|
|
2530
2532
|
if (v === 'manage') loadManage();
|
|
2531
2533
|
/* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
|
|
@@ -3525,23 +3527,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3525
3527
|
401 (signed out) is not an error here. */
|
|
3526
3528
|
function loadMemberships() {
|
|
3527
3529
|
return fetch(API + '/my-projects', { credentials: 'same-origin' })
|
|
3528
|
-
.then(function (res) {
|
|
3529
|
-
|
|
3530
|
-
? res.json().then(function (d) { return { ok: true, d: d }; })
|
|
3531
|
-
: { ok: false, d: { owned: [], building: [], applied: [] } };
|
|
3532
|
-
})
|
|
3533
|
-
.then(function (r) {
|
|
3534
|
-
var d = r.d;
|
|
3530
|
+
.then(function (res) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
|
|
3531
|
+
.then(function (d) {
|
|
3535
3532
|
mineOrigins = {};
|
|
3536
3533
|
appliedOrigins = {};
|
|
3537
3534
|
(d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
|
|
3538
3535
|
(d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
|
|
3539
3536
|
(d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
|
|
3540
|
-
/* only a genuinely successful read may repaint "Projects you build on" —
|
|
3541
|
-
a transient failure resets the map's origin maps (as before) but must
|
|
3542
|
-
not overwrite the list with a false "not building on anything" empty
|
|
3543
|
-
state on every re-entry to this view (task 1003774) */
|
|
3544
|
-
if (r.ok) renderBuildingList(d.building || []);
|
|
3545
3537
|
})
|
|
3546
3538
|
.catch(function () { mineOrigins = {}; appliedOrigins = {}; });
|
|
3547
3539
|
}
|
|
@@ -3569,65 +3561,6 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3569
3561
|
.catch(function () { ownedInstances = []; ownedByHost = {}; });
|
|
3570
3562
|
}
|
|
3571
3563
|
|
|
3572
|
-
/* ── the building-on rows (task 1003774; the invitation rows moved to
|
|
3573
|
-
/invitations with task 1004276) ── Same row shape as that page's lists: a name, its host in the mono face, and one
|
|
3574
|
-
action on the right. textContent-only, matching the deleted hall page
|
|
3575
|
-
this restores. */
|
|
3576
|
-
function mineEl(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
3577
|
-
function mineRow(p) {
|
|
3578
|
-
var row = mineEl('li', 'mineRow');
|
|
3579
|
-
var main = mineEl('div', 'mineRow__main');
|
|
3580
|
-
var name = mineEl('div', 'mineRow__name');
|
|
3581
|
-
name.textContent = p.name || p.client_id;
|
|
3582
|
-
main.appendChild(name);
|
|
3583
|
-
var host = hostOf(p.origin);
|
|
3584
|
-
if (host) { var h = mineEl('span', 'mineRow__host'); h.textContent = host; main.appendChild(h); }
|
|
3585
|
-
row.appendChild(main);
|
|
3586
|
-
return row;
|
|
3587
|
-
}
|
|
3588
|
-
|
|
3589
|
-
/* the leave/decline shape: disable, POST, drop the row on success, restore
|
|
3590
|
-
the button on failure. `onRemoved` carries whatever else the caller needs
|
|
3591
|
-
after the row is gone (the empty-state flip, the mineOrigins cleanup). */
|
|
3592
|
-
function postThenRemoveRow(url, row, btn, idleLabel, onRemoved) {
|
|
3593
|
-
btn.disabled = true;
|
|
3594
|
-
if (idleLabel) btn.textContent = idleLabel.busy;
|
|
3595
|
-
fetch(url, { method: 'POST', credentials: 'same-origin' })
|
|
3596
|
-
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
|
3597
|
-
.then(function () { row.remove(); if (onRemoved) onRemoved(); })
|
|
3598
|
-
.catch(function () { btn.disabled = false; if (idleLabel) btn.textContent = idleLabel.idle; });
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
function renderBuildingList(rows) {
|
|
3602
|
-
var ul = $('buildingList');
|
|
3603
|
-
ul.textContent = '';
|
|
3604
|
-
(rows || []).forEach(function (m) {
|
|
3605
|
-
var row = mineRow(m);
|
|
3606
|
-
var actions = mineEl('div', 'mineRow__actions');
|
|
3607
|
-
var btn = document.createElement('button');
|
|
3608
|
-
btn.type = 'button';
|
|
3609
|
-
btn.className = 'btn small ghost';
|
|
3610
|
-
btn.textContent = 'Leave';
|
|
3611
|
-
btn.addEventListener('click', function () {
|
|
3612
|
-
postThenRemoveRow(
|
|
3613
|
-
API + '/my-projects/' + encodeURIComponent(m.client_id) + '/leave', row, btn,
|
|
3614
|
-
{ busy: 'Leaving…', idle: 'Leave' },
|
|
3615
|
-
/* the map reads mineOrigins for its "you build here" pill and Apply
|
|
3616
|
-
button — clear this project's entry so a leave takes effect there
|
|
3617
|
-
on the next repaint too, not just in this list (task 1003774) */
|
|
3618
|
-
function () {
|
|
3619
|
-
delete mineOrigins[hostOf(m.origin)];
|
|
3620
|
-
if (!ul.children.length) $('buildingEmpty').hidden = false;
|
|
3621
|
-
}
|
|
3622
|
-
);
|
|
3623
|
-
});
|
|
3624
|
-
actions.appendChild(btn);
|
|
3625
|
-
row.appendChild(actions);
|
|
3626
|
-
ul.appendChild(row);
|
|
3627
|
-
});
|
|
3628
|
-
$('buildingEmpty').hidden = !!(rows && rows.length);
|
|
3629
|
-
}
|
|
3630
|
-
|
|
3631
3564
|
/* the relay the map's per-card Apply (submitApply) drives — POST
|
|
3632
3565
|
/my-projects/join, the 401 case and the 'requested' echo update kept in ONE
|
|
3633
3566
|
place (task 1003774). /invitations carries the same relay for its join field
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.69",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.69",
|
|
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.21.
|
|
3
|
+
"version": "1.21.69",
|
|
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/release-notes.json
CHANGED
|
@@ -9182,5 +9182,17 @@
|
|
|
9182
9182
|
"id": "1004257",
|
|
9183
9183
|
"text": "The cloudbongos.com landing page now has a \"Download the CLI\" button in its closing section. It picks the right download for the visitor's computer and lists the others. It stays hidden until a CLI release is published, so not"
|
|
9184
9184
|
}
|
|
9185
|
+
],
|
|
9186
|
+
"1.21.68": [
|
|
9187
|
+
{
|
|
9188
|
+
"id": "1004589",
|
|
9189
|
+
"text": "The /invitations project picker now draws its list once per keystroke instead of twice. Nothing looks different."
|
|
9190
|
+
}
|
|
9191
|
+
],
|
|
9192
|
+
"1.21.69": [
|
|
9193
|
+
{
|
|
9194
|
+
"id": "1004277",
|
|
9195
|
+
"text": "The projects you build on, with their Leave buttons, now live on your account page. My projects links there."
|
|
9196
|
+
}
|
|
9185
9197
|
]
|
|
9186
9198
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.69'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/card_apply_ui.mjs
CHANGED
|
@@ -246,7 +246,6 @@ test('loadMemberships reads GET /my-projects\' applied echo into appliedOrigins,
|
|
|
246
246
|
API: '/api/bongos',
|
|
247
247
|
mineOrigins: {},
|
|
248
248
|
appliedOrigins: {},
|
|
249
|
-
renderBuildingList: () => {},
|
|
250
249
|
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
251
250
|
fetch: () => Promise.resolve({
|
|
252
251
|
ok: true,
|
|
@@ -81,7 +81,7 @@ test('the script builds no markup from strings, and the controls are real markup
|
|
|
81
81
|
test('the door returns here, and the page calls only its own-scoped routes', () => {
|
|
82
82
|
assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
|
|
83
83
|
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
84
|
-
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
|
|
84
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/'].sort());
|
|
85
85
|
// the bar's two calls live in the shared asset: the identity read and sign out
|
|
86
86
|
const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
|
|
87
87
|
assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
|
|
@@ -180,14 +180,27 @@ const ACCOUNT = () => ({
|
|
|
180
180
|
function boot(answer) {
|
|
181
181
|
const els = new Map();
|
|
182
182
|
const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
// textContent reads the node's own text plus its children's, and setting it
|
|
184
|
+
// drops the children, as in the browser — so a list can be built, read and
|
|
185
|
+
// emptied (the projects you build on, task 1004277)
|
|
186
|
+
const withChildren = (o) => {
|
|
187
|
+
o.children = [];
|
|
188
|
+
o._text = '';
|
|
189
|
+
Object.defineProperty(o, 'textContent', {
|
|
190
|
+
get() { return o._text + o.children.map((c) => c.textContent).join(''); },
|
|
191
|
+
set(v) { o._text = String(v); o.children = []; },
|
|
192
|
+
});
|
|
193
|
+
o.appendChild = (c) => { c.parent = o; o.children.push(c); return c; };
|
|
194
|
+
o.remove = () => { if (o.parent) o.parent.children = o.parent.children.filter((c) => c !== o); };
|
|
195
|
+
return o;
|
|
196
|
+
};
|
|
197
|
+
const el = (id, hidden, attrs) => withChildren(listeners({
|
|
198
|
+
id, hidden, attrs, value: '', className: '', disabled: false, style: {},
|
|
186
199
|
getAttribute(n) { return n in attrs ? attrs[n] : null; },
|
|
187
200
|
setAttribute(n, v) { attrs[n] = v; },
|
|
188
201
|
closest(sel) { return sel === '.trig' && /\btrig\b/.test(attrs.class || '') ? this : null; },
|
|
189
202
|
focus() { dom.focused = id; document.activeElement = this; },
|
|
190
|
-
});
|
|
203
|
+
}));
|
|
191
204
|
for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
|
|
192
205
|
const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
193
206
|
els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
|
|
@@ -262,7 +275,10 @@ function boot(answer) {
|
|
|
262
275
|
if (!names.length) throw new Error(`unstubbed selector: ${sel}`);
|
|
263
276
|
return radios.filter((r) => names.includes(r.name));
|
|
264
277
|
},
|
|
265
|
-
createElement: () =>
|
|
278
|
+
createElement: (tag) => {
|
|
279
|
+
const attrs = {};
|
|
280
|
+
return withChildren(listeners({ tag, attrs, className: '', disabled: false, setAttribute(n, v) { attrs[n] = String(v); } }));
|
|
281
|
+
},
|
|
266
282
|
};
|
|
267
283
|
const fetch = (url, init = {}) => {
|
|
268
284
|
const method = init.method || 'GET';
|
|
@@ -440,6 +456,116 @@ test('a session that expired mid-edit reopens the door instead of failing silent
|
|
|
440
456
|
assert.equal(dom.els.get('signinTop').hidden, false);
|
|
441
457
|
});
|
|
442
458
|
|
|
459
|
+
// ── 3a. the projects you build on (task 1004277) ─────────────────────────────
|
|
460
|
+
// Moved here from /projects?view=mine (task 1003774's list and Leave): the owner
|
|
461
|
+
// put it on the builder's own profile, own view only, and with no hub profile
|
|
462
|
+
// page built, /account is that view (the owner's pick, 2026-10-06).
|
|
463
|
+
|
|
464
|
+
const BUILDING = [
|
|
465
|
+
{ client_id: 'otb', name: 'Example World', origin: 'https://www.example.com/' },
|
|
466
|
+
{ client_id: 'a b', name: null, origin: 'https://emersonian.cloudbongos.com' },
|
|
467
|
+
];
|
|
468
|
+
const building = (rows, leave = () => ({ status: 200, data: { ok: true, left: true } })) => (m, p) => {
|
|
469
|
+
if (m === 'GET' && p === '/me/cross-project') return { status: 200, data: ACCOUNT() };
|
|
470
|
+
if (m === 'GET' && p === '/my-projects') return rows ? { status: 200, data: { owned: [], building: rows, applied: [] } } : { status: 500, data: null };
|
|
471
|
+
if (m === 'POST' && /^\/my-projects\/[^/]+\/leave$/.test(p)) return leave(p);
|
|
472
|
+
return { status: 500, data: null };
|
|
473
|
+
};
|
|
474
|
+
const rowsOf = (dom) => dom.els.get('buildingList').children;
|
|
475
|
+
const leaveOf = (row) => row.children[1].children[0];
|
|
476
|
+
|
|
477
|
+
test('the section ships inside the signed-in body, last, as a list with an empty line and a status line', () => {
|
|
478
|
+
const body = PAGE.split('<div id="acctBody" hidden>')[1].split('</main>')[0];
|
|
479
|
+
const sec = (body.match(/<section class="aSec" id="aBuilding" aria-labelledby="aBuilding-h">[\s\S]*?<\/section>/) || [''])[0];
|
|
480
|
+
assert.ok(sec, 'the projects you build on are a section of the account body');
|
|
481
|
+
assert.ok(body.indexOf('id="aBuilding"') > body.indexOf('id="aRecord"'), 'after your record, which counts them');
|
|
482
|
+
assert.match(sec, /<h2 id="aBuilding-h">projects you build on<\/h2>/);
|
|
483
|
+
assert.match(sec, /<p class="aWhat" id="buildingEmpty" hidden>/, 'the empty line waits for a read that says so');
|
|
484
|
+
assert.match(sec, /<ul class="mineRows" id="buildingList"><\/ul>/, 'the rows are cosmos.css’s, as on /invitations');
|
|
485
|
+
assert.match(sec, /<p class="projMsg" id="buildingMsg" role="status" aria-live="polite"><\/p>/);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('signed in: the list is read once, after the account, and each row names its project and host with its own Leave', async () => {
|
|
489
|
+
const dom = boot(building(BUILDING));
|
|
490
|
+
await settle();
|
|
491
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects']);
|
|
492
|
+
assert.equal(dom.els.get('buildingLoading').hidden, true);
|
|
493
|
+
assert.equal(dom.els.get('buildingEmpty').hidden, true);
|
|
494
|
+
const rows = rowsOf(dom);
|
|
495
|
+
assert.equal(rows.length, 2);
|
|
496
|
+
assert.equal(rows[0].className, 'mineRow');
|
|
497
|
+
assert.equal(rows[0].children[0].children[0].textContent, 'Example World');
|
|
498
|
+
assert.equal(rows[0].children[0].children[1].textContent, 'example.com', 'the host, without scheme, www or path');
|
|
499
|
+
assert.equal(rows[1].children[0].children[0].textContent, 'a b', 'no name: the client id stands in');
|
|
500
|
+
assert.equal(leaveOf(rows[0]).textContent, 'Leave');
|
|
501
|
+
assert.equal(leaveOf(rows[0]).attrs['aria-label'], 'Leave Example World', 'every button reads Leave; its name says which project');
|
|
502
|
+
assert.equal(leaveOf(rows[0]).className, 'btn small ghost');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('nothing built on: the empty line shows; a failed read says so and never claims the list is empty', async () => {
|
|
506
|
+
const none = boot(building([]));
|
|
507
|
+
await settle();
|
|
508
|
+
assert.equal(rowsOf(none).length, 0);
|
|
509
|
+
assert.equal(none.els.get('buildingEmpty').hidden, false);
|
|
510
|
+
assert.match(PAGE, /id="buildingEmpty" hidden>You’re not building on any other projects yet\.<\/p>/);
|
|
511
|
+
|
|
512
|
+
const failed = boot(building(null));
|
|
513
|
+
await settle();
|
|
514
|
+
assert.equal(failed.els.get('buildingEmpty').hidden, true, 'a failed read is not "nothing"');
|
|
515
|
+
assert.equal(failed.els.get('buildingLoading').hidden, true);
|
|
516
|
+
assert.equal(failed.text('buildingMsg'), 'Couldn’t read the projects you build on — refresh in a moment.');
|
|
517
|
+
assert.equal(failed.els.get('buildingMsg').className, 'projMsg err');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('Leave posts to the project’s own client id, says so while it goes, drops the row, and the last one shows the empty line', async () => {
|
|
521
|
+
const dom = boot(building(BUILDING));
|
|
522
|
+
await settle();
|
|
523
|
+
const [first] = rowsOf(dom);
|
|
524
|
+
dom.click(leaveOf(first));
|
|
525
|
+
assert.equal(leaveOf(first).disabled, true);
|
|
526
|
+
assert.equal(leaveOf(first).textContent, 'Leaving…');
|
|
527
|
+
await settle();
|
|
528
|
+
assert.equal(dom.calls.filter((c) => c.method === 'POST')[0].path, '/my-projects/otb/leave');
|
|
529
|
+
assert.equal(rowsOf(dom).length, 1, 'the row is gone once the server says so');
|
|
530
|
+
assert.equal(dom.text('buildingMsg'), 'You left Example World.');
|
|
531
|
+
assert.equal(dom.els.get('buildingMsg').className, 'projMsg ok');
|
|
532
|
+
assert.equal(dom.els.get('buildingEmpty').hidden, true, 'one left still');
|
|
533
|
+
|
|
534
|
+
dom.click(leaveOf(rowsOf(dom)[0]));
|
|
535
|
+
await settle();
|
|
536
|
+
assert.equal(dom.calls.filter((c) => c.method === 'POST')[1].path, '/my-projects/a%20b/leave', 'the id is encoded, never spliced raw');
|
|
537
|
+
assert.equal(rowsOf(dom).length, 0);
|
|
538
|
+
assert.equal(dom.els.get('buildingEmpty').hidden, false, 'and with the last gone, the empty line');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('a refused Leave keeps the row and its button, and an expired session opens the door', async () => {
|
|
542
|
+
const refused = boot(building(BUILDING, () => ({ status: 500, data: { error: { code: 'leave_failed', message: 'internal error' } } })));
|
|
543
|
+
await settle();
|
|
544
|
+
const row = rowsOf(refused)[0];
|
|
545
|
+
refused.click(leaveOf(row));
|
|
546
|
+
await settle();
|
|
547
|
+
assert.equal(rowsOf(refused).length, 2);
|
|
548
|
+
assert.equal(leaveOf(row).disabled, false);
|
|
549
|
+
assert.equal(leaveOf(row).textContent, 'Leave');
|
|
550
|
+
assert.equal(refused.text('buildingMsg'), 'Couldn’t leave Example World. Try again.', 'plain words, not the server’s "internal error"');
|
|
551
|
+
assert.equal(refused.els.get('buildingMsg').className, 'projMsg err');
|
|
552
|
+
|
|
553
|
+
const expired = boot(building(BUILDING, () => ({ status: 401, data: null })));
|
|
554
|
+
await settle();
|
|
555
|
+
expired.click(leaveOf(rowsOf(expired)[0]));
|
|
556
|
+
await settle();
|
|
557
|
+
assert.equal(rowsOf(expired).length, 2);
|
|
558
|
+
assert.equal(expired.els.get('door').hidden, false);
|
|
559
|
+
assert.equal(expired.els.get('acctBody').hidden, true);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('/projects keeps no copy: the mine view points here, and nothing there reads or leaves the list', () => {
|
|
563
|
+
assert.doesNotMatch(HUB, /id="buildingList"|renderBuildingList|\/leave'/, 'the list and Leave moved here whole');
|
|
564
|
+
assert.match(HUB, /<h2 id="mineToAccountH">Projects you build on<\/h2>\s*<p class="lede">[^<]*<a href="\/account#aBuilding">Open your account<\/a><\/p>/, 'the mine view says where they went');
|
|
565
|
+
assert.match(HUB, /\(d\.building \|\| \[\]\)\.forEach\(function \(m\) \{ if \(m\.origin\) mineOrigins\[hostOf\(m\.origin\)\] = 'builder'; \}\);/, 'the map still reads who you build with');
|
|
566
|
+
assert.match(SCRIPT, /if \(target && typeof target\.scrollIntoView === 'function'\) target\.scrollIntoView\(\);/, 'and #aBuilding lands on the section once the body shows');
|
|
567
|
+
});
|
|
568
|
+
|
|
443
569
|
// ── 3b. the bar, executed (task 1004455) ─────────────────────────────────────
|
|
444
570
|
|
|
445
571
|
const ME = { builder: { id: '1', github_login: 'octocat', avatar_url: 'https://avatars.example/u/1' } };
|
|
@@ -253,6 +253,21 @@ test('the picker lists the map minus the hub, an anonymous row and your own proj
|
|
|
253
253
|
assert.equal(ui.$('joinOrigin').value, '', 'an Applied row cannot be picked again');
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
+
test('the picker: once the map is cached, a keystroke renders the list once (task 1004589)', async () => {
|
|
257
|
+
const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
|
|
258
|
+
const input = ui.$('joinOrigin');
|
|
259
|
+
let renders = 0;
|
|
260
|
+
const set = input.setAttribute;
|
|
261
|
+
input.setAttribute = (k, v) => { if (k === 'aria-expanded' && v === 'true') renders++; return set(k, v); };
|
|
262
|
+
await ui.fire(input, 'focus');
|
|
263
|
+
assert.equal(renders, 2, 'the first open paints "Loading projects…", then the list when the reads settle');
|
|
264
|
+
renders = 0;
|
|
265
|
+
input.value = 'grove';
|
|
266
|
+
await ui.fire(input, 'input');
|
|
267
|
+
assert.equal(renders, 1, 'not a second time when the already-settled read resolves again');
|
|
268
|
+
assert.deepEqual(optionsOf(ui).map((o) => o.children[0].textContent), ['The Grove']);
|
|
269
|
+
});
|
|
270
|
+
|
|
256
271
|
test('the picker narrows by name or address, and the keyboard picks: arrows skip an Applied row, Enter fills the address, Escape closes', async () => {
|
|
257
272
|
const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
|
|
258
273
|
const input = ui.$('joinOrigin');
|