Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/10951-element-number-no-object-state.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>((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<never>(() => {})
: Promise.resolve({ name, fields: {}, listViews: {} }),
),
};
}

function mount(schema: Record<string, unknown>, adapter: object) {
return render(
<AdapterCtx.Provider value={adapter as never}>
<SchemaRenderer schema={schema as never} />
</AdapterCtx.Provider>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<SchemaRenderer schema={NO_OBJECT as never} />
</I18nProvider>,
);
}

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.',
),
);
});
});
19 changes: 19 additions & 0 deletions packages/components/src/renderers/basic/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -543,6 +544,24 @@ function ElementNumberRenderer({ schema }: { schema: any }) {
if (dataBinding.status === 'loading') {
return <ElementDataSourceLoadingPanel testId="element-number" />;
}
// 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 (
<div
className={cn('text-xs text-muted-foreground', schema?.className)}
data-testid="element-number-no-object"
{...ariaAttrs(props.aria)}
>
{t('element.number.noObject', { defaultValue: 'No object named: set object or dataSource.object.' })}
</div>
);
}

return (
<div className={cn('flex flex-col gap-1', schema?.className)} {...ariaAttrs(props.aria)}>
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3801,6 +3801,11 @@ const ar = {
actionsEmptyBody: "لا توجد حزمة مثبّتة تعلن عن إجراء في هذا النشر. الإجراءات التي تنشئها بنفسك موجودة في Studio.",
actionsLoadFailed: "تعذّر تحميل إجراءات الحزم.",
},
element: {
number: {
noObject: "لم يُحدَّد أي كائن: عيّن object أو dataSource.object.",
},
},
};

export default ar;
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3766,6 +3766,11 @@ const ja = {
actionsEmptyBody: "このデプロイには、アクションを宣言するインストール済みパッケージがありません。自分で作成したアクションは Studio にあります。",
actionsLoadFailed: "パッケージアクションを読み込めませんでした。",
},
element: {
number: {
noObject: "オブジェクトが指定されていません。object または dataSource.object を設定してください。",
},
},
};

export default ja;
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3763,6 +3763,11 @@ const ko = {
actionsEmptyBody: "이 배포에는 액션을 선언하는 설치된 패키지가 없습니다. 직접 작성한 액션은 Studio에 있습니다.",
actionsLoadFailed: "패키지 액션을 불러오지 못했습니다.",
},
element: {
number: {
noObject: "개체가 지정되지 않았습니다. object 또는 dataSource.object를 설정하세요.",
},
},
};

export default ko;
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading