diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index bbb5969..afb6ad9 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -324,6 +324,28 @@ deliberately deferred. rebuild unchanged. Either way `browser_check.py`'s fixture should adopt the `{sop, state}` shape so it stops being the only place this discrepancy is visible. +### BL-019 — A cost code that has left the list is silently blanked on edit + +- **Found during:** T5.6 +- **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by + `loadPackageIntoForm()` +- **What:** `wp_cost` is a ` + + ${escAttr(f.label)} + ${escAttr(f.note)} + + ${fields[f.id] ? 'In use' : 'Not used'} + `).join(''); host.innerHTML = WPSections.LIST.map(sec => ` `).join(''); + ` + fieldRows(sec.id)).join(''); renderSectionSummary(); } function renderSectionSummary(){ const el = document.getElementById('section-summary'); if(!el || typeof WPSections === 'undefined') return; - const off = WPSections.offList(sopSections()); + const off = WPSections.offList(sopSections()).concat(WPSections.offFieldList(sopFields())); el.textContent = off.length - ? `${off.length} section${off.length===1?'':'s'} turned off: ${off.join(', ')}. ` + ? `${off.length} turned off: ${off.join(', ')}. ` + 'Their data is retained and returns if they are turned back on.' : 'Every section is in use. Turn off anything this project does not need.'; } @@ -1558,10 +1581,12 @@ document.addEventListener('DOMContentLoaded', function(){ if(!host) return; host.addEventListener('change', function(e){ const t = e.target; - if(!t || !t.dataset || !t.dataset.section) return; - const on = sopSections(); - on[t.dataset.section] = !!t.checked; - const wrap = t.closest('.section-toggle'); + if(!t || !t.dataset) return; + const isField = !!t.dataset.field; + if(!isField && !t.dataset.section) return; + if(isField) sopFields()[t.dataset.field] = !!t.checked; + else sopSections()[t.dataset.section] = !!t.checked; + const wrap = t.closest(isField ? '.field-toggle' : '.section-toggle'); if(wrap){ wrap.classList.toggle('is-off', !t.checked); const st = wrap.querySelector('.section-toggle-state'); @@ -1591,7 +1616,7 @@ function pushSectionsToCreator(){ try { const cw = frame.contentWindow; if(cw && typeof cw.applySopSections === 'function'){ - cw.applySopSections(sopSections()); + cw.applySopSections(sopSections(), sopFields()); return true; } } catch(e){ /* cross-document timing; the creator picks it up on its next boot */ } @@ -2014,6 +2039,7 @@ function completeSOP(){ // CR-006. Rides on the SOP because the creator already reads the SOP: this is // what makes the toggles reach a work package without a second channel. sections: (typeof WPSections !== 'undefined') ? WPSections.normalize(state.sections) : {}, + fields: (typeof WPSections !== 'undefined') ? WPSections.normalizeFields(state.fields) : {}, quality: { qcReq: state.quality.qcreq, photo: state.quality.photo, diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css index cfea21c..15047a4 100644 --- a/html/work-package-suite-styles.css +++ b/html/work-package-suite-styles.css @@ -734,6 +734,26 @@ body.embed-full { overflow: hidden; } .section-toggle.is-off { border-left-color: var(--border-strong); } .section-toggle.is-off .section-toggle-name { color: var(--text-light); } +/* CR-002: a field inside a section, not an eleventh section. Indented and + smaller so the nesting is visible, and it inherits every other rule above so + the two cannot look like different kinds of control. */ +.field-toggle { + display: flex; + align-items: flex-start; + gap: var(--wp-s3); + padding: var(--wp-s2) var(--wp-s3) var(--wp-s2) var(--wp-s6); + border: 1px solid var(--border); + border-left: 3px solid var(--success); + background: var(--bg); + margin-bottom: -1px; + cursor: pointer; +} +.field-toggle:hover { background: var(--cds-layer-hover); } +.field-toggle input { flex: none; width: 16px; height: 16px; margin-top: 2px; } +.field-toggle .section-toggle-name { font-size: 13px; } +.field-toggle.is-off { border-left-color: var(--border-strong); } +.field-toggle.is-off .section-toggle-name { color: var(--text-light); } + /* .field-error is declared once, in theme-light.css — the launcher's create form and T5.8's step validation use the same component. */ diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 99656ed..75a1410 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -122,7 +122,7 @@ function applySOP(){ updateNumber(); updateReleaseBanner(); // CR-006. Last, after every builder has rendered its card — applying it earlier // would be undone by whichever builder touched the same card afterwards. - applySopSections(SOP && SOP.sections); + applySopSections(SOP && SOP.sections, SOP && SOP.fields); } // Per-package kind. A project whose SOP has bimEnabled produces both install (IWP) // and BIM (EWP) packages; the kind selector tailors which fields, WP types, and @@ -1215,8 +1215,8 @@ function renderPackage(pkg){ ${pkg.disciplines&&pkg.disciplines.length?`Discipline(s)${esc(pkg.disciplines.join(', '))}${pkg.split?' [MASTER — split into instances]':''}${pkg.instanceOf?` [instance of ${esc(pkg.parentNumber||'')}]`:''}`:''} System / Facility Code / UPN${cell(pkg.system)} ${sectionOn('location')?`Location${cell(pkg.location)}`:''} - Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()} - Acumatica Task${cell(pkg.wbs)} + ${fieldOn('costCode')?`Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}`:''} + ${fieldOn('acumaticaTask')?`Acumatica Task${cell(pkg.wbs)}`:''} Assignees${cell(pkg.assignees)} Distribution${cell(pkg.distribution)} Due Date${cell(pkg.due)} @@ -1446,20 +1446,40 @@ const WP_SECTION_NODES = { closeout: ['#closeout-card'], }; +// CR-002: two fields inside General Information, off-switchable on their own. +// Hiding the whole .field wrapper rather than the input, so the label goes with +// it — a bare label over nothing is worse than either state. +const WP_FIELD_NODES = { + costCode: ['#field-costCode'], + acumaticaTask: ['#field-acumaticaTask'], +}; + let wpSections = (typeof WPSections !== 'undefined') ? WPSections.defaults() : {}; +let wpFields = (typeof WPSections !== 'undefined') ? WPSections.fieldDefaults() : {}; function sectionOn(id){ if(typeof WPSections === 'undefined') return true; return WPSections.isOn(wpSections, id); } +function fieldOn(id){ + if(typeof WPSections === 'undefined') return true; + return WPSections.fieldOn(wpSections, wpFields, id); +} /* Apply a section map to the form. Called from applySOP() on boot — which covers a reload, a fresh tab and the standalone page — and from the SOP wizard next door while the frame is already open (X4). Exposed on window deliberately: the wizard is a different document and can only reach a global. */ -function applySopSections(sections){ +function applySopSections(sections, fields){ if(typeof WPSections === 'undefined') return wpSections; wpSections = WPSections.normalize(sections); + wpFields = WPSections.normalizeFields(fields); + Object.keys(WP_FIELD_NODES).forEach(id => { + const on = fieldOn(id); + WP_FIELD_NODES[id].forEach(sel => { + document.querySelectorAll(sel).forEach(el => { el.hidden = !on; }); + }); + }); Object.keys(WP_SECTION_NODES).forEach(id => { const on = wpSections[id]; WP_SECTION_NODES[id].forEach(sel => { diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 5f6b698..d5feb8b 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -165,8 +165,12 @@ Information's grid. CR-004 gives it structured building/floor/sector fields of its own in wave 6; only this wrapper's contents change then. -->
-
Acumatica cost codes
-
+ +
Acumatica cost codes
+
diff --git a/html/wp-sections.js b/html/wp-sections.js index 2673ea5..478dae3 100644 --- a/html/wp-sections.js +++ b/html/wp-sections.js @@ -49,6 +49,45 @@ var IDS = LIST.map(function (s) { return s.id; }); + /* Individual fields that can be switched off inside a section — CR-002. + + A second, narrower list rather than more sections, because a section is a + block of the document and these are two rows inside one. BL-000b asks + whether General Information wants per-field toggles generally; this is not + that. It is the two fields CR-002 names, expressed as toggles because + CLAUDE.md says removals are expressed through toggles and the data is + retained — the columns and the model stay exactly as they are. + + Same rule as sections: an id is permanent, absent means ON. */ + var FIELDS = [ + { id: 'costCode', section: 'general', label: 'Acumatica cost code', + note: 'Effectively constant on a job, so it is noise on a field work package (CR-002). The value stays on every package that has one.' }, + { id: 'acumaticaTask', section: 'general', label: 'Acumatica task', + note: 'A PM concern rather than a field one (CR-002). The cost visibility the team actually wants is by building and floor — CR-004 and CR-018.' }, + ]; + + var FIELD_IDS = FIELDS.map(function (f) { return f.id; }); + + function fieldDefaults() { + var out = {}; + FIELD_IDS.forEach(function (id) { out[id] = true; }); + return out; + } + + function normalizeFields(stored) { + var out = fieldDefaults(); + if (stored && typeof stored === 'object') { + FIELD_IDS.forEach(function (id) { + if (Object.prototype.hasOwnProperty.call(stored, id)) out[id] = stored[id] !== false; + }); + } + return out; + } + + function fieldsFor(sectionId) { + return FIELDS.filter(function (f) { return f.section === sectionId; }); + } + function defaults() { // A new SOP has everything on. A project opts OUT of what it does not use; // it does not have to discover and opt in to what it does. @@ -83,6 +122,22 @@ return LIST.filter(function (x) { return !s[x.id]; }).map(function (x) { return x.label; }); } + /* A field is on only if its own toggle is on AND the section holding it is. + Asked as one question so no caller has to remember to ask both — a field + showing inside a hidden section is not a state anyone wants to reason + about. */ + function fieldOn(sections, fields, id) { + var f = FIELDS.filter(function (x) { return x.id === id; })[0]; + if (!f) return true; + if (!isOn(sections, f.section)) return false; + return normalizeFields(fields)[id]; + } + + function offFieldList(fields) { + var s = normalizeFields(fields); + return FIELDS.filter(function (x) { return !s[x.id]; }).map(function (x) { return x.label; }); + } + window.WPSections = { LIST: LIST, IDS: IDS, @@ -90,5 +145,13 @@ normalize: normalize, isOn: isOn, offList: offList, + + FIELDS: FIELDS, + FIELD_IDS: FIELD_IDS, + fieldDefaults: fieldDefaults, + normalizeFields: normalizeFields, + fieldsFor: fieldsFor, + fieldOn: fieldOn, + offFieldList: offFieldList, }; })(window); diff --git a/tests/sections_check.py b/tests/sections_check.py index 797479e..d211cb7 100644 --- a/tests/sections_check.py +++ b/tests/sections_check.py @@ -139,12 +139,34 @@ def sop_with_sections(sections): } -def set_sop(db_path, sections): +def set_sop(db_path, sections, fields=None): from server.db import SessionLocal from server import models with SessionLocal() as db: sop = db.get(models.Sop, "sopA") - sop.data = sop_with_sections(sections) + data = sop_with_sections(sections) + if fields is not None: + data["sop"]["fields"] = fields + data["state"]["fields"] = fields + else: + prev = (sop.data or {}).get("sop", {}).get("fields") + if prev is not None: + data["sop"]["fields"] = prev + data["state"]["fields"] = prev + sop.data = data + db.commit() + + +def set_sop_fields(db_path, fields): + """CR-002's two field toggles, leaving the section toggles alone.""" + from server.db import SessionLocal + from server import models + with SessionLocal() as db: + sop = db.get(models.Sop, "sopA") + data = json.loads(json.dumps(sop.data or {})) + data.setdefault("sop", {})["fields"] = fields + data.setdefault("state", {})["fields"] = fields + sop.data = data db.commit() @@ -372,6 +394,115 @@ def run(page, base, tok, db_path): chk("the shared list is the only place the labels live", shared.count("Drawings and Attachments") == 1) + print("\nCR-002 (T5.6). The two Acumatica fields, hidden and not deleted") + page.goto(base + "/work-package-suite.html?project=projA&step=12") + settle(2.0) + page.eval(STUB) + frows = json.loads(page.eval("""JSON.stringify( + [...document.querySelectorAll('#section-toggles .field-toggle')].map(l => ({ + id: (l.querySelector('input')||{}).dataset.field, + label: ((l.querySelector('.section-toggle-name')||{}).textContent||'').trim(), + on: !!(l.querySelector('input')||{}).checked, + })))""")) + chk("both fields are offered as toggles", len(frows) == 2, frows) + chk("...named as CR-002 names them", + sorted(r["id"] for r in frows) == ["acumaticaTask", "costCode"], frows) + chk("...on by default, like everything else", all(r["on"] for r in frows), frows) + chk("...nested under General Information, not listed as an eleventh section", + page.eval("""(() => { + const kids = [...document.getElementById('section-toggles').children]; + const gi = kids.findIndex(k => k.querySelector('input[data-section="general"]')); + return kids[gi+1] && kids[gi+1].classList.contains('field-toggle') + && kids[gi+2] && kids[gi+2].classList.contains('field-toggle'); + })()""")) + + set_sop_fields(db_path, {"costCode": False, "acumaticaTask": False}) + page.goto(base + "/wp-creation-index.html?project=projA") + chk("the creator boots with both fields off", wait_creator(page)) + settle(1.2) + page.eval(STUB) + chk("neither field appears in the form", page.eval("""(() => { + const a = document.getElementById('field-costCode'); + const b = document.getElementById('field-acumaticaTask'); + return !!a && !!b && a.hidden && b.hidden; + })()""")) + chk("...and their labels went with them, not just their inputs", page.eval("""(() => { + const a = document.getElementById('field-costCode'); + return a.hidden && a.querySelector('label') !== null; + })()""")) + # A REAL cost code ("4060 — Electrical Install"). The field is a