diff --git a/.changeset/11095-kpi-tile-invalidation-pin.md b/.changeset/11095-kpi-tile-invalidation-pin.md new file mode 100644 index 0000000000..ea0853a999 --- /dev/null +++ b/.changeset/11095-kpi-tile-invalidation-pin.md @@ -0,0 +1,6 @@ +--- +--- + +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 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..2d7b1daa47 --- /dev/null +++ b/packages/plugin-dashboard/src/DatasetWidget.kpiInvalidation-11095.test.tsx @@ -0,0 +1,180 @@ +/** + * 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. 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 + * `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); + }); +});