diff --git a/.changeset/drop-dead-spec-bridge-page-dashboard.md b/.changeset/drop-dead-spec-bridge-page-dashboard.md new file mode 100644 index 000000000..8c883e492 --- /dev/null +++ b/.changeset/drop-dead-spec-bridge-page-dashboard.md @@ -0,0 +1,22 @@ +--- +"@object-ui/react": major +--- + +refactor(spec-bridge): remove the dead page/dashboard bridges (#1892) + +`SpecBridge`'s `page` and `dashboard` bridges — `bridgePage`, `bridgeDashboard`, +and the `SpecBridge#transformPage` / `#transformDashboard` methods — had no +runtime consumer. Pages render through their own renderer and dashboards +through `DashboardView → DashboardRenderer → DatasetWidget` (ADR-0021); neither +path routes through `SpecBridge`. The dashboard bridge's input shape +(`object` / `categoryField` / `valueField` / `aggregate`) is the pre-ADR-0021 +widget model, which the strict `DashboardWidgetSchema` now rejects — so the +bridge could not receive a spec-valid dashboard even in principle. + +Flagged dead by the metadata-liveness audit (framework #1878 / #1892). The +`list` and `form` bridges are unaffected and remain the live authoring path. + +BREAKING CHANGE: the public exports `bridgePage`, `bridgeDashboard`, and the +`SpecBridge#transformPage` / `#transformDashboard` methods are removed. There +is no replacement — render pages and dashboards through their renderers +(`DashboardRenderer` / the page renderer) directly. diff --git a/packages/react/src/spec-bridge/SpecBridge.ts b/packages/react/src/spec-bridge/SpecBridge.ts index e51afe3fc..245bb5767 100644 --- a/packages/react/src/spec-bridge/SpecBridge.ts +++ b/packages/react/src/spec-bridge/SpecBridge.ts @@ -10,8 +10,6 @@ import type { SchemaNode } from '@object-ui/core'; import type { BridgeContext, BridgeFn } from './types'; import { bridgeListView } from './bridges/list-view'; import { bridgeFormView } from './bridges/form-view'; -import { bridgePage } from './bridges/page'; -import { bridgeDashboard } from './bridges/dashboard'; export class SpecBridge { private bridges = new Map(); @@ -21,8 +19,6 @@ export class SpecBridge { this.context = context; this.register('list', bridgeListView); this.register('form', bridgeFormView); - this.register('page', bridgePage); - this.register('dashboard', bridgeDashboard); } register(specType: string, bridge: BridgeFn): void { @@ -48,16 +44,6 @@ export class SpecBridge { return this.transform('form', spec); } - /** Transform a Page spec */ - transformPage(spec: any): SchemaNode { - return this.transform('page', spec); - } - - /** Transform a Dashboard spec */ - transformDashboard(spec: any): SchemaNode { - return this.transform('dashboard', spec); - } - updateContext(ctx: Partial): void { this.context = { ...this.context, ...ctx }; } diff --git a/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts b/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts index e9ab89a5b..ea54247d7 100644 --- a/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts +++ b/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts @@ -8,7 +8,7 @@ /** * P1 SpecBridge Protocol Alignment Tests - * Tests for the enhanced bridge transformations: ListView, FormView, Dashboard, Page + * Tests for the enhanced bridge transformations: ListView, FormView */ import { describe, it, expect } from 'vitest'; import { SpecBridge } from '../SpecBridge'; @@ -336,322 +336,6 @@ describe('P1 SpecBridge Protocol Alignment', () => { }); }); - // ======================================================================== - // P1.3 Dashboard Bridge Enhancements - // ======================================================================== - describe('P1.3 Dashboard bridge enhancements', () => { - it('should map widget data binding properties', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'sales', - widgets: [ - { - title: 'Revenue', - type: 'bar-chart', - object: 'Opportunity', - filter: [['stage', '=', 'Closed Won']], - categoryField: 'region', - valueField: 'amount', - aggregate: 'sum', - }, - ], - }); - - const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid'); - const widgets = widgetGrid.body as any[]; - expect(widgets[0].object).toBe('Opportunity'); - expect(widgets[0].categoryField).toBe('region'); - expect(widgets[0].valueField).toBe('amount'); - expect(widgets[0].aggregate).toBe('sum'); - }); - - it('should map widget color variants', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'colored', - widgets: [ - { title: 'Success', type: 'metric', colorVariant: 'success' }, - { title: 'Danger', type: 'metric', colorVariant: 'danger' }, - ], - }); - - const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid'); - const widgets = widgetGrid.body as any[]; - expect(widgets[0].colorVariant).toBe('success'); - expect(widgets[1].colorVariant).toBe('danger'); - }); - - it('should map widget measures (pivot/matrix)', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'pivot', - widgets: [ - { - title: 'Matrix', - type: 'pivot', - measures: [ - { valueField: 'amount', aggregate: 'sum', label: 'Total', format: '$0,0' }, - ], - }, - ], - }); - - const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid'); - const widgets = widgetGrid.body as any[]; - expect(widgets[0].measures).toHaveLength(1); - expect(widgets[0].measures[0].label).toBe('Total'); - }); - - it('should map globalFilters with optionsFrom', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'filtered', - widgets: [], - globalFilters: [ - { - field: 'region', - label: 'Region', - type: 'select', - optionsFrom: { - object: 'Region', - valueField: 'id', - labelField: 'name', - }, - targetWidgets: ['widget-0'], - }, - ], - }); - - const filterBar = (node.body as any[]).find((b: any) => b.type === 'filter-bar'); - expect(filterBar).toBeDefined(); - expect(filterBar.filters[0].field).toBe('region'); - expect(filterBar.filters[0].optionsFrom.object).toBe('Region'); - expect(filterBar.filters[0].targetWidgets).toEqual(['widget-0']); - }); - - it('should map dateRange filter', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'dated', - widgets: [], - dateRange: { - field: 'created_at', - defaultRange: 'last_30_days', - allowCustomRange: true, - }, - }); - - const dateFilter = (node.body as any[]).find((b: any) => b.type === 'date-range-filter'); - expect(dateFilter).toBeDefined(); - expect(dateFilter.field).toBe('created_at'); - expect(dateFilter.defaultRange).toBe('last_30_days'); - expect(dateFilter.allowCustomRange).toBe(true); - }); - - it('should map DashboardHeader with actions', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'with_header', - widgets: [], - header: { - showTitle: true, - actions: [ - { label: 'Refresh', actionType: 'refresh', icon: 'RefreshCw' }, - ], - }, - }); - - const header = (node.body as any[]).find((b: any) => b.type === 'dashboard-header'); - expect(header).toBeDefined(); - expect(header.showTitle).toBe(true); - expect(header.actions).toHaveLength(1); - }); - - it('should pass through dashboard description and refreshInterval', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'auto_refresh', - label: 'Live Dashboard', - description: 'Real-time sales data', - refreshInterval: 30, - widgets: [], - }); - - expect(node.description).toBe('Real-time sales data'); - expect(node.refreshInterval).toBe(30); - }); - - it('should pass through dashboard aria', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'accessible', - widgets: [], - aria: { ariaLabel: 'Sales Dashboard', role: 'region' }, - }); - - expect(node.aria).toBeDefined(); - expect(node.aria.ariaLabel).toBe('Sales Dashboard'); - }); - - it('should map widget action properties', () => { - const bridge = new SpecBridge(); - const node = bridge.transformDashboard({ - name: 'clickable', - widgets: [ - { - title: 'Open Deals', - type: 'metric', - actionUrl: '/opportunities?status=open', - actionType: 'navigate', - actionIcon: 'ArrowRight', - }, - ], - }); - - const widgetGrid = (node.body as any[]).find((b: any) => b.type === 'widget-grid'); - const widgets = widgetGrid.body as any[]; - expect(widgets[0].actionUrl).toBe('/opportunities?status=open'); - expect(widgets[0].actionType).toBe('navigate'); - expect(widgets[0].actionIcon).toBe('ArrowRight'); - }); - }); - - // ======================================================================== - // P1.4 Page Bridge Enhancements - // ======================================================================== - describe('P1.4 Page bridge enhancements', () => { - it('should map expanded page types', () => { - const bridge = new SpecBridge(); - const pageTypes = [ - 'record', 'home', 'app', 'utility', - 'grid', 'list', 'gallery', 'kanban', 'calendar', 'timeline', - ]; - - pageTypes.forEach((pageType) => { - const node = bridge.transformPage({ - name: `${pageType}_page`, - type: pageType, - }); - expect(node.pageType).toBe(pageType); - }); - }); - - // (Removed "should map blank page layout" — the bridge no longer maps - // blankLayout; the `blank` page type has no renderer, dropped from - // @objectstack/spec (framework#2265).) - - it('should map page variables with source', () => { - const bridge = new SpecBridge(); - const node = bridge.transformPage({ - name: 'record_page', - type: 'record', - variables: [ - { name: 'recordId', type: 'record_id', source: 'url_param' }, - { name: 'tab', type: 'string', defaultValue: 'details' }, - ], - }); - - expect(node.variables).toHaveLength(2); - expect(node.variables[0].name).toBe('recordId'); - expect(node.variables[0].type).toBe('record_id'); - expect(node.variables[0].source).toBe('url_param'); - }); - - it('should map event handlers on page components', () => { - const bridge = new SpecBridge(); - const node = bridge.transformPage({ - name: 'interactive', - regions: [ - { - name: 'main', - components: [ - { - type: 'element:button', - id: 'save-btn', - label: 'Save', - events: { - onClick: '${actions.save()}', - onHover: '${actions.highlight()}', - }, - }, - ], - }, - ], - }); - - const region = (node.body as any[])[0]; - const component = region.body[0]; - expect(component.events).toBeDefined(); - expect(component.events.onClick).toBe('${actions.save()}'); - }); - - it('should map responsive config on page components', () => { - const bridge = new SpecBridge(); - const node = bridge.transformPage({ - name: 'responsive', - regions: [ - { - name: 'main', - components: [ - { - type: 'element:text', - id: 'heading', - responsive: { - sm: { hidden: true }, - md: { hidden: false }, - }, - }, - ], - }, - ], - }); - - const region = (node.body as any[])[0]; - const component = region.body[0]; - expect(component.responsive).toBeDefined(); - expect(component.responsive.sm.hidden).toBe(true); - }); - - it('should pass through page description and icon', () => { - const bridge = new SpecBridge(); - const node = bridge.transformPage({ - name: 'detailed', - label: 'Home', - description: 'Main landing page', - icon: 'Home', - }); - - expect(node.description).toBe('Main landing page'); - expect(node.icon).toBe('Home'); - }); - - it('should pass through page aria', () => { - const bridge = new SpecBridge(); - const node = bridge.transformPage({ - name: 'accessible', - aria: { ariaLabel: 'Home Page', role: 'main' }, - }); - - expect(node.aria).toBeDefined(); - expect(node.aria.ariaLabel).toBe('Home Page'); - }); - - it('should map region width', () => { - const bridge = new SpecBridge(); - const node = bridge.transformPage({ - name: 'sidebar_page', - regions: [ - { name: 'sidebar', width: 'small', components: [] }, - { name: 'main', width: 'large', components: [] }, - ], - }); - - const regions = node.body as any[]; - expect(regions[0].width).toBe('small'); - expect(regions[1].width).toBe('large'); - }); - }); - // ======================================================================== // P2 Sharing / ExportOptions / Pagination Protocol Alignment // ======================================================================== diff --git a/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts b/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts index 1717c9c6c..bcc2a6f1b 100644 --- a/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts +++ b/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts @@ -10,19 +10,15 @@ import { describe, it, expect } from 'vitest'; import { SpecBridge } from '../SpecBridge'; import { bridgeListView } from '../bridges/list-view'; import { bridgeFormView } from '../bridges/form-view'; -import { bridgePage } from '../bridges/page'; -import { bridgeDashboard } from '../bridges/dashboard'; import type { BridgeFn } from '../types'; describe('SpecBridge', () => { describe('class orchestration', () => { it('registers built-in bridges on construction', () => { const bridge = new SpecBridge(); - // All four built-in types should work without error + // Both built-in types should work without error expect(() => bridge.transform('list', {})).not.toThrow(); expect(() => bridge.transform('form', {})).not.toThrow(); - expect(() => bridge.transform('page', {})).not.toThrow(); - expect(() => bridge.transform('dashboard', {})).not.toThrow(); }); it('throws for unknown spec types', () => { @@ -326,273 +322,4 @@ describe('SpecBridge', () => { ]); }); }); - - describe('bridgePage', () => { - it('transforms a basic page spec', () => { - const spec = { - name: 'account_detail', - label: 'Account Detail', - type: 'detail', - variables: { accountId: '123' }, - regions: [ - { - name: 'main', - components: [ - { - type: 'ui.heading', - id: 'title', - label: 'Account Details', - properties: { level: 2 }, - }, - { - type: 'ui.card', - id: 'info-card', - className: 'mt-4', - }, - ], - }, - ], - }; - - const bridge = new SpecBridge(); - const node = bridge.transformPage(spec); - - expect(node.type).toBe('page'); - expect(node.id).toBe('account_detail'); - expect(node.label).toBe('Account Detail'); - expect(node.pageType).toBe('detail'); - expect(node.variables).toEqual({ accountId: '123' }); - expect(Array.isArray(node.body)).toBe(true); - - const regions = node.body as any[]; - expect(regions).toHaveLength(1); - expect(regions[0].type).toBe('page-region'); - expect(regions[0].id).toBe('main'); - - const components = regions[0].body as any[]; - expect(components).toHaveLength(2); - expect(components[0].type).toBe('ui.heading'); - expect(components[0].level).toBe(2); - expect(components[1].className).toBe('mt-4'); - }); - - it('maps component properties and events', () => { - const node = bridgePage( - { - regions: [ - { - components: [ - { - type: 'ui.button', - id: 'save-btn', - label: 'Save', - events: { onClick: 'save' }, - visibility: '${canEdit}', - dataSource: { provider: 'api', read: '/api/data' }, - responsive: { sm: 'hidden' }, - aria: { label: 'Save record' }, - }, - ], - }, - ], - }, - {}, - ); - - const regions = node.body as any[]; - const comp = regions[0].body[0]; - expect(comp.type).toBe('ui.button'); - expect(comp.label).toBe('Save'); - expect(comp.events).toEqual({ onClick: 'save' }); - // ADR-0089: the deprecated `visibility` alias is folded into canonical `visibleWhen`. - expect(comp.visibleWhen).toBe('${canEdit}'); - expect(comp.dataSource).toEqual({ provider: 'api', read: '/api/data' }); - expect(comp.responsive).toEqual({ sm: 'hidden' }); - expect(comp.aria).toEqual({ label: 'Save record' }); - }); - - it('maps a canonical `visibleWhen` page-component predicate onto the node (ADR-0089)', () => { - const node = bridgePage( - { regions: [{ components: [{ type: 'ui.button', id: 'b', visibleWhen: '${canEdit}' }] }] }, - {}, - ); - const comp = (node.body as any[])[0].body[0]; - expect(comp.visibleWhen).toBe('${canEdit}'); - }); - - it('maps style and responsiveStyles onto the component node', () => { - // Regression: both fields were declared on the PageComponent input type - // but never copied onto the SchemaNode, so a page-authored ADR-0065 - // `responsiveStyles` override (e.g. forcing `display: 'grid'` on a - // `type: 'flex'` layout node) silently never reached SchemaRenderer — - // the node fell back to its default layout with no way to override it. - const node = bridgePage( - { - regions: [ - { - components: [ - { - type: 'flex', - id: 'band', - style: { padding: '4px' }, - responsiveStyles: { - large: { display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))' }, - }, - }, - ], - }, - ], - }, - {}, - ); - - const regions = node.body as any[]; - const comp = regions[0].body[0]; - expect(comp.style).toEqual({ padding: '4px' }); - expect(comp.responsiveStyles).toEqual({ - large: { display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))' }, - }); - }); - - it('includes template and object when present', () => { - const node = bridgePage( - { template: 'two-column', object: 'Account' }, - {}, - ); - expect(node.template).toBe('two-column'); - expect(node.object).toBe('Account'); - }); - - it('handles empty regions gracefully', () => { - const node = bridgePage({}, {}); - expect(node.type).toBe('page'); - expect(node.body).toEqual([]); - }); - }); - - describe('bridgeDashboard', () => { - it('transforms a basic dashboard spec', () => { - const spec = { - name: 'sales_dashboard', - label: 'Sales Dashboard', - widgets: [ - { - title: 'Revenue', - type: 'bar-chart', - object: 'Opportunity', - categoryField: 'stage', - valueField: 'amount', - aggregate: 'sum', - }, - { - title: 'Pipeline', - type: 'metric', - object: 'Opportunity', - valueField: 'count', - }, - ], - header: { title: 'Sales Overview' }, - globalFilters: [ - { field: 'region', type: 'select' }, - ], - }; - - const bridge = new SpecBridge(); - const node = bridge.transformDashboard(spec); - - expect(node.type).toBe('dashboard'); - expect(node.id).toBe('sales_dashboard'); - expect(node.label).toBe('Sales Dashboard'); - - const body = node.body as any[]; - // header + filter-bar + widget-grid - expect(body).toHaveLength(3); - expect(body[0].type).toBe('dashboard-header'); - expect(body[0].title).toBe('Sales Overview'); - expect(body[1].type).toBe('filter-bar'); - expect(body[1].filters).toHaveLength(1); - - const widgetGrid = body[2]; - expect(widgetGrid.type).toBe('widget-grid'); - const widgets = widgetGrid.body as any[]; - expect(widgets).toHaveLength(2); - expect(widgets[0].type).toBe('bar-chart'); - expect(widgets[0].title).toBe('Revenue'); - expect(widgets[0].categoryField).toBe('stage'); - expect(widgets[0].valueField).toBe('amount'); - expect(widgets[0].aggregate).toBe('sum'); - expect(widgets[1].type).toBe('metric'); - }); - - it('maps widget properties correctly', () => { - const node = bridgeDashboard( - { - widgets: [ - { - title: 'Chart', - description: 'A chart', - type: 'line-chart', - chartConfig: { smooth: true }, - colorVariant: 'blue', - object: 'Deal', - filter: { status: 'won' }, - categoryField: 'month', - valueField: 'revenue', - aggregate: 'avg', - measures: [{ field: 'revenue', agg: 'sum' }], - layout: { x: 0, y: 0, w: 6, h: 4 }, - }, - ], - }, - {}, - ); - - const widgetGrid = (node.body as any[])[0]; - const widget = widgetGrid.body[0]; - expect(widget.title).toBe('Chart'); - expect(widget.description).toBe('A chart'); - expect(widget.chartConfig).toEqual({ smooth: true }); - expect(widget.colorVariant).toBe('blue'); - expect(widget.object).toBe('Deal'); - expect(widget.filter).toEqual({ status: 'won' }); - expect(widget.measures).toHaveLength(1); - expect(widget.layout).toEqual({ x: 0, y: 0, w: 6, h: 4 }); - }); - - it('omits header and filters when not present', () => { - const node = bridgeDashboard({ widgets: [] }, {}); - const body = node.body as any[]; - // Only widget-grid - expect(body).toHaveLength(1); - expect(body[0].type).toBe('widget-grid'); - }); - - it('assigns sequential widget ids', () => { - const node = bridgeDashboard( - { - widgets: [ - { title: 'A', type: 'metric' }, - { title: 'B', type: 'metric' }, - { title: 'C', type: 'metric' }, - ], - }, - {}, - ); - - const widgetGrid = (node.body as any[])[0]; - const widgets = widgetGrid.body as any[]; - expect(widgets[0].id).toBe('widget-0'); - expect(widgets[1].id).toBe('widget-1'); - expect(widgets[2].id).toBe('widget-2'); - }); - - it('defaults widget type to chart', () => { - const node = bridgeDashboard( - { widgets: [{ title: 'Untitled' }] }, - {}, - ); - const widgetGrid = (node.body as any[])[0]; - expect(widgetGrid.body[0].type).toBe('chart'); - }); - }); }); diff --git a/packages/react/src/spec-bridge/bridges/dashboard.ts b/packages/react/src/spec-bridge/bridges/dashboard.ts deleted file mode 100644 index f38ca347a..000000000 --- a/packages/react/src/spec-bridge/bridges/dashboard.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * 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. - */ - -import type { SchemaNode } from '@object-ui/core'; -import type { BridgeContext, BridgeFn } from '../types'; - -interface DashboardWidget { - title?: string; - description?: string; - type?: string; - chartConfig?: any; - colorVariant?: string; - actionUrl?: string; - actionType?: string; - actionIcon?: string; - object?: string; - filter?: any; - categoryField?: string; - valueField?: string; - aggregate?: string; - measures?: any[]; - layout?: any; - options?: any; - responsive?: any; - aria?: { ariaLabel?: string; ariaDescribedBy?: string; role?: string }; -} - -interface DashboardHeader { - showTitle?: boolean; - showDescription?: boolean; - actions?: Array<{ - label: string; - actionUrl?: string; - actionType?: string; - icon?: string; - }>; - [key: string]: any; -} - -interface GlobalFilter { - field: string; - label?: string; - type?: string; - options?: string[]; - optionsFrom?: { - object: string; - valueField: string; - labelField?: string; - filter?: any; - }; - defaultValue?: any; - scope?: string; - targetWidgets?: string[]; -} - -interface DashboardSpec { - name?: string; - label?: string; - description?: string; - widgets?: DashboardWidget[]; - globalFilters?: GlobalFilter[]; - header?: DashboardHeader; - // P1.3 additions - refreshInterval?: number; - dateRange?: { - field?: string; - defaultRange?: string; - allowCustomRange?: boolean; - }; - aria?: { ariaLabel?: string; ariaDescribedBy?: string; role?: string }; - performance?: any; -} - -function mapWidget(widget: DashboardWidget, index: number): SchemaNode { - const node: SchemaNode = { - type: widget.type ?? 'chart', - id: `widget-${index}`, - }; - - if (widget.title) node.title = widget.title; - if (widget.description) node.description = widget.description; - if (widget.chartConfig) node.chartConfig = widget.chartConfig; - if (widget.colorVariant) node.colorVariant = widget.colorVariant; - if (widget.actionUrl) node.actionUrl = widget.actionUrl; - if (widget.actionType) node.actionType = widget.actionType; - if (widget.actionIcon) node.actionIcon = widget.actionIcon; - if (widget.object) node.object = widget.object; - if (widget.filter) node.filter = widget.filter; - if (widget.categoryField) node.categoryField = widget.categoryField; - if (widget.valueField) node.valueField = widget.valueField; - if (widget.aggregate) node.aggregate = widget.aggregate; - if (widget.measures) node.measures = widget.measures; - if (widget.layout) node.layout = widget.layout; - if (widget.options) node.options = widget.options; - if (widget.responsive) node.responsive = widget.responsive; - if (widget.aria) node.aria = widget.aria; - - return node; -} - -function mapGlobalFilters(filters: GlobalFilter[]): SchemaNode { - return { - type: 'filter-bar', - id: 'dashboard-filters', - filters: filters.map((f) => { - const mapped: Record = { field: f.field }; - if (f.label) mapped.label = f.label; - if (f.type) mapped.type = f.type; - if (f.options) mapped.options = f.options; - if (f.optionsFrom) mapped.optionsFrom = f.optionsFrom; - if (f.defaultValue != null) mapped.defaultValue = f.defaultValue; - if (f.scope) mapped.scope = f.scope; - if (f.targetWidgets) mapped.targetWidgets = f.targetWidgets; - return mapped; - }), - }; -} - -function mapHeader(header: DashboardHeader): SchemaNode { - const node: SchemaNode = { - type: 'dashboard-header', - id: 'dashboard-header', - ...header, - }; - return node; -} - -/** Transforms a Dashboard spec into a dashboard layout SchemaNode */ -export const bridgeDashboard: BridgeFn = ( - spec: DashboardSpec, - _context: BridgeContext, -): SchemaNode => { - const widgetNodes = (spec.widgets ?? []).map(mapWidget); - - const bodyChildren: SchemaNode[] = []; - - if (spec.header) { - bodyChildren.push(mapHeader(spec.header)); - } - - if (spec.globalFilters && spec.globalFilters.length > 0) { - bodyChildren.push(mapGlobalFilters(spec.globalFilters)); - } - - // P1.3 — Date range filter - if (spec.dateRange) { - bodyChildren.push({ - type: 'date-range-filter', - id: 'dashboard-date-range', - ...spec.dateRange, - }); - } - - bodyChildren.push({ - type: 'widget-grid', - id: 'dashboard-widgets', - body: widgetNodes, - }); - - const node: SchemaNode = { - type: 'dashboard', - id: spec.name, - body: bodyChildren, - }; - - if (spec.label) node.label = spec.label; - if (spec.description) node.description = spec.description; - if (spec.refreshInterval != null) node.refreshInterval = spec.refreshInterval; - - // P1.6 — i18n & ARIA - if (spec.aria) node.aria = spec.aria; - if (spec.performance) node.performance = spec.performance; - - return node; -}; diff --git a/packages/react/src/spec-bridge/bridges/page.ts b/packages/react/src/spec-bridge/bridges/page.ts deleted file mode 100644 index ffea1b74f..000000000 --- a/packages/react/src/spec-bridge/bridges/page.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * 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. - */ - -import type { SchemaNode } from '@object-ui/core'; -import type { BridgeContext, BridgeFn } from '../types'; - -interface PageComponent { - type: string; - id?: string; - label?: string; - properties?: Record; - events?: Record; - style?: Record; - responsiveStyles?: Record; - className?: string; - /** Canonical conditional-visibility predicate (ADR-0089). */ - visibleWhen?: string; - /** @deprecated ADR-0089 → `visibleWhen`. */ - visibility?: string; - dataSource?: any; - responsive?: any; - aria?: Record; -} - -interface PageRegion { - name?: string; - width?: string; - components?: PageComponent[]; -} - -interface PageVariable { - name: string; - type?: string; - defaultValue?: any; - source?: string; -} - -interface PageSpec { - name?: string; - label?: string; - description?: string; - icon?: string; - type?: string; - variables?: PageVariable[]; - regions?: PageRegion[]; - template?: string; - object?: string; - // NOTE: blankLayout/recordReview removed — the `blank`/`record_review` page - // types have no renderer and were dropped from @objectstack/spec (framework#2265). - isDefault?: boolean; - assignedProfiles?: string[]; - interfaceConfig?: any; - aria?: { ariaLabel?: string; ariaDescribedBy?: string; role?: string }; -} - -function mapComponent(component: PageComponent): SchemaNode { - const node: SchemaNode = { - type: component.type, - id: component.id, - }; - - if (component.label) node.label = component.label; - if (component.className) node.className = component.className; - if (component.style) node.style = component.style; - // ADR-0065 scoped per-breakpoint styles — declared on the page-spec - // component (sibling of className/style) but previously dropped here - // before reaching SchemaRenderer, so a layout override like - // `{ large: { display: 'grid', gridTemplateColumns: '...' } }` never - // compiled to CSS and the node fell back to its default layout. - if (component.responsiveStyles) node.responsiveStyles = component.responsiveStyles; - if (component.properties) { - // Avoid overwriting the component dispatch keys (`type`/`id`) with inner - // renderer-specific props. E.g. PageTabsProps.properties.type is the tab - // visual style ('line'|'card'|'pill'), NOT the component type. - for (const [k, v] of Object.entries(component.properties)) { - if (k === 'type' || k === 'id') continue; - (node as any)[k] = v; - } - // Preserve the original `properties` bag so renderers can still read - // collision-prone keys via `schema.properties.`. - (node as any).properties = component.properties; - } - if (component.events) node.events = component.events; - // ADR-0089: `visibleWhen` is the canonical conditional-visibility predicate; - // the spec folds the deprecated `visibility` alias into it at parse, so prefer - // it and fall back to `visibility` for raw / un-normalized metadata. SchemaRenderer - // reads `visibleWhen` first (show-when-truthy). - const visiblePredicate = component.visibleWhen ?? component.visibility; - if (visiblePredicate) node.visibleWhen = visiblePredicate; - if (component.dataSource) node.dataSource = component.dataSource; - if (component.responsive) node.responsive = component.responsive; - if (component.aria) node.aria = component.aria; - - return node; -} - -function mapRegion(region: PageRegion): SchemaNode { - const children = (region.components ?? []).map(mapComponent); - const node: SchemaNode = { - type: 'page-region', - id: region.name, - body: children, - }; - if (region.width) node.width = region.width; - return node; -} - -/** Transforms a Page spec into a page layout SchemaNode */ -export const bridgePage: BridgeFn = ( - spec: PageSpec, - _context: BridgeContext, -): SchemaNode => { - const regions = (spec.regions ?? []).map(mapRegion); - - const node: SchemaNode = { - type: 'page', - id: spec.name, - body: regions, - }; - - if (spec.label) node.label = spec.label; - if (spec.description) node.description = spec.description; - if (spec.icon) node.icon = spec.icon; - if (spec.type) node.pageType = spec.type; - if (spec.variables) node.variables = spec.variables; - if (spec.template) node.template = spec.template; - if (spec.object) node.object = spec.object; - - // Additional page properties (blankLayout/recordReview dropped — their page - // types have no renderer and were removed from @objectstack/spec, framework#2265) - if (spec.isDefault != null) node.isDefault = spec.isDefault; - if (spec.assignedProfiles) node.assignedProfiles = spec.assignedProfiles; - if (spec.interfaceConfig) node.interfaceConfig = spec.interfaceConfig; - - // P1.6 — i18n & ARIA - if (spec.aria) node.aria = spec.aria; - - return node; -}; diff --git a/packages/react/src/spec-bridge/index.ts b/packages/react/src/spec-bridge/index.ts index 668253d8c..3740c6d0e 100644 --- a/packages/react/src/spec-bridge/index.ts +++ b/packages/react/src/spec-bridge/index.ts @@ -10,5 +10,3 @@ export { SpecBridge } from './SpecBridge'; export type { BridgeContext, BridgeFn, ObjectDefLite } from './types'; export { bridgeListView } from './bridges/list-view'; export { bridgeFormView } from './bridges/form-view'; -export { bridgePage } from './bridges/page'; -export { bridgeDashboard } from './bridges/dashboard';