diff --git a/.changeset/adr-0085-consumer-switch.md b/.changeset/adr-0085-consumer-switch.md new file mode 100644 index 000000000..0b6f5d74d --- /dev/null +++ b/.changeset/adr-0085-consumer-switch.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-form': minor +'@object-ui/plugin-detail': minor +'@object-ui/plugin-grid': minor +'@object-ui/app-shell': minor +--- + +Consume the ADR-0085 object semantic roles from `@objectstack/spec@11.7.0`, retiring the per-surface hint dialects: + +- **Single-source fieldGroups derivation**: `plugin-form`'s `deriveFieldGroupSections` and `plugin-detail`'s `deriveFieldGroupDetailSections` are now thin adapters over the spec's `deriveFieldGroupLayout` (ADR-0085 §5) — forms, modals and detail pages render the SAME grouping from one implementation. The canonical `collapse: 'none' | 'expanded' | 'collapsed'` enum is honoured everywhere (deprecated `collapsible`/`collapsed` and `defaultExpanded` spellings still read for pre-11.7 metadata). +- **`stageField` semantic role**: the detail stepper reads the top-level `stageField`; `stageField: false` now actually suppresses stage detection (previously the `false` handling was wired to the removed `detail.stageField` key, so spec-authored `false` fell through to the name heuristic). +- **`highlightFields` rename**: default grid columns, card compact views, the detail highlight strip, child-record preview fields and interface-page default columns read the object's `highlightFields` (deprecated `compactLayout` spelling read as fallback for pre-11.7 metadata). +- **Removed dead reads**: the never-spec-writable `objectDef.views.*` UI hints and the ADR-0085-removed `detail.*` block (`sections`, `sectionGroups`, `highlightFields`, `stageField`, `useFieldGroups`, `showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`, `relatedLayout`) are no longer consulted. Per-page customization goes through an assigned Page schema (`record:reference_rail` remains available there as a renderer capability). `detail.renderViaSchema` survives only as the legacy-renderer kill-switch and is removed together with that path. diff --git a/apps/console/package.json b/apps/console/package.json index 5a80634e0..1ec654dd6 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -88,7 +88,7 @@ "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "@objectstack/client": "^11.5.0", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "@tailwindcss/postcss": "^4.3.1", "@tailwindcss/typography": "^0.5.20", "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/site/package.json b/apps/site/package.json index 8fd7fff31..bc81785e8 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -31,7 +31,7 @@ "@object-ui/plugin-view": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "fumadocs-core": "16.10.6", "fumadocs-mdx": "15.0.13", "fumadocs-ui": "16.10.6", diff --git a/package.json b/package.json index 92c5db5bf..bedbdb890 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "devDependencies": { "@changesets/cli": "^2.31.0", "@eslint/js": "^10.0.1", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "@playwright/test": "^1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 12a9a014f..ede116e62 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -48,7 +48,7 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "@sentry/react": "^10.62.0", "jsonc-parser": "^3.3.1", "lucide-react": "^1.22.0", diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index 22b5d4825..4e5b70ef9 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -69,7 +69,8 @@ function resolveSourceView(objectDef: any, sourceView?: string): any | undefined /** * Default column set when the resolved view carries none — mirrors * ObjectView's data-mode fallback so an interface page never renders a - * column-less grid. Priority: curated `compactLayout`, else the first + * column-less grid. Priority: the `highlightFields` semantic role + * (ADR-0085; deprecated `compactLayout` read as fallback), else the first * business fields (system/audit columns excluded). */ const SYSTEM_FIELDS = new Set([ @@ -78,8 +79,11 @@ const SYSTEM_FIELDS = new Set([ 'updated_by', 'updatedBy', '_version', '_rev', ]); function defaultColumnsFromObject(objectDef: any): string[] { - if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) { - return objectDef.compactLayout.filter((n: string) => objectDef.fields?.[n]); + const curated = Array.isArray(objectDef?.highlightFields) && objectDef.highlightFields.length > 0 + ? objectDef.highlightFields + : objectDef?.compactLayout; + if (Array.isArray(curated) && curated.length > 0) { + return curated.filter((n: string) => objectDef.fields?.[n]); } const fields = objectDef?.fields; if (fields && typeof fields === 'object') { diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 628995bc0..e47eecf97 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -236,8 +236,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an 'updated_by', 'updatedBy', '_version', '_rev', ]); let defaultColumns: string[] = []; - if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) { - defaultColumns = objectDef.compactLayout.filter((n: string) => objectDef.fields?.[n]); + const curated = Array.isArray(objectDef?.highlightFields) && objectDef.highlightFields.length > 0 + ? objectDef.highlightFields + : objectDef?.compactLayout; + if (Array.isArray(curated) && curated.length > 0) { + defaultColumns = curated.filter((n: string) => objectDef.fields?.[n]); } else if (objectDef?.fields) { defaultColumns = Object.entries(objectDef.fields) .filter(([name, f]: [string, any]) => f && !f.hidden && !SYSTEM_FIELDS.has(name)) @@ -486,7 +489,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec) const views = useMemo(() => { // Default column resolution priority: - // 1. `compactLayout` (curated primary business fields). + // 1. The `highlightFields` semantic role (ADR-0085; deprecated + // `compactLayout` read as fallback). // 2. Business fields only — exclude system-managed identifiers/audit // columns (id, created_at, updated_at, …) and fields explicitly // marked hidden/readonly on the schema. First 5 kept for compactness. @@ -496,8 +500,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an 'updated_by', 'updatedBy', '_version', '_rev', ]); const resolveDefaultColumns = (): string[] => { - if (Array.isArray(objectDef.compactLayout) && objectDef.compactLayout.length > 0) { - return objectDef.compactLayout.filter((n: string) => objectDef.fields?.[n]); + const curated = Array.isArray(objectDef.highlightFields) && objectDef.highlightFields.length > 0 + ? objectDef.highlightFields + : objectDef.compactLayout; + if (Array.isArray(curated) && curated.length > 0) { + return curated.filter((n: string) => objectDef.fields?.[n]); } if (objectDef.fields) { return Object.entries(objectDef.fields) diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 9e1991582..f0e7c5f41 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -28,7 +28,7 @@ import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog import { FlowRunner, type ScreenFlowState } from './FlowRunner'; import { resolveActionParams } from '../utils/resolveActionParams'; import { useRecordBreadcrumbTitle } from '../context/NavigationContext'; -import type { DetailViewSchema, FeedItem, HighlightField, SectionGroup } from '@object-ui/types'; +import type { DetailViewSchema, FeedItem, HighlightField } from '@object-ui/types'; import type { ActionDef, ActionParamDef } from '@object-ui/core'; import { useRecordApprovals } from '../hooks/useRecordApprovals'; import { getRecordDisplayName } from '../utils'; @@ -77,9 +77,8 @@ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'up /** * System/tenant fields that the framework auto-injects on every record but * which carry no business value on a detail page. Hidden from the - * auto-generated section (when the object has no explicit form sections). - * Authors who really want to show these can still list them in - * `objectDef.views.form.sections`. + * auto-generated sections. Authors who really want to surface one can + * assign it to a `fieldGroups` group explicitly (explicit listing wins). */ const HIDDEN_SYSTEM_FIELD_NAMES = new Set([ 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at', @@ -1225,49 +1224,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri (key) => key === 'name' || key === 'title' ); - // Build sections, in priority order: - // 1) explicit sections — the spec-writable `detail.sections` block, - // else legacy `views.form.sections` (the spec's ObjectSchema has no - // `views` key, so that source only exists on non-spec metadata); - // 2) sections derived from the designer's `fieldGroups` metadata - // (see the fieldGroups branch in the fallback below); - // 3) auto-grouping (primary + collapsible "More details"). - const formSections = (objectDef as any).detail?.sections ?? objectDef.views?.form?.sections; - const sections = formSections && formSections.length > 0 - ? formSections.map((sec: any) => ({ - title: sec.name ? sectionLabel(objectDef.name, sec.name, sec.title || sec.name) : sec.title, - collapsible: sec.collapsible, - defaultCollapsed: sec.defaultCollapsed, - fields: (sec.fields || []) - .filter((f: any) => { - // Honor `hidden: true` on a field def even when the form - // section explicitly lists it. Hidden fields are typically - // internal artifacts (e.g. database URL, environment id) - // that platform actions read but end-users shouldn't see. - const fieldName = typeof f === 'string' ? f : f.name; - const fieldDef = objectDef.fields?.[fieldName]; - return !fieldDef?.hidden; - }) - .map((f: any) => { - const fieldName = typeof f === 'string' ? f : f.name; - const fieldDef = objectDef.fields[fieldName]; - if (!fieldDef) { - console.warn(`[RecordDetailView] Field "${fieldName}" not found in ${objectDef.name} definition`); - return { name: fieldName, label: fieldName }; - } - const refTarget = fieldDef.reference_to || fieldDef.reference; - return { - name: fieldName, - label: fieldDef.label || fieldName, - type: fieldDef.type || 'text', - ...(fieldDef.options && { options: fieldDef.options }), - ...(refTarget && { reference_to: refTarget }), - ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }), - ...(fieldDef.currency && { currency: fieldDef.currency }), - }; - }), - })) - : (() => { + // Build sections (ADR-0085: grouping is the `fieldGroups` semantic + // role — there is no per-surface sections override; per-page + // customization goes through an assigned Page schema): + // 1) sections derived from the object's `fieldGroups`; + // 2) auto-grouping (primary + collapsible "More details"). + const sections = (() => { const toField = (key: string) => { const fieldDef = objectDef.fields[key]; const refTarget = fieldDef.reference_to || fieldDef.reference; @@ -1319,13 +1281,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ]; }; - // 2) fieldGroups-derived sections (object-designer metadata, - // same source the runtime form honours). Declared groups - // render as titled cards in declared order; the helper's + // 1) fieldGroups-derived sections (the ADR-0085 semantic role, + // same shared derivation the runtime form honours). Declared + // groups render as titled cards in declared order; the // trailing untitled bucket (ungrouped fields) still goes // through the primary/"More details" split so long-form - // fields stay tucked away. Objects can opt out via - // `detail.useFieldGroups: false`. + // fields stay tucked away. const grouped = deriveFieldGroupDetailSections(objectDef as any); if (grouped) { return grouped.flatMap((sec: any) => { @@ -1418,19 +1379,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return base; })(); - // Build highlightFields: exclusively from objectDef metadata (no - // hardcoded fallback). The spec-writable `detail.highlightFields` - // wins; `views.detail.highlightFields` stays as back-compat for - // non-spec metadata (the spec's ObjectSchema has no `views` key). - // Entries may be bare field names — normalize them to the - // HighlightField shape by resolving label/type from the field def. + // Build highlightFields from the object's semantic role (ADR-0085): + // top-level `highlightFields`, with the deprecated `compactLayout` + // spelling as fallback for pre-11.7 metadata that reaches consumers + // un-normalized. Bare field names resolve label/type from the field def. const rawHighlightFields = - (objectDef as any).detail?.highlightFields ?? objectDef.views?.detail?.highlightFields ?? []; + (objectDef as any).highlightFields ?? (objectDef as any).compactLayout ?? []; const highlightFields: HighlightField[] = (Array.isArray(rawHighlightFields) ? rawHighlightFields : []) .map((f: any): HighlightField | null => { const name = typeof f === 'string' ? f : f?.name; if (!name) return null; - if (typeof f === 'object' && f.label) return f as HighlightField; const fieldDef = objectDef.fields?.[name]; return { name, @@ -1440,12 +1398,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }) .filter((f): f is HighlightField => !!f); - // Build sectionGroups from objectDef detail/form config if available - const sectionGroups: SectionGroup[] | undefined = - (objectDef as any).detail?.sectionGroups - ?? objectDef.views?.detail?.sectionGroups - ?? objectDef.views?.form?.sectionGroups; - // Build related entries from reverse-reference child objects. // `referenceField` is the FK field on the child pointing back to this // record — passed so the related-list renderer can hide the redundant @@ -1538,10 +1490,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // right-rail can show meaningful labels (`user_agent`, `email`, // …) instead of opaque IDs like `kCc8mhJr0bRs0r9Ykd09…`. displayField: + childObjectDef?.nameField || childObjectDef?.displayNameField || - (Array.isArray(childObjectDef?.compactLayout) - ? childObjectDef.compactLayout[0] - : undefined), + (Array.isArray(childObjectDef?.highlightFields) + ? childObjectDef.highlightFields[0] + : Array.isArray(childObjectDef?.compactLayout) + ? childObjectDef.compactLayout[0] + : undefined), onNew, onViewAll, onRowClick, @@ -1576,7 +1531,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }), ...(related.length > 0 && { related }), ...(highlightFields.length > 0 && { highlightFields }), - ...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }), ...(recordHeaderActions.length > 0 && { actions: [{ type: 'action:bar', @@ -1793,17 +1747,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri headerActions: synthHeaderActions, related: synthRelated, history: synthHistory, - // Per-object opt-outs read from `objectDef.detail.*`. Lets - // catalog/atomic objects (product, task, ...) keep a focused - // single-column layout instead of inheriting the rail. - showReferenceRail: - (objectDef as any)?.detail?.showReferenceRail === true || undefined, - hideReferenceRail: - (objectDef as any)?.detail?.hideReferenceRail === true || undefined, - hideRelatedTab: - (objectDef as any)?.detail?.hideRelatedTab === true || undefined, - relatedLayout: - (objectDef as any)?.detail?.relatedLayout === 'tabs' ? 'tabs' : undefined, + // ADR-0085 removed the per-object `detail.*` presentation + // toggles (show/hideReferenceRail, hideRelatedTab, relatedLayout) + // — the synth defaults apply; per-page layout goes through an + // assigned Page schema (`record:reference_rail` stays available + // there as a renderer capability). ...(assignedSlots ? { slots: assignedSlots } : {}), }); return ( diff --git a/packages/core/package.json b/packages/core/package.json index e5bf886de..bce88b4d7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,7 +33,7 @@ "dependencies": { "@object-ui/types": "workspace:*", "@objectstack/formula": "^11.5.0", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "lodash": "^4.18.1", "zod": "^4.4.3" }, diff --git a/packages/plugin-detail/package.json b/packages/plugin-detail/package.json index 76af7e84f..b62252815 100644 --- a/packages/plugin-detail/package.json +++ b/packages/plugin-detail/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@object-ui/i18n": "workspace:*", + "@objectstack/spec": "^11.7.0", "lucide-react": "^1.22.0" }, "peerDependencies": { diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index d61a8384a..306287918 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -223,9 +223,9 @@ ComponentRegistry.register('record:details', RecordDetailsRenderer, { // Designer inputs mirror @objectstack/spec RecordDetailsProps (component.zod). inputs: [ { name: 'columns', type: 'enum', label: 'Columns', enum: ['1', '2', '3', '4'], defaultValue: '2', description: 'Number of columns for field layout (1-4)' }, - { name: 'layout', type: 'enum', label: 'Layout', enum: ['auto', 'custom'], defaultValue: 'auto', description: 'auto uses the object compactLayout; custom uses explicit sections' }, + { name: 'layout', type: 'enum', label: 'Layout', enum: ['auto', 'custom'], defaultValue: 'auto', description: 'auto uses the object highlightFields; custom uses explicit sections' }, { name: 'sections', type: 'array', label: 'Sections', description: 'Section IDs to show (required when layout is "custom")' }, - { name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides compactLayout)' }, + { name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides highlightFields)' }, ], }); diff --git a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 38e58f2b4..fa8988ff2 100644 --- a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -671,64 +671,56 @@ describe('buildDefaultPageSchema', () => { }); }); -// #2148 — spec-writable `detail.*` hints + fieldGroups-derived sections. -describe('detail.* hints (#2148 / #2065)', () => { +// ADR-0085 — top-level semantic roles (stageField / highlightFields). +describe('semantic-role hints (ADR-0085 / #2065)', () => { describe('detectStatusField', () => { - it('detail.stageField wins over top-level stageField and heuristics', () => { + it('an explicit stageField wins over heuristics', () => { expect( detectStatusField({ - detail: { stageField: 'pipeline' }, - stageField: 'legacy_stage', - fields: { pipeline: {}, legacy_stage: {}, status: {} }, + stageField: 'pipeline', + fields: { pipeline: {}, status: {} }, }), ).toBe('pipeline'); }); - it('detail.stageField: false suppresses detection entirely', () => { + it('stageField: false suppresses detection entirely', () => { expect( detectStatusField({ - detail: { stageField: false }, - stageField: 'status', + stageField: false, fields: { status: { type: 'status' } }, }), ).toBeNull(); }); - it('falls back to top-level stageField, then heuristic, when detail hint absent', () => { - expect( - detectStatusField({ detail: {}, stageField: 'stage_x', fields: { stage_x: {} } }), - ).toBe('stage_x'); - expect(detectStatusField({ detail: {}, fields: { status: {} } })).toBe('status'); + it('falls back to the heuristic when no role is declared', () => { + expect(detectStatusField({ fields: { status: {} } })).toBe('status'); }); }); describe('deriveHighlightFields', () => { - it('detail.highlightFields wins over top-level highlightFields', () => { + it('highlightFields wins over the deprecated compactLayout spelling', () => { expect( deriveHighlightFields( { ...leadDef, - detail: { highlightFields: ['phone', 'rating'] }, - highlightFields: ['email'], + highlightFields: ['phone', 'rating'], + compactLayout: ['email'], }, 'status', ), ).toEqual(['phone', 'rating']); }); - it('accepts { name } object entries and drops malformed ones', () => { + it('reads the deprecated compactLayout when highlightFields is absent', () => { expect( - deriveHighlightFields( - { ...leadDef, detail: { highlightFields: [{ name: 'email' }, 'phone', {} as any, ''] } }, - 'status', - ), + deriveHighlightFields({ ...leadDef, compactLayout: ['email', 'phone'] }, 'status'), ).toEqual(['email', 'phone']); }); - it('caps the detail list at max', () => { + it('drops non-string entries and caps the declared list at max', () => { expect( deriveHighlightFields( - { detail: { highlightFields: ['a', 'b', 'c', 'd', 'e'] }, fields: {} }, + { highlightFields: ['a', '', { name: 'x' } as any, 'b', 'c', 'd'], fields: {} }, null, 3, ), @@ -779,6 +771,19 @@ describe('deriveFieldGroupDetailSections (#2148)', () => { expect(trailing.fields.map((f: any) => f.name)).toEqual(['website']); }); + it('honours the canonical collapse enum (ADR-0085)', () => { + const sections = deriveFieldGroupDetailSections({ + fieldGroups: [ + { key: 'a', label: 'A', collapse: 'collapsed' }, + { key: 'b', label: 'B', collapse: 'expanded' }, + ], + fields: { x: { group: 'a' }, y: { group: 'b' } }, + })!; + expect(sections[0]).toMatchObject({ name: 'a', collapsible: true, defaultCollapsed: true }); + expect(sections[1]).toMatchObject({ name: 'b', collapsible: true }); + expect(sections[1].defaultCollapsed).toBeUndefined(); + }); + it('keeps audit fields an author EXPLICITLY grouped', () => { const def: ObjectDefLike = { fieldGroups: [{ key: 'meta', label: 'Meta' }], @@ -816,10 +821,6 @@ describe('deriveFieldGroupDetailSections (#2148)', () => { fields: { a: {}, b: {} }, }), ).toBeNull(); - // Explicit opt-out. - expect( - deriveFieldGroupDetailSections({ ...groupedDef, detail: { useFieldGroups: false } }), - ).toBeNull(); // Undefined def. expect(deriveFieldGroupDetailSections(undefined)).toBeNull(); }); @@ -834,7 +835,7 @@ describe('deriveFieldGroupDetailSections (#2148)', () => { }); }); -describe('resolveDetailSections priority (#2148)', () => { +describe('resolveDetailSections priority (ADR-0085)', () => { const groupedDef: ObjectDefLike = { fieldGroups: [{ key: 'g', label: 'G' }], fields: { a: { group: 'g' }, b: {} }, @@ -845,13 +846,6 @@ describe('resolveDetailSections priority (#2148)', () => { expect(resolveDetailSections(groupedDef, explicit)).toBe(explicit); }); - it('falls back to detail.sections next', () => { - const declared = [{ title: 'Declared', fields: ['a'] }]; - expect( - resolveDetailSections({ ...groupedDef, detail: { sections: declared } }), - ).toBe(declared); - }); - it('derives from fieldGroups last, else undefined', () => { const derived = resolveDetailSections(groupedDef)!; expect(derived[0]).toMatchObject({ name: 'g', title: 'G' }); @@ -882,10 +876,10 @@ describe('buildDefaultPageSchema integration (#2148)', () => { expect(details.sections[0]).toMatchObject({ name: 'basic', title: 'Basic' }); }); - it('detail.stageField: false drops record:path', () => { + it('stageField: false drops record:path', () => { const def: ObjectDefLike = { ...leadDef, - detail: { stageField: false }, + stageField: false, }; const types = buildDefaultPageSchema(def).regions[0].components.map((c: any) => c.type); expect(types).not.toContain('record:path'); diff --git a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 1504f3284..4525aeb1a 100644 --- a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -23,6 +23,8 @@ * the existing DetailView output. */ +import { deriveFieldGroupLayout } from '@objectstack/spec/data'; + /** Minimal shape of an object definition we read here. We deliberately * duck-type so this helper has zero hard dependency on a specific * object-schema shape. */ @@ -30,11 +32,17 @@ export interface ObjectDefLike { name?: string; label?: string; fields?: Record; - /** Optional stage hints — when present we emit a `record:path`. */ - stageField?: string; + /** Semantic role (ADR-0085): the linear-lifecycle field driving the + * `record:path` stepper. `false` = the status-shaped field is NOT a + * linear flow — suppress stage detection entirely (#2065). */ + stageField?: string | false; stages?: Array<{ value: any; label: string }>; - /** Optional list of fields to surface in the highlight strip. */ + /** Semantic role (ADR-0085): the object's most important fields — + * drives the highlight strip (first 4). */ highlightFields?: string[]; + /** @deprecated Renamed to `highlightFields` (ADR-0085). Read as a + * fallback for pre-11.7 metadata that reaches consumers un-normalized. */ + compactLayout?: string[]; /** Name of the field that holds the record's display title (e.g. `name`, * `subject`). When present we exclude it from the auto-derived highlight * list to avoid duplicating the page H1. */ @@ -49,29 +57,16 @@ export interface ObjectDefLike { fieldGroups?: Array<{ key?: string; label?: string; + collapse?: 'none' | 'expanded' | 'collapsed'; + /** @deprecated pre-ADR-0085 pair; honoured by the shared derivation. */ collapsible?: boolean; + /** @deprecated pre-ADR-0085 pair; honoured by the shared derivation. */ collapsed?: boolean; }>; - /** - * Author-facing detail hints. This block is the ONLY spec-writable home - * for detail-page hints: `@objectstack/spec`'s ObjectSchema rejects - * unknown top-level keys (`views`, top-level `stageField`, …) but keeps - * the `detail` block passthrough. Keys read here: - * - `stageField` — explicit status field, or `false` to suppress the - * `record:path` stepper entirely (#2065). - * - `highlightFields` — explicit highlight strip (string names or - * `{ name }` objects). - * - `sections` — explicit detail sections, same shape as - * `BuildPageOptions['sections']`. - * - `useFieldGroups: false` — opt out of fieldGroups-derived sections. - */ - detail?: { - stageField?: string | false; - highlightFields?: Array; - sections?: Array<{ title?: string; columns?: number; fields?: any[] }>; - useFieldGroups?: boolean; - [key: string]: any; - }; + // NOTE: the per-surface `detail` hints block was REMOVED from the spec by + // ADR-0085 — presentation intent is declared via the top-level semantic + // roles above (stageField / highlightFields / fieldGroups). Per-page + // customization goes through an assigned Page schema. } export interface ObjectFieldLike { @@ -224,19 +219,17 @@ function toNodeArray(slot: any | any[] | undefined): any[] { * Detect the canonical "status" / "stage" field on an object definition. * * Heuristic — same as DetailView's `autoSummaryFields`: - * 1) `objectDef.detail.stageField` — the spec-writable hint. `false` - * suppresses stage detection entirely (no `record:path`, #2065). - * 2) else an explicit top-level `objectDef.stageField` (legacy / - * non-spec metadata; the spec strips this key) - * 3) else first field named status / stage / state / phase - * 4) else null + * 1) the top-level `objectDef.stageField` semantic role (spec-typed since + * ADR-0085). `false` = the status-shaped field is NOT a linear flow — + * suppress stage detection entirely (no `record:path`, #2065). + * 2) else first field named status / stage / state / phase + * 3) else null */ export function detectStatusField(def?: ObjectDefLike): string | null { if (!def) return null; - const hint = def.detail?.stageField; + const hint = def.stageField; if (hint === false) return null; if (typeof hint === 'string' && hint) return hint; - if (def.stageField) return def.stageField; const fields = def.fields || {}; const candidates = ['status', 'stage', 'state', 'phase']; for (const key of candidates) { @@ -273,17 +266,18 @@ export function deriveHighlightFields( max = 4, ): string[] { if (!def) return []; - // Spec-writable hint first: `detail.highlightFields` accepts string - // names or `{ name }` objects (the shape RecordDetailView's legacy - // schema uses). - const declared = (Array.isArray(def.detail?.highlightFields) ? def.detail!.highlightFields! : []) - .map((f) => (typeof f === 'string' ? f : f?.name)) - .filter((n): n is string => typeof n === 'string' && n.length > 0); - if (declared.length > 0) { - return declared.slice(0, max); - } - if (Array.isArray(def.highlightFields) && def.highlightFields.length > 0) { - return def.highlightFields.slice(0, max); + // Semantic role first (ADR-0085): top-level `highlightFields`, with the + // deprecated `compactLayout` spelling as a fallback for pre-11.7 metadata + // that reaches consumers un-normalized (the spec mirrors the two on parse). + const declared = Array.isArray(def.highlightFields) && def.highlightFields.length > 0 + ? def.highlightFields + : Array.isArray(def.compactLayout) && def.compactLayout.length > 0 + ? def.compactLayout + : null; + if (declared) { + return declared + .filter((n): n is string => typeof n === 'string' && n.length > 0) + .slice(0, max); } // System fields and tenancy metadata never make useful highlights — // they either have no friendly label (organization_id renders as the @@ -409,34 +403,16 @@ export function buildDefaultHighlights( return out; } -/** - * System / audit fields the app-shell filters out of auto-generated detail - * sections. Mirrored here so fieldGroups-derived sections (used by the - * synth-only consumers — Studio page preview, metadata-admin anchors) - * match the runtime detail page. Fields an author EXPLICITLY assigns to a - * group are kept — explicit listing wins, same as authored sections. - */ -const DERIVED_SECTION_SKIP_FIELDS = new Set([ - 'created_at', 'created_by', 'updated_at', 'updated_by', - 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at', -]); - /** * Derive detail sections from the object's declared `fieldGroups` plus each - * field's `group` membership — the same metadata the object designer writes - * and the runtime form (`@object-ui/plugin-form` `deriveFieldGroupSections`) - * already honours. Semantics mirror the form side: + * field's `group` membership. The grouping SEMANTICS (declared order, empty + * groups dropped, trailing untitled bucket, audit-field handling, collapse + * behaviour incl. legacy alias handling) are single-sourced in + * `@objectstack/spec` (`deriveFieldGroupLayout`, ADR-0085 §5); this adapter + * only maps the shared result onto DetailSection's shape. * - * - sections come back in declared-group order; - * - declared groups no visible field references are dropped; - * - fields without a (declared) group collect into a trailing untitled - * section so they render flat rather than as a spurious card; - * - `collapsible` / `collapsed` pass through (as DetailSection's - * `collapsible` / `defaultCollapsed`). - * - * Returns `null` when grouping does not apply — no declared groups, no - * visible field references one, or the object opted out via - * `detail.useFieldGroups: false` — so callers fall back to their existing + * Returns `null` when grouping does not apply — no declared groups, or no + * visible field references one — so callers fall back to their existing * layout (flat or auto-split). * * Section fields are emitted as rich descriptors (`{ name, label, type, @@ -447,19 +423,9 @@ export function deriveFieldGroupDetailSections( def: ObjectDefLike | undefined, ): Array> | null { if (!def) return null; - if (def.detail?.useFieldGroups === false) return null; - const declared = (Array.isArray(def.fieldGroups) ? def.fieldGroups : []) - .filter((g): g is NonNullable => !!g && typeof g === 'object') - .map((g) => ({ - key: typeof g.key === 'string' ? g.key : '', - label: typeof g.label === 'string' ? g.label : undefined, - collapsible: typeof g.collapsible === 'boolean' ? g.collapsible : undefined, - collapsed: typeof g.collapsed === 'boolean' ? g.collapsed : undefined, - })) - .filter((g) => g.key); - if (declared.length === 0) return null; + const derived = deriveFieldGroupLayout(def); + if (!derived) return null; - const declaredKeys = new Set(declared.map((g) => g.key)); const fields = def.fields || {}; const toField = (name: string) => { const f = fields[name] || {}; @@ -476,60 +442,29 @@ export function deriveFieldGroupDetailSections( }; }; - const buckets = new Map(); - for (const g of declared) buckets.set(g.key, []); - const ungrouped: string[] = []; - let anyGrouped = false; - for (const [name, f] of Object.entries(fields)) { - if (f?.hidden) continue; - const g = typeof f?.group === 'string' && declaredKeys.has(f.group) ? f.group : null; - if (g) { - buckets.get(g)!.push(name); - anyGrouped = true; - } else if (!DERIVED_SECTION_SKIP_FIELDS.has(name)) { - ungrouped.push(name); - } - } - // No visible field references a declared group → keep the flat layout. - if (!anyGrouped) return null; - - const sections: Array> = []; - for (const g of declared) { - const names = buckets.get(g.key)!; - if (names.length === 0) continue; - sections.push({ - name: g.key, - title: g.label ?? g.key, - ...(g.collapsible !== undefined ? { collapsible: g.collapsible } : {}), - ...(g.collapsed !== undefined ? { defaultCollapsed: g.collapsed } : {}), - fields: names.map(toField), - }); - } - // Trailing untitled bucket for ungrouped fields. Omitting `name`/`title` - // keeps the section flat (record-details defaults showBorder to false - // for untitled sections) instead of surfacing an internal key as a - // header. - if (ungrouped.length > 0) { - sections.push({ fields: ungrouped.map(toField) }); - } - return sections.length > 0 ? sections : null; + // Untitled trailing bucket: omitting `name`/`title` keeps the section flat + // (record-details defaults showBorder to false for untitled sections) + // instead of surfacing an internal key as a header. + return derived.map((s) => ({ + ...(s.key !== undefined ? { name: s.key, title: s.label ?? s.key } : {}), + ...(s.collapse !== 'none' ? { collapsible: true } : {}), + ...(s.collapse === 'collapsed' ? { defaultCollapsed: true } : {}), + fields: s.fields.map(toField), + })); } /** * Resolve the sections for the Details tab body: - * 1) explicit `options.sections` from the caller (RecordDetailView folds - * `detail.sections` / legacy `views.form.sections` in here); - * 2) else the object's spec-writable `detail.sections`; - * 3) else sections derived from `fieldGroups` (designer metadata); - * 4) else `undefined` — record:details falls back to its flat layout. + * 1) explicit `options.sections` from the caller (programmatic API — + * Studio preview / metadata-admin anchors); + * 2) else sections derived from the object's `fieldGroups` semantic role; + * 3) else `undefined` — record:details falls back to its flat layout. */ export function resolveDetailSections( def: ObjectDefLike | undefined, sections?: BuildPageOptions['sections'], ): BuildPageOptions['sections'] | undefined { if (Array.isArray(sections) && sections.length > 0) return sections; - const declared = def?.detail?.sections; - if (Array.isArray(declared) && declared.length > 0) return declared; return deriveFieldGroupDetailSections(def) ?? undefined; } diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index e41777338..e2e91292d 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -27,6 +27,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", + "@objectstack/spec": "^11.7.0", "lucide-react": "^1.22.0" }, "peerDependencies": { diff --git a/packages/plugin-form/src/fieldGroups.test.ts b/packages/plugin-form/src/fieldGroups.test.ts index 1cb75b4a4..11a2e4242 100644 --- a/packages/plugin-form/src/fieldGroups.test.ts +++ b/packages/plugin-form/src/fieldGroups.test.ts @@ -1,57 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { readObjectFieldGroups, deriveFieldGroupSections } from './fieldGroups'; +import { deriveFieldGroupSections } from './fieldGroups'; import type { FormField } from '@object-ui/types'; const field = (name: string, group?: string): FormField => ({ name, label: name, ...(group ? { group } : {}) }) as FormField; -describe('readObjectFieldGroups', () => { - it('normalizes a well-formed list', () => { - expect( - readObjectFieldGroups([ - { key: 'contact', label: 'Contact' }, - { key: 'billing', label: 'Billing' }, - ]), - ).toEqual([ - { key: 'contact', label: 'Contact' }, - { key: 'billing', label: 'Billing' }, - ]); - }); - - it('drops entries without a string key and tolerates a missing label', () => { - expect( - readObjectFieldGroups([ - { key: 'contact' }, - { label: 'no key' }, - { key: 42 }, - null, - 'nope', - ]), - ).toEqual([{ key: 'contact', label: undefined }]); - }); - - it('returns [] for non-array input', () => { - expect(readObjectFieldGroups(undefined)).toEqual([]); - expect(readObjectFieldGroups(null)).toEqual([]); - expect(readObjectFieldGroups({})).toEqual([]); - }); - - it('reads collapsible/collapsed flags when present and ignores non-booleans', () => { - expect( - readObjectFieldGroups([ - { key: 'a', label: 'A', collapsible: true, collapsed: true }, - { key: 'b', label: 'B', collapsible: false }, - { key: 'c', label: 'C', collapsible: 'yes' }, - ]), - ).toEqual([ - { key: 'a', label: 'A', collapsible: true, collapsed: true }, - { key: 'b', label: 'B', collapsible: false }, - // 'yes' is not a boolean → dropped (treated as not set) - { key: 'c', label: 'C' }, - ]); - }); -}); - describe('deriveFieldGroupSections', () => { const groups = [ { key: 'contact', label: 'Contact Info' }, @@ -69,6 +22,16 @@ describe('deriveFieldGroupSections', () => { expect(deriveFieldGroupSections([field('a', 'unknown')], groups)).toBeNull(); }); + it('drops malformed group entries but keeps the valid ones', () => { + const sections = deriveFieldGroupSections( + [field('email', 'contact')], + [{ key: 'contact', label: 'Contact Info' }, { label: 'no key' }, { key: 42 }, null, 'nope'], + ); + expect(sections).toEqual([ + { name: 'contact', label: 'Contact Info', fields: ['email'] }, + ]); + }); + it('groups fields in declared order using field names', () => { const sections = deriveFieldGroupSections( [field('email', 'contact'), field('amount', 'billing'), field('phone', 'contact')], @@ -101,6 +64,19 @@ describe('deriveFieldGroupSections', () => { expect(sections?.[1]).not.toHaveProperty('label'); }); + it('keeps rendered audit/system fields by re-appending them to the trailing bucket', () => { + // The shared derivation excludes audit fields from DEFAULT buckets, but a + // field the form already renders must never be silently dropped. + const sections = deriveFieldGroupSections( + [field('email', 'contact'), field('created_at')], + groups, + ); + expect(sections).toEqual([ + { name: 'contact', label: 'Contact Info', fields: ['email'] }, + { fields: ['created_at'] }, + ]); + }); + it('falls back to a group key when no label is given', () => { const sections = deriveFieldGroupSections( [field('email', 'contact')], @@ -109,7 +85,23 @@ describe('deriveFieldGroupSections', () => { expect(sections).toEqual([{ name: 'contact', label: 'contact', fields: ['email'] }]); }); - it('passes collapsible/collapsed through to derived sections', () => { + it('maps the canonical collapse enum onto the renderer flags (ADR-0085)', () => { + const sections = deriveFieldGroupSections( + [field('email', 'contact'), field('amount', 'billing'), field('note', 'plain')], + [ + { key: 'contact', label: 'Contact Info', collapse: 'collapsed' }, + { key: 'billing', label: 'Billing', collapse: 'expanded' }, + { key: 'plain', label: 'Plain', collapse: 'none' }, + ], + ); + expect(sections).toEqual([ + { name: 'contact', label: 'Contact Info', fields: ['email'], collapsible: true, collapsed: true }, + { name: 'billing', label: 'Billing', fields: ['amount'], collapsible: true }, + { name: 'plain', label: 'Plain', fields: ['note'] }, + ]); + }); + + it('still honours the deprecated collapsible/collapsed pair (pre-ADR-0085 metadata)', () => { const sections = deriveFieldGroupSections( [field('email', 'contact'), field('amount', 'billing')], [ @@ -123,6 +115,16 @@ describe('deriveFieldGroupSections', () => { ]); }); + it('still honours the deprecated defaultExpanded spec flag', () => { + const sections = deriveFieldGroupSections( + [field('email', 'contact')], + [{ key: 'contact', label: 'Contact Info', defaultExpanded: false }], + ); + expect(sections).toEqual([ + { name: 'contact', label: 'Contact Info', fields: ['email'], collapsible: true, collapsed: true }, + ]); + }); + it('omits collapse flags entirely when a group does not declare them', () => { const [section] = deriveFieldGroupSections([field('email', 'contact')], groups)!; expect(section).not.toHaveProperty('collapsible'); diff --git a/packages/plugin-form/src/fieldGroups.ts b/packages/plugin-form/src/fieldGroups.ts index cbd5511f7..8ed803d05 100644 --- a/packages/plugin-form/src/fieldGroups.ts +++ b/packages/plugin-form/src/fieldGroups.ts @@ -9,99 +9,70 @@ /** * Field-group helpers for ObjectForm. * - * An object's metadata can declare top-level `fieldGroups` (a.k.a. sections), - * and individual fields opt into a group via `field.group === group.key`. The - * object designer already lays out fields this way; these helpers let the - * runtime form renderer honour the same grouping so authored sections show up - * on the actual record form — not just in the designer preview. - * - * The grouping semantics mirror the designer's `groupEntries`: sections render - * in declared order, and any field without a (declared) group lands in a - * trailing untitled bucket. + * An object's metadata declares top-level `fieldGroups`, and individual + * fields opt into a group via `field.group === group.key`. The grouping + * SEMANTICS (declared order, empty groups dropped, trailing untitled bucket, + * collapse behaviour incl. legacy alias handling) are single-sourced in + * `@objectstack/spec` (`deriveFieldGroupLayout`, ADR-0085 §5) — this module + * is only the adapter from that shared derivation onto the form renderer's + * `ObjectFormSection` shape and its permission-filtered `FormField` list. */ +import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import type { FormField, ObjectFormSection } from '@object-ui/types'; -/** A declared field group on the object metadata. */ -export interface DeclaredFieldGroup { - key: string; - label?: string; - /** Render the group's section header with a collapse toggle. */ - collapsible?: boolean; - /** Start the (collapsible) group collapsed. */ - collapsed?: boolean; -} - -/** Read an object's `fieldGroups` into a normalized, well-typed list. */ -export function readObjectFieldGroups(input: unknown): DeclaredFieldGroup[] { - if (!Array.isArray(input)) return []; - return input - .filter((g): g is Record => !!g && typeof g === 'object') - .map((g) => ({ - key: typeof g.key === 'string' ? (g.key as string) : '', - label: typeof g.label === 'string' ? (g.label as string) : undefined, - collapsible: typeof g.collapsible === 'boolean' ? (g.collapsible as boolean) : undefined, - collapsed: typeof g.collapsed === 'boolean' ? (g.collapsed as boolean) : undefined, - })) - .filter((g) => g.key); -} - /** * Derive form sections from an object's declared `fieldGroups` and each * rendered field's `group`. * * Returns `null` when grouping does not apply — no declared groups, or no * rendered field opts into a declared group — so callers fall back to a flat - * form. Sections come back in declared order; fields without a declared group - * collect into a trailing untitled section (no `name`/`label`) so they render - * as a plain block rather than a card. + * form. * - * Section `fields` are returned as field *names* (strings) so the result plugs - * straight into ObjectForm's existing section-render path, which resolves names - * back to generated `FormField`s and applies field-level permissions. + * The derivation runs against the RENDERED field list (post permission / + * visibility filtering), not the raw object def, so a section never names a + * field the form isn't showing. Any rendered field the shared derivation + * excludes from its default buckets (audit/system fields) is re-appended to + * the trailing bucket: the form was already told to render it, and a layout + * helper silently dropping a rendered input is exactly the failure mode + * ADR-0085 exists to kill. + * + * Section `fields` are field *names* (strings) so the result plugs straight + * into ObjectForm's existing section-render path. */ export function deriveFieldGroupSections( fields: FormField[], fieldGroups: unknown, ): ObjectFormSection[] | null { - const declared = readObjectFieldGroups(fieldGroups); - if (declared.length === 0) return null; - - const declaredKeys = new Set(declared.map((g) => g.key)); - const groupOf = (f: FormField): string | null => { - const g = (f as { group?: unknown }).group; - return typeof g === 'string' && declaredKeys.has(g) ? g : null; - }; + const derived = deriveFieldGroupLayout({ + fieldGroups, + // Pseudo-def over the rendered fields: membership is the only input the + // derivation needs per field. + fields: Object.fromEntries( + fields.map((f) => [f.name, { group: (f as { group?: unknown }).group }]), + ), + }); + if (!derived) return null; - // No field references a declared group → keep the flat layout. - if (!fields.some((f) => groupOf(f) !== null)) return null; + const placed = new Set(derived.flatMap((s) => s.fields)); + const leftover = fields.map((f) => f.name).filter((n) => !placed.has(n)); - const buckets = new Map(); - for (const g of declared) buckets.set(g.key, []); - const ungrouped: FormField[] = []; - for (const f of fields) { - const g = groupOf(f); - if (g) buckets.get(g)!.push(f); - else ungrouped.push(f); - } + const sections: ObjectFormSection[] = derived.map((s) => ({ + ...(s.key !== undefined ? { name: s.key } : {}), + ...(s.key !== undefined ? { label: s.label ?? s.key } : {}), + fields: [...s.fields], + // Map the shared `collapse` enum onto the renderer's boolean pair. + ...(s.collapse !== 'none' ? { collapsible: true } : {}), + ...(s.collapse === 'collapsed' ? { collapsed: true } : {}), + })); - const sections: ObjectFormSection[] = []; - for (const g of declared) { - const items = buckets.get(g.key)!; - if (items.length === 0) continue; - sections.push({ - name: g.key, - label: g.label ?? g.key, - fields: items.map((f) => f.name), - ...(g.collapsible !== undefined ? { collapsible: g.collapsible } : {}), - ...(g.collapsed !== undefined ? { collapsed: g.collapsed } : {}), - }); - } - // Trailing untitled bucket for ungrouped fields. Omitting `name`/`label` - // makes the section render flat (no card chrome) instead of surfacing an - // internal key as a header. - if (ungrouped.length > 0) { - sections.push({ fields: ungrouped.map((f) => f.name) }); + if (leftover.length > 0) { + const trailing = sections[sections.length - 1]; + if (trailing && trailing.name === undefined) { + trailing.fields = [...(trailing.fields ?? []), ...leftover]; + } else { + sections.push({ fields: leftover }); + } } return sections; diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json index 2af8da656..6cfedd098 100644 --- a/packages/plugin-gantt/package.json +++ b/packages/plugin-gantt/package.json @@ -38,7 +38,7 @@ "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "lucide-react": "^1.22.0" }, "peerDependencies": { diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 612b93624..9f7943888 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1196,7 +1196,8 @@ export const ObjectGrid: React.FC = ({ const generatedColumns: any[] = []; // Default columns priority (when schema doesn't specify columns): - // 1. Explicit `compactLayout` from the object schema (curated business fields). + // 1. The object's `highlightFields` semantic role (ADR-0085; deprecated + // `compactLayout` spelling read as fallback for pre-11.7 metadata). // 2. Otherwise, all schema fields with system-managed fields pushed to the end. // // Also drop fields that are platform-managed identifiers/audit columns or @@ -1208,7 +1209,8 @@ export const ObjectGrid: React.FC = ({ 'deleted_at', 'deletedAt', 'created_by', 'createdBy', 'updated_by', 'updatedBy', '_version', '_rev', ]); - const compactLayout: string[] | undefined = (objectSchema as any)?.compactLayout; + const compactLayout: string[] | undefined = + (objectSchema as any)?.highlightFields ?? (objectSchema as any)?.compactLayout; const allFieldNames = Object.keys(objectSchema.fields || {}); let fieldsToShow: string[]; if (schemaFields) { diff --git a/packages/plugin-map/package.json b/packages/plugin-map/package.json index 484e28754..c3f38f205 100644 --- a/packages/plugin-map/package.json +++ b/packages/plugin-map/package.json @@ -35,7 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "lucide-react": "^1.22.0", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.1", diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json index eb0585615..107f9a47d 100644 --- a/packages/plugin-timeline/package.json +++ b/packages/plugin-timeline/package.json @@ -36,7 +36,7 @@ "@object-ui/mobile": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "class-variance-authority": "^0.7.1", "zod": "^4.4.3" }, diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json index 2e0db3536..2e4ba899e 100644 --- a/packages/plugin-tree/package.json +++ b/packages/plugin-tree/package.json @@ -35,7 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "lucide-react": "^1.22.0" }, "peerDependencies": { diff --git a/packages/react/package.json b/packages/react/package.json index 3838beb3e..31f3acef3 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,7 +34,7 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "react-hook-form": "^7.80.0" }, "peerDependencies": { diff --git a/packages/types/package.json b/packages/types/package.json index 9929dbb85..2587ecb82 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -90,7 +90,7 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^11.5.0", + "@objectstack/spec": "^11.7.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae97101cc..1e8c73207 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) '@playwright/test': specifier: ^1.61.1 version: 1.61.1 @@ -265,8 +265,8 @@ importers: specifier: ^11.5.0 version: 11.5.0(ai@7.0.4(zod@4.4.3)) '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.1 version: 4.3.1 @@ -397,8 +397,8 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) fumadocs-core: specifier: 16.10.6 version: 16.10.6(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(zod@4.4.3) @@ -686,8 +686,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) '@sentry/react': specifier: ^10.62.0 version: 10.62.0(react@19.2.7) @@ -1069,8 +1069,8 @@ importers: specifier: ^11.5.0 version: 11.5.0(ai@7.0.4(zod@4.4.3)) '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1715,6 +1715,9 @@ importers: '@object-ui/permissions': specifier: workspace:^ version: link:../permissions + '@objectstack/spec': + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) lucide-react: specifier: ^1.22.0 version: 1.22.0(react@19.2.7) @@ -1828,6 +1831,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/spec': + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) lucide-react: specifier: ^1.22.0 version: 1.22.0(react@19.2.7) @@ -1881,8 +1887,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) lucide-react: specifier: ^1.22.0 version: 1.22.0(react@19.2.7) @@ -2110,8 +2116,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) lucide-react: specifier: ^1.22.0 version: 1.22.0(react@19.2.7) @@ -2299,8 +2305,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2351,8 +2357,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) lucide-react: specifier: ^1.22.0 version: 1.22.0(react@19.2.7) @@ -2474,8 +2480,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) react: specifier: 19.2.7 version: 19.2.7 @@ -2599,8 +2605,8 @@ importers: packages/types: dependencies: '@objectstack/spec': - specifier: ^11.5.0 - version: 11.5.0(ai@7.0.4(zod@4.4.3)) + specifier: ^11.7.0 + version: 11.7.0(ai@7.0.4(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -3857,8 +3863,8 @@ packages: react: 19.2.7 react-dom: 19.2.7 - '@mongodb-js/saslprep@1.4.11': - resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@mongodb-js/saslprep@1.4.12': + resolution: {integrity: sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==} '@mswjs/interceptors@0.41.9': resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} @@ -3965,6 +3971,15 @@ packages: ai: optional: true + '@objectstack/spec@11.7.0': + resolution: {integrity: sha512-cNK0Gh+mV2L9SyDBL0daWlJpOUcnADOCHpVlkiqq8Oi6lkixPrakvfLxFtmIoOEBNSbPiHepBdNgmGA9nCthXQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + ai: ^7.0.0 + peerDependenciesMeta: + ai: + optional: true + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -7258,6 +7273,10 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -11963,7 +11982,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@mongodb-js/saslprep@1.4.11': + '@mongodb-js/saslprep@1.4.12': dependencies: sparse-bitfield: 3.0.3 optional: true @@ -12053,6 +12072,12 @@ snapshots: optionalDependencies: ai: 7.0.4(zod@4.4.3) + '@objectstack/spec@11.7.0(ai@7.0.4(zod@4.4.3))': + dependencies: + zod: 4.4.3 + optionalDependencies: + ai: 7.0.4(zod@4.4.3) + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -12900,7 +12925,7 @@ snapshots: ajv: 8.18.0 ajv-draft-04: 1.0.0(ajv@8.18.0) ajv-formats: 3.0.1(ajv@8.18.0) - fs-extra: 11.3.5 + fs-extra: 11.3.6 import-lazy: 4.0.0 jju: 1.4.0 resolve: 1.22.12 @@ -15545,6 +15570,13 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -17148,7 +17180,7 @@ snapshots: mongodb@7.2.0: dependencies: - '@mongodb-js/saslprep': 1.4.11 + '@mongodb-js/saslprep': 1.4.12 bson: 7.3.1 mongodb-connection-string-url: 7.0.1 optional: true