Skip to content

Commit 6f095b9

Browse files
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 (#30224)
domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3 Fixes #30064 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio「全部元数据」里,只读基础包(如 CRM)的数据集不再只能看:打开后是数据集设计器加实时预览,标题栏有「定制」。第一次保存把整个数据集复制进配对包 `BASE.custom` 并声明 `overrides: { package, basedOn }`,顶栏「发布」生效,覆盖标记里的「恢复软件包原版」撤销。编辑器上方列出用到这个数据集的仪表盘、报表等(来自 引用关系 读取),发布被拒时原因按控制台语言显示在数据集编辑器里,并定位到具体度量和输入项。 - **为什么改**:客户对「在途商机总额」的口径和厂商不同,只能改仪表盘、改不了下面的数字;而一个数据集同时喂给所有基于它的仪表盘和报表,发布前必须让人看到会影响哪些。 - **风险与回滚**:只改 ui(`@object-ui/app-shell`、`@object-ui/data-objectstack`、`@object-ui/i18n`),没有服务端或 spec 改动。可写包里的数据集仍走原来的通用条目页。顺带修了一个已发布缺陷:`MetadataClient.references()` 读的是服务端从不返回的 `items` 键,Setup 的「引用关系」页和 Studio 条目工具对每个条目都显示「未找到引用,可以安全删除」;现在按声明的 `{ references: [...] }` 解析,读不懂的答复算读取失败。回滚 = revert 本 PR。 - **先看**:① `ui/packages/data-objectstack/src/metadata-client.ts` 的 `references()`(引用读取的修复,影响 Setup 引用页);② `ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx` 的 `StudioBundleUsedBy` 与设计器挂载(`Designer` 分支、`blocking` 拦截自动保存);③ `metadataError.ts` 的 `datasetMemberLocator`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx packages/data-objectstack/src/metadata-client.references-30064.test.ts`,预期 16 例全绿(其中 10 例是本卡新增)。 - **需要你做的**:无,席位落地。 ## What changes 1. **The door.** `metadataArea.ts` gains `AREA_CUSTOMIZE_EDITOR_TYPES` (`dataset`): on a READ-ONLY package the All metadata area opens a dataset in its form editor (`StudioBundleItemEditor`), the same editor that customizes an email template or a translation. `areaItemOpensFormEditor` takes the area's `readOnly` gate; on a writable package a dataset still opens the generic item page, as on Setup's. `dataset` is `overridable` in `OVERRIDE_TYPE_CLASSIFICATION` (` dataset: listed` re-checked on the base). 2. **The designer in that editor.** A type with a registered default inspector is edited in it rather than in the generic form: the dataset gets `DatasetDefaultInspector`, beside its registered `DatasetPreview`. Its shallow patch is applied to the buffer, and a blocking issue it reports (a dimension with no field) holds the autosave, as the generic page does. Email templates and translations register none, so they are unchanged. 3. **Customize, saved whole into the pair, with Restore.** Nothing new: the editor's existing route (#28711's controls, #28569's editor). The first saved edit asks the ensure door once, then writes the whole dataset into `BASE.custom` declaring `overrides: { package, basedOn }` (`overrideDraftBody`); the header's Publish publishes the pair; the override mark's popover offers "Restore package original". A note before the first save says the whole dataset is copied. 4. **What uses it, before publish.** While the dataset is customized, the editor lists every item that names it, one entry per item, each opening in the area, from the existing references read (`client.references`, the item tools' 引用关系). A failed read says it failed and offers Retry; it never reads as "nothing uses it". No new server read. 5. **The references read reads its declared answer.** `GET /meta/:type/:name/references` serves `{ references: [{ type, name, label?, path, kind }] }` bare (`FindReferencesToMetaResponseSchema`); `MetadataClient.references()` read `data.items`, which the route never serves, so it returned `[]` for every item and both panels said "Safe to delete". It now parses the declared answer and maps each element to `MetadataReference` (`fromType`, `fromName`, new optional `fromLabel` and `kind`); an answer the declaration refuses throws. Measured on the CRM example before the fix: the route answered the Pipeline Dashboard for `opportunity_metrics`, and the reader dropped it. 6. **A refused publish, at the item.** `publishLocator` gains `datasetMemberLocator`: an issue under `measures.N` or `dimensions.N` is placed on that member by its label and on the designer's input (`Not published. Check Aggregate of the measure "Total Amount": Details say what to change.` / 「未发布。请检查度量「总金额」的「聚合方式」」). The surface hands the area its kept refusals (`publishRefusalFor`), and the editor shows the one for its item, as the Pending changes entry does. The server's text stays under Details, verbatim (#26730's rule). 7. **Marks, the 仅看定制 count and the tile.** No code: they read the customization read by type, and it lists the dataset override. Measured in the browser (below). Why `field: expected_revenue` was refused: `expected_revenue` is a `formula` field, and the authoring gate's `measure-aggregate-field-type-refused` refuses `sum`/`avg` over a formula field (virtual, no stored column; its hint: store the value as a number field and aggregate that). Acceptance 6: no server or spec change was needed. ## Tests - `StudioBundleItemEditor.customize-28569.test.tsx` (the editor's shared harness), new describe "Customize a read-only base's dataset (objectstack#30064)", 6 cases: designer read-only before Customize, the whole-dataset note, the used-by list deduplicated per item; Customize + an edit write the whole dataset into the pair, declared, base untouched; a blocking report holds the edit unsent; a failed references read says so and Retry reads again; an empty answer says nothing uses it; a kept publish refusal shows at the item with Details. - `metadata-client.references-30064.test.ts` (new, 4 cases): the fixture is the declared shape; mapping with and without a label; the honest empty answer; an off-contract answer throws. - `metadataError.publishRefusal-26730.test.ts`, new describe (2 cases): the measured formula refusal placed on the measure's Aggregate in zh, engine text kept out of the sentence; a dimension named by its name; a path under no member left unplaced. - `metadataArea.test.ts`, new describe (3 cases): dataset opens the editor only on a read-only package; the editor's exceptions kept; email template and translation unchanged. <details><summary>Test runs (head aefff1f unless noted)</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/` → `Test Files 324 passed (324)` · `Tests 2412 passed (2412)`; lock `VERDICT command-exit 0`. - `pnpm --dir ui exec vitest run packages/data-objectstack/ packages/i18n/ packages/app-shell/src/views/item-tools/ packages/app-shell/src/views/metadata-admin/ResourceEditPage.references packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector` on 66709ce → 1 red: `studio-namespace.test.ts` placeholder parity (`usedByOne` en had no `{count}`); fixed in aefff1f, then `pnpm --dir ui exec vitest run packages/i18n/` → `Test Files 95 passed (95)` · `Tests 1443 passed | 13 skipped`. - `pnpm --filter @object-ui/data-objectstack --filter @object-ui/i18n --filter @object-ui/app-shell run type-check` → all three `Done`, lock `VERDICT command-exit 0` (after building the app-shell dependency closure with declarations). - Ablation 1 (`node scripts/ablation-replace.mjs`, committed head): `blocked: !editable || restoring || blocking > 0,` → `blocked: !editable || restoring,` — anchor 1 → 0, blob 8500a76 → 53bc7c49; "holds an edit the designer reports as blocking" `×`, `Tests 1 failed`; restored blob == HEAD, `git diff HEAD` empty. - Ablation 2: `fromType: ref.type,` → `fromType: ref.kind,` — anchor 1 → 0; "maps each served reference …" `×`, `Tests 1 failed | 3 passed`; restored blob == HEAD. - Both ablated files import their subject from source (`./metadata-client`, `./StudioBundleItemEditor`), not a built `dist/`. </details> ## Acceptance notes - **Published surface (Clause-② yes, widening).** `MetadataReference`, exported from the `@object-ui/data-objectstack` entry, gains two optional fields: `MetadataReference.fromLabel` (the referring item's label, when served) and `MetadataReference.kind` (the served reference kind). The same diff narrows what `MetadataClient.references()` accepts: a 200 answer `FindReferencesToMetaResponseSchema` refuses now throws instead of resolving `[]` (a non-2xx answer already threw). Every caller in this repository reads it through `useReferencesScan` (`ui/packages/app-shell/src/views/item-tools/ReferencesPanel.tsx`), whose `catch` sets the `error` arm, and none calls it bare: Setup's item page (`ResourceEditPage.tsx`, the References sheet) and Studio's item tools (`StudioItemTools.tsx`, the References sheet) both render `ReferencesPanel`'s error arm on the throw, the `refsErrorTitle` / `refsErrorDesc` sentences (the check did not finish, nothing said about deleting), the cause, and Retry, with no count badge; the new used-by list (`StudioBundleItemEditor.tsx`, `StudioBundleUsedBy`) shows `usedByError` with Retry. Callers in other repositories were not read. - **In-place fix, named:** the references read (item 5 above). It is the read this card was told to reuse; the card's Acceptance 2 cannot be met with it returning `[]`. Evidence: the route's answer for `opportunity_metrics` on the CRM example was `{"references":[{"type":"dashboard","name":"pipeline_dashboard","label":"Pipeline Dashboard","path":"widgets[].dataset","kind":"dashboard dataset"}]}`, and the reader took `data?.items ?? []`. The same fix changes what Setup's References tab and Studio's item tools show for every type. - The dataset designer keeps its own count line ("Bound by 0 reports · 1 dashboard"), read by a client-side list scan (`useDatasetUsage`); the new list is the references read. Two reads of one fact sit on the page; they agreed in the browser check. Not changed here. - A refused publish's reason (here: a formula field stores no value) stays in the server's English under Details; the Console sentence names the measure and its input. A designer sentence per rule would be a separate decision. - Seed data differs from the card's: the CRM example's 在途商机总额 reads 710,000 here, not ¥814,400. ## Verification Browser check on the CRM example (`objectstack dev --fresh`, own port) with the Console's Vite dev server on its own port, Chromium, locale zh-CN. Screenshots are in the dev's scratchpad, not uploaded. <details><summary>Steps and readings</summary> 1. `/studio/com.example.crm/metadata/dataset/opportunity_metrics`: designer read-only, 定制 in the header, the whole-dataset note, 「发布后,这里的修改会影响基于此数据集的 1 项:Pipeline Dashboard 仪表板」. 2. 定制 → measure 2 (`total_amount`) 聚合方式 求和 → 平均值: status 未发布草稿, mark 覆盖 CRM (minimal example); the pair's draft read back with `overrides: {"package":"com.example.crm","basedOn":"4.0.0"}` and all three measures. 3. 发布 → 全部发布: `GET /packages/com.example.crm/customizations` lists `{"type":"dataset","name":"opportunity_metrics","basedOn":"4.0.0"}`. Dashboard 在途商机总额 710,000 → ¥177,500. 4. Directory tile 数据集: 已定制 1; dataset list: 仅看定制(1), row mark 已定制(CRM (minimal example)). 5. Measure 2 字段 金额 → 预期收入 (`expected_revenue`, formula), 发布: Pending changes entry and the dataset editor both read 「未发布。请检查度量「总金额」的「聚合方式」:详情中说明了需要修改的内容。」, Details holds the gate's line. 6. 覆盖标记 → 恢复软件包原版 → confirm: mark gone, 定制 back, customizations read empty; dashboard 在途商机总额 ¥710,000. </details> <details><summary>Gates (local, head aefff1f)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands; every one exited 0 (`check-adr-0087-registration`, `check-changeset-no-major`, `check-closing-keyword-parity`, `check-comment-mask-corpus`, `check-empty-changeset`, `check-sdui-manifest` and `--self-test`, `check:nul-bytes`, `check:published-files`, `check:type-check-coverage`, `check:type-check-debt`, `check:undeclared-dep-imports`, `check:test-source-alias`, `check:cross-package-test-inputs`, `pnpm --dir ui exec vitest run scripts/` (`Test Files 170 passed | 2 skipped`) and the rest). `--ran` reconciliation: `35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (a DERIVED zero — all 35 recorded an exit code and none of them is 3)`. `ui/scripts/check-new-cross-file-line-citations.mjs`: `0 new citation(s)`. The type-check lanes and the wide-population families are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYnE9YNkSZen7sijLA3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 490d136 commit 6f095b9

14 files changed

Lines changed: 682 additions & 28 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/data-objectstack': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
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
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **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.
13+
- **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".
14+
- **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.
15+
- **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.
16+
- **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.

‎ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
* - Restore deletes the pair's draft and row of that one member, and the
2525
* base's version reads back;
2626
* - a walled caller sees Customize disabled, and nothing is asked or sent.
27+
*
28+
* objectstack#30064 — a read-only base's dataset, in the same editor with the
29+
* type's registered designer (a probe here): the whole dataset written into
30+
* the pair, declared; what uses it listed from the references read before
31+
* anything is published, a failed read said as failed; a designer's blocking
32+
* issue holding the edit unsent; and a kept publish refusal shown at the item.
2733
*/
2834

2935
import '@testing-library/jest-dom/vitest';
@@ -90,6 +96,7 @@ const mockClient = vi.hoisted(() => ({
9096
srv.version += 1;
9197
return { success: true, version: `v${srv.version}`, state: 'draft' };
9298
}),
99+
references: vi.fn(async (_type: string, _name: string): Promise<unknown[]> => []),
93100
reset: vi.fn(async (type: string, name: string, opts: Record<string, unknown> = {}) => {
94101
srv.resets.push({ type, name, opts: clone(opts) });
95102
const map = opts.state === 'draft' ? srv.drafts : srv.rows;
@@ -115,6 +122,43 @@ import type { StudioBaseCustomization } from './StudioDesignSurface';
115122
import { ensureCustomizationPackage } from './packages-io';
116123
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
117124
import { registerDefaultMetadataSchemas } from '../metadata-admin/default-schemas';
125+
import {
126+
registerMetadataDefaultInspector,
127+
type MetadataDefaultInspectorProps,
128+
} from '../metadata-admin/default-inspector-registry';
129+
import type { StudioRefusal } from './metadataError';
130+
131+
/**
132+
* The dataset's designer slot: the draft it is handed, its read-only gate, one
133+
* edit (the second measure's aggregate) and one blocking report.
134+
*/
135+
function DesignerProbe({ draft, onPatch, onBlockingIssuesChange, readOnly }: MetadataDefaultInspectorProps) {
136+
const measures = Array.isArray(draft.measures) ? (draft.measures as Array<Record<string, unknown>>) : [];
137+
return (
138+
<div data-testid="designer-probe" data-readonly={String(readOnly)}>
139+
<p data-testid="designer-probe-aggregate">{String(measures[1]?.aggregate ?? '')}</p>
140+
<button
141+
type="button"
142+
data-testid="designer-probe-avg"
143+
disabled={readOnly}
144+
onClick={() => onPatch({ measures: measures.map((m, i) => (i === 1 ? { ...m, aggregate: 'avg' } : m)) })}
145+
>
146+
avg
147+
</button>
148+
<button
149+
type="button"
150+
data-testid="designer-probe-blank-dimension"
151+
disabled={readOnly}
152+
onClick={() => {
153+
onPatch({ dimensions: [{ name: 'stage' }] });
154+
onBlockingIssuesChange?.(1);
155+
}}
156+
>
157+
blank
158+
</button>
159+
</div>
160+
);
161+
}
118162

119163
/** The preview slot, printing the live draft it is handed. */
120164
function PreviewProbe({ draft }: { draft: Record<string, unknown> }) {
@@ -134,6 +178,37 @@ const WELCOME_EN = {
134178
_packageId: BASE,
135179
};
136180
const WELCOME_ZH = { ...WELCOME_EN, locale: 'zh-CN', subject: '欢迎使用 HotCRM', bodyHtml: '<p>你好 {{user.name}}</p>' };
181+
const METRICS = {
182+
name: 'opportunity_metrics',
183+
label: 'Opportunity Metrics',
184+
object: 'crm_opportunity',
185+
dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }],
186+
measures: [
187+
{ name: 'opp_count', label: 'Opportunities', aggregate: 'count' },
188+
{ name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount' },
189+
],
190+
_packageId: BASE,
191+
};
192+
/** What the references read answers for the dataset (`client.references`, mapped from the served `references[]`). */
193+
const USED_BY = [
194+
{
195+
fromType: 'dashboard',
196+
fromName: 'pipeline_dashboard',
197+
fromLabel: 'Pipeline Dashboard',
198+
path: 'widgets[].dataset',
199+
kind: 'dashboard dataset',
200+
value: 'opportunity_metrics',
201+
},
202+
{
203+
fromType: 'dashboard',
204+
fromName: 'pipeline_dashboard',
205+
fromLabel: 'Pipeline Dashboard',
206+
path: 'widgets[].compare.dataset',
207+
kind: 'dashboard dataset',
208+
value: 'opportunity_metrics',
209+
},
210+
{ fromType: 'report', fromName: 'won_by_stage', path: 'dataset', kind: 'report dataset', value: 'opportunity_metrics' },
211+
];
137212
const BUNDLE_ZH = {
138213
name: 'hotcrm_zh',
139214
label: 'HotCRM zh-CN',
@@ -146,6 +221,7 @@ beforeAll(() => {
146221
registerDefaultMetadataSchemas();
147222
registerMetadataPreview('email_template', PreviewProbe as never);
148223
registerMetadataPreview('translation', PreviewProbe as never);
224+
registerMetadataDefaultInspector('dataset', DesignerProbe);
149225
});
150226

151227
beforeEach(() => {
@@ -155,6 +231,7 @@ beforeEach(() => {
155231
at(srv.rows, BASE).set(slot('email_template', 'welcome', 'zh-CN'), clone(WELCOME_ZH));
156232
at(srv.rows, BASE).set(slot('translation', 'hotcrm_zh'), clone(BUNDLE_ZH));
157233
at(srv.rows, WRITABLE).set(slot('email_template', 'welcome', 'zh-CN'), { ...clone(WELCOME_ZH), _packageId: WRITABLE });
234+
at(srv.rows, BASE).set(slot('dataset', 'opportunity_metrics'), clone(METRICS));
158235
srv.pairExists = false;
159236
srv.ensure.length = 0;
160237
srv.saves.length = 0;
@@ -196,13 +273,15 @@ function Host({
196273
packageId = BASE,
197274
walled = false,
198275
listedPair = null,
276+
publishRefusal = null,
199277
}: {
200278
type: string;
201279
name: string;
202280
member?: string;
203281
packageId?: string;
204282
walled?: boolean;
205283
listedPair?: string | null;
284+
publishRefusal?: StudioRefusal | null;
206285
}) {
207286
const readOnly = packageId === BASE;
208287
const [pairId, setPairId] = React.useState<string | null>(listedPair);
@@ -228,6 +307,7 @@ function Host({
228307
readOnly={readOnly}
229308
customization={customization}
230309
basePath={`/studio/${packageId}/metadata`}
310+
publishRefusal={publishRefusal}
231311
/>
232312
);
233313
}
@@ -437,3 +517,104 @@ describe('Customize a read-only base’s translation bundle (objectstack#28569)'
437517
await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-bundle')).toBeNull());
438518
});
439519
});
520+
521+
describe('Customize a read-only base’s dataset (objectstack#30064)', () => {
522+
beforeEach(() => {
523+
mockClient.references.mockImplementation(async () => clone(USED_BY));
524+
});
525+
526+
it('opens the designer read-only, says the whole dataset is copied, and lists what uses it before anything is published', async () => {
527+
mount({ type: 'dataset', name: 'opportunity_metrics' });
528+
const probe = await screen.findByTestId('designer-probe', undefined, { timeout: 8000 });
529+
expect(probe).toHaveAttribute('data-readonly', 'true');
530+
expect(screen.getByTestId('designer-probe-aggregate')).toHaveTextContent('sum');
531+
expect(screen.getByTestId('studio-bundle-editor-whole-dataset')).toHaveTextContent(
532+
'Customizing a dataset copies HotCRM’s whole dataset into your customization',
533+
);
534+
const usedBy = await screen.findByTestId('studio-bundle-editor-used-by');
535+
await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded'));
536+
expect(mockClient.references).toHaveBeenCalledWith('dataset', 'opportunity_metrics');
537+
// One entry per referring item: the dashboard binding it twice is one dashboard.
538+
const items = within(usedBy).getAllByTestId('studio-bundle-editor-used-by-item');
539+
expect(items.map((el) => [el.getAttribute('data-type'), el.getAttribute('data-name')])).toEqual([
540+
['dashboard', 'pipeline_dashboard'],
541+
['report', 'won_by_stage'],
542+
]);
543+
expect(items[0]).toHaveTextContent('Pipeline Dashboard');
544+
expect(items[1]).toHaveTextContent('won_by_stage');
545+
expect(usedBy).toHaveTextContent('reaches the 2 items built on this dataset');
546+
expect(srv.saves).toEqual([]);
547+
});
548+
549+
it('Customize and an edit in the designer write the whole dataset into the pair’s draft, declared; the base is untouched', async () => {
550+
mount({ type: 'dataset', name: 'opportunity_metrics' });
551+
await screen.findByTestId('designer-probe', undefined, { timeout: 8000 });
552+
await customize();
553+
await waitFor(() => expect(screen.getByTestId('designer-probe')).toHaveAttribute('data-readonly', 'false'));
554+
fireEvent.click(screen.getByTestId('designer-probe-avg'));
555+
await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 5000 });
556+
expect(srv.ensure).toEqual([201]);
557+
const [save] = srv.saves;
558+
expect(save).toMatchObject({ type: 'dataset', name: 'opportunity_metrics' });
559+
expect(save.opts).toMatchObject({ mode: 'draft', packageId: PAIR, ifNoneMatch: '*' });
560+
const { _packageId: _dropped, ...whole } = clone(METRICS);
561+
expect(save.body).toEqual({
562+
...whole,
563+
measures: [whole.measures[0], { ...whole.measures[1], aggregate: 'avg' }],
564+
overrides: { package: BASE, basedOn: '4.0.0' },
565+
});
566+
expect(await screen.findByTestId('studio-override-chip')).toHaveTextContent('Overrides HotCRM');
567+
await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-dataset')).toBeNull());
568+
// What uses it stays in view until the change is published.
569+
expect(screen.getByTestId('studio-bundle-editor-used-by')).toBeInTheDocument();
570+
expect(srv.rows.get(BASE)?.get(slot('dataset', 'opportunity_metrics'))).toEqual(METRICS);
571+
});
572+
573+
it('holds an edit the designer reports as blocking: nothing is sent', async () => {
574+
mount({ type: 'dataset', name: 'opportunity_metrics' });
575+
await screen.findByTestId('designer-probe', undefined, { timeout: 8000 });
576+
await customize();
577+
await waitFor(() => expect(screen.getByTestId('designer-probe')).toHaveAttribute('data-readonly', 'false'));
578+
fireEvent.click(screen.getByTestId('designer-probe-blank-dimension'));
579+
await sleep(2500);
580+
expect(srv.ensure).toEqual([]);
581+
expect(srv.saves).toEqual([]);
582+
});
583+
584+
it('a references read that failed says so and asks again; it never reads as nothing using the dataset', async () => {
585+
mockClient.references.mockRejectedValueOnce(Object.assign(new Error('Service Unavailable'), { status: 503 }));
586+
mount({ type: 'dataset', name: 'opportunity_metrics' });
587+
const usedBy = await screen.findByTestId('studio-bundle-editor-used-by', undefined, { timeout: 8000 });
588+
await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'error'));
589+
expect(within(usedBy).getByRole('alert')).toHaveTextContent('could not be read');
590+
expect(usedBy).not.toHaveTextContent('Nothing uses this dataset');
591+
fireEvent.click(within(usedBy).getByTestId('studio-bundle-editor-used-by-retry'));
592+
await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded'));
593+
expect(within(usedBy).getAllByTestId('studio-bundle-editor-used-by-item')).toHaveLength(2);
594+
});
595+
596+
it('a read that answers no reference says nothing uses the dataset', async () => {
597+
mockClient.references.mockImplementation(async () => []);
598+
mount({ type: 'dataset', name: 'opportunity_metrics' });
599+
const usedBy = await screen.findByTestId('studio-bundle-editor-used-by', undefined, { timeout: 8000 });
600+
await waitFor(() => expect(usedBy).toHaveAttribute('data-state', 'loaded'));
601+
expect(usedBy).toHaveTextContent('Nothing uses this dataset yet');
602+
});
603+
604+
it('shows the refusal the header’s Publish kept for the dataset at the item, with the server’s text under Details', async () => {
605+
mount({
606+
type: 'dataset',
607+
name: 'opportunity_metrics',
608+
listedPair: PAIR,
609+
publishRefusal: {
610+
message: 'Not published. Check Aggregate of the measure “Total Amount”: Details say what to change.',
611+
target: null,
612+
input: 'Aggregate',
613+
detail: 'datasets.opportunity_metrics.measures[1].aggregate — aggregate "sum" over field "expected_revenue" is refused',
614+
},
615+
});
616+
const strip = await screen.findByTestId('studio-bundle-editor-publish-refusal', undefined, { timeout: 8000 });
617+
expect(within(strip).getByRole('alert')).toHaveTextContent('Check Aggregate of the measure “Total Amount”');
618+
expect(within(strip).getByTestId('studio-refusal-detail')).toHaveTextContent('expected_revenue');
619+
});
620+
});

0 commit comments

Comments
 (0)