diff --git a/.changeset/lint-form-layout-list-binding.md b/.changeset/lint-form-layout-list-binding.md new file mode 100644 index 0000000000..00ce125be8 --- /dev/null +++ b/.changeset/lint-form-layout-list-binding.md @@ -0,0 +1,9 @@ +--- +"@objectstack/lint": patch +--- + +`validateFormLayout` now resolves the bound object for a view container's default `form` (and its `formViews.*` entries that declare no binding of their own) when that container names its object only on the `list` block (`list.data.object`, `list.object` or `list.objectName`) and nowhere on the container itself. + +Before this fix, `containerObject` had no way to see a list-only binding, so `objName` stayed `undefined` for every site under such a container — and `form-field-unknown` / `form-section-group-unknown` never fired there, however wrong the section content was. This is the same fallback rung `validate-translatable-sections.ts` already carries for its own sites; it is now shared by both. `absolute-colspan-discouraged` is unaffected by this change — it was never gated on the object binding (it needs only a field's `colSpan`), so it already fired on a list-bound container's form sections before this fix. + +Consequence: a view whose object binding lives only on `list` and whose default `form` (or an unbound `formViews.*` entry) references a nonexistent field or an undeclared `section.group` now gets a `warning` finding it did not get before. A stack with no such dangling reference sees no new output. diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index ea6b60dc05..efd337615e 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -434,3 +434,102 @@ describe('#6251 — reachable on a REAL parsed app stack', () => { expect(validateFormLayout(value!)).toEqual([]); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// #16168 — a container that binds its object only through `list.data.object` +// (HotCRM's own shape, 14/14 views) had NO `object` / `objectName` / +// `data.object` of its own, so `containerObject` — and every site under it, +// including the container's own default `form` — resolved to `undefined` and +// `form-field-unknown` / `form-section-group-unknown` never fired there. +// Fix: fall back to `viewObjectName(view.list)`, the same third rung +// `validate-translatable-sections.ts:178-179` already carries for its sites. +// ─────────────────────────────────────────────────────────────────────────── + +describe('#16168 — falls back to the container list binding', () => { + /** A container that binds ONLY through `list.data.object` — no `object` / `objectName` at the container root. */ + const listBoundContainer = (form: AnyRec) => ({ + name: 'contract_views', + list: { label: 'Contracts', type: 'grid', data: { provider: 'object', object: 'contract' }, columns: [{ field: 'name' }] }, + form, + }); + + it('P1 — flags a dangling field in the default `form` of a list-bound container', () => { + const findings = validateFormLayout({ + objects, + views: [listBoundContainer({ sections: [{ columns: 2, fields: ['name', 'ghost_field'] }] })], + }); + expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[1]`, + ]); + expect(findings[0].message).toContain('"contract"'); + }); + + it('P2 — flags an undeclared `group` in the default `form` of a list-bound container', () => { + const findings = validateFormLayout({ + objects: groupedObjects, + views: [listBoundContainer({ sections: [{ group: 'contact_info' }] })], + }); + expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_SECTION_GROUP_UNKNOWN}@views[0].form.sections[0].group`, + ]); + }); + + it('N1 — positive control: the same defects with `object` also on the container produce the SAME findings (byte-identical where/path)', () => { + const withObject = (view: AnyRec) => ({ ...view, object: 'contract' }); + const p1 = validateFormLayout({ + objects, + views: [withObject(listBoundContainer({ sections: [{ columns: 2, fields: ['name', 'ghost_field'] }] }))], + }); + expect(p1.map((f) => ({ rule: f.rule, where: f.where, path: f.path }))).toEqual([ + { rule: FORM_FIELD_UNKNOWN, where: 'view "contract_views" · form', path: 'views[0].form.sections[0].fields[1]' }, + ]); + const p2 = validateFormLayout({ + objects: groupedObjects, + views: [withObject(listBoundContainer({ sections: [{ group: 'contact_info' }] }))], + }); + expect(p2.map((f) => ({ rule: f.rule, where: f.where, path: f.path }))).toEqual([ + { rule: FORM_SECTION_GROUP_UNKNOWN, where: 'view "contract_views" · form', path: 'views[0].form.sections[0].group' }, + ]); + }); + + it('N2 — a list-bound container whose form references only real fields is clean', () => { + expect(validateFormLayout({ + objects, + views: [listBoundContainer({ sections: [{ columns: 2, fields: ['name', 'amount'] }] })], + })).toEqual([]); + }); + + it('N3 — a `formViews[]` entry with its own `objectName` resolves as today, independent of the container', () => { + const findings = validateFormLayout({ + objects, + views: [{ + name: 'contract_views', + list: { data: { object: 'contract' } }, + formViews: { edit: { objectName: 'contract', sections: [{ fields: ['ghost_via_own_binding'] }] } }, + }], + }); + expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].formViews.edit.sections[0].fields[0]`, + ]); + }); + + it('N4 — a view with neither a container nor a list binding stays silent (unchanged: the rule cannot check what it cannot resolve)', () => { + expect(validateFormLayout({ + objects, + views: [{ name: 'orphan_views', form: { sections: [{ fields: ['whatever'] }] } }], + })).toEqual([]); + }); + + it('the colSpan rule is unconditional on the object binding — it already fired on a list-bound container before this fix', () => { + // Measured for the changeset: `absolute-colspan-discouraged` sits OUTSIDE + // the `known`-gated block (validate-form-layout.ts, the `(b)` comment) — + // it needs only `entry.colSpan != null`, never `objName` / `known`. So it + // was never actually dead on HotCRM's list-bound views; #16168 only fixes + // `form-field-unknown` and `form-section-group-unknown`. + const findings = validateFormLayout({ + objects, + views: [listBoundContainer({ sections: [{ columns: 2, fields: [{ field: 'name', colSpan: 2 }] }] })], + }); + expect(findings.map((f) => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]); + }); +}); diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts index 2e68ad0dfb..df89f29990 100644 --- a/packages/lint/src/validate-form-layout.ts +++ b/packages/lint/src/validate-form-layout.ts @@ -130,16 +130,25 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { // artifact-emitted one may carry neither, so the path is the last resort. const viewName = strName(view.name) ?? strName(view.object) ?? viewPath; const containerObject = viewObjectName(view); + // [#16168] A container whose object lives on the LIST block + // (`list.data.object`, `list.object` or `list.objectName` — any anchor + // `viewObjectName` reads) has no `object` / `objectName` / `data.object` of + // its own, so `containerObject` above is `undefined` and every site under it + // — including the container's own default `form` — was unreferenceable. + // Same third rung `validate-translatable-sections.ts:178-179` already + // carries for its own sites. + const listBinding = isRec(view.list) ? viewObjectName(view.list) ?? containerObject : undefined; for (const site of formViewSites(view, viewPath)) { // A sub-container declares its own binding (`form.data.object`) and - // otherwise inherits the container's — the resolution order every other + // otherwise inherits the container's, then the container's list-bound + // object as a last resort (#16168) — the resolution order every other // view-walking rule in this package uses. The base rung is the shared // `viewObjectName` (#6662); this FALLBACK is deliberately NOT folded into // the shared walker, because the consumers compose it differently (see // `view-walk.ts`) and a refactor that changes a verdict is a failed // refactor. - const objName = viewObjectName(site.view) ?? containerObject; + const objName = viewObjectName(site.view) ?? containerObject ?? listBinding; // Only reference-check when the bound object resolves; otherwise we can't. const known = objName ? objectFields.get(objName) : undefined; const where = site.surface ? `view "${viewName}" · ${site.surface}` : `view "${viewName}"`;