From 7f712b7e0030f562b0e9dab0d5b39b9d285770b5 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 11:46:25 -0700 Subject: [PATCH] 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 --- docs/reference/file-map.md | 1 + html/wp-creation-app.js | 327 +++++++++++++++++++++++++-------- html/wp-creation-index.html | 22 ++- html/wp-creation-styles.css | 10 + tests/creator_dialogs_check.py | 226 +++++++++++++++++++++++ tests/hold_check.py | 37 ++-- 6 files changed, 536 insertions(+), 87 deletions(-) create mode 100644 tests/creator_dialogs_check.py 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||{})"))