diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index aa0f608..76d9445 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -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/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/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 diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 83abf2e..1dfd561 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -102,6 +102,126 @@ function toast(msg, kind){ t.textContent=msg; t.classList.add('show'); 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 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); } @@ -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 importSOP(ev){ 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'); - }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); } function applySOP(){ @@ -231,12 +351,17 @@ function editQuality(id){ document.getElementById(id).value=sopValueFor(id); 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:'); - if(reason===null) return; - if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; } - pkgOverrides[id]=reason.trim(); - const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus(); - lockQuality(id); track('quality_overridden',{field:id}); + 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; + pkgOverrides[id]=reason.trim(); + const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus(); + lockQuality(id); track('quality_overridden',{field:id}); + }); } function renderCtxBar(){ const bar=document.getElementById('ctx-bar'); @@ -658,10 +783,10 @@ function parseCSV(text){ } function importMaterials(ev){ 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=()=>{ 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()); const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description'); let cq=0,cu=1,cd=2; @@ -670,9 +795,9 @@ function importMaterials(ev){ for(let i=start;i{ const el=document.getElementById('wp_subject'); if(el) el.focus(); },150); + return; + } const base=collectPackage(); 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. const masterId = editingId || base.id; @@ -839,8 +972,7 @@ function splitByDiscipline(){ track('wp_split',{disciplines:pkgDisciplines.length}); toast('Split into '+children.length+' discipline instances'); 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.` : ''; - 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); + if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert'); } // ── 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.'); } } 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{ const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'}); 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).'); } 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?')){ - setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); - track('status_change',{status:'Issued',via:'constraint_clear'}); - } + 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(); + track('status_change',{status:'Issued',via:'constraint_clear'}); + } + }); const sg=document.getElementById('status-group'); 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 // reason rides on the package (data.gateOverride) and the server writes it to the // 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 // 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 @@ -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(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+ blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n')); - const reason=prompt(parts.join('\n\n')+ - '\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+ - 'Why is it being released now? (Cancel to stop.)'); - if(reason===null || !reason.trim()) return false; + const reason=await wpPromptDialog({ + title:'Release with a logged override', + message:parts.join('\n\n')+'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.', + 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={ reason: reason.trim(), at: new Date().toISOString(), @@ -1183,11 +1324,11 @@ function holdReturnStatus(){ // 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 // while the package sat on hold, the audited override is offered, never skipped. -function releaseHold(clearedName){ +async function releaseHold(clearedName){ const back=holdReturnStatus(); const r=readiness(); 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.'); updateReleaseBanner(); return; } @@ -1220,7 +1361,14 @@ function qaRejectOpen(){ function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); } function qaRejectSubmit(){ 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, by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', from:'Ready for QA' }); @@ -1232,16 +1380,16 @@ function qaRejectSubmit(){ // 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. -function urgentOverrideRelease(){ +async function urgentOverrideRelease(){ const r=readiness(); const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); 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'; updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'}); } -function onStatusChange(target){ +async function onStatusChange(target){ const idx=STATUS_ORDER.indexOf(target); const r=readiness(); // 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){ const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){ - if(!confirmEarlyRelease(r.blocking, open)){ + if(!(await confirmEarlyRelease(r.blocking, open))){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; } } 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; } } @@ -1262,7 +1411,7 @@ function onStatusChange(target){ // 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. if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ - if(!confirmEarlyRelease(r.blocking)){ + if(!(await confirmEarlyRelease(r.blocking))){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; } } @@ -1290,7 +1439,14 @@ function submitHold(){ const constraint=document.getElementById('hold-constraint').value; const details=document.getElementById('hold-details').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||'', by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', // 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=''; } buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked}); } -function onSignoffDateOverride(i){ +async function onSignoffDateOverride(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(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; } - const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:'); - if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; } + const reason=await wpPromptDialog({ + title:'Manual sign-off date — '+s.role, + 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}); } @@ -1420,20 +1585,16 @@ function collectPackage(){ updatedAt:new Date().toISOString() }; } -function savePackage(view){ - if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; } +async function savePackage(view){ + if(!wpValidateForm()) return; // 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 // re-check the predecessor gate at the point of saving. const _r=readiness(); if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){ - if(!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; + if(!(await confirmEarlyRelease(_r.blocking))) return; } + // The IFF rule is part of wpValidateForm() now - checked above with the rest. const pkg=collectPackage(); const ix=savedPackages.findIndex(p=>p.id===pkg.id); 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. function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; } -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); } -function clearSaved(){ if(!savedPackages.length) return; - 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)); } +async function deletePackage(i){ const p=savedPackages[i]; if(!p) return; + if(!canDeleteWP()){ toast('Deleting a work package needs the Project Admin role — archive it instead.', 'alert'); return; } + if(!(await wpConfirmDialog({title:'Delete work package', + 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); } // 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 // fresh Draft with a unique number/subject and approvals/closeout cleared. -function duplicateWP(){ - if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; } - const ans=prompt('How many copies of this work package do you want to create?','1'); +async function duplicateWP(){ + if(!gv('wp_subject')){ toast('Open or fill in a work package first, then Duplicate.', 'alert'); return; } + 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; 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 baseNum = base.number || ('WP'+pad2(editingSeq())); const made=[]; @@ -2589,9 +2763,8 @@ function duplicateWP(){ } editingId=null; saveStore(); renderSavedList(); 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}); - 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(){ @@ -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 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 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'; @@ -2879,9 +3052,11 @@ function dashToggleArchived(on){ ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); }); } else { renderDashboard(); } } -function dashArchive(id){ +async function dashArchive(id){ 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); 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(); @@ -3133,19 +3308,20 @@ function renderDashboard(){ h+=``; document.getElementById('dash-body').innerHTML=h; } -function dashIssue(id){ +async function dashIssue(id){ 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); if(waiting.length){ // Releasing early needs a reason, same as on the form — the dashboard must not // be the quiet way around the gate. - alert('Cannot issue from here — waiting on:\n\n• '+ - waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+ - '\n\nOpen the package to release it early with a logged reason.'); + toast('Cannot issue from here — waiting on '+waiting.map(w=>(w.number||w.id)).join(', ') + +'. Open the package to release it early with a logged reason.', 'alert'); 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'); dashRefreshAfterWrite(); } @@ -3154,7 +3330,7 @@ function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; l // ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── 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 row=(k,v)=>`${esc(k)}${v||'—'}`; let h=``; @@ -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 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 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 renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='
No comments yet.
'; 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 `
${esc(c.author||'Anonymous')}${esc(c.view||'')}${esc(when)}
${esc(c.text)}
${mine?`
`:''}
`; }).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 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 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(); } +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); } +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(); } // ── STATUS PILLS ───────────────────────────────────────────────────────────── diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index fee1d73..7802700 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -508,7 +508,7 @@ @@ -516,6 +516,24 @@ + + + @@ -524,7 +542,7 @@ diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 33dde74..b8be218 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -780,6 +780,16 @@ .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 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 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; diff --git a/tests/creator_dialogs_check.py b/tests/creator_dialogs_check.py new file mode 100644 index 0000000..fa79adb --- /dev/null +++ b/tests/creator_dialogs_check.py @@ -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"(? { + 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()) diff --git a/tests/hold_check.py b/tests/hold_check.py index 4ef7422..5f4eab8 100644 --- a/tests/hold_check.py +++ b/tests/hold_check.py @@ -168,9 +168,17 @@ def main(): n = page.eval("pkgConstraints.length") for i in range(n): constraint_btn(page, i, "cleared") - offers = [d for d in dlg(page) if d[0] == "confirm" and "release-ready" in d[1]] - chk("the release-ready offer on an unreleased package is unchanged", - len(offers) == 1, ascii_(dlg(page))) + # T7.9: the offer is the modal now, not a native confirm. Same proposition: + # clearing the last constraint on an unreleased package OFFERS to issue. + 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) click_status(page, "In Progress") chk("with everything cleared the package moves to In Progress", @@ -247,9 +255,13 @@ def main(): page.eval("!document.querySelector('.rb-act')")) reset_dlg(page) click_status(page, "Issued") - alerts = [d for d in dlg(page) if d[0] == "alert"] - chk("...and the status control still hard-blocks it with the same refusal", - len(alerts) == 1 and "still open" in alerts[0][1], ascii_(dlg(page))) + settle(0.4) + toast_txt = page.eval("(document.getElementById('toast')||{textContent:''}).textContent") + 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)) page.eval("document.getElementById('wp_priority').value='High'") @@ -264,23 +276,26 @@ def main(): page.eval("(document.querySelector('.rb-act')||{}).tagName") == "BUTTON") reset_dlg(page) - page.eval("window.__pReturn = null") # back out first page.eval("document.querySelector('.rb-act').click()") settle(0.4) + page.eval("wpDialogCancel()") # back out first + settle(0.3) chk("backing out of the prompt releases nothing", status_of(page) == "Draft", status_of(page)) chk("...and records no override", page.eval("!pkgGateOverride")) reset_dlg(page) - page.eval("window.__pReturn = 'Client directive 42 - install proceeds at risk'") page.eval("document.querySelector('.rb-act').click()") settle(0.4) - prompts = [d for d in dlg(page) if d[0] == "prompt"] open_names = json.loads(page.eval( "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", - len(prompts) == 1 and all(nm in prompts[0][1] for nm in open_names), - ascii_(prompts)) + all(nm in dlg_msg for nm in open_names), ascii_(dlg_msg)) + 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", status_of(page)) ov = json.loads(page.eval("JSON.stringify(pkgGateOverride||{})"))