T7.9 - S1 (creator): errors at the field, and the last 40 native dialogs gone

wp-creation-app.js:1144 said "Subject and WP Type are required" in an alert()
on a form ten cards deep, naming nothing, focusing nothing. The creator
carried 40 native call sites in all (43 at the wave 0 count; three had
already left with D5 and T5.8's wizard work).

Inline validation, the T5.8 wizard pattern applied to the creator:
- WP_REQUIRED is one table: field id, owning section, label. The error box,
  aria-describedby, aria-invalid and the role=alert announcement all follow
  from a row. The conditional IFF rule folded in beside them.
- Submit marks every failing field, marks the rail entry of each section
  holding one (a "!" chip - a character, not only a colour), switches to the
  section of the FIRST error, scrolls to and focuses the field, and announces
  the failure through the role=alert toast.

One modal replaced confirm() and prompt(): promise-based wpConfirmDialog()/
wpPromptDialog() with an optional input whose validation renders AT the input
(a bad answer keeps the dialog open and says why - no round-trip through a
second dialog). Escape cancels; callers read like the natives they replaced,
awaited. Pure notifications became role-differentiated toasts. The modal
validation errors for the hold log and the QA rejection render inline in
their own modals.

The A1 path: confirmEarlyRelease() keeps its name and contract - truthy means
proceed with the reason recorded - and became async; every caller awaits it
(status control, hold release, urgent override, save).

App-wide native dialog count, recorded per the done-when: the probe prints it
against the wave 0 baseline of 79 and asserts the creator contributes 0. The
probe also replaces the natives with throwing stubs for the whole run, so any
path that still reached one would fail loudly.

hold_check re-pointed, not relaxed: three flows it drove through native
stubs now drive the modal - same propositions (the release-ready offer, the
named-constraints override prompt, the hard block), new surface.

Verification (each probe run alone): NEW tests/creator_dialogs_check.py
20/20. Regressions: hold_check 50/50 (re-pointed), warning_check 17/17,
qa_gate_check 40/40, triage_check 16/16, files_check 36/36, frame_check
39/39, generalinfo_check 49/49, form_structure_check 50/51 (the standing F6
height check - see the wave exit).

Items: S1 (creator half)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:46:25 -07:00
parent 82a8f30074
commit 7f712b7e00
6 changed files with 536 additions and 87 deletions

View File

@@ -284,6 +284,7 @@ python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
``` ```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -102,6 +102,126 @@ function toast(msg, kind){
t.textContent=msg; t.classList.add('show'); t.textContent=msg; t.classList.add('show');
clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200);
} }
// ── DIALOG KIT (S1 / T7.9) ────────────────────────────────────────────────────
// The creator carried 43 native dialogs. One modal replaces them: a message, an
// optional input with an inline, announced error, and real buttons. Everything
// is promise-based, so a caller reads exactly like the confirm() it replaced -
// just awaited. Escape and the corner control both cancel.
let _dlgResolve=null;
function _openDialog(opts){
return new Promise(res=>{
_dlgResolve=res;
const ov=document.getElementById('wp-dialog'); ov._opts=opts||{};
document.getElementById('wp-dialog-title').textContent=opts.title||'Confirm';
document.getElementById('wp-dialog-msg').textContent=opts.message||'';
const wrap=document.getElementById('wp-dialog-input-wrap');
wrap.style.display=opts.input?'':'none';
document.getElementById('wp-dialog-label').textContent=opts.label||'';
const inp=document.getElementById('wp-dialog-input');
inp.value=(opts.value!=null?String(opts.value):'');
document.getElementById('wp-dialog-err').textContent='';
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
ov.classList.add('open');
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
});
}
function wpDialogOk(){
const ov=document.getElementById('wp-dialog'); const opts=ov._opts||{};
if(opts.input){
const v=document.getElementById('wp-dialog-input').value;
if(opts.validate){
const err=opts.validate(v);
if(err){ document.getElementById('wp-dialog-err').textContent=err;
document.getElementById('wp-dialog-input').focus(); return; }
}
_closeDialog(v);
} else _closeDialog(true);
}
function wpDialogCancel(){
const opts=(document.getElementById('wp-dialog')||{})._opts||{};
_closeDialog(opts.input?null:false);
}
function _closeDialog(val){
document.getElementById('wp-dialog').classList.remove('open');
const r=_dlgResolve; _dlgResolve=null; if(r) r(val);
}
// confirm() said yes or no; this says true or false.
function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); }
// prompt() said string or null; so does this - and a validate() answer renders
// AT the input instead of round-tripping through another dialog.
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
document.addEventListener('keydown', e=>{
const ov=document.getElementById('wp-dialog');
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
});
// ── INLINE VALIDATION (S1 / T7.9) ────────────────────────────────────────────
// One table: field id, the section card that holds it, the label the message
// uses. Adding a required field is one row; its error box, aria wiring and
// announcement all follow (the wizard's T5.8 pattern, applied to the creator).
const WP_REQUIRED = [
['wp_subject', 'general-card', 'Subject'],
['wp_type', 'general-card', 'WP type'],
];
function wpErrBox(id){
const el=document.getElementById(id); if(!el) return null;
let box=document.getElementById(id+'_err');
if(!box){
box=document.createElement('div');
box.className='field-error'; box.id=id+'_err';
box.setAttribute('role','alert');
(el.parentNode||document.body).insertBefore(box, el.nextSibling);
}
const prior=(el.getAttribute('aria-describedby')||'').split(/\s+/).filter(Boolean);
if(prior.indexOf(box.id)<0){ prior.push(box.id); el.setAttribute('aria-describedby', prior.join(' ')); }
return box;
}
function wpSetFieldError(id, msg){
const el=document.getElementById(id); const box=wpErrBox(id);
if(box) box.textContent=msg||'';
if(el){ if(msg) el.setAttribute('aria-invalid','true'); else el.removeAttribute('aria-invalid'); }
}
function wpMarkRailErrors(sectionIds){
document.querySelectorAll('.sec-rail-item').forEach(b=>{
const on=sectionIds.has(b.dataset.sec);
b.classList.toggle('has-error', on);
let mark=b.querySelector('.sec-err');
if(on && !mark){
mark=document.createElement('span'); mark.className='sec-err';
mark.textContent='!'; mark.setAttribute('aria-label','this section has errors');
b.insertBefore(mark, b.firstChild);
}
if(!on && mark) mark.remove();
});
}
// Validate the whole form: mark every failing field, mark the rail entries of
// the sections that hold them, then go to the FIRST one - switching sections if
// needed - and put focus in it. Returns whether the form may save.
function wpValidateForm(){
const bad=[];
WP_REQUIRED.forEach(([id, section, label])=>{
const ok=!!gv(id);
wpSetFieldError(id, ok?'':label+' is required.');
if(!ok) bad.push([id, section]);
});
if(typeof isEwp==='function' && isEwp() && typeof iffRequired==='function'
&& iffRequired() && !gv('wp_iff').trim()){
wpSetFieldError('wp_iff','Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
bad.push(['wp_iff','bim-card']);
} else if(document.getElementById('wp_iff')) wpSetFieldError('wp_iff','');
wpMarkRailErrors(new Set(bad.map(b=>b[1])));
if(bad.length){
const [firstId, firstSection]=bad[0];
if(typeof gotoSection==='function') gotoSection(firstSection);
setTimeout(()=>{ const el=document.getElementById(firstId);
if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); } }, 150);
toast((bad.length===1?'1 required field needs':bad.length+' required fields need')
+' attention — the first is focused.', 'alert');
}
return !bad.length;
}
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; } function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); } function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); } function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
@@ -117,9 +237,9 @@ function nextSeq(){ return savedPackages.length+1; }
function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); } function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); }
function importSOP(ev){ function importSOP(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader();
r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ alert('That file does not look like an SOP export from the Configuration tool.'); return; } r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ toast('That file does not look like an SOP export from the Configuration tool.', 'alert'); return; }
SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported'); SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported');
}catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; }catch(e){ toast('Could not read that file.', 'alert'); } ev.target.value=''; };
r.readAsText(f); r.readAsText(f);
} }
function applySOP(){ function applySOP(){
@@ -231,12 +351,17 @@ function editQuality(id){
document.getElementById(id).value=sopValueFor(id); document.getElementById(id).value=sopValueFor(id);
lockQuality(id); track('quality_reverted',{field:id}); return; lockQuality(id); track('quality_reverted',{field:id}); return;
} }
const reason=prompt('This field is set by the project SOP. Enter the reason for overriding it on this package:'); wpPromptDialog({
title:'Override an SOP field',
message:'This field is set by the project SOP. The override and its reason stay on the package.',
label:'Reason for overriding it', okLabel:'Override',
validate:v=>v.trim()?'':'A reason is required to override an SOP field.',
}).then(reason=>{
if(reason===null) return; if(reason===null) return;
if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; }
pkgOverrides[id]=reason.trim(); pkgOverrides[id]=reason.trim();
const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus(); const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus();
lockQuality(id); track('quality_overridden',{field:id}); lockQuality(id); track('quality_overridden',{field:id});
});
} }
function renderCtxBar(){ function renderCtxBar(){
const bar=document.getElementById('ctx-bar'); const bar=document.getElementById('ctx-bar');
@@ -658,10 +783,10 @@ function parseCSV(text){
} }
function importMaterials(ev){ function importMaterials(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const f=ev.target.files&&ev.target.files[0]; if(!f) return;
if(/\.xlsx?$/i.test(f.name)){ alert('Please save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.'); ev.target.value=''; return; } if(/\.xlsx?$/i.test(f.name)){ toast('Save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.', 'alert'); ev.target.value=''; return; }
const r=new FileReader(); r.onload=()=>{ const r=new FileReader(); r.onload=()=>{
try{ try{
const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; } const rows=parseCSV(r.result); if(!rows.length){ toast('No rows found in that file.', 'alert'); return; }
let start=0; const h=rows[0].map(c=>c.trim().toLowerCase()); let start=0; const h=rows[0].map(c=>c.trim().toLowerCase());
const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description'); const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description');
let cq=0,cu=1,cd=2; let cq=0,cu=1,cd=2;
@@ -670,9 +795,9 @@ function importMaterials(ev){
for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim(); for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
if(!desc && !(c[cq]||'').trim()) continue; if(!desc && !(c[cq]||'').trim()) continue;
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); } imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
if(!imported.length){ alert('No material rows found.'); return; } if(!imported.length){ toast('No material rows found in that file.', 'alert'); return; }
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length}); pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
}catch(e){ alert('Could not parse that CSV.'); } }catch(e){ toast('Could not parse that CSV.', 'alert'); }
ev.target.value=''; ev.target.value='';
}; };
r.readAsText(f); r.readAsText(f);
@@ -800,12 +925,20 @@ function instanceSuffixFor(index, discipline){
if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); } if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); }
return String.fromCharCode(65+index); // A, B, C… return String.fromCharCode(65+index); // A, B, C…
} }
function splitByDiscipline(){ async function splitByDiscipline(){
if(!isMultiDiscipline()){ alert('Select two or more disciplines before splitting.'); return; } if(!isMultiDiscipline()){ toast('Select two or more disciplines before splitting.', 'alert'); return; }
if(!gv('wp_subject')){ alert('Add a Subject before splitting.'); return; } if(!gv('wp_subject')){
wpSetFieldError('wp_subject','Add a Subject before splitting.');
wpMarkRailErrors(new Set(['general-card'])); gotoSection('general-card');
setTimeout(()=>{ const el=document.getElementById('wp_subject'); if(el) el.focus(); },150);
return;
}
const base=collectPackage(); const base=collectPackage();
const baseNumber=base.number||('WP'+pad2(editingSeq())); const baseNumber=base.number||('WP'+pad2(editingSeq()));
if(!confirm(`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`)) return; if(!(await wpConfirmDialog({
title:'Split by discipline',
message:`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`,
okLabel:'Split it'}))) return;
// Master: flagged as a split container, keeps all disciplines for roll-up tracking. // Master: flagged as a split container, keeps all disciplines for roll-up tracking.
const masterId = editingId || base.id; const masterId = editingId || base.id;
@@ -839,8 +972,7 @@ function splitByDiscipline(){
track('wp_split',{disciplines:pkgDisciplines.length}); track('wp_split',{disciplines:pkgDisciplines.length});
toast('Split into '+children.length+' discipline instances'); toast('Split into '+children.length+' discipline instances');
const untagged=(base.materials||[]).filter(m=>!m.discipline).length; const untagged=(base.materials||[]).filter(m=>!m.discipline).length;
const matNote = untagged ? `\n\nNote: ${untagged} material line${untagged===1?'':'s'} had no discipline tag and stayed on the master only — tag them before splitting to route them to an instance.` : ''; if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote);
} }
// ── ASSETS (controls.dev) ──────────────────────────────────────────────────── // ── ASSETS (controls.dev) ────────────────────────────────────────────────────
@@ -949,7 +1081,9 @@ async function wpFileDescSave(id, v){
}catch(e){ toast('Could not save the description — it is shown but not saved yet.'); } }catch(e){ toast('Could not save the description — it is shown but not saved yet.'); }
} }
async function wpFileDelete(id){ async function wpFileDelete(id){
if(!confirm('Remove this drawing from the package?')) return; if(!(await wpConfirmDialog({title:'Remove drawing',
message:'Remove this drawing from the package? The bytes are deleted from the project storage.',
okLabel:'Remove'}))) return;
try{ try{
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'}); const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'});
if(!r.ok) throw 0; if(!r.ok) throw 0;
@@ -1077,10 +1211,14 @@ function setConstraint(i,val){
toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).'); toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
} }
else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){ wpConfirmDialog({title:'Release-ready',
message:'All constraints are cleared — this Work Package is release-ready.',
okLabel:'Mark it as Issued', cancelLabel:'Not yet'}).then(ok=>{
if(ok){
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
track('status_change',{status:'Issued',via:'constraint_clear'}); track('status_change',{status:'Issued',via:'constraint_clear'});
} }
});
const sg=document.getElementById('status-group'); const sg=document.getElementById('status-group');
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'}); if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
} }
@@ -1138,7 +1276,7 @@ function updateReleaseBanner(){
// Releasing with an unclosed predecessor is allowed but must be explained. The // Releasing with an unclosed predecessor is allowed but must be explained. The
// reason rides on the package (data.gateOverride) and the server writes it to the // reason rides on the package (data.gateOverride) and the server writes it to the
// audit log. Returns false if the user backed out. // audit log. Returns false if the user backed out.
function confirmEarlyRelease(blocking, openConstraints){ async function confirmEarlyRelease(blocking, openConstraints){
// D4 extended this to open constraints on an URGENT package - the same audited // D4 extended this to open constraints on an URGENT package - the same audited
// path, not a new one. A silent bypass would destroy the delay-documentation // path, not a new one. A silent bypass would destroy the delay-documentation
// use case that justifies the constraint workflow, so the reason is mandatory // use case that justifies the constraint workflow, so the reason is mandatory
@@ -1148,10 +1286,13 @@ function confirmEarlyRelease(blocking, openConstraints){
if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n')); if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n'));
if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+ if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+
blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n')); blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'));
const reason=prompt(parts.join('\n\n')+ const reason=await wpPromptDialog({
'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+ title:'Release with a logged override',
'Why is it being released now? (Cancel to stop.)'); message:parts.join('\n\n')+'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.',
if(reason===null || !reason.trim()) return false; label:'Why is it being released now?', okLabel:'Release — log the reason',
validate:v=>v.trim()?'':'A reason is required — it goes on the package and in the audit log.',
});
if(reason===null) return false;
pkgGateOverride={ pkgGateOverride={
reason: reason.trim(), reason: reason.trim(),
at: new Date().toISOString(), at: new Date().toISOString(),
@@ -1183,11 +1324,11 @@ function holdReturnStatus(){
// timestamp, user, what cleared it - and returns to the recorded prior status. // timestamp, user, what cleared it - and returns to the recorded prior status.
// Predecessors stay a refusable gate on the way back out (A1): if one reopened // Predecessors stay a refusable gate on the way back out (A1): if one reopened
// while the package sat on hold, the audited override is offered, never skipped. // while the package sat on hold, the audited override is offered, never skipped.
function releaseHold(clearedName){ async function releaseHold(clearedName){
const back=holdReturnStatus(); const back=holdReturnStatus();
const r=readiness(); const r=readiness();
if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(r.blocking)){ if(!(await confirmEarlyRelease(r.blocking))){
toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.'); toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.');
updateReleaseBanner(); return; updateReleaseBanner(); return;
} }
@@ -1220,7 +1361,14 @@ function qaRejectOpen(){
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); } function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
function qaRejectSubmit(){ function qaRejectSubmit(){
const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim(); const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim();
if(!c){ alert('A comment is required — the crew needs to know what to fix.'); return; } const errBox=document.getElementById('qa-reject-comment_err');
if(!c){
if(errBox) errBox.textContent='A comment is required — the crew needs to know what to fix.';
const el=document.getElementById('qa-reject-comment'); if(el){ el.setAttribute('aria-invalid','true'); el.focus(); }
return;
}
if(errBox) errBox.textContent='';
const qel=document.getElementById('qa-reject-comment'); if(qel) qel.removeAttribute('aria-invalid');
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c, pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
from:'Ready for QA' }); from:'Ready for QA' });
@@ -1232,16 +1380,16 @@ function qaRejectSubmit(){
// D4: the banner's primary action for an Urgent package blocked by constraints. // D4: the banner's primary action for an Urgent package blocked by constraints.
// It runs the SAME audited path the status control runs - one override, one log. // It runs the SAME audited path the status control runs - one override, one log.
function urgentOverrideRelease(){ async function urgentOverrideRelease(){
const r=readiness(); const r=readiness();
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; } if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; }
if(!confirmEarlyRelease(r.blocking, open)) return; if(!(await confirmEarlyRelease(r.blocking, open))) return;
setRadio('status','Issued'); prevStatus='Issued'; setRadio('status','Issued'); prevStatus='Issued';
updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'}); updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'});
} }
function onStatusChange(target){ async function onStatusChange(target){
const idx=STATUS_ORDER.indexOf(target); const idx=STATUS_ORDER.indexOf(target);
const r=readiness(); const r=readiness();
// Constraints are a hard gate for Normal and High: nothing releases with one // Constraints are a hard gate for Normal and High: nothing releases with one
@@ -1250,11 +1398,12 @@ function onStatusChange(target){
if(idx>=ISSUED_IDX && r.open>0){ if(idx>=ISSUED_IDX && r.open>0){
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){ if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
if(!confirmEarlyRelease(r.blocking, open)){ if(!(await confirmEarlyRelease(r.blocking, open))){
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
} }
} else { } else {
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); toast('Cannot move to "'+target+'" — '+open.length+' constraint'+(open.length===1?' is':'s are')+' still open. Clear or mark N/A first.', 'alert');
gotoSection('constraint-card');
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
} }
} }
@@ -1262,7 +1411,7 @@ function onStatusChange(target){
// ahead of upstream work closing out. Refusing it requires a reason, which is // ahead of upstream work closing out. Refusing it requires a reason, which is
// stored on the package and written to the audit log by the server. // stored on the package and written to the audit log by the server.
if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(r.blocking)){ if(!(await confirmEarlyRelease(r.blocking))){
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
} }
} }
@@ -1290,7 +1439,14 @@ function submitHold(){
const constraint=document.getElementById('hold-constraint').value; const constraint=document.getElementById('hold-constraint').value;
const details=document.getElementById('hold-details').value.trim(); const details=document.getElementById('hold-details').value.trim();
const doclink=document.getElementById('hold-doclink').value.trim(); const doclink=document.getElementById('hold-doclink').value.trim();
if(!details){ alert('A comment defining the issue is required.'); return; } const hErr=document.getElementById('hold-details_err');
if(!details){
if(hErr) hErr.textContent='A comment defining the issue is required.';
const hel=document.getElementById('hold-details'); if(hel){ hel.setAttribute('aria-invalid','true'); hel.focus(); }
return;
}
if(hErr) hErr.textContent='';
const hel2=document.getElementById('hold-details'); if(hel2) hel2.removeAttribute('aria-invalid');
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'', pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'',
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
// What the release returns to. prevStatus is captured before the status pill // What the release returns to. prevStatus is captured before the status pill
@@ -1347,13 +1503,22 @@ function onSignoffSigned(i,checked){
if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; } if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; }
buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked}); buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked});
} }
function onSignoffDateOverride(i){ async function onSignoffDateOverride(i){
const s=pkgSignoffs[i]; const s=pkgSignoffs[i];
const nd=prompt('Manual sign-off date for '+s.role+' (YYYY-MM-DD). The date is normally set automatically when Signed is checked.', s.date||todayStr()); const nd=await wpPromptDialog({
title:'Manual sign-off date — '+s.role,
message:'The date is normally set automatically when Signed is checked.',
label:'Sign-off date (YYYY-MM-DD)', value:s.date||todayStr(), okLabel:'Next',
validate:v=>/^\d{4}-\d{2}-\d{2}$/.test(v.trim())?'':'Enter the date as YYYY-MM-DD.',
});
if(nd===null) return; if(nd===null) return;
if(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; } const reason=await wpPromptDialog({
const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:'); title:'Manual sign-off date — '+s.role,
if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; } message:'The override and its reason stay on the package.',
label:'Reason for the manual date', okLabel:'Override the date',
validate:v=>v.trim()?'':'A reason is required for a manual date override.',
});
if(reason===null) return;
s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role}); s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role});
} }
@@ -1420,20 +1585,16 @@ function collectPackage(){
updatedAt:new Date().toISOString() updatedAt:new Date().toISOString()
}; };
} }
function savePackage(view){ async function savePackage(view){
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; } if(!wpValidateForm()) return;
// A BIM package marked "Signed off (IFF)" without the number isn't traceable. // A BIM package marked "Signed off (IFF)" without the number isn't traceable.
// The status can also be set programmatically (the per-discipline roll-up), so // The status can also be set programmatically (the per-discipline roll-up), so
// re-check the predecessor gate at the point of saving. // re-check the predecessor gate at the point of saving.
const _r=readiness(); const _r=readiness();
if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){ if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(_r.blocking)) return; if(!(await confirmEarlyRelease(_r.blocking))) return;
}
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
return;
} }
// The IFF rule is part of wpValidateForm() now - checked above with the rest.
const pkg=collectPackage(); const pkg=collectPackage();
const ix=savedPackages.findIndex(p=>p.id===pkg.id); const ix=savedPackages.findIndex(p=>p.id===pkg.id);
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
@@ -2490,10 +2651,20 @@ async function showHistory(wpId, label){
// A project_user gets no delete button, and archiving is offered instead. // A project_user gets no delete button, and archiving is offered instead.
function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; } function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; }
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; async function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } if(!canDeleteWP()){ toast('Deleting a work package needs the Project Admin role archive it instead.', 'alert'); return; }
function clearSaved(){ if(!savedPackages.length) return; if(!(await wpConfirmDialog({title:'Delete work package',
if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } message:'Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.',
okLabel:'Delete it'}))) return;
const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted');
if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
async function clearSaved(){ if(!savedPackages.length) return;
if(!canDeleteWP()){ toast('Deleting work packages needs the Project Admin role.', 'alert'); return; }
if(!(await wpConfirmDialog({title:'Delete every saved package',
message:'Delete all '+savedPackages.length+' saved packages? This cannot be undone.',
okLabel:'Delete them all'}))) return;
const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList();
if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); } function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); }
// S3: the open package IS the page's state, so it belongs in the URL. This is the // S3: the open package IS the page's state, so it belongs in the URL. This is the
@@ -2565,12 +2736,15 @@ function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignof
// Duplicate the current work package N times (asks how many). Each copy is a // Duplicate the current work package N times (asks how many). Each copy is a
// fresh Draft with a unique number/subject and approvals/closeout cleared. // fresh Draft with a unique number/subject and approvals/closeout cleared.
function duplicateWP(){ async function duplicateWP(){
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; } if(!gv('wp_subject')){ toast('Open or fill in a work package first, then Duplicate.', 'alert'); return; }
const ans=prompt('How many copies of this work package do you want to create?','1'); const ans=await wpPromptDialog({
title:'Duplicate this work package',
label:'How many copies?', value:'1', okLabel:'Create copies',
validate:v=>{ const k=parseInt(v,10); return (k>=1&&k<=50&&String(k)===v.trim())?'':'Enter a whole number between 1 and 50.'; },
});
if(ans===null) return; if(ans===null) return;
const n=parseInt(ans,10); const n=parseInt(ans,10);
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
const base=collectPackage(); const base=collectPackage();
const baseNum = base.number || ('WP'+pad2(editingSeq())); const baseNum = base.number || ('WP'+pad2(editingSeq()));
const made=[]; const made=[];
@@ -2589,9 +2763,8 @@ function duplicateWP(){
} }
editingId=null; saveStore(); renderSavedList(); editingId=null; saveStore(); renderSavedList();
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
toast('Created '+n+' duplicate'+(n>1?'s':'')); toast('Created '+n+' duplicate'+(n>1?'s':'')+' — in the Saved Work Packages list, ready to edit.');
track('wp_duplicated',{count:n}); track('wp_duplicated',{count:n});
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
} }
function newPackage(){ function newPackage(){
@@ -2625,7 +2798,7 @@ function newPackage(){
} }
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); } function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
function exportPackages(){ function exportPackages(){
if(!savedPackages.length){ alert('No packages saved yet.'); return; } if(!savedPackages.length){ toast('No packages saved yet.', 'alert'); return; }
const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages}; const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages};
const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a'); const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json'; a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json';
@@ -2879,9 +3052,11 @@ function dashToggleArchived(on){
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); }); ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
} else { renderDashboard(); } } else { renderDashboard(); }
} }
function dashArchive(id){ async function dashArchive(id){
const p=savedPackages.find(x=>x.id===id); if(!p) return; const p=savedPackages.find(x=>x.id===id); if(!p) return;
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return; if(!(await wpConfirmDialog({title:'Archive work package',
message:'Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.',
okLabel:'Archive'}))) return;
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true); if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); } const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
saveStore(); renderSavedList(); toast('Archived '+(p.number||'')); dashRefreshAfterWrite(); saveStore(); renderSavedList(); toast('Archived '+(p.number||'')); dashRefreshAfterWrite();
@@ -3133,19 +3308,20 @@ function renderDashboard(){
h+=`</div>`; h+=`</div>`;
document.getElementById('dash-body').innerHTML=h; document.getElementById('dash-body').innerHTML=h;
} }
function dashIssue(id){ async function dashIssue(id){
const p=WPData.get(id); if(!p) return; const p=WPData.get(id); if(!p) return;
if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; } if(wpOpenConstraints(p).length>0){ toast('Cannot issue — open constraints remain.', 'alert'); return; }
const waiting=wpWaitingOn(p); const waiting=wpWaitingOn(p);
if(waiting.length){ if(waiting.length){
// Releasing early needs a reason, same as on the form — the dashboard must not // Releasing early needs a reason, same as on the form — the dashboard must not
// be the quiet way around the gate. // be the quiet way around the gate.
alert('Cannot issue from here — waiting on:\n\n• '+ toast('Cannot issue from here — waiting on '+waiting.map(w=>(w.number||w.id)).join(', ')
waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+ +'. Open the package to release it early with a logged reason.', 'alert');
'\n\nOpen the package to release it early with a logged reason.');
return; return;
} }
if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return; if(!(await wpConfirmDialog({title:'Issue work package',
message:'Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.',
okLabel:'Issue it'}))) return;
WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
dashRefreshAfterWrite(); dashRefreshAfterWrite();
} }
@@ -3154,7 +3330,7 @@ function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; l
// ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── // ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
function openSopModal(){ function openSopModal(){
if(!SOP){ alert('No SOP loaded.'); return; } if(!SOP){ toast('No SOP loaded.', 'alert'); return; }
const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{}; const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{};
const row=(k,v)=>`<tr><th style="width:170px;text-align:left;padding:4px 8px;background:var(--surface2)">${esc(k)}</th><td style="padding:4px 8px">${v||'—'}</td></tr>`; const row=(k,v)=>`<tr><th style="width:170px;text-align:left;padding:4px 8px;background:var(--surface2)">${esc(k)}</th><td style="padding:4px 8px">${v||'—'}</td></tr>`;
let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`; let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`;
@@ -3186,13 +3362,16 @@ function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d));
function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); } function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); }
function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } } function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } }
function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; } function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; }
function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); } function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ wpSetFieldError('cmt-author','Add your name first.'); document.getElementById('cmt-author').focus(); return; } wpSetFieldError('cmt-author',''); if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); } function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); }
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?wpFormatDateTime(c.ts):''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); } function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?wpFormatDateTime(c.ts):''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); }
function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } } function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } }
function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); } function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); }
function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); } function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ toast('No comments found in that file.', 'alert'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); toast(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ toast('Could not read that file.', 'alert'); } ev.target.value=''; }; r.readAsText(f); }
function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; if(!mine){ alert('No comments to clear.'); return; } if(!confirm(`Delete your ${mine} comment(s)?`)) return; d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); } async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length;
if(!mine){ toast('No comments to clear.', 'alert'); return; }
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return;
d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); } function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
// ── STATUS PILLS ───────────────────────────────────────────────────────────── // ── STATUS PILLS ─────────────────────────────────────────────────────────────

View File

@@ -508,7 +508,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="notice">Moving a package to <strong>Issue (Hold)</strong> requires logging the constraint that blocked it.</div> <div class="notice">Moving a package to <strong>Issue (Hold)</strong> requires logging the constraint that blocked it.</div>
<div class="field"><label>Constraint type <span class="req">*</span></label><select id="hold-constraint"></select></div> <div class="field"><label>Constraint type <span class="req">*</span></label><select id="hold-constraint"></select></div>
<div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?"></textarea></div> <div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?" aria-describedby="hold-details_err"></textarea><div class="field-error" id="hold-details_err" role="alert"></div></div>
<div class="field"><label>Supporting document link</label><input type="text" id="hold-doclink" placeholder="link to RFI, photo, email, etc. (optional)"></div> <div class="field"><label>Supporting document link</label><input type="text" id="hold-doclink" placeholder="link to RFI, photo, email, etc. (optional)"></div>
<div class="field"><label>Supporting photo</label><input type="file" id="hold-photo" accept="image/*" onchange="holdPhotoChange(event)"><div class="hold-photo-preview" id="hold-photo-preview"></div></div> <div class="field"><label>Supporting photo</label><input type="file" id="hold-photo" accept="image/*" onchange="holdPhotoChange(event)"><div class="hold-photo-preview" id="hold-photo-preview"></div></div>
</div> </div>
@@ -516,6 +516,24 @@
</div> </div>
</div> </div>
<!-- DIALOG (S1 / T7.9). The one replacement for the creator's 43 native
dialogs: a modal with a message, an optional input with an inline error,
and real buttons. Promise-based - wpConfirmDialog()/wpPromptDialog(). -->
<div class="modal-overlay" id="wp-dialog" role="dialog" aria-modal="true" aria-labelledby="wp-dialog-title">
<div class="modal" style="max-width:480px">
<div class="modal-head"><div class="modal-title" id="wp-dialog-title"></div><button class="cmt-x" onclick="wpDialogCancel()" title="Cancel"></button></div>
<div class="modal-body">
<div id="wp-dialog-msg" style="white-space:pre-wrap"></div>
<div class="field" id="wp-dialog-input-wrap" style="margin-top:10px">
<label id="wp-dialog-label" for="wp-dialog-input"></label>
<input type="text" id="wp-dialog-input" onkeydown="if(event.key==='Enter'){wpDialogOk();}">
<div class="field-error" id="wp-dialog-err" role="alert"></div>
</div>
</div>
<div class="modal-foot"><button class="btn btn-ghost" id="wp-dialog-cancel" onclick="wpDialogCancel()">Cancel</button><button class="btn btn-generate" id="wp-dialog-ok" onclick="wpDialogOk()">OK</button></div>
</div>
</div>
<!-- QA REJECT MODAL (CR-014). The comment is not optional: a rejection with no <!-- QA REJECT MODAL (CR-014). The comment is not optional: a rejection with no
reason is the after-the-fact surprise this gate exists to end, and the server reason is the after-the-fact surprise this gate exists to end, and the server
refuses the transition without one. --> refuses the transition without one. -->
@@ -524,7 +542,7 @@
<div class="modal-head"><div class="modal-title">Return to the crew — QA rejection</div><button class="cmt-x" onclick="qaRejectCancel()" title="Cancel"></button></div> <div class="modal-head"><div class="modal-title">Return to the crew — QA rejection</div><button class="cmt-x" onclick="qaRejectCancel()" title="Cancel"></button></div>
<div class="modal-body"> <div class="modal-body">
<div class="notice">The package goes back to <strong>In Progress</strong>. The owner and the QA group are notified, and the comment stays on the package.</div> <div class="notice">The package goes back to <strong>In Progress</strong>. The owner and the QA group are notified, and the comment stays on the package.</div>
<div class="field"><label>What needs fixing <span class="req">*</span></label><textarea id="qa-reject-comment" rows="3" placeholder="What QA found — required"></textarea></div> <div class="field"><label>What needs fixing <span class="req">*</span></label><textarea id="qa-reject-comment" rows="3" placeholder="What QA found — required" aria-describedby="qa-reject-comment_err"></textarea><div class="field-error" id="qa-reject-comment_err" role="alert"></div></div>
</div> </div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="qaRejectCancel()">Cancel</button><button class="btn btn-generate" onclick="qaRejectSubmit()">Reject — back to In Progress</button></div> <div class="modal-foot"><button class="btn btn-ghost" onclick="qaRejectCancel()">Cancel</button><button class="btn btn-generate" onclick="qaRejectSubmit()">Reject — back to In Progress</button></div>
</div> </div>

View File

@@ -780,6 +780,16 @@
.sec-rail-item:hover { color: var(--accent); border-color: var(--border); } .sec-rail-item:hover { color: var(--accent); border-color: var(--border); }
/* Not colour alone: the current entry is bolder, keeps a left marker and is the /* Not colour alone: the current entry is bolder, keeps a left marker and is the
one carrying aria-current. */ one carrying aria-current. */
/* S1 / T7.9: inline validation. The message sits AT the field (role=alert in
the DOM, so it announces), and a section holding an error says so on its
rail entry with a character, not only a colour. */
.field-error { color: var(--red); font-size: 12px; font-weight: 600; margin-top: 4px; }
.field-error:empty { display: none; }
[aria-invalid="true"] { border-color: var(--red) !important; }
.sec-rail-item .sec-err { display:inline-block; margin-right:6px; min-width:16px;
text-align:center; border-radius:8px; background:var(--red);
color:var(--cds-text-on-color); font-size:11px; font-weight:700; line-height:16px; }
/* A2: the open-constraint count. Red chip + a number - the number is the /* A2: the open-constraint count. Red chip + a number - the number is the
content, so the state is never colour-only. Sized to stay legible at 390px. */ content, so the state is never colour-only. Sized to stay legible at 390px. */
.sec-badge { display:inline-block; margin-left:8px; min-width:18px; padding:1px 6px; .sec-badge { display:inline-block; margin-left:8px; min-width:18px; padding:1px 6px;

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Are the creator's 43 native dialogs gone, and did validation move inline? — S1, T7.9.
wp-creation-app.js:1144 said "Subject and WP Type are required" in an alert(),
without naming, highlighting or scrolling to the field, on a form ten cards
deep. Now: errors AT the field (role=alert, announced), submit focuses and
scrolls to the first invalid one - switching sections if needed - and the rail
entry of every section holding an error is marked with a character, not only a
colour. confirm()/prompt() became one promise-based modal with its own inline
validation.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def native_count(src):
return len(re.findall(r"(?<![\w.$])(alert|confirm|prompt)\(", strip_js(src)))
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def dialog_state(page):
return json.loads(page.eval("""JSON.stringify((() => {
const ov = document.getElementById('wp-dialog');
return {open: !!ov && ov.classList.contains('open'),
title: (document.getElementById('wp-dialog-title')||{}).textContent || '',
err: (document.getElementById('wp-dialog-err')||{}).textContent || ''};
})())"""))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the count ──────────────────────────────────────────────────────────
print("\n1. the count")
creator = 0
total = {}
for name in sorted(os.listdir(HTML)):
if not name.endswith((".js", ".html")):
continue
n = native_count(open(os.path.join(HTML, name), encoding="utf-8").read())
if n:
total[name] = n
if name in ("wp-creation-app.js", "wp-creation-index.html"):
creator += n
grand = sum(total.values())
chk("no alert(), confirm() or prompt() remains in the creator; count is 0",
creator == 0, creator)
print(" app-wide native dialogs now: %d (wave 0 baseline: 79) — %s"
% (grand, ascii_(total)))
chk("the app-wide count is recorded, and it is far below the baseline of 79",
grand < 79, grand)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-dlg-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots", wait_creator(page))
settle(1.6)
# If ANY code path still reaches a native, these throw and fail the run.
page.eval("""window.alert=()=>{throw new Error('native alert reached')};
window.confirm=()=>{throw new Error('native confirm reached')};
window.prompt=()=>{throw new Error('native prompt reached')};""")
# ── 2. inline validation on save ─────────────────────────────────────
print("\n2. required fields validate inline")
page.eval("gotoSection('signoff-card')") # start far from the errors
settle(0.6)
page.eval("void savePackage(false)")
settle(0.8)
errs = json.loads(page.eval("""JSON.stringify({
subj: (document.getElementById('wp_subject_err')||{}).textContent || '',
type: (document.getElementById('wp_type_err')||{}).textContent || '',
subjInvalid: (document.getElementById('wp_subject')||{getAttribute:()=>''}).getAttribute('aria-invalid'),
})"""))
chk("the errors render AT the fields, naming them",
"Subject is required" in errs["subj"] and "WP type is required" in errs["type"],
ascii_(errs))
chk("...with aria-invalid set", errs["subjInvalid"] == "true")
chk("...and the error boxes are alert live regions",
page.eval("(document.getElementById('wp_subject_err')||{getAttribute:()=>''})"
".getAttribute('role')") == "alert")
chk("submit switched to the section holding the first error",
page.eval("secCurrent") == "general-card", page.eval("secCurrent"))
chk("...and focused the first invalid field",
page.eval("document.activeElement && document.activeElement.id") == "wp_subject",
page.eval("document.activeElement && document.activeElement.id"))
chk("the rail marks the section that holds errors, with a character not just a colour",
page.eval("""(() => {
const b = document.querySelector('.sec-rail-item[data-sec="general-card"]');
return b && b.classList.contains('has-error')
&& (b.querySelector('.sec-err')||{}).textContent === '!';
})()"""))
chk("the failure was announced (role=alert toast)",
page.eval("(document.getElementById('toast')||{getAttribute:()=>''}).getAttribute('role')")
== "alert")
page.eval("document.getElementById('wp_subject').value='Horn strobe conduit'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
page.eval("void savePackage(false)")
settle(0.8)
chk("with the fields filled, the save goes through",
page.eval("savedPackages.length") >= 1)
chk("...the errors clear", page.eval(
"!(document.getElementById('wp_subject_err')||{textContent:''}).textContent"))
chk("...and the rail marks clear",
page.eval("!document.querySelector('.sec-rail-item.has-error')"))
# ── 3. the modal that replaced confirm() ─────────────────────────────
print("\n3. the confirm modal")
n0 = page.eval("savedPackages.length")
page.eval("void deletePackage(0)")
settle(0.5)
d = dialog_state(page)
chk("deleting asks through the modal, a real dialog element",
d["open"] and "Delete" in d["title"], ascii_(d))
page.eval("wpDialogCancel()")
settle(0.3)
chk("cancel keeps the package", page.eval("savedPackages.length") == n0)
page.eval("void deletePackage(0)")
settle(0.4)
page.eval("wpDialogOk()")
settle(0.4)
chk("confirm deletes it", page.eval("savedPackages.length") == n0 - 1)
# ── 4. the modal that replaced prompt(), with inline validation ──────
print("\n4. the prompt modal")
page.eval("newPackage()")
settle(0.5)
page.eval("document.getElementById('wp_subject').value='Copy me'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
page.eval("void duplicateWP()")
settle(0.5)
d = dialog_state(page)
chk("duplicating asks for the count through the modal",
d["open"] and "Duplicate" in d["title"], ascii_(d))
page.eval("document.getElementById('wp-dialog-input').value='banana'")
page.eval("wpDialogOk()")
settle(0.3)
d = dialog_state(page)
chk("a bad answer is refused AT the input - the dialog stays, the error says why",
d["open"] and "whole number" in d["err"], ascii_(d))
page.eval("document.getElementById('wp-dialog-input').value='2'")
n0 = page.eval("savedPackages.length")
page.eval("wpDialogOk()")
settle(0.6)
chk("a good answer proceeds", page.eval("savedPackages.length") == n0 + 2)
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors — and no path reached a native dialog (they throw here)",
not js_errors, ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -168,9 +168,17 @@ def main():
n = page.eval("pkgConstraints.length") n = page.eval("pkgConstraints.length")
for i in range(n): for i in range(n):
constraint_btn(page, i, "cleared") constraint_btn(page, i, "cleared")
offers = [d for d in dlg(page) if d[0] == "confirm" and "release-ready" in d[1]] # T7.9: the offer is the modal now, not a native confirm. Same proposition:
chk("the release-ready offer on an unreleased package is unchanged", # clearing the last constraint on an unreleased package OFFERS to issue.
len(offers) == 1, ascii_(dlg(page))) offer = json.loads(page.eval("""JSON.stringify((() => {
const ov = document.getElementById('wp-dialog');
return {open: ov && ov.classList.contains('open'),
title: (document.getElementById('wp-dialog-title')||{}).textContent||''};
})())"""))
chk("the release-ready offer on an unreleased package is unchanged (as the modal)",
offer["open"] and "Release-ready" in offer["title"], ascii_(offer))
page.eval("wpDialogCancel()")
settle(0.3)
reset_dlg(page) reset_dlg(page)
click_status(page, "In Progress") click_status(page, "In Progress")
chk("with everything cleared the package moves to In Progress", chk("with everything cleared the package moves to In Progress",
@@ -247,9 +255,13 @@ def main():
page.eval("!document.querySelector('.rb-act')")) page.eval("!document.querySelector('.rb-act')"))
reset_dlg(page) reset_dlg(page)
click_status(page, "Issued") click_status(page, "Issued")
alerts = [d for d in dlg(page) if d[0] == "alert"] settle(0.4)
chk("...and the status control still hard-blocks it with the same refusal", toast_txt = page.eval("(document.getElementById('toast')||{textContent:''}).textContent")
len(alerts) == 1 and "still open" in alerts[0][1], ascii_(dlg(page))) chk("...and the status control still hard-blocks it with the same refusal "
"(announced, not a dialog)",
"still open" in toast_txt and page.eval(
"(document.getElementById('toast')||{getAttribute:()=>''}).getAttribute('role')")
== "alert", ascii_(toast_txt))
chk("...and the status snapped back", status_of(page) == "Draft", status_of(page)) chk("...and the status snapped back", status_of(page) == "Draft", status_of(page))
page.eval("document.getElementById('wp_priority').value='High'") page.eval("document.getElementById('wp_priority').value='High'")
@@ -264,23 +276,26 @@ def main():
page.eval("(document.querySelector('.rb-act')||{}).tagName") == "BUTTON") page.eval("(document.querySelector('.rb-act')||{}).tagName") == "BUTTON")
reset_dlg(page) reset_dlg(page)
page.eval("window.__pReturn = null") # back out first
page.eval("document.querySelector('.rb-act').click()") page.eval("document.querySelector('.rb-act').click()")
settle(0.4) settle(0.4)
page.eval("wpDialogCancel()") # back out first
settle(0.3)
chk("backing out of the prompt releases nothing", status_of(page) == "Draft", chk("backing out of the prompt releases nothing", status_of(page) == "Draft",
status_of(page)) status_of(page))
chk("...and records no override", page.eval("!pkgGateOverride")) chk("...and records no override", page.eval("!pkgGateOverride"))
reset_dlg(page) reset_dlg(page)
page.eval("window.__pReturn = 'Client directive 42 - install proceeds at risk'")
page.eval("document.querySelector('.rb-act').click()") page.eval("document.querySelector('.rb-act').click()")
settle(0.4) settle(0.4)
prompts = [d for d in dlg(page) if d[0] == "prompt"]
open_names = json.loads(page.eval( open_names = json.loads(page.eval(
"JSON.stringify(pkgConstraints.filter(c=>c.status==='open').map(c=>c.name))")) "JSON.stringify(pkgConstraints.filter(c=>c.status==='open').map(c=>c.name))"))
dlg_msg = page.eval("(document.getElementById('wp-dialog-msg')||{textContent:''}).textContent")
chk("the prompt names every open constraint it is about to cross", chk("the prompt names every open constraint it is about to cross",
len(prompts) == 1 and all(nm in prompts[0][1] for nm in open_names), all(nm in dlg_msg for nm in open_names), ascii_(dlg_msg))
ascii_(prompts)) page.eval("document.getElementById('wp-dialog-input').value="
"'Client directive 42 - install proceeds at risk'")
page.eval("wpDialogOk()")
settle(0.4)
chk("taking the override releases the package", status_of(page) == "Issued", chk("taking the override releases the package", status_of(page) == "Issued",
status_of(page)) status_of(page))
ov = json.loads(page.eval("JSON.stringify(pkgGateOverride||{})")) ov = json.loads(page.eval("JSON.stringify(pkgGateOverride||{})"))