Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3c192677d | ||
|
|
95ee19cc71 | ||
|
|
07dba7f2f5 | ||
|
|
ceee80d5b7 | ||
|
|
fa9bedb09a |
No files matched your search
@@ -269,7 +269,7 @@
|
||||
restore(p, body, kept);
|
||||
}
|
||||
const content = body.querySelector('[data-project-content]');
|
||||
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) });
|
||||
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` });
|
||||
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
|
||||
}
|
||||
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
|
||||
|
||||
@@ -205,14 +205,15 @@
|
||||
// state lives as long as the container.
|
||||
const paneViews = new WeakMap();
|
||||
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds) {
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') {
|
||||
const pid=esc(idPrefix);
|
||||
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
|
||||
const north=typeof data.north?.content==='string'?data.north.content:null;
|
||||
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const select=options.length>1?`<label>Request <select id="${pid}project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
|
||||
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
|
||||
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="${pid}project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="${pid}project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
}
|
||||
function overview(data,project,canSend,receipt='') {
|
||||
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
|
||||
@@ -255,7 +256,9 @@
|
||||
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
focus?.focus({preventScroll:true});
|
||||
}
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
|
||||
// A Side by side pane passes `idPrefix` (pane-a-, pane-b-), so the same project in both panes
|
||||
// draws distinct ids; the full view passes none and keeps its ids.
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null,idPrefix=''}) {
|
||||
if(!container || !project) return;
|
||||
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
|
||||
contentRuns.set(container,run);
|
||||
@@ -267,7 +270,7 @@
|
||||
const selected=selectedRequests.get(project.id);
|
||||
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
|
||||
const selection=selectedRequests.get(project.id);
|
||||
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
|
||||
// The generated presentation and separately mounted Markdown determine visible content.
|
||||
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
|
||||
@@ -285,12 +288,13 @@
|
||||
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
|
||||
const draw=selected=>{
|
||||
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
|
||||
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
|
||||
const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); }
|
||||
container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});});
|
||||
const requestSelect='#'+CSS.escape(idPrefix+'project-request-select');
|
||||
container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});});
|
||||
};
|
||||
draw(selection);
|
||||
restore=liveRestore || restore;
|
||||
@@ -298,7 +302,7 @@
|
||||
restoreView(container,restore);
|
||||
}
|
||||
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'});
|
||||
}
|
||||
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
|
||||
function previewCut(content,max) {
|
||||
|
||||
@@ -373,6 +373,8 @@
|
||||
}
|
||||
async function submit(form) {
|
||||
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
|
||||
// A form whose Save is disabled is sending, or waiting on the current task: it takes no second save.
|
||||
if (form.querySelector('[data-tb-submit]')?.disabled) return;
|
||||
if (!form.reportValidity()) return;
|
||||
let body, t = null;
|
||||
if (kind === 'create') {
|
||||
@@ -388,16 +390,32 @@
|
||||
}
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write(body);
|
||||
// The task changed under the form: the form reopens on the current task and keeps the refusal.
|
||||
// Until the current task is read the old form stays disabled and does not show the refusal, so
|
||||
// the refusal never stands beside the old task and no save goes out against its digest.
|
||||
if (!r.ok && r.error === 'task-conflict' && t) {
|
||||
formStatus(form, 'Reading the current task…');
|
||||
// A read a poll overtook leaves the board's old read in place, so only a read that landed counts.
|
||||
const fresh = await reload().catch(() => false);
|
||||
const now = (fresh && find(key(t))) || await current(t);
|
||||
// Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open`
|
||||
// at once but queues the event that empties the dialog, so `open` is the check that holds.
|
||||
const d = dialogEl();
|
||||
if (!d.open || !d.contains(form)) { paint(); return; }
|
||||
if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, r.message); }
|
||||
else {
|
||||
// The current task could not be read: the form still holds the old one, so it takes no save.
|
||||
opened = null;
|
||||
formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.');
|
||||
form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; });
|
||||
}
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) {
|
||||
formStatus(form, r.message);
|
||||
// The task changed under the form: the form shows the current task and keeps the refusal.
|
||||
if (r.error === 'task-conflict' && t) {
|
||||
try { await reload(); } catch {}
|
||||
const now = find(key(t)) || await current(t);
|
||||
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
|
||||
paint();
|
||||
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
|
||||
if (r.unknown) { try { await reload(); } catch {} paint(); }
|
||||
return;
|
||||
}
|
||||
const label = orgOf(body.org).label;
|
||||
|
||||
@@ -259,14 +259,16 @@ test('the same project in both panes keeps a request choice per pane', async t =
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
|
||||
const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`;
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
|
||||
const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`;
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`${values}[0] === 'request-2'`);
|
||||
// The redrawn select gets focus back through the pane's own id.
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select');
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
|
||||
});
|
||||
|
||||
@@ -279,10 +281,10 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
// The same organization and project in both panes.
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
|
||||
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
|
||||
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`);
|
||||
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
|
||||
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
|
||||
// A new conversation changes the header's count, so each pane replaces its project content.
|
||||
@@ -290,7 +292,7 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation
|
||||
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
|
||||
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
|
||||
});
|
||||
|
||||
@@ -338,3 +340,68 @@ test('each Agents pane keeps its own project choice for its organization', async
|
||||
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
|
||||
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
|
||||
});
|
||||
|
||||
// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id
|
||||
// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane.
|
||||
const idAudit = `(() => {
|
||||
const count = new Map();
|
||||
for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1);
|
||||
const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort();
|
||||
const stray = [];
|
||||
for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) {
|
||||
for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) {
|
||||
for (const el of pane.querySelectorAll('[' + attr + ']')) {
|
||||
for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) {
|
||||
const target = document.getElementById(id);
|
||||
if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) {
|
||||
const id = decodeURIComponent(a.getAttribute('href').slice(1));
|
||||
const target = id && document.getElementById(id);
|
||||
if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id);
|
||||
}
|
||||
}
|
||||
return { duplicates, stray: stray.sort() };
|
||||
})()`;
|
||||
|
||||
test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
|
||||
const set = agents => ({ state: 'ok', agents, excluded: [] });
|
||||
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
|
||||
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] },
|
||||
projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } },
|
||||
] };
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`;
|
||||
const pages = [
|
||||
['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`],
|
||||
['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`],
|
||||
[`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
|
||||
[`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`],
|
||||
[`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
|
||||
[`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`],
|
||||
[`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
|
||||
];
|
||||
const found = {}, drawn = {};
|
||||
for (const [route, ready] of pages) {
|
||||
await b.evaluate(both(route));
|
||||
await wait(ready);
|
||||
await settled(b);
|
||||
const page = route.replace(P, 'P');
|
||||
found[page] = await b.evaluate(idAudit);
|
||||
drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`);
|
||||
}
|
||||
assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }])));
|
||||
// The audit is not vacuous: the dashboard draws its system types heading and the requirements page
|
||||
// its request select, north star and PRD headings, each with the pane's prefix.
|
||||
assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']);
|
||||
assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']);
|
||||
});
|
||||
@@ -128,11 +128,14 @@ test('org and project filters narrow the board', async t => {
|
||||
});
|
||||
|
||||
test('the layout toggle is remembered across a reload', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
const { b, wait, url } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('location.reload()');
|
||||
// Load the page again through navigate(), which waits for the load. A location.reload() from evaluate
|
||||
// returns before the reload commits, so the next wait ran on the old page and the reload killed it
|
||||
// (as instances-browser found). The same URL with only a hash would not load, so add a query.
|
||||
await b.navigate(url.replace('/#', '/?reload=1#'));
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
|
||||
@@ -221,6 +224,107 @@ test('an edit dialog left open while another writer changes the task saves again
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
|
||||
});
|
||||
|
||||
// Polish 1 (#1873): after a conflict the dialog reads the current task before it reopens. This holds
|
||||
// that read: the first workspace read after the write's answer waits until window.__release().
|
||||
const HOLD_CONFLICT_READ = `(() => {
|
||||
const real = window.fetch;
|
||||
window.__armed = false; window.__held = false;
|
||||
window.fetch = (u, o) => {
|
||||
const url = String(u);
|
||||
if (url === '/api/workspace/tasks') return real(u, o).then(r => { window.__armed = true; return r; });
|
||||
if (url.startsWith('/api/workspace?') && window.__armed && !window.__held) {
|
||||
window.__held = true;
|
||||
return new Promise(go => { window.__release = () => real(u, o).then(go); });
|
||||
}
|
||||
return real(u, o);
|
||||
};
|
||||
})()`;
|
||||
const SAVE_AS = title => `(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = ${JSON.stringify(title)}; f.requestSubmit(); })()`;
|
||||
const STATUS = 'document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent';
|
||||
|
||||
test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
||||
await b.evaluate(HOLD_CONFLICT_READ);
|
||||
await b.evaluate(SAVE_AS('Invoice export, CSV'));
|
||||
await wait('window.__held');
|
||||
// The write was refused and the current task is still being read: the form still holds the old task,
|
||||
// so it does not show the refusal and its Save is off.
|
||||
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export, CSV');
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
|
||||
// A save in this window, even one sent past the disabled button, goes nowhere.
|
||||
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 200))');
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
|
||||
// Once the read answers, the refusal and the current task appear together.
|
||||
await b.evaluate('window.__release()');
|
||||
await wait('/changed after the board read it/.test(' + STATUS + ')');
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), false);
|
||||
await b.evaluate(SAVE_AS('Invoice export, CSV'));
|
||||
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
||||
assert.equal(orgs.writes.length, 2);
|
||||
});
|
||||
|
||||
test('after a conflict a task that can no longer be read leaves the dialog unable to save', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
t42.rev++;
|
||||
await b.evaluate(HOLD_CONFLICT_READ);
|
||||
await b.evaluate(SAVE_AS('Invoice export, CSV'));
|
||||
await wait('window.__held');
|
||||
// The task leaves the store before the dialog's read answers, so neither read finds it.
|
||||
orgs.store.splice(orgs.store.indexOf(t42), 1);
|
||||
await b.evaluate('window.__release()');
|
||||
await wait('/could not be read/.test(' + STATUS + ')');
|
||||
assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [['Archive', true], ['Cancel', false], ['Save', true]]);
|
||||
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 200))');
|
||||
assert.equal(orgs.writes.length, 1);
|
||||
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
|
||||
await wait('!document.querySelector("#tb-dialog").open');
|
||||
});
|
||||
|
||||
test('a dialog closed just before the conflict read answers stays closed', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
orgs.store.find(s => s.n === 42).rev++;
|
||||
// Hold the read's body, not the request, so the close and the read's answer land in one task:
|
||||
// close() clears `open` at once, but the event that empties the dialog runs after the read resumes.
|
||||
await b.evaluate(`(() => {
|
||||
const real = window.fetch;
|
||||
let armed = false, held = false;
|
||||
window.fetch = (u, o) => {
|
||||
if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; });
|
||||
if (String(u).startsWith('/api/workspace?') && armed && !held) {
|
||||
held = true;
|
||||
return real(u, o).then(async r => {
|
||||
const body = await r.json();
|
||||
r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); });
|
||||
return r;
|
||||
});
|
||||
}
|
||||
return real(u, o);
|
||||
};
|
||||
})()`);
|
||||
await b.evaluate(SAVE_AS('Invoice export, CSV'));
|
||||
await wait('typeof window.__releaseJson === "function"');
|
||||
await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 250))');
|
||||
assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']);
|
||||
assert.equal(orgs.writes.length, 1);
|
||||
});
|
||||
|
||||
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
|
||||
Reference in new issue
Block a user