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
13 changes: 13 additions & 0 deletions .changeset/11070-strict-face-read-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
---

The strict authoring face accepts keys a registered renderer reads, which it used to refuse as undeclared (objectui#11070). Each key below is now declared on the TypeScript face and on its zod mirror, so `StrictAnyComponentSchema` accepts it and `objectui validate` on that face no longer tells an author to delete a key that works.

- **`form.showSubmit`** (`FormSchema`, a boolean, default `true`). The `form` renderer draws its submit button only while it holds, so `false` renders the fields with no submit button.
- **`form.fields[]`** (`FormField` / `FormFieldSchema`): `multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, `max`, `minLength`, `maxLength` and `pattern`. A hand-authored form has no object schema behind it, so the renderer hands each field widget the field entry itself as its metadata carrier, and the built-in `input` / `textarea` branches spread the entry onto the native control. These are the keys that path reads: `multiple` (the `file`, `image`, `lookup` and `user` widgets), `rows` (the `<textarea>` and the `markdown` widget), `accept` (the `file` widget's picker), `dimensions` (the `vector` widget), `reference` (the `lookup` and `user` widgets' target object), `min` / `max` (the `number` widget and the native control), `minLength` / `maxLength` (the native control; `maxLength` is also the `textarea` counter's ceiling) and `pattern` (the native control). All but `pattern` are `@objectstack/spec`'s `FieldSchema` members by reference, with the spec's own value rules; `pattern` is a string, since JSON has no `RegExp`.
- **`dataSource` on `object-grid`, `object-form`, `object-kanban`, `list-view`, `object-gantt`, `object-map` and `object-calendar`**: `@objectstack/spec`'s per-element binding `ElementDataSourceSchema`, by reference (`{ object, view?, filter?, sort?, limit? }`). It is metadata, not the data-source adapter: `SchemaRenderer` strips it from the props it spreads, and each block's gate-wrapped registration reads it off the node through `ElementDataSourceGate`. The registry already published it as an authored input on these blocks (objectui#6678).

**What now refuses that did not.** A declared key is judged by its declared type on BOTH faces. On `FormFieldSchema`, which strips undeclared keys, a wrong-typed value for one of the ten field keys used to be dropped silently and is now refused (for example `accept: "application/pdf"`, since the spec types `accept` as an array, or `rows: 0`). On the passthrough nodes, a wrong-typed `showSubmit` or `dataSource` used to be kept unjudged and is now refused. That includes a `dataSource` holding the host's adapter object or `null`, which is what the react-page wrapper writes on the nodes it builds in memory: those nodes are rendered, not validated, and `ElementDataSourceGate` ignores a value that is not a binding, so their rendering does not change. An `object-view`'s `table` slot now refuses `dataSource` by name, like the other record sources the view owns (`data`, `staticData`, `bind`), because the view does not hand it to its grid; its `form` slot carries `dataSource` as it carries `bind` and `data`. On the TypeScript face these keys are now typed members rather than the `[key: string]: any` index signature, so a wrong-typed value is a compile error. Measured over the schema catalog, the docs JSON fences and the apps' authored documents, at this change's base (`88fbd793d`) and on this change: the tolerant face refused the same documents both times.

**What is deliberately NOT declared.** The legacy spellings `reference_to` and `min_length` stay refused on the strict face; write the spec's `reference` and `minLength`. `return_type`, `summary_type`, the grid field's `columns`, `object-chart`'s `dataSource` and the dashboard widget keys stay refused until objectui#11070 settles them.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Top-level page container. Defines a full page with optional regions (header, sid
"regions": [
{
"name": "header",
"children": [{ "type": "text", "content": "Welcome back" }]
"components": [{ "type": "text", "content": "Welcome back" }]
}
],
"children": [
Expand Down
7 changes: 5 additions & 2 deletions content/docs/guide/objectos-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,14 @@ const dataSource = new ObjectStackAdapter({
baseUrl: 'http://localhost:3000/api'
});

// ObjectQL queries are automatically handled
// The adapter is injected ONCE, at the renderer boundary —
// `<SchemaRendererProvider dataSource={dataSource}>` from `@object-ui/react` —
// never as a schema key. A node's `dataSource` is the per-element BINDING
// (`{ object, view?, filter?, sort?, limit? }`), not the adapter, and
// `SchemaRenderer` strips it from the props it spreads.
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'contact',
dataSource,
// Grid filter, JSON-rules form: an array of `{ field, operator, value }`
// entries, AND-ed together. `operator` takes the canonical view-filter
// vocabulary (`equals`, `greater_than_or_equal`, ...).
Expand Down
5 changes: 1 addition & 4 deletions content/docs/guide/schema-playground.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,7 @@ A complete form with validation, driven entirely by schema:
"label": "Email",
"required": true,
"placeholder": "jane@example.com",
"validation": {
"pattern": "^[^@]+@[^@]+\\.[^@]+$",
"message": "Enter a valid email address"
}
"pattern": "^[^@]+@[^@]+\\.[^@]+$"
},
{
"name": "role",
Expand Down
20 changes: 11 additions & 9 deletions content/docs/plugins/plugin-detail.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,17 @@ its own feed. Drop it anywhere inside a record page:
```json
{
"type": "page:tabs",
"items": [
{
"label": "Activity",
"value": "activity",
"children": [
{ "type": "record:activity", "limit": 20, "showCompleted": false }
]
}
]
"properties": {
"items": [
{
"label": "Activity",
"value": "activity",
"children": [
{ "type": "record:activity", "limit": 20, "showCompleted": false }
]
}
]
}
}
```

Expand Down
5 changes: 4 additions & 1 deletion content/docs/utilities/data-objectstack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,10 @@ its own:
```json
{
"type": "object-kanban",
"dataSource": { "object": "task", "filter": { "project": "acme" } }
"dataSource": {
"object": "task",
"filter": [{ "field": "project", "operator": "equals", "value": "acme" }]
}
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"name": "ticket_id",
"label": "Ticket ID",
"type": "auto_number",
"format": "TKT-{YYYY}{MM}-{0000}",
"readonly": true
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"name": "invoice_id",
"label": "Invoice ID",
"type": "auto_number",
"format": "INV-{0000}",
"readonly": true
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"label": "Assigned To",
"type": "lookup",
"placeholder": "Select user...",
"reference_to": "users",
"reference": "users",
"options": [
{
"label": "John Doe",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"label": "Team Members",
"type": "lookup",
"placeholder": "Select team members...",
"reference_to": "users",
"reference": "users",
"multiple": true,
"options": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"type": "password",
"placeholder": "At least 8 characters",
"required": true,
"min_length": 8
"minLength": 8
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ const ABSENCES: Record<string, Absence> = {
performance: { kind: 'unread', reason: 'Imported from the spec by reference; no objectui renderer reads it.' },
pageName: { kind: 'unread', reason: 'Page-context key with no reader on the list path.' },
tabs: { kind: 'unread', reason: "Spec view-tab list. ListView has no reader — the object page's tab bar is `ViewTabBar`, driven by `buildViewTabs`, and the `tabs` readers in the tree belong to `plugin-detail`'s DetailView." },
dataSource: { kind: 'unread', reason: "The spec's per-element binding (declared on the list-view arm by objectui#11070). `ListView` has no read of `schema.dataSource`: the binding is resolved one layer up, by the registered `list-view` renderer `ListViewBlock` through `ElementDataSourceGate`, and `renderListView` bypasses that layer. It renders `ListView` directly with `schema={fullSchema}` and takes the ADAPTER as its separate `dataSource` argument (`ds`), not off the node." },

// ── Authored on the NODE, resolved by the host ────────────────────────────
operations: { kind: 'node-authored', reason: "Legacy CRUD affordance authored on the object-view node (`examples/.../object-view-record-surface.json`), not on a view record; the host resolves it upstream (`schema.operations || schema.table?.operations || …`). ListView does read `schema.operations?.export`, but what would feed it here is the NODE's value, and forwarding it is the caller's composition to make — objectui#5097's surface, not a per-view rung." },
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/__tests__/form-field-zod-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ const DECLARED_KEYS = [
'span',
// objectui#6236 — the section grouping claim (section-divider rows only).
'fields',
// objectui#11070 — field metadata a hand-authored form writes on the entry
// itself, read off it by the field widgets and the built-in branches. All
// but `pattern` are the spec's `FieldSchema` members by reference.
'multiple',
'rows',
'accept',
'dimensions',
'reference',
'min',
'max',
'minLength',
'maxLength',
'pattern',
];

describe('FormFieldSchema covers the FormField contract', () => {
Expand Down
14 changes: 10 additions & 4 deletions packages/types/src/__tests__/imported-defaults-8317.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ import {
checkDashboardWidgetStageOrder,
checkDashboardWidgetMetricMeasureArity,
} from '@objectstack/spec/ui';
import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data';
import { FieldSchema as SpecFieldSchema, SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data';
import { stripImportedDefaults } from '../zod/imported-defaults.js';

const HERE = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -270,6 +270,11 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
// like every other one.
['ElementNumberPropsSchema', SpecElementNumberPropsSchema],
['ElementDataSourceSchema', SpecElementDataSourceSchema],
// objectui#11070: `FormFieldSchema` reads nine of the spec's `FieldSchema`
// members by reference (the field metadata a hand-authored form writes on
// the entry itself), and `multiple` carries the spec's `.default(false)` —
// exactly what this boundary exists to keep out of a parse output.
['FieldSchema', SpecFieldSchema],
] as const;

/** The subset that actually carries an imported default — where the strip does work. */
Expand Down Expand Up @@ -345,14 +350,15 @@ describe('the import boundary strips every imported default (objectui#8317)', ()
it('the walker docblock\'s `lazy` count is re-derived, not quoted', () => {
// The `lazy` arm is the one place the identity property cannot hold: it
// must rebuild without forcing the getter, so a clean subtree behind a
// `z.lazy` is rebuilt anyway. The module's docblock names FOUR such
// nodes (the fourth, `ViewSchema`'s form-field group, since objectui#7928)
// `z.lazy` is rebuilt anyway. The module's docblock names FIVE such
// nodes (the fourth, `ViewSchema`'s form-field group, since objectui#7928;
// the fifth, under `FieldSchema.relatedListFilter`, since objectui#11070)
// and says the exception costs nothing extra for every owner but the
// ones `REBUILT_CLEAN` names (objectui#10872 batch 2), because each
// other owner sits inside a schema that is being rebuilt regardless.
// Both halves are measured here, so a spec bump or an import that moves
// either one is red rather than quietly making the docblock false.
expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(4);
expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(5);
const lazyOwners = IMPORTED.filter(([, s]) => walk([s]).lazies > 0);
expect(lazyOwners.length, 'no schema owns a lazy — the count above found them elsewhere').toBeGreaterThan(0);
// The clean owners are exactly the named set: rebuilt (so NOT the spec's
Expand Down
31 changes: 30 additions & 1 deletion packages/types/src/__tests__/list-view-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ const HANDLER_KEY_REFUSALS = new Set<string>([
'onPageSizeChange',
]);

/**
* A FOURTH category (objectui#11070): spec members that belong to the page
* COMPONENT envelope rather than to the list view it carries.
*
* `dataSource` is `@objectstack/spec`'s `PageComponentSchema.dataSource` — the
* per-element binding every page component may carry — so the spec's
* `ListViewSchema` (a view config) has no such member, and this arm, being
* the `list-view` NODE, declares it. Neither branch of the #2231 decision
* fits: it is not objectui-only, and it cannot be promoted into the spec's
* view schema because the spec already owns it one level up. It arrives by
* reference to the spec's `ElementDataSourceSchema`; the test below checks
* that it behaves as that binding, so this set cannot park a local field.
*/
const PAGE_COMPONENT_ENVELOPE = new Set<string>(['dataSource']);

describe('ListView spec parity (#2231 drift guard)', () => {
it('covers every @objectstack/spec ListView field (spec cannot grow a field objectui ignores)', () => {
// Fails when the spec adds a field that objectui neither imports nor envelope-owns —
Expand All @@ -154,11 +169,25 @@ describe('ListView spec parity (#2231 drift guard)', () => {
!specShape[k] &&
!ENVELOPE.has(k) &&
!SANCTIONED_LOCAL.has(k) &&
!HANDLER_KEY_REFUSALS.has(k),
!HANDLER_KEY_REFUSALS.has(k) &&
!PAGE_COMPONENT_ENVELOPE.has(k),
);
expect(rogue).toEqual([]);
});

it('every PAGE_COMPONENT_ENVELOPE member is the spec binding — the set cannot hide a local field (objectui#11070)', () => {
const node = (extra: Record<string, unknown>) => ({ type: 'list-view', objectName: 'accounts', ...extra });
for (const key of PAGE_COMPONENT_ENVELOPE) {
expect(ouiKeys.has(key), `${key} is listed but not declared on the arm`).toBe(true);
// The spec's binding: a named object with the binding's own keys parses…
expect(OuiListViewSchema.safeParse(node({ [key]: { object: 'account', view: 'hot', limit: 10 } })).success).toBe(true);
// …a binding that names no object does not (the spec requires `object`)…
expect(OuiListViewSchema.safeParse(node({ [key]: { view: 'hot' } })).success).toBe(false);
// …and neither does an adapter-shaped value, which is not metadata at all.
expect(OuiListViewSchema.safeParse(node({ [key]: 'objectstack' })).success).toBe(false);
}
});

it('every HANDLER_KEY_REFUSALS member really refuses — the set cannot hide an authorable field', () => {
const node = (extra: Record<string, unknown>) => ({
type: 'list-view',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,10 @@ describe('objectui#8990 — `groupBy` requiredness IS overturned; the record-sou
// `objectName` upstream of the node), so this document has no record source
// — and it is STILL refused for that, which is the record-source rule doing
// its job. What changed is that `groupBy` is no longer among the reasons.
const fragment = { type: 'object-kanban', dataSource: { object: 'task', filter: { project: 'acme' } } };
const fragment = {
type: 'object-kanban',
dataSource: { object: 'task', filter: [{ field: 'project', operator: 'equals', value: 'acme' }] },
};
const f = ObjectKanbanSchema.safeParse(fragment);
expect(f.success).toBe(false);
if (!f.success) {
Expand Down
15 changes: 9 additions & 6 deletions packages/types/src/__tests__/object-view-slot-key-lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@
*
* ⚠️ Those two member counts are the HISTORICAL reading that produced this
* pin, kept verbatim because the `-> 0` half is only legible beside them. The
* LIVE counts are 62 and 68: objectui#6357 declared `bind` on `BaseSchema`,
* LIVE counts are 63 and 69: objectui#6357 declared `bind` on `BaseSchema`,
* both schemas inherit it, and this guard turned red naming them — which is
* precisely the drift it exists to catch. The key was added to both slot
* unions in the same change, so the slots still ship the full configuration.
* objectui#11070 then declared `dataSource` (the spec's per-element binding)
* on both source schemas and the guard named it again: the `form` slot carries
* it, and the `table` slot withholds it as a record source the view owns.
*
* Nothing errored — the index signature answers every key as `any` — so the
* symptoms were in the tools that READ the declaration: `table: { colunms: 3 }`
Expand Down Expand Up @@ -134,7 +137,7 @@ const TABLE_WITHHELD_BY_REASON = {
*/
notRelayed: ['description', 'emptyState'],
/** The view owns it: its own record source, its own row click, its grid's identity. */
viewOwned: ['bind', 'data', 'id', 'navigation', 'onNavigate', 'staticData'],
viewOwned: ['bind', 'data', 'dataSource', 'id', 'navigation', 'onNavigate', 'staticData'],
/**
* A node-level `BaseSchema` key. `ObjectView` draws its grid as a component,
* not as a schema node, so no renderer applies one of these to it.
Expand Down Expand Up @@ -252,18 +255,18 @@ function slotType(slot: 'table' | 'form'): ts.Type {
* inherited: bump them deliberately when a member is genuinely added.
*/
describe('the source schemas still declare their full member sets', () => {
it('ObjectGridSchema declares 62 members and carries the #5155 index signature', () => {
it('ObjectGridSchema declares 63 members and carries the #5155 index signature', () => {
const grid = exportedType('ObjectGridSchema');
expect(memberNames(grid)).toHaveLength(62);
expect(memberNames(grid)).toHaveLength(63);
expect(memberNames(grid)).toEqual(expect.arrayContaining(['columns', 'pageSize', 'rowActions']));
// When this flips to `false`, objectui#5155 has removed the root index
// signature and the `Pick` lists this file pins become removable.
expect(declaresStringIndex(grid)).toBe(true);
});

it('ObjectFormSchema declares 68 members and carries the #5155 index signature', () => {
it('ObjectFormSchema declares 69 members and carries the #5155 index signature', () => {
const form = exportedType('ObjectFormSchema');
expect(memberNames(form)).toHaveLength(68);
expect(memberNames(form)).toHaveLength(69);
expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText']));
expect(declaresStringIndex(form)).toBe(true);
});
Expand Down
Loading
Loading