Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/lint-form-layout-list-binding.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions packages/lint/src/validate-form-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
13 changes: 11 additions & 2 deletions packages/lint/src/validate-form-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"`;
Expand Down
Loading