Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -940,6 +953,18 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '关闭节点',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[];
onPatchSpy?: (patch: Record<string, unknown>) => void;
[k: string]: unknown;
}) {
const [draft, setDraft] = React.useState<Record<string, unknown>>({ widgets: initialWidgets });
return (
<DashboardWidgetInspector
type="dashboard"
name="sales"
locale={'en-US'}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
selection={{ kind: 'widget', id: 'w1' }}
{...rest}
draft={draft}
onPatch={(patch: Record<string, unknown>) => {
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<string, unknown> = {}) => ({
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(
<DashboardWidgetInspector
{...baseProps}
draft={{ widgets: [widget()] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={vi.fn()}
/>,
);
// 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(
<DashboardWidgetInspector
{...baseProps}
draft={{ widgets: [widget({ dataset: 'sales_pipeline', dimensions: ['stage'], values: ['revenue'] })] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={vi.fn()}
/>,
);
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(<StatefulInspector initialWidgets={[widget()]} onPatchSpy={onPatchSpy} />);

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(
<DashboardWidgetInspector
{...baseProps}
draft={{ widgets: [widget({ dataset: 'sales_pipeline' })] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={onPatch}
/>,
);
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(
<DashboardWidgetInspector
{...baseProps}
draft={{ widgets: [widget({ object: 'crm_opportunity', valueField: 'amount' })] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={vi.fn()}
/>,
);
// 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(
<DashboardWidgetInspector
{...baseProps}
locale={'zh-CN'}
draft={{ widgets: [widget({ dataset: 'sales_pipeline' })] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={vi.fn()}
/>,
);
expect(screen.getByText('数据集绑定')).toBeInTheDocument();
expect(screen.getByText('维度')).toBeInTheDocument();
});

it('disables the dataset inputs when readOnly', () => {
render(
<DashboardWidgetInspector
{...baseProps}
readOnly
draft={{ widgets: [widget({ dataset: 'sales_pipeline' })] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={vi.fn()}
/>,
);
expect(labelledInput('Dataset')).toBeDisabled();
expect(labelledInput('Dimensions')).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -177,6 +188,64 @@ export function DashboardWidgetInspector({
</Select>
</Field>

{/* Dataset binding (ADR-0021) — governed cross-object semantic layer.
When `dataset` is set, DashboardRenderer renders this widget via
<DatasetWidget> (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). */}
<div className="space-y-3 rounded-md border border-dashed border-primary/40 bg-primary/5 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wider text-primary/80">
{t('engine.inspector.widget.datasetSection', locale)}
</div>
<Field id="widget-dataset" label={t('engine.inspector.widget.dataset', locale)}>
<Input
id="widget-dataset"
value={datasetName}
placeholder={t('engine.inspector.widget.datasetPlaceholder', locale)}
onChange={(e) =>
patchWidget({ dataset: e.target.value || undefined } as Partial<DashboardWidgetSchema>)
}
disabled={readOnly}
/>
<p className="text-[10px] leading-snug text-muted-foreground">
{t('engine.inspector.widget.datasetHint', locale)}
</p>
</Field>
{datasetName && (
<>
<Field id="widget-dimensions" label={t('engine.inspector.widget.dimensions', locale)}>
<Input
id="widget-dimensions"
value={dimensionsCsv}
placeholder={t('engine.inspector.widget.dimensionsPlaceholder', locale)}
onChange={(e) =>
patchWidget({ dimensions: parseList(e.target.value) } as Partial<DashboardWidgetSchema>)
}
disabled={readOnly}
/>
<p className="text-[10px] leading-snug text-muted-foreground">
{t('engine.inspector.widget.dimensionsHint', locale)}
</p>
</Field>
<Field id="widget-values" label={t('engine.inspector.widget.values', locale)}>
<Input
id="widget-values"
value={valuesCsv}
placeholder={t('engine.inspector.widget.valuesPlaceholder', locale)}
onChange={(e) =>
patchWidget({ values: parseList(e.target.value) } as Partial<DashboardWidgetSchema>)
}
disabled={readOnly}
/>
<p className="text-[10px] leading-snug text-muted-foreground">
{t('engine.inspector.widget.valuesHint', locale)}
</p>
</Field>
</>
)}
</div>

<Field id="widget-object" label={t('engine.inspector.widget.object', locale)}>
<Input
id="widget-object"
Expand Down
Loading