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
35 changes: 35 additions & 0 deletions .changeset/listview-spec-derive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@object-ui/types": minor
---

feat(types): derive `ListViewSchema` from `@objectstack/spec/ui` instead of a hand-written copy (#2231)

`@object-ui/types` shipped a hand-written mirror of the spec's UI ListView zod
(`packages/types/src/zod/objectql.zod.ts`) plus a parallel hand-written TS `interface`
(`objectql.ts`). Both had drifted from the authoritative `@objectstack/spec/ui`
`ListViewSchema`, with nothing enforcing they stay in sync.

- The zod `ListViewSchema` now **derives** from the spec's `ListViewSchema`: spec-owned
fields (`filter`, `sort`, `selection`, `navigation`, `pagination`, `grouping`,
`rowColor`, `userActions`, `appearance`, `tabs`, `addRecord`, `rowHeight`, `sharing`,
`chart`/`tree` configs, `responsive`, `performance`, …) flow in **by reference** instead
of being re-typed. The component envelope (`type: 'list-view'` discriminator +
`objectName`) and the legacy objectui vocabulary (`viewType`, `fields`, `filters`, the
`show*` toolbar flags, `densityMode`, `color`, …) plus the configs whose objectui shape
is intentionally broader than spec's (`userFilters`, `sharing`, `aria`,
`conditionalFormatting`, `exportOptions`, `kanban`/`calendar`/`gantt`/`gallery`/
`timeline`) remain as sanctioned local `.extend()`s. Existing payloads keep validating;
spec-canonical payloads (`columns`/`filter`/`userActions`) now validate too.
- The hand-written TS `interface ListViewSchema` is replaced by
`z.infer<typeof ListViewSchema> & ListViewRuntimeProps`, so the type can no longer drift
from the schema. Non-serializable runtime-only props (`onNavigate`, `onDensityChange`,
`refreshTrigger`) live in `ListViewRuntimeProps`.
- Added a drift-guard test (`list-view-spec-parity.test.ts`) that fails if the spec grows a
field objectui hasn't triaged, renames an aliased anchor (`type`/`columns`/`filter`), or
an objectui-only field is added outside the sanctioned-local set.
- Bumped the `@objectstack/spec` dependency `^14.6.0` → `^15.1.0` across the workspace
(15.1.0 carries the framework#3021 `lazySchema`/`z.toJSONSchema` identity fix that the
spec-derived Page/View inspectors depend on).

Migrating the legacy vocabulary to the spec-canonical keys and adopting spec's narrower
sub-shapes are deferred follow-ups (see #2231). No runtime behavior change.
4 changes: 2 additions & 2 deletions apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/client": "^14.6.0",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20",
"@testing-library/jest-dom": "^6.9.1",
Expand All @@ -113,8 +113,8 @@
"vitest": "^4.1.10"
},
"dependencies": {
"@object-ui/sdui-parser": "workspace:*",
"@object-ui/react-runtime": "workspace:*",
"@object-ui/sdui-parser": "workspace:*",
"sucrase": "^3.35.0"
}
}
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@object-ui/plugin-view": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"fumadocs-core": "16.11.3",
"fumadocs-mdx": "15.0.13",
"fumadocs-ui": "16.11.3",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@eslint/js": "^10.0.1",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"@playwright/test": "^1.61.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"@object-ui/types": "workspace:*",
"@objectstack/formula": "^14.6.0",
"@objectstack/lint": "^14.6.0",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"@sentry/react": "^10.65.0",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.24.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"dependencies": {
"@object-ui/types": "workspace:*",
"@objectstack/formula": "^14.6.0",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"lodash": "^4.18.1",
"zod": "^4.4.3"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-detail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"dependencies": {
"@object-ui/i18n": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"lucide-react": "^1.24.0"
},
"peerDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"@object-ui/permissions": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"lucide-react": "^1.24.0"
},
"peerDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-gantt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@object-ui/plugin-detail": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"lucide-react": "^1.24.0",
"qrcode": "^1.5.4",
"sonner": "^2.0.7"
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@object-ui/core": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"lucide-react": "^1.24.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-timeline/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@object-ui/mobile": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"class-variance-authority": "^0.7.1",
"zod": "^4.4.3"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-tree/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@object-ui/core": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"lucide-react": "^1.24.0"
},
"peerDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-view/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@objectstack/spec": "^14.6.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@object-ui/components": "workspace:*",
Expand All @@ -31,6 +30,7 @@
"@object-ui/plugin-grid": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^15.1.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^1.24.0"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"react-hook-form": "^7.81.0"
},
"peerDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"directory": "packages/types"
},
"dependencies": {
"@objectstack/spec": "^14.6.0",
"@objectstack/spec": "^15.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
143 changes: 143 additions & 0 deletions packages/types/src/__tests__/list-view-spec-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* ListView ↔ @objectstack/spec drift guard (issue #2231).
*
* objectui's `ListViewSchema` (packages/types/src/zod/objectql.zod.ts) is DERIVED from
* the spec's `ListViewSchema` (`@objectstack/spec/ui`): spec-owned fields flow in by
* reference, and only objectui-only / legacy fields are declared locally. That derivation
* means new spec fields are picked up automatically — but it can still silently break in
* three ways, which these tests catch:
*
* 1. The spec grows a field objectui never triaged (would flow in unnoticed, or — if the
* omit list references it — vanish). We assert every spec field is present in the
* objectui shape (or explicitly envelope-owned by BaseSchema).
* 2. The spec RENAMES/REMOVES a field objectui specifically aliases or relaxes
* (`type` → `viewType`, `columns` relaxed, `filter` alongside legacy `filters`). The
* alias would then point at nothing. We assert those anchors still exist upstream.
* 3. Someone adds an objectui-local field that shadows what should have been a spec field
* (re-opening the drift the unification closed). We assert every objectui-only key is
* in the explicitly-sanctioned local set below, forcing a conscious local-vs-upstream
* decision on every new field.
*
* When one of these fails, do NOT just edit the sets to make it green — decide whether the
* field belongs upstream in `@objectstack/spec` (promote it) or is a genuine objectui-only
* extension (add it to SANCTIONED_LOCAL with a rationale). See #2231.
*/
import { describe, it, expect } from 'vitest';
import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui';
import { ListViewSchema as OuiListViewSchema } from '../zod/objectql.zod.js';
import { BaseSchema } from '../zod/base.zod.js';

const specShape = (SpecListViewSchema as unknown as { shape: Record<string, unknown> }).shape;
const ouiShape = (OuiListViewSchema as unknown as { shape: Record<string, unknown> }).shape;
const baseShape = (BaseSchema as unknown as { shape: Record<string, unknown> }).shape;

const specKeys = Object.keys(specShape);
const ouiKeys = new Set(Object.keys(ouiShape));
// Component-envelope keys owned by BaseSchema (id, className, visible, etc.) — derived, so
// this test never needs editing when the envelope changes.
const ENVELOPE = new Set(Object.keys(baseShape));

/**
* objectui-only ListView fields — sanctioned local extensions on top of the spec base.
* Each is either legacy vocabulary kept for back-compat (migration to the spec-canonical
* key is deferred — #2231) or a genuinely objectui-only renderer concern. Adding to this
* set is a deliberate act: prefer promoting the field into `@objectstack/spec` instead.
*/
const SANCTIONED_LOCAL = new Set<string>([
// component binding (spec binds via data.provider:'object')
'objectName',
// renamed spec `type` (view-kind enum); `type` itself is the component discriminator
'viewType',
// legacy aliases for spec `columns` / `filter`
'fields',
'filters',
// legacy toolbar visibility flags (spec-canonical: `userActions`)
'showSearch',
'showSort',
'showFilters',
'showHideFields',
'showGroup',
'showColor',
'showDensity',
'showDescription',
'allowExport',
// legacy density shorthand (spec-canonical: `rowHeight`)
'densityMode',
// legacy row/text coloring shorthand (spec-canonical: `rowColor`)
'color',
'fieldTextColor',
'prefixField',
// objectui renderer flags with no spec equivalent (yet)
'wrapHeaders',
'clickIntoRecordDetails',
'addRecordViaForm',
'addDeleteRecordsInline',
'collapseAllByDefault',
'operations',
'options',
]);

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 —
// i.e. a field that needs a local-vs-upstream triage decision.
const missing = specKeys.filter((k) => !ouiKeys.has(k) && !ENVELOPE.has(k));
expect(missing).toEqual([]);
});

it('keeps the spec anchors objectui remaps/relaxes (`type`, `columns`, `filter`)', () => {
// Fails when the spec renames/removes a field objectui aliases, orphaning the alias.
expect(specShape).toHaveProperty('type');
expect(specShape).toHaveProperty('columns');
expect(specShape).toHaveProperty('filter');
});

it('declares no objectui-only field outside the sanctioned-local set', () => {
// Fails when a new objectui-only field is added without deciding local-vs-upstream.
const rogue = [...ouiKeys].filter(
(k) => !specShape[k] && !ENVELOPE.has(k) && !SANCTIONED_LOCAL.has(k),
);
expect(rogue).toEqual([]);
});

it('preserves the component discriminator + required objectName', () => {
const bad = OuiListViewSchema.safeParse({ objectName: 'accounts' }); // no type
expect(bad.success).toBe(false);
const good = OuiListViewSchema.safeParse({ type: 'list-view', objectName: 'accounts' });
expect(good.success).toBe(true);
const noObject = OuiListViewSchema.safeParse({ type: 'list-view' }); // no objectName
expect(noObject.success).toBe(false);
});

it('accepts both legacy (fields/filters/show*) and spec-canonical (columns/filter/userActions) payloads', () => {
const legacy = OuiListViewSchema.safeParse({
type: 'list-view',
objectName: 'accounts',
viewType: 'kanban',
fields: ['name', 'stage'],
filters: [['stage', '=', 'won']],
showSearch: true,
showHideFields: true,
densityMode: 'compact',
});
expect(legacy.success).toBe(true);

const canonical = OuiListViewSchema.safeParse({
type: 'list-view',
objectName: 'accounts',
columns: ['name', 'stage'],
filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
userActions: { search: true, filter: true },
rowHeight: 'compact',
});
expect(canonical.success).toBe(true);
});
});
Loading
Loading