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/10516-xaxis-presentation-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/plugin-charts': patch
---

A chart's `xAxis` object is no longer dropped when it carries `min`, `max`, `stepSize`,
`logarithmic` or `position` without also carrying `title`, `format` or `showGridLines`
(objectui#10516).

`normalizeChartSchema` kept the x-axis object only when one of those three hand-named keys
was present. Any other axis lost everything but its `field`, so a scatter authored with
`xAxis: { field: 'progress', min: 0, max: 200, stepSize: 50 }` drew the auto-fitted x
domain and ticks instead of the ones it declared, and nothing refused. The object is now
kept whenever it carries any key of the spec's `ChartAxisSchema` other than `field`. The
key set is the one the normalizer reads, not a second hand-written list.

**User-visible render change.** A scatter whose `xAxis` declares `min` / `max` /
`stepSize` / `logarithmic` with no title, format or grid flag now draws the x scale it asked
for. That completes objectui#9675's x-axis repair for axes written that way. An `xAxis`
carrying only `field` renders as before.

`position` on `xAxis` now survives normalization too, but at this change no renderer reads
it: the x axis draws along the bottom whatever it says.
2 changes: 0 additions & 2 deletions content/docs/plugins/plugin-charts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,6 @@ const schema: ChartSchema = {
| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | The side of the chart the axis belongs to. |
| `logarithmic` | `boolean` | Logarithmic scale on a numeric axis. |

⚠️ On `xAxis`, `min`, `max`, `stepSize` and `logarithmic` currently take effect only when the same object also carries `title`, `format` or `showGridLines`.

A bare string is not an axis object. `xAxis: 'month'` is the sibling spelling of `xAxisKey` and folds onto it at parse; a single object or a bare string on `yAxis` is refused — write a one-entry list.

```tsx
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/utils/chart-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,12 @@ export function seriesPresentation(raw: Record<string, unknown>): AuthoredSeries
* turns on the secondary axis (`yAxes.length > 1`) — survives, including for
* an entry that declares nothing but its own existence.
*
* Keys the renderer does not read on a given axis are dropped by
* `normalizeChartSchema`, the ONE normalization layer (#2880 S1): today it
* keeps `format`/`title`/`showGridLines` on the x-axis and the full set on the
* y-axes. That narrowing is deliberately NOT mirrored here — a second copy
* would drift from the renderer's real capability the moment it grew.
* Which of these keys survive onto each axis is decided by
* `normalizeChartSchema`, the ONE normalization layer (#2880 S1), and is
* deliberately neither mirrored nor restated here: a second copy drifts from
* the renderer's real capability the moment it moves — this paragraph's own
* spelled-out x-axis answer did, at objectui#10516. The x-axis rule is pinned
* in plugin-charts by `normalizeChartSchema.xAxisPresentationGate-10516.test.ts`.
*/
export function axisPresentation(raw: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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#10516 — the seam half: a scatter's authored `xAxis` scale reaches
* the drawn x axis when the axis object carries no `title`, `format` or
* `showGridLines`.
*
* Each side of the seam was already pinned on its own, and both stayed green
* while the chart drew the wrong scale:
*
* - `AdvancedChartImpl.scatterSpecAxes-9675.test.tsx` hands the renderer an
* already-normalized `xAxis` and proves it honours `min` / `max` /
* `stepSize`;
* - `normalizeChartSchema.specAxisKeys-7690.test.ts` proves the normalizer
* reads every spec axis key — through the y-axis list.
*
* Between the two sat the x-axis gate in `normalizeChartSchema`, which kept
* the object only when it carried one of three hand-named keys. So these go
* through `ChartRenderer`, the component `type: 'chart'` resolves to, with the
* axis written in the spec's shape: the normalizer and the renderer run as one
* path, and the tick labels on the bottom axis are the reading.
*
* The `title`-carrying twin is the lit control: it passed the old gate, so it
* draws the authored ticks before the fix and after it. A reading that cannot
* see the authored scale at all fails the control, not just the fix case.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0
// under the headless DOM, so nothing paints. Hand it the box the
// objectui#9675 scatter file uses, so the two read the same geometry.
vi.mock('recharts', async () => {
const actual = await vi.importActual<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactElement<{ width?: number; height?: number }> }) =>
React.cloneElement(children, { width: 510, height: 350 }),
};
});

import { ChartRenderer } from './ChartRenderer';
// `ChartRenderer` lazy-loads `./AdvancedChartImpl`. Importing it here with the
// SAME specifier moves that cost into the import phase, which no test timeout
// applies to (AGENTS.md "测试纪律").
import './AdvancedChartImpl';

afterEach(cleanup);

/** The data spans x 0..100, so an authored 0..200 domain is visibly not the fitted one. */
const ROWS = [
{ progress: 0, avg_estimate: 16.5 },
{ progress: 45, avg_estimate: 40 },
{ progress: 55, avg_estimate: 60 },
{ progress: 80, avg_estimate: 24 },
{ progress: 100, avg_estimate: 12 },
];

const AUTHORED_TICKS = ['0', '50', '100', '150', '200'];

/** Tick label texts on the bottom (x) axis. */
const bottomTicks = (c: HTMLElement) =>
[...c.querySelectorAll('text.recharts-cartesian-axis-tick-value[orientation="bottom"]')].map((t) =>
(t.textContent ?? '').trim(),
);

async function renderScatter(xAxis: Record<string, unknown>) {
const { container } = render(
<ChartRenderer
schema={{
type: 'chart',
chartType: 'scatter',
data: ROWS,
xAxis,
yAxis: [{ field: 'avg_estimate' }],
isAnimationActive: false,
}}
/>,
);
await waitFor(() => expect(container.querySelector('.recharts-surface')).toBeTruthy());
return container;
}

describe('objectui#10516 — a scatter x axis draws its authored scale through ChartRenderer', () => {
it('min / max / stepSize with no title set the bottom axis ticks', async () => {
const container = await renderScatter({ field: 'progress', min: 0, max: 200, stepSize: 50 });
expect(bottomTicks(container), 'the authored x scale was dropped before it reached the axis').toEqual(
AUTHORED_TICKS,
);
});

it('(control) the same axis with a title draws the same ticks', async () => {
const container = await renderScatter({ field: 'progress', title: 'Progress', min: 0, max: 200, stepSize: 50 });
expect(bottomTicks(container)).toEqual(AUTHORED_TICKS);
});
});
13 changes: 12 additions & 1 deletion packages/plugin-charts/src/normalizeChartSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,20 @@ export function normalizeChartSchema(
// ── axes ────────────────────────────────────────────────────────────────
// Spec `xAxis` is an object; the report surface narrows it to a bare string.
// Both mean "the column on the category axis".
//
// The x-axis object is kept whenever it carries ANY presentation key — every
// key `normalizeAxis` kept except `field`, the data key hoisted to `xAxisKey`
// below (objectui#10516). The set is what `normalizeAxis` read, so it is the
// spec's `ChartAxisSchema` key set by construction
// (`normalizeChartSchema.specAxisKeys-7690.test.ts` ties the two), and ⛔ it
// is not restated here: this gate used to name `format` / `title` /
// `showGridLines` by hand, so an axis carrying only `min` / `max` /
// `stepSize` / `logarithmic` / `position` was dropped whole and a scatter's
// authored x scale never reached its axis. The test asks which keys
// survived, never how truthy their values are: `min: 0` counts.
const xAxisRaw = schema.xAxis;
const xAxisSpec = normalizeAxis(xAxisRaw, language);
if (xAxisSpec && (xAxisSpec.format || xAxisSpec.title || xAxisSpec.showGridLines !== undefined)) {
if (xAxisSpec && Object.keys(xAxisSpec).some((key) => key !== 'field')) {
out.xAxis = xAxisSpec;
}
const xAxisKey = str(schema.xAxisKey) ?? xAxisSpec?.field ?? str(xAxisRaw);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 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#10516 — `normalizeChartSchema` keeps an `xAxis` object whenever it
* carries ANY declared presentation key, not only `format`, `title` or
* `showGridLines`.
*
* The x-axis gate used to name three keys by hand. An axis carrying only
* `min` / `max` / `stepSize` / `logarithmic` / `position` beside its `field`
* failed it, so the whole object was dropped and only `xAxisKey` survived: a
* scatter's authored x domain, tick spacing and scale never reached its
* `XAxis`, and nothing said so.
*
* ## The key set is the spec's, read off the installed spec
*
* The presentation keys are `ChartAxisSchema`'s keys minus `field`, which is
* the one DATA key on an axis and is hoisted to `xAxisKey`. The sibling file
* `normalizeChartSchema.specAxisKeys-7690.test.ts` ties `normalizeAxis`' read
* set to that same list in both directions, so a key the spec adds joins the
* per-key pins below on the next install, with no edit here.
*
* ## Controls
*
* - `title` — kept before the fix and after it: the lit control that this
* read observes a surviving object at all (`format` and `showGridLines`,
* the gate's other two hand-named keys, behave the same way);
* - `{ field }` alone — no presentation key, so still no `xAxis`;
* - a value `normalizeAxis` refuses is not a presentation key, so the gate
* reads the NORMALIZED axis, not the author's raw object;
* - a falsy declared value (`min: 0`, `logarithmic: false`,
* `showGridLines: false`) still counts: the gate asks which keys survived,
* never how truthy their values are.
*/

import { describe, it, expect } from 'vitest';
import { ChartAxisSchema as SpecChartAxisSchema } from '@objectstack/spec/ui';
import { normalizeChartSchema } from './normalizeChartSchema';

/** Every spec axis key except `field`, the data key the x axis hoists to `xAxisKey`. */
const PRESENTATION_KEYS = Object.keys(SpecChartAxisSchema.shape)
.filter((key) => key !== 'field')
.sort();

/** One value per presentation key that `normalizeAxis` admits. */
const SAMPLE: Record<string, unknown> = {
title: 'Progress',
format: '0.0%',
min: 0,
max: 200,
stepSize: 50,
showGridLines: true,
position: 'top',
logarithmic: true,
};

const normalizeX = (xAxis: Record<string, unknown>) =>
normalizeChartSchema({ type: 'chart', chartType: 'scatter', xAxis });

describe('objectui#10516 — the x-axis object survives on any declared presentation key', () => {
it('the sample covers exactly the spec presentation keys — a new spec key fails here first', () => {
expect(Object.keys(SAMPLE).sort()).toEqual(PRESENTATION_KEYS);
});

it.each(PRESENTATION_KEYS)('`%s` alone beside `field` keeps the x-axis object', (key) => {
const axis = { field: 'progress', [key]: SAMPLE[key] };
const out = normalizeX(axis);
expect(out.xAxis, `an xAxis carrying only \`${key}\` beside its field was dropped`).toEqual(axis);
expect(out.xAxisKey).toBe('progress');
});

it('the card reproduction — min, max, stepSize, logarithmic and position together, with no title', () => {
const axis = { field: 'progress', min: 0, max: 200, stepSize: 50, logarithmic: true, position: 'top' };
const out = normalizeX(axis);
expect(out.xAxis).toEqual(axis);
expect(out.xAxisKey).toBe('progress');
});

it('a falsy declared value still counts — the gate reads which keys survived, not their truthiness', () => {
for (const axis of [
{ field: 'progress', min: 0 },
{ field: 'progress', logarithmic: false },
{ field: 'progress', showGridLines: false },
]) {
expect(normalizeX(axis).xAxis).toEqual(axis);
}
});

it('(control) `field` alone carries no presentation key — no xAxis, and the column still lands on xAxisKey', () => {
const out = normalizeX({ field: 'progress' });
expect(out.xAxis).toBeUndefined();
expect(out.xAxisKey).toBe('progress');
});

it('(control) a value normalizeAxis refuses is not a presentation key', () => {
// A non-positive `stepSize`, a non-numeric `min` and an undeclared
// `position` are each dropped by `normalizeAxis`; none keeps the object.
const out = normalizeX({ field: 'progress', stepSize: -5, min: 'zero', position: 'middle' });
expect(out.xAxis).toBeUndefined();
expect(out.xAxisKey).toBe('progress');
});
});
Loading