diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 93a9b617d..d572dbe4f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -303,6 +303,19 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.widget.width': 'Width', 'engine.inspector.widget.height': 'Height', 'engine.inspector.widget.remove': 'Remove widget', + // Dataset binding (ADR-0021) — governed cross-object semantic layer. + 'engine.inspector.widget.datasetSection': 'Dataset binding', + 'engine.inspector.widget.dataset': 'Dataset', + 'engine.inspector.widget.datasetPlaceholder': 'e.g. sales_pipeline', + 'engine.inspector.widget.datasetHint': + 'Bind a governed dataset. When set, it takes precedence over the inline object query above and the widget renders via the dataset (consistent, cross-object, RLS-enforced).', + 'engine.inspector.widget.dimensions': 'Dimensions', + 'engine.inspector.widget.dimensionsPlaceholder': 'e.g. stage, region (comma-separated)', + 'engine.inspector.widget.dimensionsHint': + 'Dataset dimension names to group by. Leave empty for a single-value KPI metric.', + 'engine.inspector.widget.values': 'Values (measures)', + 'engine.inspector.widget.valuesPlaceholder': 'e.g. revenue, deal_count (comma-separated)', + 'engine.inspector.widget.valuesHint': 'Dataset measure names to show.', // Flow node inspector 'engine.inspector.flowNode.kind': 'Node', 'engine.inspector.flowNode.close': 'Close node', @@ -940,6 +953,18 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.widget.width': '宽度', 'engine.inspector.widget.height': '高度', 'engine.inspector.widget.remove': '删除组件', + // Dataset binding (ADR-0021) — governed cross-object semantic layer. + 'engine.inspector.widget.datasetSection': '数据集绑定', + 'engine.inspector.widget.dataset': '数据集', + 'engine.inspector.widget.datasetPlaceholder': '例如:sales_pipeline', + 'engine.inspector.widget.datasetHint': + '绑定一个受治理的数据集。设置后优先于上方的内联对象查询,组件改为按数据集渲染(口径一致、可跨对象、强制 RLS)。', + 'engine.inspector.widget.dimensions': '维度', + 'engine.inspector.widget.dimensionsPlaceholder': '例如:stage, region(逗号分隔)', + 'engine.inspector.widget.dimensionsHint': '用于分组的数据集维度名。留空则渲染为单值 KPI。', + 'engine.inspector.widget.values': '值(度量)', + 'engine.inspector.widget.valuesPlaceholder': '例如:revenue, deal_count(逗号分隔)', + 'engine.inspector.widget.valuesHint': '要展示的数据集度量名。', // Flow node inspector 'engine.inspector.flowNode.kind': '节点', 'engine.inspector.flowNode.close': '关闭节点', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx new file mode 100644 index 000000000..0415269a0 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DashboardWidgetInspector — dataset binding (ADR-0021). The hand-built widget + * inspector lets you bind a governed dataset + pick dimensions/values BY NAME, + * symmetric to the report's dataset binding, so per-widget dataset binding is + * editable in the form (not only via the raw source tab / API). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { DashboardWidgetInspector } from './DashboardWidgetInspector'; + +afterEach(cleanup); + +/** + * Stateful host that actually applies `onPatch` back into the draft — needed + * to exercise controlled-input transitions across multiple edits (a bare spy + * leaves the input value frozen, so a second edit to the same value is a no-op). + */ +function StatefulInspector({ + initialWidgets, + onPatchSpy, + ...rest +}: { + initialWidgets: Record[]; + onPatchSpy?: (patch: Record) => void; + [k: string]: unknown; +}) { + const [draft, setDraft] = React.useState>({ widgets: initialWidgets }); + return ( + ) => { + onPatchSpy?.(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + /> + ); +} + +const baseProps = { + type: 'dashboard', + name: 'sales', + locale: 'en-US' as const, + onClearSelection: vi.fn(), + onSelectionChange: vi.fn(), + readOnly: false, +}; + +function labelledInput(label: string): HTMLInputElement { + const lab = screen.getByText(label); + const input = lab.parentElement!.querySelector('input, textarea'); + return input as HTMLInputElement; +} + +const widget = (extra: Record = {}) => ({ + id: 'w1', + type: 'bar', + title: 'Revenue', + ...extra, +}); + +describe('DashboardWidgetInspector — dataset binding', () => { + it('shows the Dataset field; dimensions/values appear only once a dataset is bound', () => { + const { rerender } = render( + , + ); + // Dataset input is always present… + expect(labelledInput('Dataset')).toBeInTheDocument(); + // …but dimensions/values are gated behind a bound dataset. + expect(screen.queryByText('Dimensions')).not.toBeInTheDocument(); + expect(screen.queryByText('Values (measures)')).not.toBeInTheDocument(); + + rerender( + , + ); + expect(labelledInput('Dataset').value).toBe('sales_pipeline'); + expect(labelledInput('Dimensions').value).toBe('stage'); + expect(labelledInput('Values (measures)').value).toBe('revenue'); + }); + + it('commits the dataset name via onPatch (and clears with empty → undefined)', () => { + const onPatchSpy = vi.fn(); + render(); + + fireEvent.change(labelledInput('Dataset'), { target: { value: 'sales_pipeline' } }); + expect(onPatchSpy).toHaveBeenLastCalledWith({ + widgets: [expect.objectContaining({ id: 'w1', dataset: 'sales_pipeline' })], + }); + + // The bound value is now reflected (stateful host applied the patch). + expect(labelledInput('Dataset').value).toBe('sales_pipeline'); + + onPatchSpy.mockClear(); + fireEvent.change(labelledInput('Dataset'), { target: { value: '' } }); + expect(onPatchSpy).toHaveBeenLastCalledWith({ + widgets: [expect.objectContaining({ id: 'w1', dataset: undefined })], + }); + }); + + it('parses comma-separated dimensions/values into string arrays', () => { + const onPatch = vi.fn(); + render( + , + ); + fireEvent.change(labelledInput('Dimensions'), { target: { value: 'stage, region ,' } }); + expect(onPatch).toHaveBeenCalledWith({ + widgets: [expect.objectContaining({ dimensions: ['stage', 'region'] })], + }); + + onPatch.mockClear(); + fireEvent.change(labelledInput('Values (measures)'), { target: { value: 'revenue,deal_count' } }); + expect(onPatch).toHaveBeenCalledWith({ + widgets: [expect.objectContaining({ values: ['revenue', 'deal_count'] })], + }); + }); + + it('keeps the inline object query editable alongside dataset binding (additive dual-form)', () => { + render( + , + ); + // Both the dataset binding and the legacy inline object field are present. + expect(labelledInput('Dataset')).toBeInTheDocument(); + expect(labelledInput('Data Source (Object)').value).toBe('crm_opportunity'); + expect(labelledInput('Value Field').value).toBe('amount'); + }); + + it('renders Chinese labels under zh-CN', () => { + render( + , + ); + expect(screen.getByText('数据集绑定')).toBeInTheDocument(); + expect(screen.getByText('维度')).toBeInTheDocument(); + }); + + it('disables the dataset inputs when readOnly', () => { + render( + , + ); + expect(labelledInput('Dataset')).toBeDisabled(); + expect(labelledInput('Dimensions')).toBeDisabled(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx index 5995ac619..404576993 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx @@ -108,6 +108,17 @@ export function DashboardWidgetInspector({ onPatch({ widgets }); } + // ── Dataset binding (ADR-0021) ────────────────────────────────────────── + // Field access goes through `as any`: the bundled `@object-ui/types` + // `DashboardWidgetSchema` only gains `dataset`/`dimensions`/`values` once + // objectui bumps `@objectstack/spec`. Same accessor pattern as DatasetWidget. + const w = widget as any; + const datasetName = typeof w.dataset === 'string' ? (w.dataset as string) : ''; + const dimensionsCsv = Array.isArray(w.dimensions) ? (w.dimensions as string[]).join(', ') : ''; + const valuesCsv = Array.isArray(w.values) ? (w.values as string[]).join(', ') : ''; + const parseList = (s: string): string[] => + s.split(',').map((x) => x.trim()).filter(Boolean); + function moveWidget(to: number) { onPatch({ widgets: moveArray(widgetsAll, index, to) }); if (widget.id) { @@ -177,6 +188,64 @@ export function DashboardWidgetInspector({ + {/* Dataset binding (ADR-0021) — governed cross-object semantic layer. + When `dataset` is set, DashboardRenderer renders this widget via + (consistent numbers, cross-object, RLS-enforced), + taking precedence over the inline single-object query below. The + inline fields are kept visible so existing widgets stay editable + (additive dual-form, mirroring report's dataset binding). */} +
+
+ {t('engine.inspector.widget.datasetSection', locale)} +
+ + + patchWidget({ dataset: e.target.value || undefined } as Partial) + } + disabled={readOnly} + /> +

+ {t('engine.inspector.widget.datasetHint', locale)} +

+
+ {datasetName && ( + <> + + + patchWidget({ dimensions: parseList(e.target.value) } as Partial) + } + disabled={readOnly} + /> +

+ {t('engine.inspector.widget.dimensionsHint', locale)} +

+
+ + + patchWidget({ values: parseList(e.target.value) } as Partial) + } + disabled={readOnly} + /> +

+ {t('engine.inspector.widget.valuesHint', locale)} +

+
+ + )} +
+