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
78 changes: 78 additions & 0 deletions .changeset/react-block-field-props-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
"@objectstack/lint": minor
"@objectstack/spec": minor
---

feat(lint): every field-bearing prop on a React page block resolves against the
object it names

#4329 closed ONE of them — `<ListView searchableFields>` — by running the
metadata rule's core from the gate that owns React block props. That prop was an
instance, not the class: every other prop a `kind:'react'` page binds BY FIELD
NAME shipped exactly as typed, the same silent drift `page-field-unknown`
already closes for the page-component `properties` bag one surface over.

`validate-react-page-props` now resolves all of them:

- `<ListView>` `fields` / `columns` / `sort` / `grouping` / `userFilters` /
`hiddenFields` / `fieldOrder` / `filterableFields`
- `<ObjectForm>` `fields`, `initialValues` KEYS, `sections[].fields[]`
- `<RecordHighlights>` / `<RecordDetails>` / `<RecordPath>` /
`<RecordRelatedList>` — via the SAME `COMPONENT_FIELD_SPECS` table the
metadata surface uses, keyed by the block's `schemaType`, so the two surfaces
agree by construction rather than by two lists that happen to match
- `<Block type="…">` — the escape hatch reaches the same table by the type the
author writes, so it is checked instead of being a hole

Findings carry the metadata rule's id (`page-field-unknown`) at its advisory
severity, because the consumer behaves the same way: an unknown name is skipped
and the rest renders.

**A FILTER position gates instead.** `<ListView filters>` / `<ObjectChart
filter>` name fields in a QUERY, and an unknown column there is not a skipped
column: the predicate can never match, `SqlDriver` swallows the driver's
"no such column" and returns `[]`, and the surface renders an empty list that
looks exactly like "there is no data" — the silent zero `filter-token-unknown`
and `validate-flow-template-paths`' filter-position call both gate on. Those
are reported as `error`.

Filter positions are also resolved INDEPENDENTLY of each other, unlike every
other value this gate reads. `filters={['status', '=', stage]}` — a static field
beside a React-state value — is the shape a react page actually writes, and the
all-or-nothing static reader skipped the whole array, including the one position
that was knowable.

Everything else is unchanged: a value from a variable, a call, or behind a
spread is unresolvable rather than wrong and is skipped silently (ADR-0072 D1),
as are cross-package objects, objects with no authored field map, dotted
relationship paths, and registry-injected system columns.

### Breaking: `<RecordRelatedList objectName>` is the RELATED object, as the spec always said

`RecordRelatedListProps.objectName` is the related (child) object — that is what
`record:related_list` means on every metadata surface, what
`validate-page-field-bindings` resolves its `columns` against, and what the one
registry component behind both surfaces consumes. The React overlay declared
`objectName` a SECOND time and glossed it "The parent object", and the generated
contract publishes the overlay's description in place of the schema's — so the
react surface both contradicted the spec and lost any way to name the object it
renders.

FROM → TO for a page authored against the old gloss:

```diff
- <RecordRelatedList objectName="account" recordId={id} relationshipField="account_id" columns={['name','total']} />
+ <RecordRelatedList objectName="invoice" recordId={id} relationshipField="account_id" columns={['name','total']} />
```

`objectName` names the CHILD object being listed; the parent record stays bound
by `recordId`, and `relationshipField` is the child's field pointing back at it.
The lint above reports the old spelling (the child's columns and its FK do not
resolve against the parent). `objectName` is now also published as required, as
the schema declares it.

The class is closed as well as the instance: `REACT_OVERLAY_SHADOWS` in
`@objectstack/spec/ui` ledgers every overlay prop that restates a spec-schema
prop, and a test asserts the ledger equals the real collision set — so the next
overlay entry that silently redefines a schema prop fails a test instead of
shipping a second dialect.
46 changes: 45 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ Which object a component binds follows `dataSource.object` → `properties.objec
against one page-wide guess. A `record:related_list`'s `columns`/`sort`/`filter`
resolve against its **related** object (`objectName`), and its add-picker against
its own. Advisory, like form-layout field references — every consumer degrades
rather than failing.
rather than failing. The same descriptor table drives the react page surface
(§10), so a component is described once and checked wherever it is authored.

Skipped, to keep false positives at zero: relationship paths (`account.name`,
resolved by the query engine), registry-injected system fields (`created_at`,
Expand Down Expand Up @@ -274,6 +275,49 @@ most-authored surfaces, so the tightening is scheduled on what this check finds
rather than assumed. `defineStack` reports the same findings at config-load
time, so an author sees them without running the CLI at all.

### 10. React block props naming fields the object doesn't have

§5 is about a metadata page's untyped `properties` bag. A `kind:'react'` page
authors the **same** components as JSX props, and every prop that binds by field
name has the same failure: the block skips the name and renders one column, one
filter chip, or one form field short.

```jsx
<ListView objectName="crm_account" columns={['name', 'revenue']} />
// ↑ not a crm_account field → warning
```

Checked on every injected block: `<ListView>`'s
`fields`/`columns`/`sort`/`grouping`/`userFilters`, `<ObjectForm>`'s `fields`,
`initialValues` keys, `sections[].fields[]` and `subforms` (each against its own
`childObject`), and `<RecordHighlights>` / `<RecordDetails>` / `<RecordPath>` /
`<RecordRelatedList>` — those last four through the **same** descriptor table
§5 uses, so a component's field-bearing props are described once and checked on
both surfaces. `<Block type="…">` reaches that table by the type the author
writes, so the escape hatch is covered rather than left as a hole.

`<RecordRelatedList objectName>` is the **related (child)** object whose records
are listed — the parent record is bound by `recordId`, and `relationshipField`
is the child's field pointing back at it. Passing the parent there is the
mistake this check was extended to catch.

A **filter position** is the exception that gates:

```jsx
<ListView objectName="crm_account" filters={['revenu', '=', stage]} />
// ↑ error, not a warning
```

An unknown column in a predicate is not a skipped column — the predicate can
never match, the driver's "no such column" is swallowed, and the list comes back
empty and indistinguishable from *"there is no data"*. Each position of a filter
is judged on its own, so the field above is still checked even though `stage`
comes from React state.

Skipped, to keep false positives at zero: the same set as §8 — non-static
values, `{...spread}` usages, relationship paths, system fields, and objects
another package defines.

## The one gate, two entry points

`os validate` and `os build` (alias of `os compile`) run the **same** validator:
Expand Down
6 changes: 4 additions & 2 deletions content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,15 @@ const accountRecordPage = {
type: 'record:related_list',
id: 'contacts',
label: 'Contacts',
properties: { object: 'contact', relationship: 'account' },
// `objectName` is the RELATED (child) object being listed;
// `relationshipField` is the child's field pointing back at this record.
properties: { objectName: 'contact', relationshipField: 'account' },
},
{
type: 'record:related_list',
id: 'opportunities',
label: 'Opportunities',
properties: { object: 'opportunity', relationship: 'account' },
properties: { objectName: 'opportunity', relationshipField: 'account' },
},
],
},
Expand Down
11 changes: 10 additions & 1 deletion examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import { definePage } from '@objectstack/spec/ui';
* `groupBy`) and `total` (its `field`) — not by a dataset-style measure name.
* `os validate` checks both halves.
*
* `<RecordRelatedList>` binds the CHILD object it lists (`showcase_invoice`),
* not the parent — the parent is `recordId`, and `relationshipField="account"`
* is the invoice's lookup back to it. This page used to pass the parent, which
* is what #4340 found: the react contract had glossed `objectName` as "the
* parent object" while the schema (and the renderer behind both surfaces) read
* it as the related one, so the list resolved `total` against an account and
* came back empty. Every field-bearing prop on the page is now checked against
* the object it actually names.
*
* Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`;
* data blocks and the drawer bring their own compiled styling. The drawer sets
* NO pixel width: per #2578 pixel widths are deprecated (the author can't know
Expand Down Expand Up @@ -122,7 +131,7 @@ function Page() {

<ObjectChart objectName="showcase_invoice" type="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]} series={[{ name: 'total', label: 'Invoice value' }]} title="Invoice value by status" showLegend={true} />

<RecordRelatedList objectName="showcase_account" recordId={sel} relationshipField="account" columns={['name', 'status', 'total']} limit={5} showViewAll={true} title="Invoices" />
<RecordRelatedList objectName="showcase_invoice" recordId={sel} relationshipField="account" columns={['name', 'status', 'total']} limit={5} showViewAll={true} title="Invoices" />

{editing ? (
<ObjectForm objectName="showcase_account" mode="edit" recordId={sel}
Expand Down
27 changes: 27 additions & 0 deletions packages/lint/src/validate-page-field-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,30 @@ describe('validatePageFieldBindings — false-positive floor', () => {
expect(findings).toEqual([]);
});
});

describe('validatePageFieldBindings — legacy bare-string sort (#4340)', () => {
/**
* `ListViewSchema.sort` still accepts `"created_at desc"`. Reading the whole
* string as a field name reported `"amount desc"` as unknown — a finding
* whose "field" the author never wrote, and one no fix could satisfy. The
* shared `sortFieldRefs` reads its head instead, the way the renderer does.
*/
it('judges the head segment, not the whole "field desc" string', () => {
const listPage = (sort: unknown) => ({
...baseStack(),
pages: [
{
name: 'list_page',
type: 'list',
object: 'crm_lead',
interfaceConfig: { source: 'crm_lead', columns: ['name'], sort },
},
],
});
expect(validatePageFieldBindings(listPage('amount desc'))).toEqual([]);
const bad = validatePageFieldBindings(listPage('ghost_col desc'));
expect(bad).toHaveLength(1);
expect(bad[0].message).toContain('"ghost_col"');
expect(bad[0].message).not.toContain('ghost_col desc');
});
});
Loading
Loading