From 8c51fad6d4da1ab0ce292286c15b51b48111cd83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:18:55 +0000 Subject: [PATCH 1/3] test(plugin-dashboard): a dimension-less KPI tile re-reads on the data-invalidation bus once its answer names the base object DatasetWidget subscribes useDataInvalidation on the base object the dataset query's answer names. The analytics service now names it on every dataset answer (objectstack-ai/objectstack#20644, AnalyticsResult.object in @objectstack/spec 17.6.0), so a metric widget with no dimensions subscribes. Pin that half: a write to the answer's object re-reads the tile once in place, another object does not, the unscoped '*' change the console's timer and Refresh All declare re-reads it, a zero-row first answer subscribes too, and an answer naming no object subscribes to nothing (no fallback key). Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11095-kpi-tile-invalidation-pin.md | 4 + ...tasetWidget.kpiInvalidation-11095.test.tsx | 179 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 .changeset/11095-kpi-tile-invalidation-pin.md create mode 100644 packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx diff --git a/.changeset/11095-kpi-tile-invalidation-pin.md b/.changeset/11095-kpi-tile-invalidation-pin.md new file mode 100644 index 0000000000..303cc4fe27 --- /dev/null +++ b/.changeset/11095-kpi-tile-invalidation-pin.md @@ -0,0 +1,4 @@ +--- +--- + +Tests only, no package released: `@object-ui/plugin-dashboard` gains a pin that a dataset-bound KPI tile (a `metric` widget with no dimensions) re-reads on the data-invalidation bus once its query's answer names the dataset's base object. The answer names it on every dataset query since objectstack-ai/objectstack#20644, which `@objectstack/spec` 17.6.0 declares as `AnalyticsResult.object`. The widget already subscribed on that key, so no source changed (objectui#11095). diff --git a/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx b/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx new file mode 100644 index 0000000000..2c604f2c16 --- /dev/null +++ b/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx @@ -0,0 +1,179 @@ +/** + * 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. + */ + +/** + * objectui#11095 — a dataset-bound KPI tile (a `metric` widget with no + * dimensions) re-reads on the data-invalidation bus. + * + * `DatasetWidget` subscribes `useDataInvalidation` on the base object its + * query's ANSWER names (`object`, objectui#10815). The analytics service used + * to name it only beside drill-through metadata, which needs a drillable + * dimension and at least one row, so a dimension-less tile's answer named + * nothing and the tile ignored every bus event: the dashboard timer, Refresh + * All, and declared writes. The fix is the producer's + * (objectstack-ai/objectstack#20644): `AnalyticsResult.object` is declared in + * `@objectstack/spec` 17.6.0 and named on every dataset answer, with or + * without dimensions and with or without rows. Nothing changed here, so these + * cases pin the consumer half of that contract. + * + * The answers below have the shape the producer returns for a + * `dimensions: []` selection: `rows`, `fields` and `object`, with no + * `dimensionFields` and no `drillRawRows`. The last case keeps the triage + * ruling on the card: the subscription key is the answer's `object` and + * nothing else, so there is no `'*'` subscription and no second read of the + * dataset's metadata. + * + * Rendered on the page path: the real `SchemaRenderer` under a + * `SchemaRendererProvider`, this package's own registration, no host prop. + * The bare `useDataInvalidation` reader mounted beside the block is the + * positive control: it proves the event reached subscribers in this harness. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider, notifyDataChanged, useDataInvalidation } from '@object-ui/react'; +// Side-effect imports at MODULE scope (AGENTS.md's flaky-test rule). +import '@object-ui/components'; +import './index'; +import type { DataSource } from '@object-ui/types'; + +type StoredNode = Parameters[0]['schema']; +const asAdapter = (fake: object) => fake as unknown as DataSource; + +beforeEach(() => { + // The dimension-label metadata probe falls back to the global fetch when the + // provider carries no `apiFetch`; answer it locally. Not what these cases are about. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 150))); + +const BASE_OBJECT = 'showcase_project'; + +type Answer = { rows: Array>; fields: unknown[]; object?: string }; + +/** + * A `queryDataset`-capable adapter answering a dimension-less selection. + * `count` is the number the next answer carries (`null` answers zero rows). + * `object` is what every answer names (`undefined` answers the pre-fix shape). + */ +function makeKpiSource(opts: { count: number | null; object?: string }) { + let count = opts.count; + const answer = (): Answer => ({ + rows: count === null ? [] : [{ project_count: count }], + fields: [{ name: 'project_count', type: 'number', label: 'Projects' }], + ...(opts.object ? { object: opts.object } : {}), + }); + return { + setCount(next: number | null) { + count = next; + }, + queryDataset: vi.fn((_dataset: string, _selection?: { dimensions?: string[] }) => Promise.resolve(answer())), + }; +} + +/** The positive control: a bare reader of the dataset's base object, beside the block. */ +function BusControl() { + const nonce = useDataInvalidation(BASE_OBJECT); + return {`bus:${nonce}`}; +} + +// The showcase ops dashboard's KPI shape: a `metric` over a dataset, no dimensions. +const KPI_WIDGET = { id: 'kpi_at_risk', type: 'metric', title: 'At-Risk (Red)', dataset: 'showcase_project_ds', values: ['project_count'] }; +const dashboard = { type: 'dashboard', widgets: [KPI_WIDGET] }; + +const renderOnPage = (ds: ReturnType) => + render( + + + + , + ); + +describe('a dataset-bound KPI tile re-reads on the data-invalidation bus once its answer names the base object (objectui#11095)', () => { + it('a write to the answer’s object re-reads the tile once, in place; a write to another object does not', async () => { + const ds = makeKpiSource({ count: 1, object: BASE_OBJECT }); + const { getByTestId } = renderOnPage(ds); + const tileValue = await screen.findByText('1'); + await settle(); + expect(ds.queryDataset).toHaveBeenCalledTimes(1); + // The query really is the dimension-less one this card is about. + expect(ds.queryDataset.mock.calls[0][1]?.dimensions).toEqual([]); + + ds.setCount(2); + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(ds.queryDataset, 'a change to another object re-read this tile').toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: BASE_OBJECT, recordId: 'p-7' }); + }); + await settle(); + expect(getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('bus:1'); + expect(ds.queryDataset, 'the KPI tile never re-read after a write to its base object').toHaveBeenCalledTimes(2); + // In place: the same value node now carries the new number. + expect(await screen.findByText('2')).toBe(tileValue); + }); + + it('the unscoped change the console’s timer and Refresh All declare (objectName "*") re-reads the tile once', async () => { + const ds = makeKpiSource({ count: 1, object: BASE_OBJECT }); + const { getByTestId } = renderOnPage(ds); + await screen.findByText('1'); + await settle(); + expect(ds.queryDataset).toHaveBeenCalledTimes(1); + + ds.setCount(3); + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + expect(getByTestId('bus-control').textContent).toBe('bus:1'); + expect(ds.queryDataset, 'the KPI tile never re-read after an unscoped change').toHaveBeenCalledTimes(2); + expect(await screen.findByText('3')).toBeInTheDocument(); + }); + + it('a zero-row first answer that names the object subscribes too, so the first record written reaches the tile', async () => { + const ds = makeKpiSource({ count: null, object: BASE_OBJECT }); + renderOnPage(ds); + // A metric over an empty answer reads 0, not an empty state. + await screen.findByText('0'); + await settle(); + expect(ds.queryDataset).toHaveBeenCalledTimes(1); + + ds.setCount(1); + await act(async () => { + notifyDataChanged({ objectName: BASE_OBJECT }); + }); + await settle(); + expect(ds.queryDataset, 'an empty first answer left the tile unsubscribed').toHaveBeenCalledTimes(2); + expect(await screen.findByText('1')).toBeInTheDocument(); + }); + + it('the subscription key is the answer’s object and nothing else: an answer naming none subscribes to nothing', async () => { + // The pre-fix producer's dimension-less answer. The tile has no fallback key + // (no '*' subscription, no read of the dataset's metadata), so it stays put. + const ds = makeKpiSource({ count: 1 }); + const { getByTestId } = renderOnPage(ds); + await screen.findByText('1'); + await settle(); + expect(ds.queryDataset).toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: '*' }); + }); + await settle(); + expect(getByTestId('bus-control').textContent, 'control: the event never reached a subscriber').toBe('bus:1'); + expect(ds.queryDataset, 'a tile whose answer names no object re-read on the bus').toHaveBeenCalledTimes(1); + }); +}); From a36c241b5d045b630429b01d359ef162c5d5a5c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:25:56 +0000 Subject: [PATCH 2/3] test(plugin-dashboard): point the KPI pin's header at the upstream producer pin Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../src/DatasetWidget.kpiInvalidation-11095.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx b/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx index 2c604f2c16..2d7b1daa47 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx @@ -18,8 +18,9 @@ * All, and declared writes. The fix is the producer's * (objectstack-ai/objectstack#20644): `AnalyticsResult.object` is declared in * `@objectstack/spec` 17.6.0 and named on every dataset answer, with or - * without dimensions and with or without rows. Nothing changed here, so these - * cases pin the consumer half of that contract. + * without dimensions and with or without rows. The producer half is pinned + * upstream, by `service-analytics`'s `dataset-answer-object.test.ts`, not here. + * Nothing changed in this package, so these cases pin the consumer half. * * The answers below have the shape the producer returns for a * `dimensions: []` selection: `rows`, `fields` and `object`, with no From 17d39ad27eeb397aa7ca545732a12c1efa2e9deb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:03:44 +0000 Subject: [PATCH 3/3] docs(app-shell): refreshDashboardData's comment states that a dataset-bound KPI tile re-reads on the bus Every dataset answer names its base object since objectstack-ai/objectstack#20644 (AnalyticsResult.object), so the paragraph that described a dimension-less KPI tile as never re-reading on this handler or on a declared write is false. It now says what holds and points at the widget's pin (DatasetWidget.kpiInvalidation-11095.test.tsx) and the producer's upstream pin. Comment only; the changeset stays empty-frontmatter and says the comment ships in dist JS (objectui#11095, seat comment 5986840462). Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11095-kpi-tile-invalidation-pin.md | 4 +++- packages/app-shell/src/views/DashboardView.tsx | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.changeset/11095-kpi-tile-invalidation-pin.md b/.changeset/11095-kpi-tile-invalidation-pin.md index 303cc4fe27..ea0853a999 100644 --- a/.changeset/11095-kpi-tile-invalidation-pin.md +++ b/.changeset/11095-kpi-tile-invalidation-pin.md @@ -1,4 +1,6 @@ --- --- -Tests only, no package released: `@object-ui/plugin-dashboard` gains a pin that a dataset-bound KPI tile (a `metric` widget with no dimensions) re-reads on the data-invalidation bus once its query's answer names the dataset's base object. The answer names it on every dataset query since objectstack-ai/objectstack#20644, which `@objectstack/spec` 17.6.0 declares as `AnalyticsResult.object`. The widget already subscribed on that key, so no source changed (objectui#11095). +No package released: tests and one doc comment, no behaviour change. `@object-ui/plugin-dashboard` gains a pin that a dataset-bound KPI tile (a `metric` widget with no dimensions) re-reads on the data-invalidation bus once its query's answer names the dataset's base object. The answer names it on every dataset query since objectstack-ai/objectstack#20644, which `@objectstack/spec` 17.6.0 declares as `AnalyticsResult.object`. The widget already subscribed on that key, so no executable code changed (objectui#11095). + +`@object-ui/app-shell`'s `refreshDashboardData` doc comment stops describing that gap as current. The comment is not inert text: `tsc` keeps it in the emitted `dist/views/DashboardView.js`, which is published. It sits above a non-exported function, so it is in no `.d.ts`. It changes no export, type or behaviour, so it releases nothing and ships with the next release of the group. diff --git a/packages/app-shell/src/views/DashboardView.tsx b/packages/app-shell/src/views/DashboardView.tsx index b1f3c8cb46..e9a946d939 100644 --- a/packages/app-shell/src/views/DashboardView.tsx +++ b/packages/app-shell/src/views/DashboardView.tsx @@ -50,12 +50,14 @@ import { useObjectTranslation } from '@object-ui/i18n'; * `DashboardView.autoRefresh-11062.test.tsx` counts the re-reads and checks the * widget's node survives them. * - * ⚠️ A `DatasetWidget` matches nothing until its answer names the dataset's - * base object, and the server names it only beside drill-through metadata. A - * dataset-bound KPI tile (no dimensions) therefore never re-reads on the bus, - * from this handler or from a declared write. That gap is the widget's - * subscription key, not this handler; it was measured in a running console on - * objectui#11062. + * A `DatasetWidget` subscribes on the base object its query's answer names. + * Every dataset answer names it (`AnalyticsResult.object`, + * objectstack-ai/objectstack#20644), with or without dimensions and rows, so a + * dataset-bound KPI tile (no dimensions) re-reads on this handler and on a + * declared write to that object. `DatasetWidget.kpiInvalidation-11095.test.tsx` + * in `plugin-dashboard` pins the widget's half (objectui#11095); the + * producer's half is pinned upstream by `service-analytics`'s + * `dataset-answer-object.test.ts`. * * The scope is the bus's unknown-scope value, `'*'`, the same one `PageView` * uses after a page action. A timed re-read does not know what changed, and