From affc832f76f8b86a1864261fa7a250bb4a876c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:23:30 +0000 Subject: [PATCH] fix(components): element:number shows a "no object named" state instead of a silent dash An element:number that authors an aggregate and names neither properties.object nor dataSource.object now draws a short, muted, localized notice (element.number.noObject, in all ten packs) instead of painting the empty dash. Only authored absence qualifies: a binding that names an object keeps its loading and error panels while its view resolves or after it fails, and a node with no aggregate keeps the dash. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10951-element-number-no-object-state.md | 22 +++ content/docs/guide/data-source.md | 4 +- ...elementNumber.noObjectState-10951.test.tsx | 162 ++++++++++++++++++ ...tNumber.noObjectStateLocale-10951.test.tsx | 67 ++++++++ .../src/renderers/basic/elements.tsx | 19 ++ packages/i18n/src/locales/ar.ts | 5 + packages/i18n/src/locales/de.ts | 5 + packages/i18n/src/locales/en.ts | 8 + packages/i18n/src/locales/es.ts | 5 + packages/i18n/src/locales/fr.ts | 5 + packages/i18n/src/locales/ja.ts | 5 + packages/i18n/src/locales/ko.ts | 5 + packages/i18n/src/locales/pt.ts | 5 + packages/i18n/src/locales/ru.ts | 5 + packages/i18n/src/locales/zh.ts | 5 + 15 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 .changeset/10951-element-number-no-object-state.md create mode 100644 packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx create mode 100644 packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx diff --git a/.changeset/10951-element-number-no-object-state.md b/.changeset/10951-element-number-no-object-state.md new file mode 100644 index 0000000000..966623f638 --- /dev/null +++ b/.changeset/10951-element-number-no-object-state.md @@ -0,0 +1,22 @@ +--- +'@object-ui/components': patch +'@object-ui/i18n': patch +--- + +fix(components): an `element:number` that asks for an aggregate and names no object says so instead of painting a silent dash + +Since `element:number` accepts its object from the node-level `dataSource` +binding (objectui#10909), `object` is no longer a required input, and the +manifest has no way to say "one of `object` and `dataSource.object`". A node +that authors an `aggregate` with neither therefore passes the html tier with no +diagnostic, and the renderer used to paint "—", which reads like a real empty +value. + +The renderer now draws a short muted notice in that case: "No object named: set +object or dataSource.object." It is renderer chrome, so it reads the locale +packs (`element.number.noObject`, added to all ten packs) and speaks the session +language. It does not throw and it queries nothing. + +Only authored absence draws it. A binding that names an object keeps its own +panels while its `view` is resolving or after it failed to resolve, and a node +with no `aggregate` still paints the dash exactly as before. diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index 883c1afc2a..a1c41e7854 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -291,7 +291,9 @@ dropped, and a filter refused while combining them shows the configuration-error panel instead of a count. On `element:number`, `{ "dataSource": { "object": "contact" }, "properties": { "aggregate": "count" } }` is a complete metric; its `sort` and `limit` are not read, because an aggregate -has no ordering and a capped count would be a wrong number. +has no ordering and a capped count would be a wrong number. An `element:number` +that sets `aggregate` but names no object in either place (no `properties.object`, +no `dataSource.object`) shows a short "no object named" notice instead of a count. On `record:related_list` and `record:line_items` the composed filter is AND-combined with the parent relationship condition, never substituted for it: a diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx new file mode 100644 index 0000000000..d95a69f100 --- /dev/null +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx @@ -0,0 +1,162 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `element:number` with an `aggregate` and NO object draws a "no object + * named" state instead of the silent dash (objectui#10951). + * + * PR objectui#10944 (objectui#10909) registered the block through + * `elementDataSourceBlock` so `dataSource.object` could supply the object, and + * `object` stopped being `required`. The manifest has no way to say "one of + * `object` and `dataSource.object`", so a node carrying NEITHER now passes the + * html tier without a diagnostic, and the renderer painted "—" with nothing to + * tell the author why. + * + * What these rows pin: + * + * - an aggregate with neither object: the notice, not the dash, and no query; + * - a `dataSource` that names no object is still authored absence; + * - controls, each able to fail: `properties.object` alone and + * `dataSource.object` alone aggregate as before; a binding whose `view` is + * still resolving, or failed to resolve, keeps its own panel and never + * shows the new state; a node with no `aggregate` keeps today's dash. + * + * No `I18nProvider` is mounted in this file, so the notice is the English + * `defaultValue` (the provider leg is `elementNumber.noObjectStateLocale-10951` + * — its own FILE, because `createI18n` installs a module-global instance that + * would leak into every later provider-less render here). + * + * Driven through the real `SchemaRenderer` and this package's registrations, + * under the `AdapterCtx` provider the renderer reads its adapter from. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +// Registers every `element:*` renderer at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../../../renderers'; + +afterEach(cleanup); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +const NOTICE_ID = 'element-number-no-object'; +/** Written out, not read from the pack: a pin must not agree by construction. */ +const NOTICE_EN = 'No object named: set object or dataSource.object.'; + +/** + * An adapter that can aggregate and can list saved views. `views` decides what + * `getObjectSchema` answers; `pending` makes it never settle, which holds a + * named `view` in its `loading` state for the whole test. + */ +function makeAdapter(opts: { pending?: boolean } = {}) { + return { + aggregate: vi.fn(async (..._args: unknown[]) => [{ count: 7 }]), + find: vi.fn(async (..._args: unknown[]) => ({ data: [], total: 0 })), + getObjectSchema: vi.fn((name: string) => + opts.pending + ? new Promise(() => {}) + : Promise.resolve({ name, fields: {}, listViews: {} }), + ), + }; +} + +function mount(schema: Record, adapter: object) { + return render( + + + , + ); +} + +describe('element:number — an aggregate naming no object shows the "no object named" state (objectui#10951)', () => { + it('neither properties.object nor dataSource.object: the notice replaces the dash, and nothing is queried', async () => { + const adapter = makeAdapter(); + mount({ type: 'element:number', id: 'n', properties: { aggregate: 'count' } }, adapter); + await settle(); + + const notice = screen.getByTestId(NOTICE_ID); + expect(notice.textContent).toBe(NOTICE_EN); + expect(screen.queryByText('—')).toBeNull(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('a dataSource that names no object is still authored absence', async () => { + const adapter = makeAdapter(); + mount( + { type: 'element:number', id: 'n', dataSource: { object: '' }, properties: { aggregate: 'sum', field: 'amount' } }, + adapter, + ); + await settle(); + + expect(screen.getByTestId(NOTICE_ID).textContent).toBe(NOTICE_EN); + expect(screen.queryByText('—')).toBeNull(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + }); +}); + +describe('element:number — controls: every form that names an object is unchanged (objectui#10951)', () => { + it('control: properties.object alone aggregates and paints the value', async () => { + const adapter = makeAdapter(); + mount({ type: 'element:number', id: 'n', properties: { object: 'contact', aggregate: 'count' } }, adapter); + + await waitFor(() => expect(screen.getByText('7')).toBeTruthy()); + expect(adapter.aggregate.mock.calls.map((c) => c[0])).toEqual(['contact']); + expect(screen.queryByTestId(NOTICE_ID)).toBeNull(); + }); + + it('control: dataSource.object alone aggregates and paints the value', async () => { + const adapter = makeAdapter(); + mount( + { type: 'element:number', id: 'n', dataSource: { object: 'contact' }, properties: { aggregate: 'count' } }, + adapter, + ); + + await waitFor(() => expect(screen.getByText('7')).toBeTruthy()); + expect(adapter.aggregate.mock.calls.map((c) => c[0])).toEqual(['contact']); + expect(screen.queryByTestId(NOTICE_ID)).toBeNull(); + }); + + it('control: a binding whose view is still resolving keeps the loading panel, not the new state', async () => { + const adapter = makeAdapter({ pending: true }); + mount( + { type: 'element:number', id: 'n', dataSource: { object: 'contact', view: 'hot' }, properties: { aggregate: 'count' } }, + adapter, + ); + await settle(); + + expect(screen.getByTestId('element-number-resolving-view')).toBeTruthy(); + expect(screen.queryByTestId(NOTICE_ID)).toBeNull(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + }); + + it('control: a binding whose view failed to resolve keeps the error panel, not the new state', async () => { + const adapter = makeAdapter(); + mount( + { type: 'element:number', id: 'n', dataSource: { object: 'contact', view: 'nope' }, properties: { aggregate: 'count' } }, + adapter, + ); + + await waitFor(() => expect(screen.getByTestId('element-number-datasource-error')).toBeTruthy()); + expect(screen.queryByTestId(NOTICE_ID)).toBeNull(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + }); + + it('control: no aggregate and no object keeps the dash it paints today', async () => { + const adapter = makeAdapter(); + mount({ type: 'element:number', id: 'n', properties: { format: 'number' } }, adapter); + await settle(); + + expect(screen.getByText('—')).toBeTruthy(); + expect(screen.queryByTestId(NOTICE_ID)).toBeNull(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx new file mode 100644 index 0000000000..4852b3f0b5 --- /dev/null +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx @@ -0,0 +1,67 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `element:number` "no object named" notice speaks the session language + * (objectui#10951). + * + * The notice is renderer chrome, so it reads the locale packs through + * `useObjectTranslation` (`element.number.noObject`) rather than carrying an + * English literal. Two locales, chosen for what each can decide: `zh`, a + * non-Latin pack where a value equal to `en` would be decidable evidence of an + * untranslated string, and `de`, where the German words are named outright. + * Values are literals, not read back from the pack, so the pin cannot agree + * with an empty pack by construction. + * + * Its own FILE: `createI18n` installs its instance as react-i18next's + * module-global default and it survives `cleanup()`, so the provider-less + * English leg (`elementNumber.noObjectState-10951.test.tsx`) must not share a + * file with these renders. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, screen, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRenderer } from '@object-ui/react'; +// Registers every `element:*` renderer at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../../../renderers'; + +afterEach(cleanup); + +const NO_OBJECT = { type: 'element:number', id: 'n', properties: { aggregate: 'count' } }; +const NOTICE_ID = 'element-number-no-object'; + +function renderIn(language: string) { + return render( + + + , + ); +} + +describe('element:number "no object named" notice — localized (objectui#10951)', () => { + it('renders the Chinese sentence under zh', async () => { + renderIn('zh'); + // `waitFor`, not `findBy`: a lazily loaded catalogue may land after the + // first paint, and the element exists (in English) before it does. + await waitFor(() => + expect(screen.getByTestId(NOTICE_ID).textContent).toBe('未指定对象:请设置 object 或 dataSource.object。'), + ); + }); + + it('renders the German sentence under de', async () => { + renderIn('de'); + await waitFor(() => + expect(screen.getByTestId(NOTICE_ID).textContent).toBe( + 'Kein Objekt angegeben: Legen Sie object oder dataSource.object fest.', + ), + ); + }); +}); diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index 3ab0d9a745..6c2e326522 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -411,6 +411,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) { // source the aggregate reads from. const dataBinding = useElementDataSource(schema, adapter); const composed = dataBinding.composed; + const { t } = useObjectTranslation(); // The filter this metric aggregates over. With no binding it is the node's // own `filter` exactly as authored, so the `properties` form is unchanged. // With one, the node's own filter is AND-combined with the binding's (which @@ -543,6 +544,24 @@ function ElementNumberRenderer({ schema }: { schema: any }) { if (dataBinding.status === 'loading') { return ; } + // objectui#10951 — an aggregate that names no object at all, in either + // place. `object` stopped being `required` when the binding became a second + // way to supply it (objectui#10944), and the manifest cannot say "one of the + // two", so the html tier no longer reports this node: say it here rather + // than paint the dash. Only AUTHORED absence qualifies (`absent` = no + // binding naming an object); a binding whose view is still resolving or + // failed to resolve is answered by the two panels above. + if (props.aggregate && !props.object && dataBinding.status === 'absent') { + return ( +
+ {t('element.number.noObject', { defaultValue: 'No object named: set object or dataSource.object.' })} +
+ ); + } return (
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index d91a43d17d..467903d299 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3801,6 +3801,11 @@ const ar = { actionsEmptyBody: "لا توجد حزمة مثبّتة تعلن عن إجراء في هذا النشر. الإجراءات التي تنشئها بنفسك موجودة في Studio.", actionsLoadFailed: "تعذّر تحميل إجراءات الحزم.", }, + element: { + number: { + noObject: "لم يُحدَّد أي كائن: عيّن object أو dataSource.object.", + }, + }, }; export default ar; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5703e328c3..a3fe2e13c2 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3765,6 +3765,11 @@ const de = { actionsEmptyBody: "In dieser Installation deklariert kein installiertes Paket eine Aktion. Selbst erstellte Aktionen finden Sie im Studio.", actionsLoadFailed: "Paket-Aktionen konnten nicht geladen werden.", }, + element: { + number: { + noObject: "Kein Objekt angegeben: Legen Sie object oder dataSource.object fest.", + }, + }, }; export default de; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 801225657d..46feeef446 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4317,6 +4317,14 @@ const en = { 'No installed package declares an action on this deployment. Actions you author yourself live in Studio.', actionsLoadFailed: 'Could not load packaged actions.', }, + element: { + // objectui#10951 — `element:number` authored with an aggregate and no + // object (neither `object` nor `dataSource.object`). Kept terse: this pack + // is eager, and the console closure budget weighs it. + number: { + noObject: 'No object named: set object or dataSource.object.', + }, + }, } as const; export default en; diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2099d03620..0dc36b2fb2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3769,6 +3769,11 @@ const es = { actionsEmptyBody: "Ningún paquete instalado declara una acción en esta implementación. Las acciones que creas tú están en Studio.", actionsLoadFailed: "No se pudieron cargar las acciones de paquete.", }, + element: { + number: { + noObject: "No se ha indicado ningún objeto: define object o dataSource.object.", + }, + }, }; export default es; diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 755037abfc..e9370e8870 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3767,6 +3767,11 @@ const fr = { actionsEmptyBody: "Aucun package installé ne déclare d'action sur ce déploiement. Les actions que vous créez vous-même se trouvent dans Studio.", actionsLoadFailed: "Impossible de charger les actions des packages.", }, + element: { + number: { + noObject: "Aucun objet indiqué : définissez object ou dataSource.object.", + }, + }, }; export default fr; diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 12a82967ee..1fd798a9f9 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3766,6 +3766,11 @@ const ja = { actionsEmptyBody: "このデプロイには、アクションを宣言するインストール済みパッケージがありません。自分で作成したアクションは Studio にあります。", actionsLoadFailed: "パッケージアクションを読み込めませんでした。", }, + element: { + number: { + noObject: "オブジェクトが指定されていません。object または dataSource.object を設定してください。", + }, + }, }; export default ja; diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e95aef406e..574b5c46e2 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3763,6 +3763,11 @@ const ko = { actionsEmptyBody: "이 배포에는 액션을 선언하는 설치된 패키지가 없습니다. 직접 작성한 액션은 Studio에 있습니다.", actionsLoadFailed: "패키지 액션을 불러오지 못했습니다.", }, + element: { + number: { + noObject: "개체가 지정되지 않았습니다. object 또는 dataSource.object를 설정하세요.", + }, + }, }; export default ko; diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 833b9084d0..15e6812b3a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3764,6 +3764,11 @@ const pt = { actionsEmptyBody: "Nenhum pacote instalado declara uma ação nesta implantação. As ações que você mesmo cria ficam no Studio.", actionsLoadFailed: "Não foi possível carregar as ações de pacote.", }, + element: { + number: { + noObject: "Nenhum objeto indicado: defina object ou dataSource.object.", + }, + }, }; export default pt; diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index a0f2a4eccd..027e51a521 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3795,6 +3795,11 @@ const ru = { actionsEmptyBody: "В этой установке ни один установленный пакет не объявляет действие. Действия, которые вы создаёте сами, находятся в Studio.", actionsLoadFailed: "Не удалось загрузить действия из пакетов.", }, + element: { + number: { + noObject: "Объект не указан: задайте object или dataSource.object.", + }, + }, }; export default ru; diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ca79498e18..8e71fea0be 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3835,6 +3835,11 @@ const zh = { actionsEmptyBody: '本部署中没有任何已安装软件包声明动作。你自己编写的动作在 Studio 中。', actionsLoadFailed: '无法加载打包动作。', }, + element: { + number: { + noObject: '未指定对象:请设置 object 或 dataSource.object。', + }, + }, } as const; export default zh;