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
63 changes: 63 additions & 0 deletions .changeset/quiet-moons-listen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@object-ui/core': minor
'@object-ui/plugin-charts': patch
'@object-ui/plugin-dashboard': patch
---

A clicked cartesian mark names its own series, and the drill title reads its label

objectui#4672, objectui#4682.

**The dead pivoted drill.** objectui#4680 fixed what a cartesian click could
read out of recharts 3's `MouseHandlerDataParam`, and measured the wall it could
not get past: a chart-level click is an AXIS interaction, and recharts
dispatches those with `activeDataKey` hard-coded `undefined`, because the shared
cursor spans every series at that tick. A pivoted dataset chart — 2 dimensions,
1 measure, the shape ADR-0021 introduced — needs the series to resolve its drill
row, so every segment of every such dashboard chart stayed a dead click. The
series was left unresolved rather than guessed, and the card carried the rest.

The answer is the mark itself. This renderer draws the `Bar` / `Line` / `Area`,
so an item-level `onClick` closes over the very `dataKey` it was rendered with —
the series is statically known, not inferred from tooltip state.

Both handlers fire for one gesture (measured: item first, chart second, sharing
one `nativeEvent` object), so the item handler does not emit. It RECORDS its
series, stamped with that gesture, and the chart-level handler composes the one
event. That is the double-fire answer and the additive property together:

- **one click, one drill event**, because there is one emit site — not a second
event suppressed after the fact;
- **a click that lands on no mark is untouched**: it records nothing and falls
through to the objectui#4680 axis answer exactly as shipped — category, bucket
identity, and the series only where one series is plotted. Empty plot area
stays category-only, and "drill the whole category" was rejected as a
different product question. Nothing that resolved before stops resolving; a
line's `dot={false}` stroke simply GAINS the exact series where it is hit;
- pairing on the shared DOM event rather than on a flag means a record left by
one gesture can never be adopted by a later click.

The clicked key is forwarded exactly as rendered, `''` included: the
empty-string second-dimension group draws its own bar since objectui#4673, and
`''` is falsy, so a truthiness test on the way out would send no series at all
and leave that bar's drill standing on the reader's coercion instead of on what
was clicked.

**The opaque drill title.** `ChartSegmentClickEvent` gains `seriesLabel`, and
`DatasetWidget`'s drill drawer titles itself from `seriesLabel ?? series`.
`ev.series` stays the LOOKUP key — `findChartSeriesRow` resolves it through the
same assignment `buildChartSeries` made — and only the title reads the label.

The two strings are equal for every ordinary group, which is why reading the key
as a title went unnoticed. They part company when a group's label cannot name
it: the null bucket beside a record whose stored value literally spells
`(None)`, which is objectui#4508's collision on the series axis, reachable since
objectui#4673. Both groups then key by `chartBucketId`, and the drawer opened on
the right records under the title `Backlog / [null]`. An internal id where a
label belongs reads as broken DATA rather than as a broken title.

Neither string can do the other's job, which is why this is a second field
rather than a change to the first: the label is not resolvable (it is exactly
what the colliding groups share) and the key is not showable. `seriesLabel` is
optional and absent wherever a renderer resolved no label, so every other
chart's title is byte-identical.
62 changes: 62 additions & 0 deletions packages/core/src/utils/chart-series.nullCategory.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@ import {
chartRowBucketId,
CHART_BUCKET_ID_KEY,
NULL_CATEGORY_LABEL,
type ChartSegmentClickEvent,
} from './chart-series';
import { pivotBucketId, pivotDimensionValue } from './dataset-pivot';

Expand DownExpand Up@@ -1004,3 +1005,64 @@ describe('findChartSeriesRow — the null second-dimension segment keeps its dri
).toBe(1);
});
});

/**
* objectui#4682 — the click event carries the group's LABEL beside its key.
*
* `ChartSegmentClickEvent.series` is a `dataKey` {@link buildChartSeries}
* assigned, and {@link findChartSeriesRow} resolves it back through that same
* assignment. A consumer that TITLES itself from that key is reading an
* internal id: for every ordinary group the key is the label, but the groups
* whose label cannot name them key by identity, and the drawer then announced
* `[null]` over a segment the user saw labelled `(None)`.
*
* `seriesLabel` is a second field rather than a change to the first, and this
* block is why: the two strings answer different questions, and neither can do
* the other's job. The label is not resolvable — it is precisely what the
* colliding groups SHARE — and the key is not showable.
*/
describe('ChartSegmentClickEvent — the series key and its label (objectui#4682)', () => {
const RAW = [
{ status: 'Backlog', priority: NULL_CATEGORY_LABEL, est_hours: 1 },
{ status: 'Backlog', priority: null, est_hours: 2 },
];
const DIMS = ['status', 'priority'];
const VALS = ['est_hours'];

it('the LABEL cannot do the lookup’s job — which is why the key stays the key', () => {
const { series } = buildChartSeries(RAW, DIMS, VALS);
// One label over two groups: resolving by it is not merely lossy, it is
// undefined — there is no row it names.
expect(series.map((s) => s.label)).toEqual([NULL_CATEGORY_LABEL, NULL_CATEGORY_LABEL]);
expect(findChartSeriesRow(RAW, DIMS, VALS, 'Backlog', NULL_CATEGORY_LABEL)).toBe(-1);
// The keys resolve, each to its own row.
expect(findChartSeriesRow(RAW, DIMS, VALS, 'Backlog', series[0].dataKey)).toBe(0);
expect(findChartSeriesRow(RAW, DIMS, VALS, 'Backlog', series[1].dataKey)).toBe(1);
});

it('carries a label a consumer may show, alongside the key it must not', () => {
const { series } = buildChartSeries(RAW, DIMS, VALS);
// The shape a renderer composes: `series` for `findChartSeriesRow`,
// `seriesLabel` for the title. Typed here so the field cannot be dropped
// from the interface without this file going red.
const ev: ChartSegmentClickEvent = {
category: 'Backlog',
series: series[1].dataKey,
seriesLabel: series[1].label,
};
expect(ev.series).toBe('[null]');
expect(ev.seriesLabel).toBe(NULL_CATEGORY_LABEL);
expect(findChartSeriesRow(RAW, DIMS, VALS, ev.category, ev.series)).toBe(1);
// The title's read, as the consumer performs it.
expect(ev.seriesLabel ?? ev.series).toBe(NULL_CATEGORY_LABEL);
});

it('is OPTIONAL — an event without one still titles from the key', () => {
// Renderers that resolve a series they have no label for (the scatter /
// treemap / sankey mappers) omit it, and their titles must not lose the
// series half.
const ev: ChartSegmentClickEvent = { category: 'Backlog', series: 'High' };
expect(ev.seriesLabel).toBeUndefined();
expect(ev.seriesLabel ?? ev.series).toBe('High');
});
});
23 changes: 23 additions & 0 deletions packages/core/src/utils/chart-series.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,29 @@ export interface ChartSegmentClickEvent {
categoryId?: string;
/** The clicked series' key — a measure, or a pivoted second-dimension value. */
series?: string;
/**
* The clicked series' DISPLAY LABEL, when the renderer knew one
* (objectui#4682) — `series[].label` as {@link buildChartSeries} assigned it.
*
* {@link ChartSegmentClickEvent.series} stays the LOOKUP key and this never
* substitutes for it: {@link findChartSeriesRow} resolves a key through the
* same assignment `buildChartSeries` made, and a label cannot be resolved
* that way — it is not unique (that is precisely why the colliding groups key
* by identity instead). This field exists for what a key cannot do: be SHOWN.
*
* The two strings are equal for every ordinary group, which is why reading
* the key as a title went unnoticed. They part company when a group's label
* cannot name it — objectui#4508's collision on the series axis — and the key
* becomes an opaque `chartBucketId`. A drawer titled `Backlog / [null]` over
* a segment the user saw labelled `(None)` reads as broken DATA rather than
* as a broken title, which is why the label travels with the click rather
* than being re-derived by the consumer.
*
* Absent when the renderer resolved no series, or when the series carries no
* label distinct from its key; a consumer titles itself from
* `seriesLabel ?? series` and so is unchanged wherever it is absent.
*/
seriesLabel?: string;
/** The measure value at the click point. */
value?: number;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -203,13 +203,35 @@ describe('AdvancedChartImpl — the cartesian handler over the recharts 3 payloa
expect(clicks[0].value).toBe(11);
});

it('leaves the series unresolved when the shared cursor names none — it does not guess one', () => {
/**
* RESTATED, not relaxed, by objectui#4672's ruled half.
*
* This assertion was written when NO cartesian click could resolve a series
* under the shared cursor, and it pinned the non-guessing contract for all of
* them. The mark-level handler has since answered the case it was standing in
* for: a click that lands ON a segment now resolves its series exactly
* (`AdvancedChartImpl.itemSeriesClick.test.tsx`).
*
* What it pins is therefore NARROWER now, and still load-bearing — it is
* objectui#4672's sub-decision 3 verbatim. Re-measured rather than assumed:
* this case drives the chart-level handler DIRECTLY, which is precisely the
* shape of a click that reached no mark (empty plot area, an axis label, a
* gap between bars), so the assertion holds unchanged and now says the thing
* the ruling requires — such a click stays category-only, and "drill the
* whole category" was rejected as a different product question.
*
* The end-to-end form of the same contract, through a real DOM click on the
* plot surface, is in the sibling file; both are kept because they fail for
* different reasons — that one if a mark handler ever fires for a non-mark
* target, this one if the handler itself starts guessing.
*/
it('leaves the series unresolved when a click reached no mark — it does not guess one', () => {
const clicks: ChartSegmentClickEvent[] = [];
renderPivoted((ev) => clicks.push(ev));

// The same click under the DEFAULT (shared/axis) cursor these charts
// render: recharts dispatches axis interactions with `activeDataKey`
// hard-coded `undefined`, so the payload names no series at all.
// The DEFAULT (shared/axis) cursor these charts render: recharts dispatches
// axis interactions with `activeDataKey` hard-coded `undefined`, so the
// payload names no series at all — and no mark claimed this gesture.
seam.onClick!({
activeCoordinate: { x: 372.5, y: 200 },
activeDataKey: undefined,
Expand All@@ -224,9 +246,9 @@ describe('AdvancedChartImpl — the cartesian handler over the recharts 3 payloa
expect(clicks[0].category).toBe('Done');
// ...and the series is left open rather than filled with series[0], which
// would drill to another group's records — a WRONG drill, worse than the
// dead one. Resolving this needs the clicked mark, not this payload:
// objectui#4672's open half.
// dead one.
expect(clicks[0].series).toBeUndefined();
expect(clicks[0].seriesLabel).toBeUndefined();
expect(clicks[0].value).toBeUndefined();
});

Expand DownExpand Up@@ -287,6 +309,13 @@ describe('AdvancedChartImpl — the cartesian handler over the recharts 3 payloa
* label but NO `activeDataKey`. That is why the pivoted drill cannot be
* resolved from this payload, and it is the premise to re-measure when recharts
* is upgraded.
*
* Still true and still the reason the mark-level handler exists
* (objectui#4672's ruled Option A): the series is resolved from the mark that
* was clicked, never from this payload. If a future recharts starts populating
* `activeDataKey` for axis interactions, this test goes red and the fallback
* arm above becomes reachable again — which is the outcome to notice, not to
* paper over.
*/
describe('recharts 3 — what a shared-cursor cartesian click actually reports', () => {
it('reports an index and a label, and no series key', async () => {
Expand Down
Loading
Loading