Skip to content

Commit 1c87beb

Browse files
fix(plugin-dashboard): metric tile captions its value with the widget description (#27972)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgUYxjmbL396fhj5JfyuNH3Q)_ ## 维护者速读 - **改了什么**:仪表盘 KPI(`metric`)卡片数值下方的那行小字,作者写了 `description` 时显示 `description`,没写时仍显示度量名(如 "Total Amount")。 - **为什么改**:HotCRM 反馈 KPI 卡只印度量名,说明"这个数统计的是什么"的 `description` 只藏在标题的悬停提示里(#27430 第 2 行)。 - **风险与回滚**:只改 `plugin-dashboard` 里一处取值,不新增元数据键;已写 `description` 的现有 KPI 卡,小字会从度量名变成描述,度量名仍在标题悬停提示里。回滚即 revert 本 PR。 - **先看**:`DatasetWidget.tsx` 里 `const caption` 一行及其渲染处;新测试 `DashboardSurfaces.metricTileCaption-27430.test.tsx`;两份旧测试里被改写的"描述不显示"断言。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-dashboard/src/__tests__/DashboardSurfaces.metricTileCaption-27430.test.tsx`,预期 10 passed。 - **需要你做的**:无,席位落地。 Fixes #27430 Clause-②: no Metadata-protocol: none ## What changes Row 2 of #27430 only. A dataset-bound `metric` tile (`DatasetWidget` with a `heading`, drawn by both `DashboardRenderer` and `DashboardGridLayout`) now captions its value with the widget's `description` when one is authored, and with the measure label otherwise. The caption keeps the measure label's slot and class (`METRIC_TILE_MEASURE_LABEL_CLASS`), so the tile keeps its two lines of text and still drops the caption below 160 px. The title's tooltip is unchanged: the description, then the measure label, which is where the measure label stays reachable under a description caption. No new key is read. `description` is the existing widget-level key of `DashboardWidgetSchema` ("Widget description text below the header"), already resolved and localized by both surfaces and handed to the tile in `heading`. Nothing reads a caption from the options bag. ## Why HotCRM's Service and Sales dashboards declare a `description` on each KPI to state its base, and the tile showed only the measure label under the value. The description reached the screen only as a hover tooltip. ## Tests - New `ui/packages/plugin-dashboard/src/__tests__/DashboardSurfaces.metricTileCaption-27430.test.tsx`, on both surfaces: a described metric draws the description as the caption, once, and the measure label only in the tooltip; an undescribed metric and an empty description fall back to the measure label; an inline per-locale description follows the UI language; CONTROL, a dataset-bound table draws no metric caption. - `DashboardRenderer.metricTileDensity-12528.test.tsx` and `DashboardSurfaces.metricTileTwoLines-12573.test.tsx` pinned "the description is drawn nowhere else". Their layout pins now render a metric with no description (caption = measure label), and the 12528 tooltip pin also asserts the description caption. - Ablation, at `6c8d7e20`, through `scripts/ablation-replace.mjs` (no dist leg: the root vitest config aliases `@object-ui/*` to `src/`): the caption line set back to the measure label made 5 tests fail (both surfaces' description and per-locale pins, and the 12528 description pin) while the 8 fallback and control pins passed; restore verified blob == HEAD and `git diff HEAD` empty. ## Acceptance notes | Row | Bucket | Reading on `main` at `ed0e71db` | |:--|:--|:--| | 2 KPI tile caption | TAKE (this PR) | The tile drew the measure label as its caption; `widget.description` reached the tile in `heading` and was used only for the title tooltip. | | 3 percent always a bar | CARD → #27973 | `PercentCellRenderer` draws the bar unconditionally and reads no face key. Neither the field (`format` has no vocabulary and only promotes text types, `widget` is the form override) nor the list column (`type` is a renderer override; `number` would print the stored fraction) selects a plain percentage. Needs a new display key. | | 4 report chart second dimension, summary subtotals | CARD → #27974 (series), #27975 (subtotals) | `ReportChartSchema` is `xAxis` plus `yAxis`, with no series dimension; the dashboard path already pivots a second dimension (`buildChartSeries`). A `summary` report asks only for the grand total (`totals.groupings` of the empty grouping) and the spec has no subtotal key. | - This reverses the "description drawn nowhere else" pins from objectui#12528 and objectui#12573 on purpose, as #27430 row 2 asks. The density those cards bought holds: no line is added, the caption replaces the measure label. - Observation, not filed: the `DashboardWidgetOptionsSchema` doc comment in `packages/spec/src/ui/dashboard.zod.ts` says `widget.description` is "rendered as the card-header subtitle". A dataset-bound metric tile has had no card header since objectui#12528; after this PR its description is the caption under the value. Carrier: seat. - Rows 3 and 4 are filed by the seat as #27973, #27974 and #27975. Row 1 landed earlier (PR #27689, PR #27800), so this PR closes the card. ## Verification <details><summary>Local runs at 6c8d7e2</summary> - `pnpm --filter @object-ui/plugin-dashboard test`: Test Files 198 passed (198), Tests 1908 passed, 6 skipped; lock VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-dashboard type-check`: exit 0. `eslint` on the six touched files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 families, each run, all exit 0, including `node scripts/check-sdui-manifest.mjs` and its `--self-test`, `pnpm check:nul-bytes`, and `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped). `--ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm check:new-line-citations` (ui): 0 new citations. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes on the touched files. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the maintainer's summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXAa3A4UiNf9JKBZ7R)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e15a87c commit 1c87beb

7 files changed

Lines changed: 213 additions & 31 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
fix(plugin-dashboard): a dataset-bound `metric` tile captions its value with the widget's `description`, falling back to the measure label
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A `metric` dashboard tile drew the measure's label (for example "Total Amount") under its value, and the widget's `description`, which says what the number counts, only in the title's tooltip. When a widget declares a `description`, the tile now draws it as the caption under the value, in the measure label's place. A widget with no description (or an empty one) keeps the measure label as its caption. The measure label still rides in the title's tooltip, under the description, so it stays reachable at every tile width.
11+
12+
Both dashboard surfaces (`DashboardRenderer` and `DashboardGridLayout`) show the same caption. The tile keeps its two lines of text, and no new metadata key is read: `description` is the existing widget-level key of `DashboardWidgetSchema`.

‎ui/packages/plugin-dashboard/src/DashboardRenderer.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,10 +1093,9 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
10931093
// objectui#12528 — a dataset-bound METRIC tile draws no card header:
10941094
// the header (title, then a description paragraph under a rule) and
10951095
// the tile (value, then the measure label) stacked four lines for one
1096-
// number. The tile draws the title and the measure label on one line
1097-
// above the value instead, and the description is the title's tooltip.
1098-
// The spec declares no widget key to show it inline, so it shows
1099-
// nowhere else. Every other widget keeps the frame as it was.
1096+
// number. The tile draws the title above the value instead, and the
1097+
// description is the caption under it in place of the measure label
1098+
// (objectstack#27430). Every other widget keeps the frame as it was.
11001099
const datasetMetric = datasetBound && entryType === 'metric';
11011100
const resolvedTitle = tWidgetTitle(widget);
11021101
const resolvedDescription = tWidgetDescription(widget);

‎ui/packages/plugin-dashboard/src/DatasetWidget.tsx‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -629,16 +629,19 @@ export { chartConfigPresentation } from '@object-ui/core';
629629
* bag: the metric sub-caption is retired at both ends (objectui#11389, ruling
630630
* C, after objectstack's half in `@objectstack/spec` 17.7.0). A widget keeps
631631
* one authored description, `widget.description`, which the dashboard surface
632-
* draws as the card-header subtitle. If a caption under a metric's value
633-
* returns, it returns as a declared widget-level key outside the options bag,
634-
* never as a key inside it. The options census in `@objectstack/sdui-parser`
632+
* draws as the card-header subtitle, or, for a metric tile, hands in through
633+
* `heading` to draw as the caption under the value. That is the declared
634+
* widget-level key outside the options bag; a caption is never read from a key
635+
* inside it. The options census in `@objectstack/sdui-parser`
635636
* reads this file's text, comments included, which is why this note does not
636637
* spell the bag's member access.
637638
*/
638639
/**
639640
* [objectui#12528] The heading a dashboard surface hands a dataset-bound
640641
* metric tile instead of drawing its own card header: the widget's resolved
641-
* title, and its resolved description, which becomes the title's tooltip.
642+
* title, and its resolved description, which the tile draws as the caption
643+
* under the value in place of the measure label (objectstack#27430) and
644+
* carries in the title's tooltip.
642645
*/
643646
export interface DatasetMetricHeading {
644647
title: string;
@@ -647,8 +650,10 @@ export interface DatasetMetricHeading {
647650

648651
/**
649652
* The heading line of a metric tile: the title alone (objectui#12623). It owns
650-
* the line, up to two lines; the measure label is drawn under the value, and
651-
* rides in this tooltip so a tile too narrow to draw it still carries it.
653+
* the line, up to two lines; the caption is drawn under the value, and the
654+
* description and the measure label ride in this tooltip, so a tile too narrow
655+
* to draw the caption, or one whose caption is the description, still carries
656+
* the measure label.
652657
*/
653658
function MetricHeadingRow({ heading, label }: { heading: DatasetMetricHeading; label?: string }) {
654659
return (
@@ -1379,22 +1384,26 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
13791384
// (objectui#7293), fed by the server's `subCaption` overlay and a client
13801385
// bundle limb; both ends are retired, and the spec never declared the key.
13811386
// An authored one now draws the parser's `unconsumed-widget-option` warning
1382-
// instead of a caption node. The card-header subtitle, `widget.description`,
1383-
// is the surface's to draw, not this tile's.
1387+
// instead of a caption node. The widget-level `widget.description` is the
1388+
// surface's to resolve: handed in through `heading`, it is the caption
1389+
// under the value, in place of the measure label, which then rides only in
1390+
// the title's tooltip (objectstack#27430).
1391+
const caption = heading?.description || measureLabel;
13841392
return (
13851393
// Positioned only while a re-read is in flight, for the refresh bar: the
13861394
// idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`,
13871395
// `.subCaption`, `.unrenderedMeasures-8894` tests), and it moves by nothing.
13881396
<div className={cn('flex h-full w-full flex-col items-start justify-center gap-1 p-2', heading && cn('min-w-0', METRIC_TILE_CONTAINER_CLASS), state.refreshing && 'relative')}>
13891397
{refreshBar}
13901398
{/* objectui#12623 — with a heading, the title owns the first line, the
1391-
value never wraps, and the measure label is a line under it (the
1392-
shared rule in `./metricTile`). Without one, the tile's markup is
1393-
unchanged: the label is then the only text naming the value. */}
1399+
value never wraps, and the caption (the description, else the
1400+
measure label) is a line under it (the shared rule in
1401+
`./metricTile`). Without one, the tile's markup is unchanged: the
1402+
label is then the only text naming the value. */}
13941403
{heading && <MetricHeadingRow heading={heading} label={measureLabel} />}
13951404
<span className={cn(heading ? cn('font-semibold', METRIC_TILE_VALUE_CLASS) : 'text-2xl font-semibold tabular-nums', accentClass)}>{formatMeasure(value, f?.format, f?.currency, f?.percentScale, displayLocale)}</span>
1396-
{heading && measureLabel && (
1397-
<span className={METRIC_TILE_MEASURE_LABEL_CLASS} data-testid="dataset-metric-label">{measureLabel}</span>
1405+
{heading && caption && (
1406+
<span className={METRIC_TILE_MEASURE_LABEL_CLASS} data-testid="dataset-metric-label">{caption}</span>
13981407
)}
13991408
{delta && (
14001409
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground" data-testid="dataset-compare-trend">

‎ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricTileDensity-12528.test.tsx‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
* that is wide enough; the width rules themselves are
2727
* `metricTileLayout-12623.test.tsx`'s.
2828
*
29+
* objectstack#27430 draws an authored description as the caption under the
30+
* value in place of the measure label: the first pin renders a metric that
31+
* declares none, and the second asserts the description as that caption.
32+
*
2933
* CONTROL: a non-metric dataset widget (a table) keeps the card header, and
3034
* its description paragraph, exactly as before.
3135
*/
@@ -64,6 +68,9 @@ const METRIC = {
6468
values: ['total_amount'],
6569
};
6670

71+
// The same tile with no description: its caption is the measure label.
72+
const METRIC_UNDESCRIBED = { ...METRIC, description: undefined };
73+
6774
function renderDashboard(...widgets: Record<string, unknown>[]) {
6875
return render(
6976
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
@@ -74,7 +81,7 @@ function renderDashboard(...widgets: Record<string, unknown>[]) {
7481

7582
describe('DashboardRenderer — a dataset-bound metric tile on two lines (objectui#12528)', () => {
7683
it('draws the title alone on the first line, then the value, then the measure label (objectui#12623)', async () => {
77-
const { container } = renderDashboard(METRIC);
84+
const { container } = renderDashboard(METRIC_UNDESCRIBED);
7885
// The answered tile, not the loading one: a loading tile draws the same
7986
// heading line, and only the answer names the measure label.
8087
const value = await screen.findByText('1964500');
@@ -93,16 +100,20 @@ describe('DashboardRenderer — a dataset-bound metric tile on two lines (object
93100
expect(screen.getAllByText('Total Pipeline')).toHaveLength(1);
94101
});
95102

96-
it('draws no description paragraph, and carries the description (then the measure label) as the title tooltip', async () => {
103+
it('draws no description paragraph: the description is the caption under the value, and leads the title tooltip (objectstack#27430)', async () => {
97104
const { container } = renderDashboard(METRIC);
98-
await screen.findByText('1964500');
105+
const value = await screen.findByText('1964500');
99106
const title = screen.getByTestId('dataset-metric-title');
100107

101108
// objectui#12623 — the measure label rides in the tooltip on its own line,
102-
// the one place it survives a tile too narrow to draw it.
109+
// the one place it survives a tile too narrow to draw it, and the one
110+
// place it survives under a description caption.
103111
expect(title).toHaveAttribute('title', 'Sum of open opportunity value\nTotal Amount');
104112
expect(container.querySelector('p')).toBeNull();
105-
expect(container.textContent).not.toContain('Sum of open opportunity value');
113+
expect(value.nextElementSibling).toBe(screen.getByTestId('dataset-metric-label'));
114+
expect(screen.getByTestId('dataset-metric-label')).toHaveTextContent('Sum of open opportunity value');
115+
expect(container.textContent?.match(/Sum of open opportunity value/g)).toHaveLength(1);
116+
expect(container.textContent).not.toContain('Total Amount');
106117
});
107118

108119
it('CONTROL: a non-metric dataset widget keeps its card header and description paragraph', async () => {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27430 row 2 — a dataset-bound `metric` tile captions its value
11+
* with the widget's `description` when one is authored, and with the measure
12+
* label otherwise.
13+
*
14+
* The tile drew the measure label ("Total Amount") under every value, and the
15+
* widget's `description` (the spec's `DashboardWidget.description`, "Widget
16+
* description text below the header"), which says what the number counts,
17+
* only in the title's tooltip. Now the description is the caption, in the
18+
* measure label's place: the tile keeps its two lines of text, and the
19+
* measure label still rides in the title's tooltip, under the description.
20+
*
21+
* Pins, on both dashboard surfaces (`DashboardRenderer`, `DashboardGridLayout`):
22+
* - a described metric: the caption under the value is the description, drawn
23+
* once; the measure label is drawn nowhere but the tooltip;
24+
* - an undescribed metric (and one whose description is empty): the caption is
25+
* the measure label, as before;
26+
* - an inline per-locale description map: the caption follows the UI language;
27+
* - CONTROL: a dataset-bound table draws no metric caption (`DashboardRenderer`'s
28+
* card-header description paragraph is pinned in
29+
* `DashboardRenderer.metricTileDensity-12528.test.tsx`).
30+
*
31+
* No `dist/` is involved: the root `vitest.config.mts` aliases every
32+
* `@object-ui/*` specifier to that package's `src/`, and the subjects are
33+
* imported relatively.
34+
*/
35+
36+
import * as React from 'react';
37+
import { describe, it, expect, vi, afterEach } from 'vitest';
38+
import { render, screen, cleanup } from '@testing-library/react';
39+
import { I18nProvider } from '@object-ui/i18n';
40+
import type { DashboardComponentSchema } from '@object-ui/types';
41+
// Module scope, never inside a hook (AGENTS.md 测试纪律): both surfaces resolve
42+
// non-dataset nodes through the registries these barrels populate.
43+
import '@object-ui/components';
44+
import { DashboardRenderer } from '../DashboardRenderer';
45+
import { DashboardGridLayout } from '../DashboardGridLayout';
46+
47+
afterEach(cleanup);
48+
49+
const dashboard = (widgets: Record<string, unknown>[]): DashboardComponentSchema =>
50+
({ type: 'dashboard', name: 'sales', widgets }) as unknown as DashboardComponentSchema;
51+
52+
const source = () => ({
53+
queryDataset: vi.fn(async () => ({
54+
rows: [{ total_amount: 1964500, stage: 'won' }],
55+
fields: [
56+
{ name: 'total_amount', label: 'Total Amount', type: 'number' },
57+
{ name: 'stage', label: 'Stage', type: 'text' },
58+
],
59+
})),
60+
});
61+
62+
const METRIC = {
63+
id: 'total_pipeline_value',
64+
type: 'metric',
65+
title: 'Total Pipeline',
66+
description: 'Open opportunities, all owners',
67+
dataset: 'opportunity_metrics',
68+
values: ['total_amount'],
69+
};
70+
71+
type Surface = (schema: DashboardComponentSchema, dataSource: unknown) => React.ReactElement;
72+
73+
const SURFACES: Array<[string, Surface]> = [
74+
['DashboardRenderer', (schema, dataSource) => <DashboardRenderer schema={schema} dataSource={dataSource} />],
75+
['DashboardGridLayout', (schema, dataSource) => <DashboardGridLayout schema={schema} dataSource={dataSource} />],
76+
];
77+
78+
const renderOn = (surface: Surface, schema: DashboardComponentSchema, language = 'en') =>
79+
render(
80+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
81+
{surface(schema, source())}
82+
</I18nProvider>,
83+
);
84+
85+
describe.each(SURFACES)('%s — the metric tile caption (objectstack#27430)', (_name, surface) => {
86+
it('draws an authored description as the caption under the value, once, in place of the measure label', async () => {
87+
const { container } = renderOn(surface, dashboard([METRIC]));
88+
const value = await screen.findByText('1964500');
89+
const caption = screen.getByTestId('dataset-metric-label');
90+
91+
expect(value.nextElementSibling).toBe(caption);
92+
expect(caption).toHaveTextContent('Open opportunities, all owners');
93+
expect(container.textContent?.match(/Open opportunities, all owners/g)).toHaveLength(1);
94+
// The measure label is no longer drawn; the title's tooltip still carries
95+
// it, under the description.
96+
expect(container.textContent).not.toContain('Total Amount');
97+
expect(screen.getByTestId('dataset-metric-title')).toHaveAttribute(
98+
'title',
99+
'Open opportunities, all owners\nTotal Amount',
100+
);
101+
});
102+
103+
it.each([
104+
['declares no description', undefined],
105+
['declares an empty description', ''],
106+
])('falls back to the measure label when the widget %s', async (_case, description) => {
107+
renderOn(surface, dashboard([{ ...METRIC, description }]));
108+
const value = await screen.findByText('1964500');
109+
110+
expect(value.nextElementSibling).toBe(screen.getByTestId('dataset-metric-label'));
111+
expect(screen.getByTestId('dataset-metric-label')).toHaveTextContent('Total Amount');
112+
});
113+
114+
it('follows the UI language for an inline per-locale description', async () => {
115+
renderOn(
116+
surface,
117+
dashboard([{ ...METRIC, description: { en: 'Open opportunities, all owners', 'zh-CN': '所有负责人的未结商机' } }]),
118+
'zh-CN',
119+
);
120+
await screen.findByText('1964500');
121+
122+
expect(screen.getByTestId('dataset-metric-label')).toHaveTextContent('所有负责人的未结商机');
123+
});
124+
125+
it('CONTROL: a dataset-bound table draws no metric caption', async () => {
126+
const { container } = renderOn(surface, dashboard([{
127+
id: 'by_stage',
128+
type: 'table',
129+
title: 'Pipeline by Stage',
130+
description: 'Open opportunity value at each sales stage',
131+
dataset: 'opportunity_metrics',
132+
dimensions: ['stage'],
133+
values: ['total_amount'],
134+
}]));
135+
136+
// The answered table: its cell draws the value.
137+
await screen.findByText('Pipeline by Stage');
138+
await screen.findAllByText(/1,?964,?500/);
139+
expect(container.querySelector('[data-testid="dataset-metric-label"]')).toBeNull();
140+
expect(container.querySelector('[data-testid="dataset-metric-heading"]')).toBeNull();
141+
});
142+
});

‎ui/packages/plugin-dashboard/src/__tests__/DashboardSurfaces.metricTileTwoLines-12573.test.tsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@
2626
* surfaces instead of the shared line; the width rules are
2727
* `metricTileLayout-12623.test.tsx`'s.
2828
*
29+
* objectstack#27430 draws an authored description as the caption under the
30+
* value in place of the measure label, so the first pin below renders a metric
31+
* that declares none (its caption is the measure label), and the second keeps
32+
* only the tooltip; the caption itself is
33+
* `DashboardSurfaces.metricTileCaption-27430.test.tsx`'s.
34+
*
2935
* Pins:
3036
* - both surfaces: the metric draws the title alone before the value and the
31-
* measure label after it, the label once, the title once, the description
37+
* measure label after it, the label once, the title once; the description
3238
* then the label as the title's tooltip;
3339
* - CONTROL, both surfaces: a dataset-bound table and chart keep the card
3440
* header and draw no metric heading;
@@ -80,6 +86,9 @@ const METRIC = {
8086
values: ['total_amount'],
8187
};
8288

89+
// The same tile with no description: its caption is the measure label.
90+
const METRIC_UNDESCRIBED = { ...METRIC, description: undefined };
91+
8392
const TABLE = {
8493
id: 'by_stage_table',
8594
type: 'table',
@@ -114,7 +123,7 @@ const renderOn = (surface: Surface, schema: DashboardComponentSchema) =>
114123

115124
describe.each(SURFACES)('%s — a dataset-bound metric tile on two lines (objectui#12573)', (_name, surface) => {
116125
it('draws the title alone above the value and the measure label under it, each once (objectui#12623)', async () => {
117-
const { container } = renderOn(surface, dashboard([METRIC]));
126+
const { container } = renderOn(surface, dashboard([METRIC_UNDESCRIBED]));
118127
// The answered tile: only the answer names the measure label.
119128
const value = await screen.findByText('1964500');
120129
const heading = screen.getByTestId('dataset-metric-heading');
@@ -129,12 +138,11 @@ describe.each(SURFACES)('%s — a dataset-bound metric tile on two lines (object
129138
expect(screen.getAllByText('Total Pipeline')).toHaveLength(1);
130139
});
131140

132-
it('carries the description, then the measure label, as the title tooltip, the description drawn nowhere else', async () => {
133-
const { container } = renderOn(surface, dashboard([METRIC]));
141+
it('carries the description, then the measure label, as the title tooltip', async () => {
142+
renderOn(surface, dashboard([METRIC]));
134143
await screen.findByText('1964500');
135144

136145
expect(screen.getByTestId('dataset-metric-title')).toHaveAttribute('title', 'Sum of open opportunity value\nTotal Amount');
137-
expect(container.textContent).not.toContain('Sum of open opportunity value');
138146
});
139147

140148
it.each([

‎ui/packages/plugin-dashboard/src/metricTile.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
* narrower than 160 px, 24 px from 160 px up. A longer one — a seven-digit
1414
* money figure with cents — takes a size ladder from 14 px to 24 px whose
1515
* steps start where that size fits (`metricTileValueClass`).
16-
* - The measure label (dataset-bound tile only) is an 11 px muted line under
17-
* the value, dropped below 160 px. The title's tooltip carries it, so it is
18-
* still there at every width.
16+
* - The caption (dataset-bound tile only) is an 11 px muted line under the
17+
* value, dropped below 160 px: the widget's description when one is
18+
* authored, else the measure label. The title's tooltip carries the measure
19+
* label, so it is still there at every width and under a description.
1920
*
2021
* Width rules key off the TILE's own box (`@container` on the tile, `@[160px]:`
2122
* variants), never the viewport: a dashboard grid, a page block and the
@@ -100,7 +101,7 @@ export function metricTileValueClass(value: string | number): string {
100101
return `${METRIC_TILE_VALUE_BASE} ${rung ? rung.sizes : METRIC_TILE_VALUE_FINAL_SIZES}`;
101102
}
102103

103-
/** The measure label: an 11 px muted line under the value, dropped below 160 px. */
104+
/** The caption (description, else measure label): an 11 px muted line under the value, dropped below 160 px. */
104105
export const METRIC_TILE_MEASURE_LABEL_CLASS = 'hidden max-w-full truncate text-[11px] text-muted-foreground @[160px]:block';
105106

106107
/**

0 commit comments

Comments
 (0)