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
16 changes: 16 additions & 0 deletions .changeset/30064-studio-dataset-customize.md
Original file line number Diff line number Diff line change
@@ -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 `<base>.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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<unknown[]> => []),
reset: vi.fn(async (type: string, name: string, opts: Record<string, unknown> = {}) => {
srv.resets.push({ type, name, opts: clone(opts) });
const map = opts.state === 'draft' ? srv.drafts : srv.rows;
Expand All @@ -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<Record<string, unknown>>) : [];
return (
<div data-testid="designer-probe" data-readonly={String(readOnly)}>
<p data-testid="designer-probe-aggregate">{String(measures[1]?.aggregate ?? '')}</p>
<button
type="button"
data-testid="designer-probe-avg"
disabled={readOnly}
onClick={() => onPatch({ measures: measures.map((m, i) => (i === 1 ? { ...m, aggregate: 'avg' } : m)) })}
>
avg
</button>
<button
type="button"
data-testid="designer-probe-blank-dimension"
disabled={readOnly}
onClick={() => {
onPatch({ dimensions: [{ name: 'stage' }] });
onBlockingIssuesChange?.(1);
}}
>
blank
</button>
</div>
);
}

/** The preview slot, printing the live draft it is handed. */
function PreviewProbe({ draft }: { draft: Record<string, unknown> }) {
Expand All @@ -134,6 +178,37 @@ const WELCOME_EN = {
_packageId: BASE,
};
const WELCOME_ZH = { ...WELCOME_EN, locale: 'zh-CN', subject: '欢迎使用 HotCRM', bodyHtml: '<p>你好 {{user.name}}</p>' };
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',
Expand All @@ -146,6 +221,7 @@ beforeAll(() => {
registerDefaultMetadataSchemas();
registerMetadataPreview('email_template', PreviewProbe as never);
registerMetadataPreview('translation', PreviewProbe as never);
registerMetadataDefaultInspector('dataset', DesignerProbe);
});

beforeEach(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -196,13 +273,15 @@ function Host({
packageId = BASE,
walled = false,
listedPair = null,
publishRefusal = null,
}: {
type: string;
name: string;
member?: string;
packageId?: string;
walled?: boolean;
listedPair?: string | null;
publishRefusal?: StudioRefusal | null;
}) {
const readOnly = packageId === BASE;
const [pairId, setPairId] = React.useState<string | null>(listedPair);
Expand All @@ -228,6 +307,7 @@ function Host({
readOnly={readOnly}
customization={customization}
basePath={`/studio/${packageId}/metadata`}
publishRefusal={publishRefusal}
/>
);
}
Expand Down Expand Up @@ -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');
});
});
Loading
Loading