From 3cc912993bf3f1e6134dc8e39fbbb5c8513134a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:58:33 +0000 Subject: [PATCH 1/2] fix(app-shell): the Create View dialog binds a chart view to an ADR-0021 dataset of its object (objectui#11576) The dialog wrote `chart: { chartType, xAxisField, yAxisFields }`, which the spec's strict list chart block refuses by name, so the view write door refused every chart view created from the console (both doors). The chart picks are now `dataset` (only datasets whose base `object` is the view's object, through useDatasetCatalog), `values` and optional `dimensions` (the chosen dataset's members, through useDatasetSemantics). An object with no dataset disables the chart type through the existing unavailable path. The four axis i18n keys leave all ten packs; eight dataset-binding keys join. The catalog entry carries the served base `object`. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- ...11576-create-view-chart-dataset-binding.md | 29 ++ ...eateViewDialog.chartBinding-11576.test.tsx | 322 ++++++++++++++++++ .../app-shell/src/views/CreateViewDialog.tsx | 313 +++++++++++++---- .../ObjectView.createChartView-11576.test.tsx | 241 +++++++++++++ .../previews/useDatasetCatalog.ts | 9 + ...bjectView-config-keys-retired-4730.test.ts | 33 +- packages/i18n/src/locales/ar.ts | 12 +- packages/i18n/src/locales/de.ts | 12 +- packages/i18n/src/locales/en.ts | 12 +- packages/i18n/src/locales/es.ts | 12 +- packages/i18n/src/locales/fr.ts | 12 +- packages/i18n/src/locales/ja.ts | 12 +- packages/i18n/src/locales/ko.ts | 12 +- packages/i18n/src/locales/pt.ts | 12 +- packages/i18n/src/locales/ru.ts | 12 +- packages/i18n/src/locales/zh.ts | 12 +- 16 files changed, 945 insertions(+), 122 deletions(-) create mode 100644 .changeset/11576-create-view-chart-dataset-binding.md create mode 100644 packages/app-shell/src/views/CreateViewDialog.chartBinding-11576.test.tsx create mode 100644 packages/app-shell/src/views/ObjectView.createChartView-11576.test.tsx diff --git a/.changeset/11576-create-view-chart-dataset-binding.md b/.changeset/11576-create-view-chart-dataset-binding.md new file mode 100644 index 0000000000..9b8b44f6a6 --- /dev/null +++ b/.changeset/11576-create-view-chart-dataset-binding.md @@ -0,0 +1,29 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +The console's Create View dialog now creates chart views the platform accepts (objectui#11576). + +The dialog wrote a chart view as `chart: { chartType, xAxisField, yAxisFields }`, the inline +axes `@objectstack/spec` retired under ADR-0021. The spec's list chart block refuses those two +keys by name and requires `dataset` and `values`, so the platform's view write door refused +every chart view created from the console, from both "Save as view" and the view tab bar's +add button. The dialog had already closed, so the user saw nothing happen. + +The chart type now binds a semantic-layer dataset instead: + +- the dialog offers the ADR-0021 datasets whose base `object` is the view's own object, and + only those; +- it then offers the chosen dataset's measures (`values`) and, when the dataset declares any, + its dimensions (`dimensions`, optional); +- an object that exposes no dataset shows the chart type as unavailable, with its own reason. + +The payload is `chart: { chartType, dataset, values, dimensions? }`, with no second spelling. +The dataset catalog reader the dashboard and report editors use now also carries each dataset's +base `object`. + +In `@object-ui/i18n`, the four `console.objectView` axis labels (`xAxisField`, `xAxisFieldHelp`, +`yAxisField`, `yAxisFieldHelp`) leave all ten packs, and eight keys label the dataset, measure +and dimension picks (`dataset`, `datasetHelp`, `chartMeasure`, `chartMeasureHelp`, +`chartDimension`, `chartDimensionHelp`, `viewTypeUnavailableDataset`, `noDatasetMeasure`). diff --git a/packages/app-shell/src/views/CreateViewDialog.chartBinding-11576.test.tsx b/packages/app-shell/src/views/CreateViewDialog.chartBinding-11576.test.tsx new file mode 100644 index 0000000000..b7e7dcc314 --- /dev/null +++ b/packages/app-shell/src/views/CreateViewDialog.chartBinding-11576.test.tsx @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11576: the Create View dialog authors the spec's ADR-0021 chart + * binding (`chartType` + `dataset` + `values`, plus `dimensions` where the + * chosen dataset declares any), and nothing else. + * + * ## The defect this pins + * + * The dialog wrote a chart view as `chart: { chartType, xAxisField, + * yAxisFields }`. `@objectstack/spec`'s `ListChartConfigSchema` is a strict + * object that requires `dataset` and `values` and refuses those two keys by + * name, so the platform's view write door refused every chart view the console + * created. Measured before the fix through the "Save as view" door (the last + * describe below, which replays the spec's own view gate at the network + * boundary): the PUT answered 422, the dialog had already closed, the page did + * not move, and nothing told the user. + * + * ## The source of the dataset list + * + * The picker reads `useDatasetCatalog` (every dataset the metadata client + * lists) and offers only the datasets whose base `object` is this view's + * object, then resolves the chosen dataset's measures and dimensions through + * `useDatasetSemantics`, the dashboard widget editor's pair. The fixtures + * below serve one dataset for this object and one for ANOTHER object; the + * foreign one must never be offered. + * + * ## What the pins assert, and the direction written before the run + * + * The payload pins assert the spec's verdict (`ListViewSchema`, and the record + * gate `ViewItemSchema` on the envelope) and the absence of the two legacy + * keys. Predicted on the unmodified tree: every case that opens the chart + * picker goes RED (there is no dataset picker, and the payload carries the + * axis keys), the door case goes RED on its 422, and the fixture control stays + * GREEN in both worlds. The `ObjectView.handleViewCreate` door and the render + * on the object page are pinned next door, in + * `ObjectView.createChartView-11576.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, waitFor, screen, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { DatasetSchema, ListViewSchema, ViewItemSchema } from '@objectstack/spec/ui'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +/** A served dataset over THIS view's object: one dimension, two measures. */ +const DEAL_METRICS = { + name: 'deal_metrics', + label: 'Deal metrics', + object: 'crm_deal', + dimensions: [{ name: 'stage', label: 'Stage', field: 'stage' }], + measures: [ + { name: 'total_amount', label: 'Total amount', aggregate: 'sum', field: 'amount' }, + { name: 'deal_count', label: 'Deals', aggregate: 'count' }, + ], +}; +/** A served dataset over ANOTHER object: never offered on a `crm_deal` view. */ +const ACCOUNT_METRICS = { + name: 'account_metrics', + label: 'Account metrics', + object: 'crm_account', + dimensions: [{ name: 'industry', field: 'industry' }], + measures: [{ name: 'account_count', aggregate: 'count' }], +}; +/** A served dataset over this object that declares no dimension. */ +const DEAL_TOTALS = { + name: 'deal_totals', + label: 'Deal totals', + object: 'crm_deal', + dimensions: [], + measures: [{ name: 'pipeline_value', aggregate: 'sum', field: 'amount' }], +}; + +let servedDatasets: Array> = []; +const puts: Array<{ url: string; body: any; status: number }> = []; +const datasetLists: string[] = []; + +/** + * The metadata wire, replayed: `GET …/meta/dataset` lists the served + * documents, `GET …/meta/dataset/NAME` answers one, and `PUT …/meta/view/NAME` + * runs the installed spec's view gate, answering the dispatcher's 422 + * `INVALID_METADATA` envelope on refusal, as the platform's write door does. + */ +function wire() { + return vi.fn(async (input: string, init?: RequestInit) => { + const path = new URL(input, 'http://localhost').pathname; + if (init?.method === 'PUT' && path.includes('/meta/view/')) { + const body = JSON.parse(String(init.body)); + const verdict = getMetadataTypeSchema('view')!.safeParse(body); + puts.push({ url: input, body, status: verdict.success ? 200 : 422 }); + return verdict.success + ? new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } }) + : new Response( + JSON.stringify({ + success: false, + error: { code: 'INVALID_METADATA', message: 'view failed validation', details: { code: 'INVALID_METADATA', issues: verdict.error.issues } }, + }), + { status: 422, headers: { 'content-type': 'application/json' } }, + ); + } + if (path.endsWith('/meta/dataset')) { + datasetLists.push(path); + return new Response(JSON.stringify(servedDatasets), { status: 200, headers: { 'content-type': 'application/json' } }); + } + const one = path.match(/\/meta\/dataset\/([^/]+)$/); + if (one) { + const doc = servedDatasets.find((d) => d.name === decodeURIComponent(one[1])); + return new Response(JSON.stringify(doc ?? null), { status: doc ? 200 : 404, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); +} +let wireFetch: ReturnType; +/** One client per test, as `useMetadataClient` memoises one per mount. */ +let client: MetadataClient; + +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => client, +})); +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => ({ + check: () => ({ allowed: true }), checkField: () => true, getFieldPermissions: () => [], + getRowFilter: () => undefined, getObjectApiOperations: () => undefined, roles: [], isLoaded: false, + hasCapabilities: () => true, can: () => true, cannot: () => false, + }), + useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }), + }; +}); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: () => null, +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { CreateViewDialog } from './CreateViewDialog'; +import { toCatalogEntry } from './metadata-admin/previews/useDatasetCatalog'; +import { ObjectDataPage, buildSaveAsViewSpec } from './ObjectDataPage'; +import { viewEnvelope } from './runtime-metadata-persistence'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const DEAL = { + name: 'crm_deal', + label: 'Deal', + managedBy: 'platform', + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'select', label: 'Stage', options: [{ value: 'open', label: 'Open' }] }, + amount: { type: 'number', label: 'Amount' }, + }, +}; +const COLUMNS = ['name', 'stage', 'amount']; +const LEGACY_KEYS = ['xAxisField', 'yAxisFields']; + +const select = (key: string) => screen.getByTestId(`create-view-required-${key}`) as HTMLSelectElement; +const optionValues = (el: HTMLSelectElement) => Array.from(el.options).map((o) => o.value); + +/** Open the dialog on `crm_deal`, pick Chart, and wait for this object's catalog. */ +async function openChartPicker(onCreate = vi.fn()) { + render( {}} onCreate={onCreate} objectDef={DEAL} />); + await waitFor(() => expect((screen.getByTestId('create-view-type-chart') as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(screen.getByTestId('create-view-type-chart')); + return onCreate; +} + +/** Pick `measure` in the open chart picker and press Create. */ +async function pickMeasureAndCreate(measure: string) { + await waitFor(() => expect(optionValues(select('values'))).toContain(measure)); + fireEvent.change(select('values'), { target: { value: measure } }); + const submit = screen.getByTestId('create-view-submit') as HTMLButtonElement; + await waitFor(() => expect(submit.disabled).toBe(false)); + fireEvent.click(submit); +} + +/** Pick `measure`, submit, and return the payload the dialog handed `onCreate`. */ +async function submitWithMeasure(onCreate: ReturnType, measure: string) { + await pickMeasureAndCreate(measure); + expect(onCreate).toHaveBeenCalledTimes(1); + return onCreate.mock.calls[0][0] as Record; +} + +/** Assert the spec accepts `body`, surfacing its issues when it does not. */ +function expectAccepted(schema: { safeParse: (v: unknown) => any }, body: unknown) { + const verdict = schema.safeParse(body); + expect(verdict.success, `refused by the spec: ${JSON.stringify(verdict.error?.issues)}\nbody=${JSON.stringify(body)}`).toBe(true); +} + +beforeEach(() => { + cleanup(); + servedDatasets = [DEAL_METRICS, ACCOUNT_METRICS]; + puts.length = 0; + datasetLists.length = 0; + wireFetch = wire(); + client = new MetadataClient({ baseUrl: 'http://localhost', fetch: wireFetch as any }); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('the served fixtures are datasets the spec accepts (control, objectui#11576)', () => { + it.each([DEAL_METRICS, ACCOUNT_METRICS, DEAL_TOTALS])('$name parses as a DatasetSchema document', (doc) => { + expectAccepted(DatasetSchema, doc); + }); +}); + +describe('the catalog reader carries each dataset\'s base object (objectui#11576)', () => { + it('keeps the served `object`, and leaves it absent when none is served', () => { + expect(toCatalogEntry(DEAL_METRICS, 'en-US')?.object).toBe('crm_deal'); + const bare = toCatalogEntry({ name: 'names_only' }, 'en-US'); + expect(bare).not.toBeNull(); + expect(bare && 'object' in bare).toBe(false); + }); +}); + +describe('the chart picker offers only the datasets this object exposes (objectui#11576)', () => { + it('lists the dataset over crm_deal, never the one over crm_account', async () => { + await openChartPicker(); + await waitFor(() => expect(optionValues(select('dataset'))).toContain('deal_metrics')); + expect(optionValues(select('dataset'))).not.toContain('account_metrics'); + // The one dataset this object exposes is picked for the user, as a single + // eligible field is for every other view type. + expect(select('dataset').value).toBe('deal_metrics'); + }); + + it('offers the chosen dataset\'s own measures and dimensions', async () => { + await openChartPicker(); + await waitFor(() => expect(optionValues(select('values'))).toEqual(['', 'total_amount', 'deal_count'])); + expect(optionValues(select('dimensions'))).toEqual(['', 'stage']); + expect(screen.queryByTestId('create-view-required-xAxisField')).toBeNull(); + expect(screen.queryByTestId('create-view-required-yAxisFields')).toBeNull(); + }); + + it('disables the chart type when the object exposes no dataset, through the "type unavailable" path', async () => { + servedDatasets = [ACCOUNT_METRICS]; + render( {}} onCreate={vi.fn()} objectDef={DEAL} />); + await waitFor(() => expect(datasetLists.length).toBeGreaterThan(0)); + const card = screen.getByTestId('create-view-type-chart') as HTMLButtonElement; + await waitFor(() => expect(card.disabled).toBe(true)); + expect(card).toHaveAttribute('title', 'console.objectView.viewTypeUnavailableDataset'); + expect(within(card).getByText('console.objectView.viewTypeUnavailableShort')).toBeTruthy(); + }); + + it('fetches no catalog while the dialog is closed', async () => { + render( {}} onCreate={vi.fn()} objectDef={DEAL} />); + await new Promise((r) => setTimeout(r, 50)); + expect(datasetLists).toEqual([]); + }); +}); + +describe('the chart payload is the spec ListView chart block (objectui#11576)', () => { + it('writes chartType + dataset + values + dimensions, and no legacy axis key', async () => { + const payload = await submitWithMeasure(await openChartPicker(), 'total_amount'); + expect(payload.type).toBe('chart'); + expect(payload.chart).toEqual({ + chartType: 'bar', + dataset: 'deal_metrics', + values: ['total_amount'], + dimensions: ['stage'], + }); + for (const key of LEGACY_KEYS) expect(Object.keys(payload.chart)).not.toContain(key); + }); + + it('omits `dimensions` when the chosen dataset declares none', async () => { + servedDatasets = [DEAL_TOTALS]; + const payload = await submitWithMeasure(await openChartPicker(), 'pipeline_value'); + expect(screen.queryByTestId('create-view-required-dimensions')).toBeNull(); + expect(payload.chart).toEqual({ chartType: 'bar', dataset: 'deal_totals', values: ['pipeline_value'] }); + expectAccepted(ListViewSchema, buildSaveAsViewSpec(payload, COLUMNS, [])); + }); + + it('DOOR 1, ObjectDataPage.buildSaveAsViewSpec: the spec ListViewSchema and the ViewItem gate accept it', async () => { + const payload = await submitWithMeasure(await openChartPicker(), 'total_amount'); + const spec = buildSaveAsViewSpec(payload, COLUMNS, []); + expectAccepted(ListViewSchema, spec); + expectAccepted(ViewItemSchema, viewEnvelope('crm_deal', spec, { name: payload.name, label: payload.label })); + for (const key of LEGACY_KEYS) expect(Object.keys(spec.chart)).not.toContain(key); + }); +}); + +function Where() { + const loc = useLocation(); + return
{loc.pathname + loc.search}
; +} + +describe('the save door answers 2xx for a chart view created through "Save as view" (objectui#11576)', () => { + it('PUTs the ADR-0021 block, the spec view gate accepts it, and the user lands on the new draft', async () => { + render( + + + + ({ data: [] })) }} objects={[DEAL]} />} /> + } /> + + + , + ); + fireEvent.click(screen.getByTestId('object-data-save-as-view')); + await waitFor(() => expect((screen.getByTestId('create-view-type-chart') as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(screen.getByTestId('create-view-type-chart')); + await pickMeasureAndCreate('total_amount'); + await waitFor(() => expect(puts).toHaveLength(1)); + expect(puts[0].body.config.chart).toEqual({ + chartType: 'bar', + dataset: 'deal_metrics', + values: ['total_amount'], + dimensions: ['stage'], + }); + expect(puts[0].status).toBe(200); + await waitFor(() => expect(screen.getByTestId('where').textContent).toMatch(/\/view\/crm_deal\.[a-z0-9_]+\?preview=draft$/)); + }); +}); diff --git a/packages/app-shell/src/views/CreateViewDialog.tsx b/packages/app-shell/src/views/CreateViewDialog.tsx index 1071e31323..a8cc632c0a 100644 --- a/packages/app-shell/src/views/CreateViewDialog.tsx +++ b/packages/app-shell/src/views/CreateViewDialog.tsx @@ -3,19 +3,27 @@ * * Step 1: User picks a view type from a visual grid of cards (icon + label * + short description). Selection is highlighted. - * Step 2: For view types that need them, the user picks the required - * configuration fields (e.g. the group-by field for kanban, the start-date - * field for calendar/timeline/gantt, lat/lng for map, image for gallery). - * The Create button stays disabled until every required field is set. + * Step 2: For view types that need them, the user picks the type's + * configuration (e.g. the group-by field for kanban, the start-date field for + * calendar/timeline/gantt, lat/lng for map, image for gallery; for chart, an + * ADR-0021 dataset of this object and that dataset's measure, plus one of its + * dimensions when it declares any). The Create button stays disabled until + * every required pick is set; an optional pick (the chart dimension) never + * gates it. * Step 3: The user enters a display label (required, defaults to "Grid 1" etc.) * and a machine `name` (the metadata key). The name auto-fills from the label * via `slugify`; for non-Latin (CJK/…) labels slugify yields nothing, so the * field is left empty and the user must type a key before Create enables * (#2767 P5 — no more silent random names). * - * On submit, calls `onCreate({ type, label, name, [type]: {...required fields} })`. - * The parent is responsible for actually persisting the view (we keep this - * component pure — no dataSource coupling). + * On submit, calls `onCreate({ type, label, name, [type]: {...picked config} })`. + * The parent persists the view; this dialog WRITES nothing. It does READ: + * while open, for the chart type, it lists the datasets through the metadata + * client (`useDatasetCatalog`, keeping only those whose base `object` is this + * object) and resolves the chosen dataset's measures and dimensions + * (`useDatasetSemantics`), the same pair the dashboard widget editor binds + * with (objectui#11576). A closed dialog, or one with no `objectDef`, fetches + * nothing. */ import { useEffect, useMemo, useState } from 'react'; @@ -33,6 +41,11 @@ import { import { useObjectTranslation } from '@object-ui/i18n'; import type { ListViewVisualization } from '@object-ui/core'; import { slugify } from './metadata-admin/createDerive.js'; +import { + useDatasetCatalog, + useDatasetSemantics, + type DatasetCatalogEntry, +} from './metadata-admin/previews/useDatasetCatalog.js'; import { deriveFieldOptions, isImageLikeField, @@ -60,9 +73,11 @@ export interface CreateViewDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** - * Called with a fully-formed view config payload. Required type-specific - * fields are nested under their type key (e.g. `kanban.groupByField`), - * matching the @objectstack/spec NamedListView shape. + * Called with a fully-formed view config payload. The type-specific picks + * are nested under their type key (e.g. `kanban.groupByField`, + * `chart.dataset`), the block of that name on the spec's `ListViewSchema`; + * see `REQUIRED_FIELDS_BY_TYPE` below for which of those blocks a test + * re-derives against the spec. */ onCreate: (config: Record & { type: string; label: string; name: string }) => void; /** Used to suggest unique default names like "Grid 2" if "Grid 1" exists. */ @@ -136,18 +151,31 @@ function suggestName(typeLabel: string, existing: Set): string { // Required-field schema per view type // --------------------------------------------------------------------------- // -// Each entry describes the sub-config keys a view type *must* have set before -// a record can be persisted. Aligned with the @objectstack/spec NamedListView -// shape (kanban.groupByField, calendar.startDateField, gantt.startDateField + -// endDateField, gallery.imageField, map.latitudeField + longitudeField, -// chart.chartType + xAxisField + yAxisFields). +// Each entry describes a sub-config key a view type writes under its own +// block (kanban.groupByField, calendar.startDateField, gantt.startDateField + +// endDateField, gallery.coverField, map.latitudeField + longitudeField, +// chart.chartType + dataset + values (+ dimensions), tree.parentField). The +// blocks are meant to be the spec ListView blocks of those names. What +// re-derives that is a parse against `ListViewSchema`, and only `chart` has +// one: `CreateViewDialog.chartBinding-11576.test.tsx` (the "Save as view" +// door) and `ObjectView.createChartView-11576.test.tsx` (the add-view door). +// No test drives the other types' payloads through this dialog against the +// spec (stated per AGENTS.md #9, not implied). // -// `filter` narrows the dropdown options so users only see appropriate fields -// (e.g. only date fields for date selectors; only image/file/url fields for -// gallery covers; only lat-named numeric fields for map latitude). +// `chart` was the one block that had drifted: it wrote `xAxisField` / +// `yAxisFields`, the pre-ADR-0021 inline axes the spec's strict +// `ListChartConfigSchema` refuses by name, so the platform's view write door +// refused every chart view this dialog created (objectui#11576). It now +// writes the ADR-0021 binding only; there is no second spelling. // -// `kind: 'enum'` renders a static-options select instead of a field selector, -// used for chart.chartType. +// `kind` says where a select's options come from: +// - 'field' (default): the object's fields, narrowed by `filter` (e.g. only +// date fields for date selectors; only image/file/url fields for gallery +// covers; only lat-named numeric fields for map latitude); +// - 'enum': the static `enumOptions` (chart.chartType); +// - 'dataset': the ADR-0021 datasets whose base `object` is this object, +// never the whole catalog; +// - 'measure' / 'dimension': the CHOSEN dataset's own members, by name. // // `preferred` provides a list of name substrings to auto-pick when the user // hasn't chosen anything yet — improves first-run quality of common cases @@ -164,12 +192,38 @@ interface RequiredFieldDef { filter?: (f: FieldOption) => boolean; /** Preferred name substrings for smart-default auto-pick. */ preferred?: readonly string[]; - /** When 'enum', renders a static options select with `enumOptions`. */ - kind?: 'field' | 'enum'; + /** Where the options come from (see the comment above). Default 'field'. */ + kind?: 'field' | 'enum' | 'dataset' | 'measure' | 'dimension'; /** Static options for `kind: 'enum'` selects. */ enumOptions?: ReadonlyArray<{ value: string; i18nKey: string }>; /** Default value to seed when the dialog first opens (for enum). */ defaultValue?: string; + /** The spec declares a list here: the one picked name is written as `[name]`. */ + list?: boolean; + /** + * Not required: no asterisk, never gates Create, omitted from the payload + * when empty. The row is shown only while it has options to offer. + */ + optional?: boolean; +} + +/** One option of a config select: the stored value and the text shown. */ +interface PickOption { + value: string; + label: string; +} + +/** + * The catalog a dialog that cannot use one asks for: none. Passed to + * `useDatasetCatalog` as its pre-resolved list while the dialog is closed or + * has no object, so mounting it fetches nothing. A module constant, so the + * hook sees one list rather than a fresh array per render. + */ +const NO_DATASETS: DatasetCatalogEntry[] = []; + +/** `LABEL (name)`, or the bare name when the dataset declares no other label. */ +function datasetOptionLabel(d: DatasetCatalogEntry): string { + return d.label && d.label !== d.name ? `${d.label} (${d.name})` : d.name; } const CHART_TYPE_OPTIONS = [ @@ -274,17 +328,25 @@ const REQUIRED_FIELDS_BY_TYPE: Record = { defaultValue: 'bar', }, { - key: 'xAxisField', - i18nKey: 'console.objectView.xAxisField', - helpI18nKey: 'console.objectView.xAxisFieldHelp', - filter: (f) => f.type === 'select' || f.type === 'boolean' || f.type === 'date' || f.type === 'text', - preferred: KANBAN_GROUP_PREFERRED, + key: 'dataset', + i18nKey: 'console.objectView.dataset', + helpI18nKey: 'console.objectView.datasetHelp', + kind: 'dataset', }, { - key: 'yAxisFields', - i18nKey: 'console.objectView.yAxisField', - helpI18nKey: 'console.objectView.yAxisFieldHelp', - filter: (f) => f.type === 'number', + key: 'values', + i18nKey: 'console.objectView.chartMeasure', + helpI18nKey: 'console.objectView.chartMeasureHelp', + kind: 'measure', + list: true, + }, + { + key: 'dimensions', + i18nKey: 'console.objectView.chartDimension', + helpI18nKey: 'console.objectView.chartDimensionHelp', + kind: 'dimension', + list: true, + optional: true, }, ], tree: [ @@ -372,16 +434,69 @@ export function CreateViewDialog({ // Required fields for the currently selected type const requiredFields = REQUIRED_FIELDS_BY_TYPE[selectedType] ?? []; - /** True when this required field has at least one eligible option (or is an - * enum/has no filter). Used by both submit-gating and type-grid disabling. */ + // ── The chart's ADR-0021 dataset binding (objectui#11576) ──────────────── + // Called unconditionally (stable hook order). A closed dialog, or one with + // no object to filter by, hands the hook an empty pre-resolved list, so it + // fetches nothing; an open one lists the catalog, and the dialog keeps only + // the datasets whose base `object` is this object. Never the whole list. + const objectName = objectDef?.name; + const catalog = useDatasetCatalog(open && objectName ? undefined : NO_DATASETS); + const objectDatasets = useMemo( + () => (objectName ? catalog.datasets.filter((d) => d.object === objectName) : []), + [catalog.datasets, objectName], + ); + const chosenDataset = requiredFieldValues['chart.dataset'] ?? ''; + // Only a dataset this object exposes is ever resolved, so a stale or foreign + // name can never feed the measure and dimension pickers. + const boundDataset = objectDatasets.some((d) => d.name === chosenDataset) ? chosenDataset : undefined; + const semantics = useDatasetSemantics(boundDataset, catalog); + // What the effects below re-run on: the NAMES offered, never the identity + // of a memoised list (AGENTS.md #10). + const datasetKey = objectDatasets.map((d) => d.name).join('\u0000'); + const measureKey = semantics.measures.map((m) => m.name).join('\u0000'); + const dimensionKey = semantics.dimensions.map((d) => d.name).join('\u0000'); + + /** The object's fields a `kind: 'field'` select may offer. */ + const eligibleFields = (rf: RequiredFieldDef): FieldOption[] => + rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions; + + /** The options a config select offers, from where its `kind` says. */ + const optionsFor = (rf: RequiredFieldDef): PickOption[] => { + switch (rf.kind) { + case 'enum': + return (rf.enumOptions ?? []).map((o) => ({ value: o.value, label: t(o.i18nKey) })); + case 'dataset': + return objectDatasets.map((d) => ({ value: d.name, label: datasetOptionLabel(d) })); + case 'measure': + return semantics.measures.map((m) => ({ value: m.name, label: m.label ?? m.name })); + case 'dimension': + return semantics.dimensions.map((d) => ({ value: d.name, label: d.label ?? d.name })); + default: + return eligibleFields(rf); + } + }; + + /** True when this pick can be satisfied on this object. Used by the type + * grid to disable a card whose configuration the object cannot supply. */ const hasEligible = (rf: RequiredFieldDef): boolean => { - if (rf.kind === 'enum') return (rf.enumOptions?.length ?? 0) > 0; - if (!objectDef) return true; // no objectDef → skip eligibility checks - const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions; - return eligible.length > 0; + switch (rf.kind) { + case 'enum': + return (rf.enumOptions?.length ?? 0) > 0; + case 'dataset': + // No object, or none of its datasets: blocked. While the catalog is + // still loading the answer is not in, so the card is not greyed yet. + return !!objectName && (catalog.loading || objectDatasets.length > 0); + case 'measure': + case 'dimension': + // Judged against the chosen dataset, in the picker itself. + return true; + default: + if (!objectDef) return true; // no objectDef → skip eligibility checks + return eligibleFields(rf).length > 0; + } }; - /** Map: viewType -> reason ("missing field type") if it can't be created. + /** Map: viewType -> the pick blocking it, if it can't be created. * Computed once per render so the type grid knows which cards to disable. */ const typeUnavailability = useMemo(() => { const out: Record = {}; @@ -392,15 +507,46 @@ export function CreateViewDialog({ }); return out; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [types, fieldOptions, objectDef]); + }, [types, fieldOptions, objectDef, objectName, datasetKey, catalog.loading]); const getRequiredValue = (key: string) => requiredFieldValues[`${selectedType}.${key}`] ?? ''; const setRequiredValue = (key: string, value: string) => setRequiredFieldValues(prev => ({ ...prev, [`${selectedType}.${key}`]: value })); + /** + * The value a pick actually holds. A dataset, measure or dimension name + * counts only while it is among the options offered now: a name left from + * another dataset, or one the catalog no longer lists, reads as unset, so it + * can neither open Create nor reach the payload. + */ + const pickedValue = (rf: RequiredFieldDef): string => { + const v = getRequiredValue(rf.key); + if (!v) return ''; + if (rf.kind === 'dataset' || rf.kind === 'measure' || rf.kind === 'dimension') { + return optionsFor(rf).some((o) => o.value === v) ? v : ''; + } + return v; + }; + + /** Set a pick. Choosing a dataset clears the measure and dimension picked + * from the previous one: they are names OF a dataset. */ + const choose = (rf: RequiredFieldDef, value: string) => { + if (rf.kind !== 'dataset') { + setRequiredValue(rf.key, value); + return; + } + setRequiredFieldValues((prev) => { + const next = { ...prev, [`${selectedType}.${rf.key}`]: value }; + requiredFields + .filter((f) => f.kind === 'measure' || f.kind === 'dimension') + .forEach((f) => { next[`${selectedType}.${f.key}`] = ''; }); + return next; + }); + }; + const trimmed = label.trim(); const isDuplicate = trimmed.length > 0 && existingSet.has(trimmed); - const allRequiredFilled = requiredFields.every(f => getRequiredValue(f.key).length > 0); + const allRequiredFilled = requiredFields.every(f => f.optional || pickedValue(f).length > 0); // Machine name: same snake_case shape slugify emits and the metadata `name` // pattern (`^[a-z_][a-z0-9_]*$`) accepts. Empty → prompt; malformed → hint. const NAME_RE = /^[a-z_][a-z0-9_]*$/; @@ -409,43 +555,45 @@ export function CreateViewDialog({ const nameInvalid = nameKey.length > 0 && !NAME_RE.test(nameKey); const canSubmit = trimmed.length > 0 && !isDuplicate && allRequiredFilled && !nameMissing && !nameInvalid; - // Auto-pick a sensible default for any required field. Runs whenever the - // type or available options change, but only fills slots the user hasn't - // touched yet. Strategy: + // Auto-pick a sensible default for any pick. Runs whenever the type or the + // available options change, but only fills slots that hold nothing yet. + // Strategy: // - enum: seed with `defaultValue` (e.g. chart → 'bar') - // - field with single eligible option: pick it (saves a click) - // - field with multiple eligibles + `preferred`: pick the first match + // - a single eligible option, of any kind: pick it (saves a click) + // - several eligible object fields + `preferred`: pick the first match // in the preferred list (e.g. kanban groupBy → status > stage > …) useEffect(() => { if (requiredFields.length === 0) return; requiredFields.forEach((rf) => { - if (getRequiredValue(rf.key).length > 0) return; + if (pickedValue(rf).length > 0) return; if (rf.kind === 'enum') { if (rf.defaultValue) setRequiredValue(rf.key, rf.defaultValue); return; } - const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions; + const eligible = optionsFor(rf); if (eligible.length === 0) return; if (eligible.length === 1) { setRequiredValue(rf.key, eligible[0].value); return; } - const picked = pickPreferredField(eligible, rf.preferred ?? []); + if (rf.kind && rf.kind !== 'field') return; // `preferred` names object fields only + const picked = pickPreferredField(eligibleFields(rf), rf.preferred ?? []); if (picked) setRequiredValue(rf.key, picked); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedType, fieldOptions]); + }, [selectedType, fieldOptions, datasetKey, measureKey, dimensionKey]); const handleSubmit = () => { if (!canSubmit) return; - // Bundle required fields under their type-specific sub-key, matching the - // NamedListView spec (e.g. { type: "kanban", kanban: { groupByField: ... } }). - // `yAxisFields` is wrapped in an array per spec (chart supports multi-y). + // Bundle the picks under their type-specific sub-key, the spec ListView + // block of that name (e.g. { type: "kanban", kanban: { groupByField } }). + // A `list` pick is written as a one-element array: the chart's `values` + // and `dimensions` are lists in the spec. const subConfig: Record = {}; requiredFields.forEach((rf) => { - const v = getRequiredValue(rf.key); + const v = pickedValue(rf); if (!v) return; - subConfig[rf.key] = rf.key === 'yAxisFields' ? [v] : v; + subConfig[rf.key] = rf.list ? [v] : v; }); const payload: Record & { type: string; label: string; name: string } = { type: selectedType, @@ -477,8 +625,12 @@ export function CreateViewDialog({ const selected = type === selectedType; const blocker = typeUnavailability[type]; const disabled = !!blocker; + // A dataset is not a field, so a chart card blocked on one says so + // in its own words rather than "no field that can serve as". const disabledTitle = disabled - ? t('console.objectView.viewTypeUnavailable', { field: t(blocker!.i18nKey) }) + ? blocker!.kind === 'dataset' + ? t('console.objectView.viewTypeUnavailableDataset') + : t('console.objectView.viewTypeUnavailable', { field: t(blocker!.i18nKey) }) : undefined; return (