Skip to content

Commit 9e4f1cf

Browse files
fix(ui): dashboard KPI values and chart labels stay readable on a phone (#28223)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoTrt9MbRftG8VpmSncerMJE)_ Part of #27520 — rows 3, 15 (the chart half) and 18 (the chart part). Every other row stays on the card. Clause-②: yes (widening: @object-ui/core gains the public export formatMonthBucketLabel; no accept set moves) Metadata-protocol: none ## 维护者速读 - **改了什么**:仪表盘在手机上的可读性。KPI 大数字放不下时自动缩小而不是被截断;按月分桶的图表轴显示 "Oct 2025" 而不是 "2025-10";柱图/面积图的图例补上色块,对比(compareTo)系列的色块画成空心以和主系列区分;手机上的类目标签不再被截成 "In Progr…"(横向柱图换行显示,竖向柱图旋转显示,仍被截断的标签悬停可见全名)。 - **为什么改**:18.3.0 RC 走查(#27520 第 3、15、18 行)在 390px 手机上看到 Total Budget 显示成 "$1,090,00...",CRM Pipeline 仪表盘的对比系列与主系列同色、阶段柱图图例没有色块、月份轴是原始键。 - **风险与回滚**:只改渲染层(三个 `@object-ui` 包),不碰 spec、元数据和数据;数据行里的月份键不变,下钻与排序不受影响。回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-charts/src/AdvancedChartImpl.tsx`(标签格式化、两个刻度组件、图例色块);`ui/packages/plugin-dashboard/src/metricTile.ts`(数据集 KPI 的字号阶梯);`ui/packages/plugin-charts/src/ChartContainerImpl.tsx`(图例色块)。 - **两分钟验证**:手机宽度(390px)打开 showcase 的 Delivery Operations 仪表盘,Total Budget 应完整显示 `$1,090,000.00`;打开 CRM 的 Pipeline Dashboard,趋势图 X 轴应为 "Oct 2025 … Oct 2026",图例里 "vs last year" 是空心色块。 - **需要你做的**:无,席位落地。 ## What changes - **Row 3 — a dataset-bound KPI value shrinks before it clips.** `DatasetWidget`'s metric tile drew every value at the short face (20 px, 24 px from a 160 px tile), so `$1,090,000.00` clipped in a phone's two-per-row tile. It now takes `METRIC_TILE_DATASET_VALUE_LADDER` in `metricTile.ts`: the same rungs as the plain tiles' ladder (objectui#12942), measured against the dataset tile's own box (no Card inset), never drawing 24 px under 160 px. Past the last rung the value keeps the ellipsis and carries the full figure as its tooltip. A short value keeps today's face and draws no tooltip. Compact notation (`$1.09M`) was not needed: shrink-to-fit holds the figure on a 390 px phone, and it would override the measure's declared `format`. - **Row 15 — month axis.** `@object-ui/core` gains `formatMonthBucketLabel(value, locale)` (`utils/date-bucket-label.ts`): a `YYYY-MM` bucket key reads `Oct 2025` in the display locale, formatted in UTC so no zone moves it a month; anything else answers `undefined` and keeps its text. The chart's category label reads it. The row's key is untouched, so drill-through and ordering are unchanged. The line/area edge inset (objectui#13390) is now sized from that face (`Mmm 0000`) instead of `0000-00`. - **Row 15 — legend swatches.** A bar or area mark fills from a gradient, so recharts handed the legend `url(#…)`, which no swatch can draw: every bar chart's legend had no swatch. `AdvancedChartImpl` now hands `ChartLegendContent` a per-series `LegendSwatch` (the colour the mark is drawn in). A `compareTo` overlay keeps its primary's hue (it mirrors it on purpose) and its swatch is drawn hollow, the way its mark is muted, so the two read apart. A lone bar series painted per category (semantic option colours) draws its label with no swatch, since no one colour names it. - **Row 18 — category labels on a phone.** The phone cut to eight characters lived in the shared category formatter, so it hit every axis. Now: - the horizontal-bar category axis draws each label whole, wrapped into the axis width (sized from the widest label, 60–140 px) to at most two lines, through `WrappedCategoryTick`; a label still too long ends in `…` with the full text as an SVG title; - on the vertical x axis the cut moved into `xAxisTickFormatter`, so the fit and rotation decisions measure the full label: a phone's few buckets rotate and draw `In Progress` whole (the rotated cap of 12 characters holds it). An unrotated label on a phone, and a rotated one past 12 characters, are still cut, and `TitledCategoryTick` (recharts' own `Text`) carries the full label as its tooltip. The widget descriptions on the CRM Pipeline dashboard that leak authoring notes ("Pie/donut/funnel ignore `compareTo`", "(compareTo)") are example content in `examples/app-crm/src/dashboards/pipeline.dashboard.ts`; not edited here, per the order. ## Tests - New: `ui/packages/core/src/utils/__tests__/date-bucket-label.test.ts`, `ui/packages/plugin-charts/src/AdvancedChartImpl.dashboardReadability-27520.test.tsx`, `ui/packages/plugin-dashboard/src/__tests__/metricTileDatasetValueScale-27520.test.tsx`. - Adapted, because they pinned the behaviour this changes: - `AdvancedChartImpl.pointAxisEdgeLabels-13390` and `AdvancedChartImpl.missingCategoryKey` read the month labels as `Aug 2026` / `Jan 2026`; the edge-inset geometry assertions are unchanged and pass. - `AdvancedChartImpl.labelFitBand-13241` asserted the full name was absent from the chart's whole `textContent`; the tick's tooltip now carries it, so the assertion reads the drawn labels and pins the tooltip. - `metricTileLayout-12623` pins the short face, and its fixture `1964500` is now past the short rung, so it uses `196450`; the long value is pinned in the new 27520 file. - Ablations, each through `scripts/ablation-replace.mjs` with an on-disk restore to the HEAD blob: removing the month label, the dataset ladder, the legend swatch colour or the wrap each turns its new test red (1 failed each). The first month ablation was a no-op (an empty replacement is refused) and was redone with `--delete`. ## Acceptance notes - **The KPI money half of row 15 does not hold on `main`.** The order says it belongs to #27430 (closed). #27430 shipped the tile caption (PR #27972) and its row 1 is the formula `returnType: currency` face; neither puts a currency on a dataset KPI. Measured on this branch and at `9f13ee81`: the CRM Pipeline dashboard's Total Pipeline tile reads `710,000`, Won This Quarter `235,000`, Avg Deal Size `81,000`, with no currency, while the showcase's Total Budget reads `$1,090,000.00`. Not fixed here; reported to the seat for routing. - The chart tooltip header still shows a month bucket's raw key (`2025-10`), as it already did for an ISO day; only the axis reads the face. - The KPI ladder's em table is an upper estimate, so on a 390 px phone Total Budget draws at 14 px with room to spare where 16 px would also fit. ## Verification Browser, Chromium at 390×844 and 1440×900, Console from this branch's `ui/` src (Vite) against `examples/app-showcase` and `examples/app-crm` booted `--fresh --seed-admin`; the before column is the same stacks with the Console at the merge base `9f13ee81`. <details><summary>Measured before / after</summary> | Where | Before (9f13ee8) | After (this branch) | |---|---|---| | Showcase Delivery Operations, Total Budget, phone | 20 px, scrollWidth 143 in a 123 px box: clipped | 14 px, 100 px: whole | | Same, desktop | 24 px, whole | 24 px, whole (unchanged) | | Showcase Tasks by Status x axis, phone | `In Progr…`, `In Revie…` | `In Progress`, `In Review` (rotated) | | Showcase Task Throughput x axis | `2026-09`, `2026-10` | `Sep 2026`, `Oct 2026` | | Showcase Tasks by Status legend | swatch background transparent | no swatch (per-category bars), label only | | CRM Pipeline Trend x axis | `2025-10` … `2026-10` | `Oct 2025` … `Oct 2026` | | CRM Pipeline Trend legend | both swatches solid `rgb(36, 99, 235)` | primary solid, `vs last year` hollow (1 px border, same hue) | | CRM Opportunities by Stage legend | both swatches transparent (`url(#…)`) | primary solid, `vs previous period` hollow | | CRM Opportunities by Stage x axis, phone | `Prospect…`, `Qualific…`, `Closed W…` | `Prospecting`, `Qualificati…` (title: Qualification), `Closed Won` | </details> <details><summary>Local runs</summary> At head `23380e90` (this branch merged with `origin/main` at `35f20551`): - Under `scripts/pm/os-verify-lock.sh`: `pnpm install --frozen-lockfile`, the `@object-ui/plugin-dashboard...` + `@object-ui/plugin-charts...` closure build, `type-check` of `@object-ui/core`, `@object-ui/plugin-charts` and `@object-ui/plugin-dashboard`, then `pnpm --dir ui exec vitest run --maxWorkers=2 packages/core/ packages/plugin-charts/ packages/plugin-dashboard/`: `VERDICT command-exit 0`; Test Files 523 passed (523), Tests 7418 passed, 42 skipped (7460). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 commands, each run with `</dev/null`, all `exit 0`; `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED. `pnpm --dir ui exec vitest run scripts/` was SIGKILLed on its first run (the box was out of memory with the browser stacks still up); rerun alone after stopping them: exit 0, Test Files 169 passed, 2 skipped (171), Tests 4901 passed, 2 skipped. - Also run: `pnpm --dir ui check:new-line-citations` exit 0 (outside the derivation), `node scripts/check-sdui-manifest.mjs` exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzKuj2HUUN2wUhZ9Go)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c8c27e commit 9e4f1cf

14 files changed

Lines changed: 775 additions & 63 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-charts': patch
4+
'@object-ui/plugin-dashboard': patch
5+
---
6+
7+
Clause-②: yes (widening: @object-ui/core gains the public export formatMonthBucketLabel; no accept set moves)
8+
Metadata-protocol: none
9+
10+
Dashboard numbers and chart labels stay readable on a phone:
11+
12+
- **KPI value.** A dataset-bound KPI tile's value now shrinks to fit its tile, the way the plain metric tiles already did, instead of being cut with an ellipsis (`$1,090,00...` on a phone). Past the smallest size it keeps the ellipsis and shows the full figure as a tooltip. A short value looks as before.
13+
- **Month axis.** A chart over a month-bucketed date dimension labels its axis `Oct 2025` in the reader's locale, not the raw bucket key `2025-10`. The chart tooltip's header reads the same label. The key itself is unchanged in the data, so drill-through and ordering are unaffected. `@object-ui/core` adds `formatMonthBucketLabel(value, locale)`, which answers that label for a `YYYY-MM` key and `undefined` for anything else.
14+
- **Legend swatches.** A bar or area chart's legend draws a colour swatch for every series; it drew none, because those marks fill from a gradient. A comparison (`compareTo`) series' swatch is drawn hollow, so it reads apart from the solid swatch of the series it overlays in the same colour.
15+
- **Horizontal bar labels.** A horizontal bar chart's category labels are drawn in full on a phone, wrapped to at most two lines inside the axis, instead of cut to eight characters (`In Progr…`). A label still too long ends in an ellipsis and shows the full text as a tooltip.

‎ui/packages/core/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export * from './utils/number-display.js';
164164
// below could not import, so a date-valued measure rendered its raw ISO
165165
// string. `@object-ui/fields` re-exports these names unchanged.
166166
export * from './utils/date-display.js';
167+
// The display face of a `YYYY-MM` month bucket key (`Oct 2025`) — read by the
168+
// chart axis, which keeps the key itself as the bucket's identity.
169+
export * from './utils/date-bucket-label.js';
167170
// The native date/time control adapters (objectui#3127), moved down from
168171
// `@object-ui/fields` so `@object-ui/components`' data table can share them
169172
// instead of keeping private copies (objectui#10625). `@object-ui/fields`
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
* The month bucket key's display face (objectstack#27520 row 15): a
11+
* `month`-granularity date dimension answers `2025-10`, and an axis reads
12+
* `Oct 2025`.
13+
*/
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { formatMonthBucketLabel } from '../date-bucket-label';
17+
18+
describe('formatMonthBucketLabel', () => {
19+
it('reads a month bucket key as the short month and the year, in the locale', () => {
20+
expect(formatMonthBucketLabel('2025-10', 'en-US')).toBe('Oct 2025');
21+
expect(formatMonthBucketLabel('2026-01', 'en-US')).toBe('Jan 2026');
22+
expect(formatMonthBucketLabel('2025-10', 'zh-CN')).toBe(new Date(Date.UTC(2025, 9, 1)).toLocaleDateString('zh-CN', { month: 'short', year: 'numeric', timeZone: 'UTC' }));
23+
});
24+
25+
it('names the month the key names whatever the time zone (formatted in UTC)', () => {
26+
// The first of the month at UTC midnight is the previous day west of UTC;
27+
// a local-zone face would print September for `2025-10` there.
28+
expect(formatMonthBucketLabel('2025-10', 'en-US')).not.toMatch(/Sep/);
29+
expect(formatMonthBucketLabel('2025-12', 'en-US')).toBe('Dec 2025');
30+
});
31+
32+
it('keeps a year below 100 as written', () => {
33+
expect(formatMonthBucketLabel('0050-06', 'en-US')).toMatch(/50/);
34+
expect(formatMonthBucketLabel('0050-06', 'en-US')).not.toMatch(/1950/);
35+
});
36+
37+
it('answers undefined for every other shape, so the caller keeps the value as written', () => {
38+
for (const v of ['2025-10-01', '2025-Q4', '2025', '2025-13', '2025-00', '2025-1', 'In Progress', '', null, undefined, 202510]) {
39+
expect(formatMonthBucketLabel(v, 'en-US'), String(v)).toBeUndefined();
40+
}
41+
});
42+
43+
it('answers undefined for a locale Intl refuses', () => {
44+
expect(formatMonthBucketLabel('2025-10', 'not a locale!')).toBeUndefined();
45+
});
46+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
* date-bucket-label — the display face of a month bucket key.
11+
*
12+
* The analytics executor answers a `month`-granularity date dimension with a
13+
* sort-stable bucket KEY, `YYYY-MM` (`2025-10`). The key is the row's identity:
14+
* a drill-through filters by it and the chart orders by it, so it stays as the
15+
* executor wrote it. What a reader sees on an axis is this face instead,
16+
* `Oct 2025` in the display locale.
17+
*
18+
* Only the key shape is recognised: four digits, a dash, a month 01..12, and
19+
* nothing else. A day (`YYYY-MM-DD`) is the date-display path's, and a quarter
20+
* (`2025-Q4`) or a year (`2025`) already reads as written.
21+
*
22+
* The month is formatted in UTC from a UTC date, so no display or browser
23+
* time zone can move it to the neighbouring month.
24+
*/
25+
26+
const MONTH_BUCKET_KEY_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;
27+
28+
/**
29+
* `Oct 2025` for the month bucket key `2025-10`, in `locale`; `undefined` for
30+
* any other value, or when `Intl` refuses the locale, so the caller keeps the
31+
* key as written.
32+
*/
33+
export function formatMonthBucketLabel(value: unknown, locale?: string): string | undefined {
34+
if (typeof value !== 'string' || !MONTH_BUCKET_KEY_RE.test(value)) return undefined;
35+
const [year, month] = value.split('-').map(Number);
36+
const date = new Date(0);
37+
// `setUTCFullYear`, not `Date.UTC(year, …)`: the latter maps years 0-99 onto 1900+y.
38+
date.setUTCFullYear(year, month - 1, 1);
39+
try {
40+
return date.toLocaleDateString(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' });
41+
} catch {
42+
return undefined;
43+
}
44+
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
* Dashboard chart readability (objectstack#27520 rows 15 and 18).
11+
*
12+
* - A month bucket key (`2025-10`) reads `Oct 2025` on the category axis.
13+
* - The legend draws a swatch for a bar series (whose mark fills from a
14+
* gradient, so recharts hands the legend a `url(#…)` it cannot draw), and a
15+
* comparison series' swatch is hollow, apart from the primary's solid one.
16+
* - A horizontal bar's category label is drawn in full on a phone, wrapped
17+
* into the axis width, instead of cut to eight characters; a label that
18+
* still has to be cut carries the full text as its tooltip.
19+
*/
20+
21+
import React from 'react';
22+
import { describe, it, expect, vi, afterEach } from 'vitest';
23+
import { render, cleanup, act } from '@testing-library/react';
24+
25+
vi.mock('recharts', async () => {
26+
const actual = await vi.importActual<any>('recharts');
27+
return {
28+
...actual,
29+
ResponsiveContainer: ({ children }: any) =>
30+
React.cloneElement(children, { width: 360, height: 320 }),
31+
};
32+
});
33+
34+
import AdvancedChartImpl from './AdvancedChartImpl';
35+
import { ChartContainer, ChartTooltipContent } from './ChartContainerImpl';
36+
37+
const DESKTOP_WIDTH = window.innerWidth;
38+
39+
afterEach(() => {
40+
cleanup();
41+
(window as any).innerWidth = DESKTOP_WIDTH;
42+
});
43+
44+
// The legend payload registers from a layout effect; read it after a tick.
45+
const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 60)); });
46+
47+
const swatches = (c: HTMLElement) =>
48+
Array.from(c.querySelectorAll<HTMLElement>('.recharts-legend-wrapper .rounded-\\[2px\\]'));
49+
50+
const categoryTicks = (c: HTMLElement) =>
51+
Array.from(c.querySelectorAll<SVGTextElement>('.recharts-yAxis .recharts-cartesian-axis-tick-value, .recharts-cartesian-axis-tick-labels text'));
52+
53+
describe('month bucket keys on the category axis (row 15)', () => {
54+
it('reads `2025-10` as `Oct 2025`', () => {
55+
const { container } = render(
56+
<AdvancedChartImpl
57+
chartType="line"
58+
data={[{ month: '2025-10', n: 3 }, { month: '2025-11', n: 5 }, { month: '2025-12', n: 4 }]}
59+
xAxisKey="month"
60+
series={[{ dataKey: 'n', label: 'Count' }]}
61+
isAnimationActive={false}
62+
/>,
63+
);
64+
const text = container.textContent ?? '';
65+
expect(text).toContain('Oct 2025');
66+
expect(text).toContain('Dec 2025');
67+
expect(text).not.toContain('2025-10');
68+
});
69+
70+
it('CONTROL: a category that only looks partly like a date is drawn as written', () => {
71+
const { container } = render(
72+
<AdvancedChartImpl
73+
chartType="bar"
74+
data={[{ q: '2025-Q4', n: 3 }, { q: '2026-Q1', n: 5 }]}
75+
xAxisKey="q"
76+
series={[{ dataKey: 'n', label: 'Count' }]}
77+
isAnimationActive={false}
78+
/>,
79+
);
80+
expect(container.textContent).toContain('2025-Q4');
81+
});
82+
});
83+
84+
describe('legend swatches (row 15)', () => {
85+
const compared = [
86+
{ dataKey: 'n', label: 'Count', variant: 'current' as const },
87+
{ dataKey: 'n__compare', label: 'Count · vs last quarter', variant: 'comparison' as const },
88+
];
89+
const rows = [{ stage: 'Prospecting', n: 3, n__compare: 2 }, { stage: 'Proposal', n: 5, n__compare: 4 }];
90+
91+
it('a bar chart draws a colour on every swatch, never a gradient reference', async () => {
92+
const { container } = render(
93+
<AdvancedChartImpl chartType="bar" data={rows} xAxisKey="stage" series={compared} isAnimationActive={false} />,
94+
);
95+
await settle();
96+
const drawn = swatches(container);
97+
expect(drawn).toHaveLength(2);
98+
for (const swatch of drawn) {
99+
const color = swatch.style.getPropertyValue('--color-bg');
100+
expect(color).not.toBe('');
101+
expect(color).not.toMatch(/^url\(/);
102+
}
103+
});
104+
105+
it.each(['bar', 'line', 'area'] as const)('%s: the comparison swatch is hollow and the primary one solid, in the same colour', async (chartType) => {
106+
const { container } = render(
107+
<AdvancedChartImpl chartType={chartType} data={rows} xAxisKey="stage" series={compared} isAnimationActive={false} />,
108+
);
109+
await settle();
110+
const [primary, comparison] = swatches(container);
111+
expect(primary.classList.contains('bg-(--color-bg)')).toBe(true);
112+
expect(primary.getAttribute('data-swatch')).toBeNull();
113+
expect(comparison.getAttribute('data-swatch')).toBe('comparison');
114+
expect(comparison.classList.contains('bg-transparent')).toBe(true);
115+
expect(comparison.classList.contains('border-(--color-bg)')).toBe(true);
116+
// The overlay mirrors its primary's colour; the swatch shape tells them apart.
117+
expect(comparison.style.getPropertyValue('--color-bg')).toBe(primary.style.getPropertyValue('--color-bg'));
118+
});
119+
});
120+
121+
describe('horizontal bar category labels on a phone (row 18)', () => {
122+
const asPhone = () => {
123+
(window as any).innerWidth = 390;
124+
window.dispatchEvent(new Event('resize'));
125+
};
126+
const statuses = [
127+
{ status: 'In Progress', n: 4 },
128+
{ status: 'Backlog', n: 2 },
129+
{ status: 'Waiting on the customer to confirm the delivery window', n: 1 },
130+
];
131+
132+
it('draws "In Progress" whole, not cut to "In Progr…"', () => {
133+
asPhone();
134+
const { container } = render(
135+
<AdvancedChartImpl chartType="horizontal-bar" data={statuses} xAxisKey="status" series={[{ dataKey: 'n', label: 'Tasks' }]} isAnimationActive={false} />,
136+
);
137+
const texts = categoryTicks(container).map((t) => Array.from(t.querySelectorAll('tspan')).map((s) => s.textContent).join(' '));
138+
expect(texts).toContain('In Progress');
139+
expect(container.textContent).not.toContain('In Progr…');
140+
});
141+
142+
it('wraps a long label to at most two lines and carries the full label as its tooltip', () => {
143+
asPhone();
144+
const { container } = render(
145+
<AdvancedChartImpl chartType="horizontal-bar" data={statuses} xAxisKey="status" series={[{ dataKey: 'n', label: 'Tasks' }]} isAnimationActive={false} />,
146+
);
147+
const long = categoryTicks(container).find((t) => t.querySelector('title'));
148+
expect(long).toBeDefined();
149+
expect(long!.querySelector('title')!.textContent).toBe('Waiting on the customer to confirm the delivery window');
150+
const lines = Array.from(long!.querySelectorAll('tspan')).map((s) => s.textContent ?? '');
151+
expect(lines).toHaveLength(2);
152+
expect(lines[1].endsWith('…')).toBe(true);
153+
// A label that fits carries no tooltip.
154+
const backlog = categoryTicks(container).find((t) => t.textContent === 'Backlog');
155+
expect(backlog?.querySelector('title')).toBeNull();
156+
});
157+
});
158+
159+
describe('vertical bar category labels on a phone (row 18)', () => {
160+
const asPhone = () => {
161+
(window as any).innerWidth = 390;
162+
window.dispatchEvent(new Event('resize'));
163+
};
164+
const xTicks = (c: HTMLElement) =>
165+
Array.from(c.querySelectorAll<SVGTextElement>('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value'));
166+
167+
it('a few buckets rotate and draw "In Progress" whole, where the phone cut drew "In Progr…"', () => {
168+
asPhone();
169+
const { container } = render(
170+
<AdvancedChartImpl
171+
chartType="bar"
172+
data={[{ s: 'Backlog', n: 2 }, { s: 'To Do', n: 2 }, { s: 'In Progress', n: 2 }, { s: 'In Review', n: 2 }, { s: 'Done', n: 2 }]}
173+
xAxisKey="s"
174+
series={[{ dataKey: 'n', label: 'Tasks' }]}
175+
isAnimationActive={false}
176+
/>,
177+
);
178+
const labels = xTicks(container).map((t) => t.textContent);
179+
expect(labels).toContain('In Progress');
180+
expect(container.textContent).not.toContain('In Progr…');
181+
});
182+
183+
it('a label still cut carries the full label as its tooltip; a whole one carries none', () => {
184+
const long = 'Waiting on the customer to confirm';
185+
const { container } = render(
186+
<AdvancedChartImpl
187+
chartType="bar"
188+
data={[{ s: long, n: 2 }, { s: 'Done', n: 3 }]}
189+
xAxisKey="s"
190+
series={[{ dataKey: 'n', label: 'Tasks' }]}
191+
isAnimationActive={false}
192+
/>,
193+
);
194+
const ticks = Array.from(container.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-label'));
195+
const cut = ticks.find((t) => (t.textContent ?? '').includes('…'));
196+
expect(cut, 'the long label was not cut, so this measures nothing').toBeDefined();
197+
expect(cut!.querySelector('title')?.textContent).toBe(long);
198+
const whole = ticks.find((t) => t.querySelector('.recharts-cartesian-axis-tick-value')?.textContent === 'Done');
199+
expect(whole?.querySelector('title')).toBeNull();
200+
});
201+
});
202+
203+
describe('a lone bar series painted per category (row 15 control)', () => {
204+
it('draws its legend label with no swatch, since no one colour names it', async () => {
205+
const { container } = render(
206+
<AdvancedChartImpl
207+
chartType="bar"
208+
data={[{ h: 'green', n: 3 }, { h: 'red', n: 1 }]}
209+
xAxisKey="h"
210+
series={[{ dataKey: 'n', label: 'Projects' }]}
211+
config={{ n: { label: 'Projects' } }}
212+
categoryColors={{ green: '#22c55e', red: '#ef4444' }}
213+
isAnimationActive={false}
214+
/>,
215+
);
216+
await settle();
217+
expect(container.querySelector('.recharts-legend-wrapper')?.textContent).toContain('Projects');
218+
expect(swatches(container)).toHaveLength(0);
219+
});
220+
});
221+
222+
223+
describe('the tooltip header names a month bucket the way the axis does (objectstack#27520 row 15)', () => {
224+
const header = (label: string) => {
225+
const { container } = render(
226+
<ChartContainer config={{ amount: { label: 'Amount' } }}>
227+
<ChartTooltipContent active label={label} payload={[{ value: 5, name: 'amount', dataKey: 'amount', payload: {} }]} />
228+
</ChartContainer>,
229+
);
230+
return container.querySelector('.font-medium')?.textContent;
231+
};
232+
233+
it('reads `Oct 2025` for the bucket key `2025-10`', () => {
234+
expect(header('2025-10')).toBe('Oct 2025');
235+
});
236+
237+
it('CONTROL: any other category reads as written', () => {
238+
expect(header('2025-Q4')).toBe('2025-Q4');
239+
expect(header('In Progress')).toBe('In Progress');
240+
});
241+
});

‎ui/packages/plugin-charts/src/AdvancedChartImpl.labelFitBand-13241.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ describe('an x-axis label rotates and is cut only when its band cannot hold it (
113113
// The cut name is still reachable whole: the pointer over its bar opens
114114
// the tooltip, which names the category in full. recharts throttles a
115115
// pointer move through `requestAnimationFrame`, so the frame is flushed.
116-
expect(container.textContent).not.toContain('Needs Analysis');
116+
// The axis draws only the cut; the full name rides in the tick's own
117+
// tooltip (objectstack#27520) and in the chart tooltip below.
118+
expect(labelsOf(ticks)).not.toContain('Needs Analysis');
119+
expect(ticks[2].closest('.recharts-cartesian-axis-tick-label')?.querySelector('title')?.textContent).toBe('Needs Analysis');
117120
const bar = container.querySelectorAll('.recharts-rectangle')[2] as SVGElement;
118121
const x = Number(bar.getAttribute('x')) + Number(bar.getAttribute('width')) / 2;
119122
const y = Number(bar.getAttribute('y')) + Number(bar.getAttribute('height')) / 2;

‎ui/packages/plugin-charts/src/AdvancedChartImpl.missingCategoryKey.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ describe('AdvancedChartImpl — unreadable-data guard (framework#4033)', () => {
100100

101101
expect(container.querySelector('[data-chart-error="missing-category-key"]')).toBeNull();
102102
expect(container.querySelector('svg')).not.toBeNull();
103-
expect(container.textContent).toContain('2026-01');
103+
// The month bucket key draws its display face (objectstack#27520).
104+
expect(container.textContent).toContain('Jan 2026');
104105
expect(warn).not.toHaveBeenCalled();
105106
warn.mockRestore();
106107
});

0 commit comments

Comments
 (0)