diff --git a/.changeset/30064-studio-dataset-customize.md b/.changeset/30064-studio-dataset-customize.md
new file mode 100644
index 00000000000..0d1bc3b69bc
--- /dev/null
+++ b/.changeset/30064-studio-dataset-customize.md
@@ -0,0 +1,16 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/data-objectstack': patch
+'@object-ui/i18n': patch
+---
+
+feat(studio): customize a read-only package's dataset in the All metadata area, see what uses it before publishing, and read a refused publish at the dataset
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+- **Customize a dataset.** On a read-only base package, Studio's All metadata area opens a dataset in its form editor with the dataset designer and live preview, and offers Customize. The first saved edit copies the whole dataset into the base's paired `.custom` package, declaring `overrides: { package, basedOn }`; the header's Publish applies it, and "Restore package original" on the override mark deletes the copy. On a writable package a dataset still opens the generic item page.
+- **What uses it.** While a dataset is customized, the editor lists the dashboards, reports and other items that name it, read from the References read, so the person sees what a published change reaches. A failed read says it failed and offers to read again; it never reads as "nothing uses it".
+- **A refused publish at the item.** A publish the server refuses for a dataset is placed on its measure or dimension and the designer's input (`Not published. Check Aggregate of the measure "Total Amount"`), in the Console's language, and shown at the dataset's editor as well as on its Pending changes entry. The server's own text stays under Details.
+- **References read fixed.** `MetadataClient.references()` now reads the route's declared answer (`{ references: [{ type, name, label, path, kind }] }`) and maps each element to `MetadataReference` (`fromType`, `fromName`, new optional `fromLabel` and `kind`). It used to read a key the route never serves, so Setup's References tab and Studio's item tools showed "Nothing in the metadata graph points at this item. Safe to delete." for every item. An answer the declaration refuses is now a failed read.
+- **Published surface.** `MetadataReference`, exported from `@object-ui/data-objectstack`, gains two optional fields, `fromLabel` (the referring item's label, when the server serves one) and `kind` (the served reference kind); existing fields are unchanged. `MetadataClient.references()` now accepts less: a `200` answer that `FindReferencesToMetaResponseSchema` refuses throws instead of resolving `[]`, so a caller must handle the rejection as it already does for a non-2xx answer; in this repository every caller reads it through `useReferencesScan`, which catches it and shows the failed-check state.
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx
index 189fbf2ec9c..48cd7b9ef21 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx
@@ -24,6 +24,12 @@
* - Restore deletes the pair's draft and row of that one member, and the
* base's version reads back;
* - a walled caller sees Customize disabled, and nothing is asked or sent.
+ *
+ * objectstack#30064 — a read-only base's dataset, in the same editor with the
+ * type's registered designer (a probe here): the whole dataset written into
+ * the pair, declared; what uses it listed from the references read before
+ * anything is published, a failed read said as failed; a designer's blocking
+ * issue holding the edit unsent; and a kept publish refusal shown at the item.
*/
import '@testing-library/jest-dom/vitest';
@@ -90,6 +96,7 @@ const mockClient = vi.hoisted(() => ({
srv.version += 1;
return { success: true, version: `v${srv.version}`, state: 'draft' };
}),
+ references: vi.fn(async (_type: string, _name: string): Promise => []),
reset: vi.fn(async (type: string, name: string, opts: Record = {}) => {
srv.resets.push({ type, name, opts: clone(opts) });
const map = opts.state === 'draft' ? srv.drafts : srv.rows;
@@ -115,6 +122,43 @@ import type { StudioBaseCustomization } from './StudioDesignSurface';
import { ensureCustomizationPackage } from './packages-io';
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { registerDefaultMetadataSchemas } from '../metadata-admin/default-schemas';
+import {
+ registerMetadataDefaultInspector,
+ type MetadataDefaultInspectorProps,
+} from '../metadata-admin/default-inspector-registry';
+import type { StudioRefusal } from './metadataError';
+
+/**
+ * The dataset's designer slot: the draft it is handed, its read-only gate, one
+ * edit (the second measure's aggregate) and one blocking report.
+ */
+function DesignerProbe({ draft, onPatch, onBlockingIssuesChange, readOnly }: MetadataDefaultInspectorProps) {
+ const measures = Array.isArray(draft.measures) ? (draft.measures as Array>) : [];
+ return (
+
+
{String(measures[1]?.aggregate ?? '')}
+
+
+
+ );
+}
/** The preview slot, printing the live draft it is handed. */
function PreviewProbe({ draft }: { draft: Record }) {
@@ -134,6 +178,37 @@ const WELCOME_EN = {
_packageId: BASE,
};
const WELCOME_ZH = { ...WELCOME_EN, locale: 'zh-CN', subject: '欢迎使用 HotCRM', bodyHtml: '
你好 {{user.name}}
' };
+const METRICS = {
+ name: 'opportunity_metrics',
+ label: 'Opportunity Metrics',
+ object: 'crm_opportunity',
+ dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }],
+ measures: [
+ { name: 'opp_count', label: 'Opportunities', aggregate: 'count' },
+ { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount' },
+ ],
+ _packageId: BASE,
+};
+/** What the references read answers for the dataset (`client.references`, mapped from the served `references[]`). */
+const USED_BY = [
+ {
+ fromType: 'dashboard',
+ fromName: 'pipeline_dashboard',
+ fromLabel: 'Pipeline Dashboard',
+ path: 'widgets[].dataset',
+ kind: 'dashboard dataset',
+ value: 'opportunity_metrics',
+ },
+ {
+ fromType: 'dashboard',
+ fromName: 'pipeline_dashboard',
+ fromLabel: 'Pipeline Dashboard',
+ path: 'widgets[].compare.dataset',
+ kind: 'dashboard dataset',
+ value: 'opportunity_metrics',
+ },
+ { fromType: 'report', fromName: 'won_by_stage', path: 'dataset', kind: 'report dataset', value: 'opportunity_metrics' },
+];
const BUNDLE_ZH = {
name: 'hotcrm_zh',
label: 'HotCRM zh-CN',
@@ -146,6 +221,7 @@ beforeAll(() => {
registerDefaultMetadataSchemas();
registerMetadataPreview('email_template', PreviewProbe as never);
registerMetadataPreview('translation', PreviewProbe as never);
+ registerMetadataDefaultInspector('dataset', DesignerProbe);
});
beforeEach(() => {
@@ -155,6 +231,7 @@ beforeEach(() => {
at(srv.rows, BASE).set(slot('email_template', 'welcome', 'zh-CN'), clone(WELCOME_ZH));
at(srv.rows, BASE).set(slot('translation', 'hotcrm_zh'), clone(BUNDLE_ZH));
at(srv.rows, WRITABLE).set(slot('email_template', 'welcome', 'zh-CN'), { ...clone(WELCOME_ZH), _packageId: WRITABLE });
+ at(srv.rows, BASE).set(slot('dataset', 'opportunity_metrics'), clone(METRICS));
srv.pairExists = false;
srv.ensure.length = 0;
srv.saves.length = 0;
@@ -196,6 +273,7 @@ function Host({
packageId = BASE,
walled = false,
listedPair = null,
+ publishRefusal = null,
}: {
type: string;
name: string;
@@ -203,6 +281,7 @@ function Host({
packageId?: string;
walled?: boolean;
listedPair?: string | null;
+ publishRefusal?: StudioRefusal | null;
}) {
const readOnly = packageId === BASE;
const [pairId, setPairId] = React.useState(listedPair);
@@ -228,6 +307,7 @@ function Host({
readOnly={readOnly}
customization={customization}
basePath={`/studio/${packageId}/metadata`}
+ publishRefusal={publishRefusal}
/>
);
}
@@ -437,3 +517,104 @@ describe('Customize a read-only base’s translation bundle (objectstack#28569)'
await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-bundle')).toBeNull());
});
});
+
+describe('Customize a read-only base’s dataset (objectstack#30064)', () => {
+ beforeEach(() => {
+ mockClient.references.mockImplementation(async () => clone(USED_BY));
+ });
+
+ it('opens the designer read-only, says the whole dataset is copied, and lists what uses it before anything is published', async () => {
+ mount({ type: 'dataset', name: 'opportunity_metrics' });
+ const probe = await screen.findByTestId('designer-probe', undefined, { timeout: 8000 });
+ expect(probe).toHaveAttribute('data-readonly', 'true');
+ expect(screen.getByTestId('designer-probe-aggregate')).toHaveTextContent('sum');
+ expect(screen.getByTestId('studio-bundle-editor-whole-dataset')).toHaveTextContent(
+ 'Customizing a dataset copies HotCRM’s whole dataset into your customization',
+ );
+ const usedBy = await screen.findByTestId('studio-bundle-editor-used-by');
+ await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded'));
+ expect(mockClient.references).toHaveBeenCalledWith('dataset', 'opportunity_metrics');
+ // One entry per referring item: the dashboard binding it twice is one dashboard.
+ const items = within(usedBy).getAllByTestId('studio-bundle-editor-used-by-item');
+ expect(items.map((el) => [el.getAttribute('data-type'), el.getAttribute('data-name')])).toEqual([
+ ['dashboard', 'pipeline_dashboard'],
+ ['report', 'won_by_stage'],
+ ]);
+ expect(items[0]).toHaveTextContent('Pipeline Dashboard');
+ expect(items[1]).toHaveTextContent('won_by_stage');
+ expect(usedBy).toHaveTextContent('reaches the 2 items built on this dataset');
+ expect(srv.saves).toEqual([]);
+ });
+
+ it('Customize and an edit in the designer write the whole dataset into the pair’s draft, declared; the base is untouched', async () => {
+ mount({ type: 'dataset', name: 'opportunity_metrics' });
+ await screen.findByTestId('designer-probe', undefined, { timeout: 8000 });
+ await customize();
+ await waitFor(() => expect(screen.getByTestId('designer-probe')).toHaveAttribute('data-readonly', 'false'));
+ fireEvent.click(screen.getByTestId('designer-probe-avg'));
+ await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 5000 });
+ expect(srv.ensure).toEqual([201]);
+ const [save] = srv.saves;
+ expect(save).toMatchObject({ type: 'dataset', name: 'opportunity_metrics' });
+ expect(save.opts).toMatchObject({ mode: 'draft', packageId: PAIR, ifNoneMatch: '*' });
+ const { _packageId: _dropped, ...whole } = clone(METRICS);
+ expect(save.body).toEqual({
+ ...whole,
+ measures: [whole.measures[0], { ...whole.measures[1], aggregate: 'avg' }],
+ overrides: { package: BASE, basedOn: '4.0.0' },
+ });
+ expect(await screen.findByTestId('studio-override-chip')).toHaveTextContent('Overrides HotCRM');
+ await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-dataset')).toBeNull());
+ // What uses it stays in view until the change is published.
+ expect(screen.getByTestId('studio-bundle-editor-used-by')).toBeInTheDocument();
+ expect(srv.rows.get(BASE)?.get(slot('dataset', 'opportunity_metrics'))).toEqual(METRICS);
+ });
+
+ it('holds an edit the designer reports as blocking: nothing is sent', async () => {
+ mount({ type: 'dataset', name: 'opportunity_metrics' });
+ await screen.findByTestId('designer-probe', undefined, { timeout: 8000 });
+ await customize();
+ await waitFor(() => expect(screen.getByTestId('designer-probe')).toHaveAttribute('data-readonly', 'false'));
+ fireEvent.click(screen.getByTestId('designer-probe-blank-dimension'));
+ await sleep(2500);
+ expect(srv.ensure).toEqual([]);
+ expect(srv.saves).toEqual([]);
+ });
+
+ it('a references read that failed says so and asks again; it never reads as nothing using the dataset', async () => {
+ mockClient.references.mockRejectedValueOnce(Object.assign(new Error('Service Unavailable'), { status: 503 }));
+ mount({ type: 'dataset', name: 'opportunity_metrics' });
+ const usedBy = await screen.findByTestId('studio-bundle-editor-used-by', undefined, { timeout: 8000 });
+ await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'error'));
+ expect(within(usedBy).getByRole('alert')).toHaveTextContent('could not be read');
+ expect(usedBy).not.toHaveTextContent('Nothing uses this dataset');
+ fireEvent.click(within(usedBy).getByTestId('studio-bundle-editor-used-by-retry'));
+ await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded'));
+ expect(within(usedBy).getAllByTestId('studio-bundle-editor-used-by-item')).toHaveLength(2);
+ });
+
+ it('a read that answers no reference says nothing uses the dataset', async () => {
+ mockClient.references.mockImplementation(async () => []);
+ mount({ type: 'dataset', name: 'opportunity_metrics' });
+ const usedBy = await screen.findByTestId('studio-bundle-editor-used-by', undefined, { timeout: 8000 });
+ await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded'));
+ expect(usedBy).toHaveTextContent('Nothing uses this dataset yet');
+ });
+
+ it('shows the refusal the header’s Publish kept for the dataset at the item, with the server’s text under Details', async () => {
+ mount({
+ type: 'dataset',
+ name: 'opportunity_metrics',
+ listedPair: PAIR,
+ publishRefusal: {
+ message: 'Not published. Check Aggregate of the measure “Total Amount”: Details say what to change.',
+ target: null,
+ input: 'Aggregate',
+ detail: 'datasets.opportunity_metrics.measures[1].aggregate — aggregate "sum" over field "expected_revenue" is refused',
+ },
+ });
+ const strip = await screen.findByTestId('studio-bundle-editor-publish-refusal', undefined, { timeout: 8000 });
+ expect(within(strip).getByRole('alert')).toHaveTextContent('Check Aggregate of the measure “Total Amount”');
+ expect(within(strip).getByTestId('studio-refusal-detail')).toHaveTextContent('expected_revenue');
+ });
+});
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx
index e0d004151c7..8500a768810 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx
@@ -31,6 +31,17 @@
* edit never moves into another member. A translation is one locale bundle
* whose override replaces the whole bundle (ADR-0146 D2), and the editor says
* so before the first save.
+ *
+ * ## A dataset (objectstack#30064)
+ *
+ * On a read-only base the area opens a dataset here too
+ * (`metadataArea.ts#AREA_CUSTOMIZE_EDITOR_TYPES`): the type's registered
+ * designer (`DatasetDefaultInspector`) in place of the form, beside its
+ * preview, customized as above, the whole dataset copied into the pair. A
+ * dataset feeds every dashboard and report built on it, so the editor lists
+ * what uses it, from the references read (`client.references`), before
+ * anything is published; and a refusal the header's Publish kept for it is
+ * shown here, at the item, as on its Pending changes entry.
*/
import * as React from 'react';
@@ -44,15 +55,18 @@ import {
cn,
} from '@object-ui/components';
import { extractDraftBody, formatMetadataError } from '@object-ui/data-objectstack';
-import { History, Lock, MoreHorizontal } from 'lucide-react';
+import { History, Link2, Lock, MoreHorizontal } from 'lucide-react';
import { SchemaForm } from '../metadata-admin/SchemaForm.js';
import { resolveResourceConfig } from '../metadata-admin/registry.js';
import { useRegisteredMetadataPreview } from '../metadata-admin/preview-registry.js';
+import { useRegisteredMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js';
+import { useReferencesScan, type ReferencesState } from '../item-tools/ReferencesPanel.js';
import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata.js';
import { readDraftForSave, useDraftSaveGuard } from '../metadata-admin/DraftConflictDialog.js';
import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadata-admin/i18n.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { plainRefusal, type StudioRefusal } from './metadataError.js';
+import { StudioRefusalStrip } from './StudioRefusalStrip.js';
import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js';
import {
StudioCustomizeButton,
@@ -79,10 +93,119 @@ function savedBody(doc: Record): Record {
return body;
}
+/** The referring items of a references read, one per item: a dashboard binding it twice is one dashboard. */
+function referringItems(state: ReferencesState): Array<{ type: string; name: string; label: string }> {
+ if (state.status !== 'loaded') return [];
+ const seen = new Set();
+ const out: Array<{ type: string; name: string; label: string }> = [];
+ for (const ref of state.items) {
+ const key = `${ref.fromType}/${ref.fromName}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ type: ref.fromType, name: ref.fromName, label: ref.fromLabel || ref.fromName });
+ }
+ return out;
+}
+
+/**
+ * objectstack#30064 — what uses this item, read once by the references read
+ * (`client.references`, the item tools' 引用关系): a dataset's dashboards and
+ * reports, each opening in the area. A failed read says it failed and offers
+ * the read again; it never reads as "nothing uses it".
+ */
+function StudioBundleUsedBy({
+ type,
+ name,
+ packageId,
+ basePath,
+ locale,
+}: {
+ type: string;
+ name: string;
+ packageId: string;
+ basePath: string;
+ locale: string;
+}): React.ReactElement {
+ const navigate = useNavigate();
+ const client = useMetadataClient();
+ const { state, load } = useReferencesScan(client, type, name);
+ const loadRef = React.useRef(load);
+ React.useLayoutEffect(() => {
+ loadRef.current = load;
+ });
+ React.useEffect(() => {
+ void loadRef.current();
+ }, []);
+ const items = referringItems(state);
+ return (
+
+ {state.status === 'error' ? (
+
+ >
+ )}
+
+ );
+}
+
export interface StudioBundleItemEditorProps {
/** The package the area is open on. */
packageId: string;
- /** `email_template` or `translation` (`AREA_FORM_EDITOR_TYPES`). */
+ /** `email_template` or `translation` (`AREA_FORM_EDITOR_TYPES`), or a read-only package's `dataset`. */
type: string;
name: string;
/** The bundle member the URL names (`?member=`); absent for the canonical one and for a translation. */
@@ -97,6 +220,8 @@ export interface StudioBundleItemEditorProps {
onDraftSaved?: (saved?: { type: string; name: string }) => void;
/** The area's root (`MetadataAreaHost.basePath`), for the item's history link. */
basePath: string;
+ /** objectstack#30064 — what the header's last Publish refused for this item, kept until it is resolved. */
+ publishRefusal?: StudioRefusal | null;
}
export function StudioBundleItemEditor({
@@ -109,6 +234,7 @@ export function StudioBundleItemEditor({
publishNonce = 0,
onDraftSaved,
basePath,
+ publishRefusal = null,
}: StudioBundleItemEditorProps): React.ReactElement {
const locale = useMetadataLocale();
const navigate = useNavigate();
@@ -121,6 +247,10 @@ export function StudioBundleItemEditor({
(entry?.schema as Record | undefined) ??
(config.defaultSchema as Record | undefined);
const Preview = useRegisteredMetadataPreview(type);
+ // objectstack#30064 — a type with a designer of its own (a dataset) is edited in it, not in the form.
+ const Designer = useRegisteredMetadataDefaultInspector(type);
+ // What the designer reports as blocking (a dimension with no field): held, never sent.
+ const [blocking, setBlocking] = React.useState(0);
const memberScope = React.useMemo(() => (member ? { member } : {}), [member]);
const target = JSON.stringify([packageId, type, name, member ?? '']);
@@ -304,7 +434,7 @@ export function StudioBundleItemEditor({
target,
loadedFor,
dirty,
- blocked: !editable || restoring,
+ blocked: !editable || restoring || blocking > 0,
snapshot: draft,
save: doSave,
});
@@ -340,6 +470,11 @@ export function StudioBundleItemEditor({
setDraft(next);
setDirty(true);
}, []);
+ // The designer's shallow patch, as the generic item page applies it.
+ const onPatch = React.useCallback((patch: Record) => {
+ setDraft((prev) => ({ ...prev, ...patch }));
+ setDirty(true);
+ }, []);
const baseLabel = customization?.baseLabel ?? packageId;
// The member the URL names, else the one the canonical read served.
@@ -444,9 +579,22 @@ export function StudioBundleItemEditor({
})}