Round-1 test feedback + smoke-test script
- API smoke test (server/smoketest.py): stdlib end-to-end check of health, projects, SOPs, WPs, the AWP issue gate (409 → 200), status, metrics, comments, and cascade delete. Referenced from DEPLOYMENT.md. SOP config: - Constraints: fix custom constraints never appearing — renderStandardConstraints no longer clobbers state.constraints; customs render in their own list with remove buttons; modal gains a free-text "Add" field. - Sources: add column headers (Data Type / Location-Platform / URL / Notes); preset data types are now fixed labels, "Add Source" creates an editable custom row. - Issuance strategy: add a tooltip + worked examples for each option. - Remove the "Comment submitted" acknowledgement popup (home + suite); keep the commenter name between comments. WP creator: - Clearing the last open constraint now offers to mark the package Issued and scrolls to the status control. - Form sections are collapsible (click a section heading to fold it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -664,9 +664,7 @@
|
||||
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
document.getElementById('commenter-name').value = '';
|
||||
loadComments();
|
||||
alert('Thank you! Feedback submitted.');
|
||||
}
|
||||
|
||||
function exportFeedback() {
|
||||
|
||||
@@ -477,18 +477,45 @@ function removeRole(i){
|
||||
renderOptionalRoles();
|
||||
}
|
||||
|
||||
// Seed the standard 10 once; after that, render reflects state.constraints
|
||||
// (checkbox = whether each standard one is active) and never clobbers customs.
|
||||
let _constraintsSeeded = false;
|
||||
function renderStandardConstraints(){
|
||||
const container = document.getElementById('standard-constraints');
|
||||
if(!_constraintsSeeded){
|
||||
if(!state.constraints || !state.constraints.length){
|
||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||
}
|
||||
_constraintsSeeded = true;
|
||||
}
|
||||
const active = name => state.constraints.some(c=>c.name===name);
|
||||
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
|
||||
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||
<div style="flex:1;">
|
||||
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
|
||||
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||
renderCustomConstraints();
|
||||
}
|
||||
|
||||
// Render the custom (non-standard) constraints into their own list with remove buttons.
|
||||
function renderCustomConstraints(){
|
||||
const el = document.getElementById('custom-constraints-list'); if(!el) return;
|
||||
const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name);
|
||||
const customs = state.constraints.filter(c=>!stdNames.includes(c.name));
|
||||
el.innerHTML = customs.length ? customs.map(c=>`
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||
<strong>${escAttr(c.name)}</strong>
|
||||
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
|
||||
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
||||
}
|
||||
|
||||
function removeCustomConstraint(name){
|
||||
state.constraints = state.constraints.filter(c=>c.name!==name);
|
||||
renderCustomConstraints();
|
||||
}
|
||||
|
||||
function toggleConstraint(name){
|
||||
@@ -514,13 +541,24 @@ function closeConstraintModal(){
|
||||
}
|
||||
|
||||
function addCustomConstraint(name){
|
||||
if(!state.constraints.find(c=>c.name===name)){
|
||||
if(name && !state.constraints.find(c=>c.name===name)){
|
||||
state.constraints.push({name,description:''});
|
||||
}
|
||||
closeConstraintModal();
|
||||
renderStandardConstraints();
|
||||
}
|
||||
|
||||
// Free-text custom constraint from the modal's input.
|
||||
function addCustomConstraintText(){
|
||||
const inp = document.getElementById('custom-constraint-input');
|
||||
const name = (inp && inp.value || '').trim();
|
||||
if(!name){ if(inp) inp.focus(); return; }
|
||||
if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
|
||||
state.constraints.push({name, description:''});
|
||||
if(inp) inp.value='';
|
||||
renderStandardConstraints();
|
||||
}
|
||||
|
||||
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
||||
|
||||
let seqDragIndex = null;
|
||||
@@ -600,20 +638,30 @@ const DEFAULT_SOURCES = [
|
||||
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function renderSources(){
|
||||
const container = document.getElementById('sources-list');
|
||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph}));
|
||||
container.innerHTML = state.sources.map((s,i)=>`
|
||||
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
||||
const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
|
||||
const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
|
||||
const header = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
|
||||
<div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
|
||||
</div>`;
|
||||
container.innerHTML = header + state.sources.map((s,i)=>{
|
||||
// Preset data types are fixed labels; custom rows (Add Source) get an editable name.
|
||||
const dataType = s.preset
|
||||
? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
|
||||
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
|
||||
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
${dataType}
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addSource(){
|
||||
state.sources.push({label:'',system:'',notes:'',link:''});
|
||||
// Added rows are custom — the user types their own data type here.
|
||||
state.sources.push({label:'', system:'', notes:'', link:'', preset:false});
|
||||
renderSources();
|
||||
}
|
||||
|
||||
@@ -823,9 +871,7 @@ function submitComment(){
|
||||
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
document.getElementById('commenter-name').value = '';
|
||||
loadStepComments();
|
||||
alert('✓ Comment submitted!');
|
||||
}
|
||||
|
||||
function exportComments(){
|
||||
|
||||
@@ -172,14 +172,23 @@
|
||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Issuance Strategy</label>
|
||||
<select id="gov_issuance" multiple size="3">
|
||||
<label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
|
||||
<select id="gov_issuance" multiple size="4">
|
||||
<option selected>By Sector / Area</option>
|
||||
<option>By Discipline</option>
|
||||
<option>By Phase / Sequence</option>
|
||||
<option>By Resource Availability</option>
|
||||
</select>
|
||||
<small>Hold Ctrl to select multiple</small>
|
||||
<small>Hold Ctrl (Cmd on Mac) to select multiple.</small>
|
||||
<div class="notice" style="margin-top:0.6rem; font-size:12px;">
|
||||
<strong>Examples:</strong>
|
||||
<ul style="margin:0.35rem 0 0; padding-left:1.1rem;">
|
||||
<li><strong>By Sector / Area</strong> — one package per physical area, e.g. <em>all work in Sector 1P, Level 2 chase</em>.</li>
|
||||
<li><strong>By Discipline</strong> — separate packages per trade, e.g. <em>Electrical wire-pull</em> vs <em>Mechanical install</em>.</li>
|
||||
<li><strong>By Phase / Sequence</strong> — follow the build order, e.g. <em>rough-in → wire pull → terminations</em>.</li>
|
||||
<li><strong>By Resource Availability</strong> — size to a crew/equipment window, e.g. <em>one boom-lift crew's week</em>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,7 +383,12 @@
|
||||
<h3>Add Custom Constraint</h3>
|
||||
<button class="modal-close" onclick="closeConstraintModal()">✕</button>
|
||||
</div>
|
||||
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div>
|
||||
<div style="display:flex; gap:0.5rem; margin:1rem 0 0.5rem;">
|
||||
<input type="text" id="custom-constraint-input" placeholder="Type a custom constraint name…" style="flex:1; padding:0.55rem 0.65rem; border:1px solid var(--border); border-radius:4px;" onkeydown="if(event.key==='Enter'){addCustomConstraintText();event.preventDefault();}">
|
||||
<button class="add-btn" onclick="addCustomConstraintText()">Add</button>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-dim); margin-bottom:0.5rem;">…or pick from the library:</div>
|
||||
<div id="constraint-library" style="max-height: 320px; overflow-y: auto; margin: 0 0 1rem;"></div>
|
||||
<button class="nav-btn" onclick="closeConstraintModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -529,6 +529,20 @@ function setConstraint(i,val){
|
||||
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
||||
prevStatus=getRadio('status'); holdContext={index:i, before};
|
||||
openHoldModal(pkgConstraints[i].name, true);
|
||||
return;
|
||||
}
|
||||
// Clearing the LAST open constraint makes the package release-ready — offer to
|
||||
// issue it and scroll up to the status control so the change is visible.
|
||||
if(before==='open' && val!=='open' && readiness().open===0){
|
||||
const st=getRadio('status');
|
||||
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'});
|
||||
}
|
||||
const sg=document.getElementById('status-group');
|
||||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
}
|
||||
}
|
||||
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
|
||||
@@ -771,7 +785,24 @@ function setFormChrome(on){
|
||||
if(nav) nav.style.display = on ? '' : 'none';
|
||||
if(save) save.style.display = on ? 'flex' : 'none';
|
||||
document.body.classList.toggle('has-sticky-save', !!on);
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); }
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
|
||||
}
|
||||
// Make each form card collapsible by clicking its heading (idempotent).
|
||||
function makeCollapsible(){
|
||||
document.querySelectorAll('.main > .card').forEach(card=>{
|
||||
if(card.id==='saved-card') return;
|
||||
const head=card.querySelector('.section-header, .sub-heading');
|
||||
if(!head || head.dataset.collapsible) return;
|
||||
head.dataset.collapsible='1';
|
||||
head.style.cursor='pointer';
|
||||
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
|
||||
head.insertBefore(chev, head.firstChild);
|
||||
head.addEventListener('click', e=>{
|
||||
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
|
||||
const collapsed=card.classList.toggle('collapsed');
|
||||
chev.textContent = collapsed ? '▸' : '▾';
|
||||
});
|
||||
});
|
||||
}
|
||||
function buildSectionNav(){
|
||||
const nav=document.getElementById('section-nav'); if(!nav) return;
|
||||
|
||||
@@ -564,6 +564,11 @@
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* Collapsible form sections */
|
||||
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
|
||||
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
|
||||
.card.collapsed .section-desc { display:none; }
|
||||
|
||||
/* Section nav (jump chips) */
|
||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
|
||||
|
||||
Reference in New Issue
Block a user