Skip to content

Commit d0097af

Browse files
fix(app-shell): the Create View dialog binds a chart view to an ADR-0021 dataset of its object (objectui#11576) (#11580)
Fixes #11576 Clause-②: no The console's Create View dialog now authors the spec's ADR-0021 chart binding, `chart: { chartType, dataset, values, dimensions? }`, and nothing else. Until now it wrote `chart: { chartType, xAxisField, yAxisFields }`, which `@objectstack/spec` 17.6.0's strict `ListChartConfigSchema` refuses by name. So the platform's view write door refused every chart view the console created. Shape: option A of the seat's answer on the card (comment `5973755839`, amending claim `5973664751`), under triage `5973399361`. ## What users see today (ruling step 1, measured before any edit) Measured at objectui `main` `0903617` (= this branch's base). The real `ObjectDataPage` and the real dialog were mounted, with a real `MetadataClient` whose fetch replays the platform's view gate (the installed spec 17.6.0 `getMetadataTypeSchema('view')`) at the network boundary: - Picking Chart in "Save as view" and pressing Create sends `PUT …/meta/view/crm_deal.KEY?mode=draft` with `config.chart = {chartType: bar, xAxisField: stage, yAxisFields: [amount]}`. - The gate refuses it. Its ViewItem arm reports `unrecognized_keys [xAxisField, yAxisFields]` and `dataset` / `values` missing, and the client throws a **422**. - **The user sees nothing.** The dialog has already closed (its submit does not await the save). The page stays on `/data`, with no navigation, no toast and no alert. The only trace is `console.error('[ObjectDataPage] Failed to save view:')`. - `ObjectView.handleViewCreate` (the view tab bar's add button) has the same catch-and-log shape. That silent failure is a defect of its own and outlives this payload change. It is **not** addressed here: the seat files it as its own card. ## What changed - **`CreateViewDialog`.** The chart type's picks are now: - `chartType`, unchanged; - `dataset`: only the ADR-0021 datasets whose base `object` is the view's object, never the whole catalog; - `values`: one of the chosen dataset's measures, required, written as a one-element list; - `dimensions`: one of its dimensions, optional, shown only when the dataset declares any, and omitted from the payload when empty. - **How the dialog reads them.** It reads the catalog itself through `useDatasetCatalog()` / `useDatasetSemantics()`, the pair the dashboard widget editor binds with. A closed dialog, or one with no `objectDef`, fetches nothing. A dataset, measure or dimension name counts only while it is among the current options, so a stale or foreign name can neither open Create nor reach the payload. Choosing another dataset clears the measure and dimension picked from the last one. - **An object with no dataset.** The chart card is disabled through the dialog's existing "type unavailable" path. Its tooltip names the reason ("exposes no dataset") instead of the field wording, because a dataset is not a field. - **`xAxisField` / `yAxisFields` are gone from the dialog.** There is no dual spelling. - **Header comments corrected.** The "pure, no dataSource coupling" sentence now says what the dialog reads and when. The spec-alignment claim now points at the two pins that re-derive it for `chart`, and says in writing that no test drives the other types' payloads through the dialog against the spec (AGENTS.md #9). - **`useDatasetCatalog`.** `DatasetCatalogEntry` gains an optional `object`, the served document's base object; `toCatalogEntry` keeps it when served. No other consumer reads it. - **`@object-ui/i18n`.** The four axis keys (`console.objectView.xAxisField`, `xAxisFieldHelp`, `yAxisField`, `yAxisFieldHelp`) leave all ten packs; the dialog was their only reader. Eight keys label the new picks: `dataset`, `datasetHelp`, `chartMeasure`, `chartMeasureHelp`, `chartDimension`, `chartDimensionHelp`, `viewTypeUnavailableDataset`, `noDatasetMeasure`. Terms follow each pack's existing dashboard dataset vocabulary. `objectView-config-keys-retired-4730.test.ts` owns this namespace's arithmetic, so it moves with them: 123 retired, 102 surviving, 225 total. - **Changeset.** One, patch, for `@object-ui/app-shell` and `@object-ui/i18n`. **Clause-② stays `no`.** `CreateViewDialogProps` is unchanged. Neither `DatasetCatalogEntry` nor `useDatasetCatalog` (nor `CreateViewDialog` itself) is exported from `packages/app-shell/src/index.ts`, and the package's `exports` publishes only `.`. ## Pins - `CreateViewDialog.chartBinding-11576.test.tsx`: - the served dataset fixtures parse as spec `DatasetSchema` documents (control); - `toCatalogEntry` carries `object`; - the picker offers the `crm_deal` dataset and never the `crm_account` one; - it offers the chosen dataset's own measures and dimensions; - the chart card is disabled when the object exposes no dataset; - no catalog is fetched while the dialog is closed; - the payload is exactly the ADR-0021 block, with no legacy key; - `dimensions` is omitted when the dataset declares none; - **DOOR 1**, `ObjectDataPage.buildSaveAsViewSpec`: the payload parses against the 17.6.0 `ListViewSchema`, and its envelope passes `ViewItemSchema`; - **the save-door probe, turned around**: the same replayed view gate now answers **200** for the new payload, and the user lands on the new draft (`…/view/crm_deal.KEY?preview=draft`). - `ObjectView.createChartView-11576.test.tsx`: - **DOOR 2**, `ObjectView.handleViewCreate`: a chart view is created through the tab bar's add button with the real `ObjectView` and the real dialog. The body handed to `metadataClient.save` passes `ListViewSchema` (config) and `ViewItemSchema` (envelope), with no legacy key. - **Renders on the object page**: that saved config, mounted as the object's list view, reaches `ObjectChart` through the dataset branch (`dataset` / `values` / `dimensions`; no `objectName` / `aggregate`). ## Evidence **Red first, at base `0903617`:** - The probe above answered 422, with the user seeing nothing. - The two new pin files, run on the unmodified tree: `Tests 9 failed | 4 passed (13)`. Every failure is "no dataset / values picker exists", or (the unavailable-card case) "the old dialog reads no catalog". The 4 passes are the 3 fixture controls and "no catalog while closed", which the old dialog satisfies trivially. **Ablation**, at `5db856f`, run by objectstack's `scripts/ablation-replace.mjs` with an `EXIT INT TERM` restore trap: - The mutation plants `subConfig.xAxisField = 'stage'` into the dialog's payload: anchor 1 to 0, blob `a8cc632c` to `6b44f4a0`, "mutation landed". - Predicted before the run: 5 red, 9 green. Observed: `Tests 5 failed | 9 passed (14)`. - Red, on the spec's own refusal: the payload case, the no-dimensions case (`refused by the spec: unrecognized_keys`), DOOR 1 (`unrecognized_keys`), the 2xx door (`expected 422 to be 200`) and DOOR 2 (`ListViewSchema` refusal). - Green: the fixture and catalog controls, the four picker cases, and the render case. - Restored by blob hash: `a8cc632c` == HEAD blob, `git diff HEAD` empty, marker count 0. **Green**, every run below at `5db856f` (`git rev-parse --short HEAD`): - `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0. - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n type-check`: both echoed `type-check: Done`. `tsc -p tsconfig.test.json --listFilesOnly` counts both new pins (app-shell) and the 4730 pin (i18n) in the checked program. - Narrowed `pnpm exec vitest run --maxWorkers=2` over 140 files: every `CreateViewDialog*`, `ObjectDataPage*` and `ObjectView*` suite in app-shell, every suite naming the catalog reader, and the whole `packages/i18n/` (all-locales parity included). Result: `Test Files 140 passed (140)`, `Tests 2012 passed | 13 skipped (2025)`. This is a declared narrowing: the rest of app-shell's suites are CI's. - Gates, all exit 0: - `check:control-bytes` (`OK`); - `check:new-line-citations` (`VERDICT … 0 new citation(s)`); - `check:changeset-claims` (report-only); - `check:pending-changeset-literals` (`No test source names a pending changeset`); - `check:i18n-keys` (every call-site key resolves against the en pack); - `check:i18n-drift` (`No en value changed in this range`); - `check:i18n-designer-parity`; - `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`; - `check:test-path-roots`; - `check:metadata-write-doors`; - `check:unreferenced-sources`; - `node scripts/check-changeset-presence.mjs` (`15 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)`). - Bundle gates, after a `@object-ui/console` build (`check:eager-closure`, `check:eager-locale-catalogues`), exit 0: - aggregate eager closure 3311.7 KB against a 3330.4 KB budget; - all four per-chunk ceilings and chunk membership held; - exactly one locale catalogue (en) eager. - Lint, a proven narrowing: - Population: the root `eslint.config.js` `files: ['**/*.{ts,tsx}']` blocks; the packages carry no config of their own. - Count: `--format json` linted 15 files with 0 errors. - Invariance: the config sets no `parserOptions.project` / `projectService` (no type-aware linting), and no rule under `eslint-rules/` reads another file, so this diff cannot move a verdict in an untouched file. - Warnings: the two changed source files carry the same warnings as at base (8 and 8, 1 and 1). The 9 others are `no-explicit-any` in the two new test files. ## Acceptance notes - `UnifiedViewConfig.chart` in `@object-ui/types` (`packages/types/src/designer.ts`) still declares `chartType`, `xAxisField`, `yAxisFields`, `aggregation`, `series`, `config` and `filter`, and no `dataset` / `values`. It is exported from the package index and has zero consumers in this repo. Noted, not changed here. - Render-side legacy chart readers still read the inline axes, which the 17.6.0 write door now refuses at save, so only rows saved before the cutover can reach them. Noted, not changed here; the ruling scopes the removal to the dialog. The readers: - `ObjectView`'s non-dataset chart branch (the "Translate here" block over `xAxisField` / `yAxisFields` / `aggregation`); - `ListView`'s `case 'chart'` fallback ("Legacy inline aggregate (deprecated …) Kept as a fallback"). - The new picks' labels reach `t()` through `REQUIRED_FIELDS_BY_TYPE`'s `i18nKey`, so `check:i18n-dead-keys` (report-only) lists six of them as needs-review. That is the same tier the dialog's existing keys (`chartType`, `groupByField`, `startDateField`, …) already sit in. - Dataset and member labels come from `useDatasetCatalog`, which resolves an author's `I18nLabel` in the designer locale (`en-US` or `zh-CN`), as the dashboard and report editors show them. A de or ja session therefore sees a dataset's English label where the author declared one. - No test drives the kanban, calendar, gallery, map or tree payloads through this dialog against the spec. The corrected header says so rather than claiming alignment. Write route: `objectstack-fleet[bot]` through the fleet-write relay. Session `https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2`. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f54dca commit d0097af

16 files changed

Lines changed: 947 additions & 122 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
The console's Create View dialog now creates chart views the platform accepts (objectui#11576).
7+
8+
The dialog wrote a chart view as `chart: { chartType, xAxisField, yAxisFields }`, the inline
9+
axes `@objectstack/spec` retired under ADR-0021. The spec's list chart block refuses those two
10+
keys by name and requires `dataset` and `values`, so the platform's view write door refused
11+
every chart view created from the console, from both "Save as view" and the view tab bar's
12+
add button. The dialog had already closed, so the user saw nothing happen.
13+
14+
The chart type now binds a semantic-layer dataset instead:
15+
16+
- the dialog offers the ADR-0021 datasets whose base `object` is the view's own object, and
17+
only those;
18+
- it then offers the chosen dataset's measures (`values`) and, when the dataset declares any,
19+
its dimensions (`dimensions`, optional);
20+
- an object that exposes no dataset shows the chart type as unavailable, with its own reason.
21+
22+
The payload is `chart: { chartType, dataset, values, dimensions? }`, with no second spelling.
23+
The dataset catalog reader the dashboard and report editors use now also carries each dataset's
24+
base `object`.
25+
26+
In `@object-ui/i18n`, the four `console.objectView` axis labels (`xAxisField`, `xAxisFieldHelp`,
27+
`yAxisField`, `yAxisFieldHelp`) leave all ten packs, and eight keys label the dataset, measure
28+
and dimension picks (`dataset`, `datasetHelp`, `chartMeasure`, `chartMeasureHelp`,
29+
`chartDimension`, `chartDimensionHelp`, `viewTypeUnavailableDataset`, `noDatasetMeasure`).
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11576: the Create View dialog authors the spec's ADR-0021 chart
5+
* binding (`chartType` + `dataset` + `values`, plus `dimensions` where the
6+
* chosen dataset declares any), and nothing else.
7+
*
8+
* ## The defect this pins
9+
*
10+
* The dialog wrote a chart view as `chart: { chartType, xAxisField,
11+
* yAxisFields }`. `@objectstack/spec`'s `ListChartConfigSchema` is a strict
12+
* object that requires `dataset` and `values` and refuses those two keys by
13+
* name, so the platform's view write door refused every chart view the console
14+
* created. Measured before the fix through the "Save as view" door (the last
15+
* describe below, which replays the spec's own view gate at the network
16+
* boundary): the PUT answered 422, the dialog had already closed, the page did
17+
* not move, and nothing told the user.
18+
*
19+
* ## The source of the dataset list
20+
*
21+
* The picker reads `useDatasetCatalog` (every dataset the metadata client
22+
* lists) and offers only the datasets whose base `object` is this view's
23+
* object, then resolves the chosen dataset's measures and dimensions through
24+
* `useDatasetSemantics`, the dashboard widget editor's pair. The fixtures
25+
* below serve one dataset for this object and one for ANOTHER object; the
26+
* foreign one must never be offered.
27+
*
28+
* ## What the pins assert, and the direction written before the run
29+
*
30+
* The payload pins assert the spec's verdict (`ListViewSchema`, and the record
31+
* gate `ViewItemSchema` on the envelope) and the absence of the two legacy
32+
* keys. Predicted on the unmodified tree: every case that opens the chart
33+
* picker goes RED (there is no dataset picker, and the payload carries the
34+
* axis keys), the door case goes RED on its 422, and the fixture control stays
35+
* GREEN in both worlds. The `ObjectView.handleViewCreate` door and the render
36+
* on the object page are pinned next door, in
37+
* `ObjectView.createChartView-11576.test.tsx`.
38+
*/
39+
40+
import * as React from 'react';
41+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
42+
import { render, cleanup, fireEvent, waitFor, screen, within } from '@testing-library/react';
43+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
44+
import { DatasetSchema, ListViewSchema, ViewItemSchema } from '@objectstack/spec/ui';
45+
import { getMetadataTypeSchema } from '@objectstack/spec/kernel';
46+
import { MetadataClient } from '@object-ui/data-objectstack';
47+
48+
/** A served dataset over THIS view's object: one dimension, two measures. */
49+
const DEAL_METRICS = {
50+
name: 'deal_metrics',
51+
label: 'Deal metrics',
52+
object: 'crm_deal',
53+
dimensions: [{ name: 'stage', label: 'Stage', field: 'stage' }],
54+
measures: [
55+
{ name: 'total_amount', label: 'Total amount', aggregate: 'sum', field: 'amount' },
56+
{ name: 'deal_count', label: 'Deals', aggregate: 'count' },
57+
],
58+
};
59+
/** A served dataset over ANOTHER object: never offered on a `crm_deal` view. */
60+
const ACCOUNT_METRICS = {
61+
name: 'account_metrics',
62+
label: 'Account metrics',
63+
object: 'crm_account',
64+
dimensions: [{ name: 'industry', field: 'industry' }],
65+
measures: [{ name: 'account_count', aggregate: 'count' }],
66+
};
67+
/** A served dataset over this object that declares no dimension. */
68+
const DEAL_TOTALS = {
69+
name: 'deal_totals',
70+
label: 'Deal totals',
71+
object: 'crm_deal',
72+
dimensions: [],
73+
measures: [{ name: 'pipeline_value', aggregate: 'sum', field: 'amount' }],
74+
};
75+
76+
let servedDatasets: Array<Record<string, unknown>> = [];
77+
const puts: Array<{ url: string; body: any; status: number }> = [];
78+
const datasetLists: string[] = [];
79+
80+
/**
81+
* The metadata wire, replayed: `GET …/meta/dataset` lists the served
82+
* documents, `GET …/meta/dataset/NAME` answers one, and `PUT …/meta/view/NAME`
83+
* runs the installed spec's view gate, answering the dispatcher's 422
84+
* `INVALID_METADATA` envelope on refusal, as the platform's write door does.
85+
*/
86+
function wire() {
87+
return vi.fn(async (input: string, init?: RequestInit) => {
88+
const path = new URL(input, 'http://localhost').pathname;
89+
if (init?.method === 'PUT' && path.includes('/meta/view/')) {
90+
const body = JSON.parse(String(init.body));
91+
const verdict = getMetadataTypeSchema('view')!.safeParse(body);
92+
puts.push({ url: input, body, status: verdict.success ? 200 : 422 });
93+
return verdict.success
94+
? new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } })
95+
: new Response(
96+
JSON.stringify({
97+
success: false,
98+
error: { code: 'INVALID_METADATA', message: 'view failed validation', details: { code: 'INVALID_METADATA', issues: verdict.error.issues } },
99+
}),
100+
{ status: 422, headers: { 'content-type': 'application/json' } },
101+
);
102+
}
103+
if (path.endsWith('/meta/dataset')) {
104+
datasetLists.push(path);
105+
return new Response(JSON.stringify(servedDatasets), { status: 200, headers: { 'content-type': 'application/json' } });
106+
}
107+
const one = path.match(/\/meta\/dataset\/([^/]+)$/);
108+
if (one) {
109+
const doc = servedDatasets.find((d) => d.name === decodeURIComponent(one[1]));
110+
return new Response(JSON.stringify(doc ?? null), { status: doc ? 200 : 404, headers: { 'content-type': 'application/json' } });
111+
}
112+
return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } });
113+
});
114+
}
115+
let wireFetch: ReturnType<typeof wire>;
116+
/** One client per test, as `useMetadataClient` memoises one per mount. */
117+
let client: MetadataClient;
118+
119+
vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({
120+
...(await importOriginal<Record<string, unknown>>()),
121+
useMetadataClient: () => client,
122+
}));
123+
vi.mock('@object-ui/permissions', async (importOriginal) => {
124+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
125+
return {
126+
...actual,
127+
usePermissions: () => ({
128+
check: () => ({ allowed: true }), checkField: () => true, getFieldPermissions: () => [],
129+
getRowFilter: () => undefined, getObjectApiOperations: () => undefined, roles: [], isLoaded: false,
130+
hasCapabilities: () => true, can: () => true, cannot: () => false,
131+
}),
132+
useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }),
133+
};
134+
});
135+
vi.mock('@object-ui/auth', async (importOriginal) => ({
136+
...(await importOriginal<Record<string, unknown>>()),
137+
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
138+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
139+
createAuthenticatedFetch: () => vi.fn(),
140+
}));
141+
vi.mock('@object-ui/plugin-list', async (importOriginal) => ({
142+
...(await importOriginal<typeof import('@object-ui/plugin-list')>()),
143+
ListView: () => null,
144+
}));
145+
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
146+
147+
import { CreateViewDialog } from './CreateViewDialog';
148+
import { toCatalogEntry } from './metadata-admin/previews/useDatasetCatalog';
149+
import { ObjectDataPage, buildSaveAsViewSpec } from './ObjectDataPage';
150+
import { viewEnvelope } from './runtime-metadata-persistence';
151+
import { ExpressionProvider } from '../providers/ExpressionProvider';
152+
153+
const DEAL = {
154+
name: 'crm_deal',
155+
label: 'Deal',
156+
managedBy: 'platform',
157+
fields: {
158+
name: { type: 'text', label: 'Name' },
159+
stage: { type: 'select', label: 'Stage', options: [{ value: 'open', label: 'Open' }] },
160+
amount: { type: 'number', label: 'Amount' },
161+
},
162+
};
163+
const COLUMNS = ['name', 'stage', 'amount'];
164+
const LEGACY_KEYS = ['xAxisField', 'yAxisFields'];
165+
166+
const select = (key: string) => screen.getByTestId(`create-view-required-${key}`) as HTMLSelectElement;
167+
const optionValues = (el: HTMLSelectElement) => Array.from(el.options).map((o) => o.value);
168+
169+
/** Open the dialog on `crm_deal`, pick Chart, and wait for this object's catalog. */
170+
async function openChartPicker(onCreate = vi.fn()) {
171+
render(<CreateViewDialog open onOpenChange={() => {}} onCreate={onCreate} objectDef={DEAL} />);
172+
await waitFor(() => expect((screen.getByTestId('create-view-type-chart') as HTMLButtonElement).disabled).toBe(false));
173+
fireEvent.click(screen.getByTestId('create-view-type-chart'));
174+
return onCreate;
175+
}
176+
177+
/** Pick `measure` in the open chart picker and press Create. */
178+
async function pickMeasureAndCreate(measure: string) {
179+
await waitFor(() => expect(optionValues(select('values'))).toContain(measure));
180+
fireEvent.change(select('values'), { target: { value: measure } });
181+
const submit = screen.getByTestId('create-view-submit') as HTMLButtonElement;
182+
await waitFor(() => expect(submit.disabled).toBe(false));
183+
fireEvent.click(submit);
184+
}
185+
186+
/** Pick `measure`, submit, and return the payload the dialog handed `onCreate`. */
187+
async function submitWithMeasure(onCreate: ReturnType<typeof vi.fn>, measure: string) {
188+
await pickMeasureAndCreate(measure);
189+
expect(onCreate).toHaveBeenCalledTimes(1);
190+
return onCreate.mock.calls[0][0] as Record<string, any>;
191+
}
192+
193+
/** Assert the spec accepts `body`, surfacing its issues when it does not. */
194+
function expectAccepted(schema: { safeParse: (v: unknown) => any }, body: unknown) {
195+
const verdict = schema.safeParse(body);
196+
expect(verdict.success, `refused by the spec: ${JSON.stringify(verdict.error?.issues)}\nbody=${JSON.stringify(body)}`).toBe(true);
197+
}
198+
199+
beforeEach(() => {
200+
cleanup();
201+
servedDatasets = [DEAL_METRICS, ACCOUNT_METRICS];
202+
puts.length = 0;
203+
datasetLists.length = 0;
204+
wireFetch = wire();
205+
client = new MetadataClient({ baseUrl: 'http://localhost', fetch: wireFetch as any });
206+
});
207+
afterEach(() => {
208+
vi.restoreAllMocks();
209+
});
210+
211+
describe('the served fixtures are datasets the spec accepts (control, objectui#11576)', () => {
212+
it.each([DEAL_METRICS, ACCOUNT_METRICS, DEAL_TOTALS])('$name parses as a DatasetSchema document', (doc) => {
213+
expectAccepted(DatasetSchema, doc);
214+
});
215+
});
216+
217+
describe('the catalog reader carries each dataset\'s base object (objectui#11576)', () => {
218+
it('keeps the served `object`, and leaves it absent when none is served', () => {
219+
expect(toCatalogEntry(DEAL_METRICS, 'en-US')?.object).toBe('crm_deal');
220+
const bare = toCatalogEntry({ name: 'names_only' }, 'en-US');
221+
expect(bare).not.toBeNull();
222+
expect(bare && 'object' in bare).toBe(false);
223+
});
224+
});
225+
226+
describe('the chart picker offers only the datasets this object exposes (objectui#11576)', () => {
227+
it('lists the dataset over crm_deal, never the one over crm_account', async () => {
228+
await openChartPicker();
229+
await waitFor(() => expect(optionValues(select('dataset'))).toContain('deal_metrics'));
230+
expect(optionValues(select('dataset'))).not.toContain('account_metrics');
231+
// The one dataset this object exposes is picked for the user, as a single
232+
// eligible field is for every other view type.
233+
expect(select('dataset').value).toBe('deal_metrics');
234+
});
235+
236+
it('offers the chosen dataset\'s own measures and dimensions', async () => {
237+
await openChartPicker();
238+
await waitFor(() => expect(optionValues(select('values'))).toEqual(['', 'total_amount', 'deal_count']));
239+
expect(optionValues(select('dimensions'))).toEqual(['', 'stage']);
240+
expect(screen.queryByTestId('create-view-required-xAxisField')).toBeNull();
241+
expect(screen.queryByTestId('create-view-required-yAxisFields')).toBeNull();
242+
});
243+
244+
it('disables the chart type when the object exposes no dataset, through the "type unavailable" path', async () => {
245+
servedDatasets = [ACCOUNT_METRICS];
246+
render(<CreateViewDialog open onOpenChange={() => {}} onCreate={vi.fn()} objectDef={DEAL} />);
247+
await waitFor(() => expect(datasetLists.length).toBeGreaterThan(0));
248+
const card = screen.getByTestId('create-view-type-chart') as HTMLButtonElement;
249+
await waitFor(() => expect(card.disabled).toBe(true));
250+
expect(card).toHaveAttribute('title', 'console.objectView.viewTypeUnavailableDataset');
251+
expect(within(card).getByText('console.objectView.viewTypeUnavailableShort')).toBeTruthy();
252+
});
253+
254+
it('fetches no catalog while the dialog is closed', async () => {
255+
render(<CreateViewDialog open={false} onOpenChange={() => {}} onCreate={vi.fn()} objectDef={DEAL} />);
256+
await new Promise((r) => setTimeout(r, 50));
257+
expect(datasetLists).toEqual([]);
258+
});
259+
});
260+
261+
describe('the chart payload is the spec ListView chart block (objectui#11576)', () => {
262+
it('writes chartType + dataset + values + dimensions, and no legacy axis key', async () => {
263+
const payload = await submitWithMeasure(await openChartPicker(), 'total_amount');
264+
expect(payload.type).toBe('chart');
265+
expect(payload.chart).toEqual({
266+
chartType: 'bar',
267+
dataset: 'deal_metrics',
268+
values: ['total_amount'],
269+
dimensions: ['stage'],
270+
});
271+
for (const key of LEGACY_KEYS) expect(Object.keys(payload.chart)).not.toContain(key);
272+
});
273+
274+
it('omits `dimensions` when the chosen dataset declares none', async () => {
275+
servedDatasets = [DEAL_TOTALS];
276+
const payload = await submitWithMeasure(await openChartPicker(), 'pipeline_value');
277+
expectAccepted(ListViewSchema, buildSaveAsViewSpec(payload, COLUMNS, []));
278+
expect(screen.queryByTestId('create-view-required-dimensions')).toBeNull();
279+
expect(payload.chart).toEqual({ chartType: 'bar', dataset: 'deal_totals', values: ['pipeline_value'] });
280+
});
281+
282+
it('DOOR 1, ObjectDataPage.buildSaveAsViewSpec: the spec ListViewSchema and the ViewItem gate accept it', async () => {
283+
const payload = await submitWithMeasure(await openChartPicker(), 'total_amount');
284+
const spec = buildSaveAsViewSpec(payload, COLUMNS, []);
285+
expectAccepted(ListViewSchema, spec);
286+
expectAccepted(ViewItemSchema, viewEnvelope('crm_deal', spec, { name: payload.name, label: payload.label }));
287+
for (const key of LEGACY_KEYS) expect(Object.keys(spec.chart)).not.toContain(key);
288+
});
289+
});
290+
291+
function Where() {
292+
const loc = useLocation();
293+
return <div data-testid="where">{loc.pathname + loc.search}</div>;
294+
}
295+
296+
describe('the save door answers 2xx for a chart view created through "Save as view" (objectui#11576)', () => {
297+
it('PUTs the ADR-0021 block, the spec view gate accepts it, and the user lands on the new draft', async () => {
298+
render(
299+
<ExpressionProvider user={{ id: 'u1', name: 'Ada' }}>
300+
<MemoryRouter initialEntries={['/apps/demo/crm_deal/data']}>
301+
<Routes>
302+
<Route path="/apps/:appName/:objectName/data" element={<ObjectDataPage dataSource={{ find: vi.fn(async () => ({ data: [] })) }} objects={[DEAL]} />} />
303+
<Route path="*" element={<Where />} />
304+
</Routes>
305+
</MemoryRouter>
306+
</ExpressionProvider>,
307+
);
308+
fireEvent.click(screen.getByTestId('object-data-save-as-view'));
309+
await waitFor(() => expect((screen.getByTestId('create-view-type-chart') as HTMLButtonElement).disabled).toBe(false));
310+
fireEvent.click(screen.getByTestId('create-view-type-chart'));
311+
await pickMeasureAndCreate('total_amount');
312+
await waitFor(() => expect(puts).toHaveLength(1));
313+
// The door's verdict first: before this card it answered 422 here.
314+
expect(puts[0].status).toBe(200);
315+
expect(puts[0].body.config.chart).toEqual({
316+
chartType: 'bar',
317+
dataset: 'deal_metrics',
318+
values: ['total_amount'],
319+
dimensions: ['stage'],
320+
});
321+
await waitFor(() => expect(screen.getByTestId('where').textContent).toMatch(/\/view\/crm_deal\.[a-z0-9_]+\?preview=draft$/));
322+
});
323+
});

0 commit comments

Comments
 (0)