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
40 changes: 40 additions & 0 deletions .changeset/10932-pivot-drilldown-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/types': minor
'@object-ui/plugin-dashboard': minor
---

`drillDown` is retired on the bare `pivot` node: author `object-pivot` to drill (objectui#10932)

**Breaking for authored metadata, graded `minor` by this repo's convention (AGENTS.md: a
breaking change here is `minor`, never `major`):** a `pivot` node no longer accepts
`drillDown` on either published face. The TypeScript `PivotTableSchema.drillDown` is a
`?: never` tombstone, and the `pivot` arm of the zod mirror, the door `objectui validate` /
`safeValidateSchema` run, refuses the key by name with `invalid_type` at `drillDown`. The
refusal names the remedy. Migration: delete the key, or, for a pivot whose cells open the
records behind a value, author an `object-pivot` (`objectName` plus the same `rowField` /
`columnField` / `valueField`) and put the `drillDown` there. Every other `pivot` member is
accepted exactly as before.

Nothing ever honoured the key on this node. `PivotTable` fired a drill only when its host
passed an `onDrillDown` handler. The one host that does is `ObjectPivotTable`, which renders
`object-pivot`, and the `pivot` registration passes none. So an authored `drillDown`
validated and clicking a cell did nothing, with no signal. A pivot over inline `data` also
names no object to list the drilled records from. No shipped or example document authors
the key on a `pivot` node.

This supersedes what three pending entries say about the `pivot` arm's `drillDown`: the
objectui#10859 (batch 2) entry ("`drillDown` is the shared `DrillDownConfigSchema`"), and the
dated notes on the objectui#7352 and objectui#10685 entries, which read that key as the shared
mirror (a third referencing declaration, and `mode` accepted there). As of this change the
arm refuses the key.

- `@object-ui/types`: `PivotTableSchema.drillDown` is `?: never`, and the zod `pivot` arm's
`drillDown` is a `retirementTombstone()`. The content-channel refusal on `body` / `children`
no longer lists `drillDown` among what a `pivot` renders.
- `@object-ui/plugin-dashboard`: `PivotTable` reads nothing off `schema` for its drill.
**Behaviour change for React callers:** its `onDrillDown` prop is now the only switch. A
caller that passes a handler gets interactive cells, headers and totals, and a
`schema.drillDown` no longer gates them (the type refuses that key anyway). A caller that
wants no drill passes no handler. `ObjectPivotTable` already passed its handler only when
its own `drillDown` was enabled, so `object-pivot` behaves exactly as before, and its
`schema.drillDown` keeps the `ObjectPivotDrillDownConfig` type.
35 changes: 27 additions & 8 deletions packages/plugin-dashboard/src/ObjectPivotTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,23 @@ import type { ObjectPivotDrillDownConfig, PivotTableSchema } from '@object-ui/ty
*/
const EMPTY_ROWS = Object.freeze([]) as unknown as any[];

/**
* `PivotTableSchema` minus `drillDown`, which is a retirement tombstone on the
* `pivot` node (objectui#10932). `object-pivot` declares its own `drillDown`
* below, and intersecting it with the tombstone would collapse that member to
* `never`: the one pivot that drills could not be authored with a drill.
*
* Key remapping, not `Omit`: `PivotTableSchema` extends `BaseSchema`, whose
* `[key: string]: any` widens `keyof` to all of `string`, so
* `Omit<PivotTableSchema, 'drillDown'>` would keep the index signature and drop
* every declared member.
*/
type PivotTableSchemaWithoutDrillDown = {
[K in keyof PivotTableSchema as K extends 'drillDown' ? never : K]: PivotTableSchema[K];
};

export interface ObjectPivotTableProps {
schema: PivotTableSchema & {
schema: PivotTableSchemaWithoutDrillDown & {
objectName?: string;
/**
* RETIRED (objectui#7353, ADR-0049 remove arm, ruling 5809008870) — write
Expand Down Expand Up @@ -96,11 +111,12 @@ export interface ObjectPivotTableProps {
// sink, so the drill is correct for both arms whatever this key is typed.
filter?: any;
/**
* This block's drill-down shape, not the shared `DrillDownConfig` that
* `PivotTableSchema` carries: `mode` is refused by name
* (`ObjectPivotDrillDownConfig`, objectui#10685, applying objectui#9002's
* ruling B). Every click point on a pivot is an aggregated bucket, so it
* always drills through; there is no row for `mode` to open as a record.
* This block's drill-down shape, `ObjectPivotDrillDownConfig`: `mode` is
* refused by name (objectui#10685, applying objectui#9002's ruling B).
* Every click point on a pivot is an aggregated bucket, so it always drills
* through; there is no row for `mode` to open as a record. `PivotTableSchema`
* carries no drill at all: on a plain `pivot` node the key is a retirement
* tombstone (objectui#10932), and this block is where a pivot drill lives.
*/
drillDown?: ObjectPivotDrillDownConfig;
};
Expand Down Expand Up @@ -354,9 +370,12 @@ export const ObjectPivotTable: React.FC<ObjectPivotTableProps> = ({ schema, data
);
}

// Delegate to PivotTable with resolved data
// Delegate to PivotTable with resolved data. `drillDown` is this block's and
// stays behind: `PivotTable` drills through `onDrillDown` below, and on the
// `pivot` node it renders the key is a retirement tombstone (objectui#10932).
const { drillDown: _objectPivotDrillDown, ...pivotSchema } = schema;
const finalSchema: PivotTableSchema = {
...schema,
...pivotSchema,
data: finalData,
};

Expand Down
20 changes: 14 additions & 6 deletions packages/plugin-dashboard/src/PivotTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import React, { useMemo } from 'react';
import type { PivotTableSchema, PivotAggregation } from '@object-ui/types';
import { cn } from '@object-ui/components';
import { isDrillEnabled, type DrillEvent } from '@object-ui/core';
import type { DrillEvent } from '@object-ui/core';
import { useSafeTranslate } from '@object-ui/i18n';
import { WidgetEmptyState } from './WidgetEmptyState';

Expand Down Expand Up @@ -41,9 +41,17 @@ export interface PivotTableProps {
*/
sourceLabel?: string;
/**
* Drill-down click handler. When provided **and** `schema.drillDown` is
* enabled, cells / row & column headers / totals become interactive.
* Receives the click context which is forwarded to the drill-down engine.
* Drill-down click handler, and the ONLY drill switch. When provided, cells /
* row & column headers / totals become interactive and each click hands its
* context here; the host decides what a drill opens. `ObjectPivotTable`
* passes it exactly when its `object-pivot` `drillDown` is enabled.
*
* Nothing is read off `schema` for this: `drillDown` on a `pivot` node is a
* retirement tombstone (objectui#10932), since this component used to drill
* only for a host that passed this handler and the `pivot` registration
* passes none. Not a `drillDown` prop either: `SchemaRenderer` spreads a
* node's keys as props, so a prop of that name would hand an authored
* `drillDown` straight back to this component.
*/
onDrillDown?: (event: DrillEvent) => void;
}
Expand Down Expand Up @@ -179,11 +187,11 @@ export const PivotTable: React.FC<PivotTableProps> = ({ schema, className, rowLa
showColumnTotals = false,
format,
columnColors,
drillDown,
} = schema;
const totalLabel = useTotalLabel();

const drillEnabled = isDrillEnabled(drillDown) && typeof onDrillDown === 'function';
// The host's handler is the switch (see `onDrillDown`); nothing on the node is.
const drillEnabled = typeof onDrillDown === 'function';
const fireDrill = (ev: DrillEvent) => {
if (!drillEnabled) return;
onDrillDown!({
Expand Down
30 changes: 20 additions & 10 deletions packages/plugin-dashboard/src/__tests__/PivotTable.drill.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,34 @@ const baseSchema: any = {
};

describe('PivotTable drill-down', () => {
it('does not render interactive cells when drillDown is omitted', () => {
it('does not render interactive cells without a host `onDrillDown`', () => {
render(<PivotTable schema={baseSchema} />);
// No element with role="button"
expect(screen.queryAllByRole('button').length).toBe(0);
});

it('does not call onDrillDown when drillDown.enabled !== true', () => {
const onDrillDown = vi.fn();
render(<PivotTable schema={baseSchema} onDrillDown={onDrillDown} />);
// objectui#10932: `drillDown` on a `pivot` node is a retirement tombstone,
// and `PivotTable` reads nothing off the node for its drill. The host's
// `onDrillDown` is the only switch (`ObjectPivotTable` passes it exactly when
// its `object-pivot` drill is enabled). Both directions are pinned: the node
// key can neither turn the drill on nor turn it off.
it('the node\'s `drillDown` cannot turn the drill on: no handler, no interactive cells (objectui#10932)', () => {
render(<PivotTable schema={{ ...baseSchema, drillDown: { enabled: true } }} />);
expect(screen.queryAllByRole('button').length).toBe(0);
expect(onDrillDown).not.toHaveBeenCalled();
});

it('the node\'s `drillDown` cannot turn the drill off: the host handler is the switch (objectui#10932)', () => {
const onDrillDown = vi.fn();
render(<PivotTable schema={{ ...baseSchema, drillDown: { enabled: false } }} onDrillDown={onDrillDown} />);
fireEvent.click(screen.getByLabelText('Drill into stage=won, source=web'));
expect(onDrillDown).toHaveBeenCalledTimes(1);
});

it('emits cell payload with rowKey/colKey/value/scope on click', () => {
const onDrillDown = vi.fn();
render(
<PivotTable
schema={{ ...baseSchema, drillDown: { enabled: true } }}
schema={baseSchema}
onDrillDown={onDrillDown}
/>,
);
Expand All @@ -62,7 +72,7 @@ describe('PivotTable drill-down', () => {
const onDrillDown = vi.fn();
render(
<PivotTable
schema={{ ...baseSchema, drillDown: { enabled: true } }}
schema={baseSchema}
onDrillDown={onDrillDown}
/>,
);
Expand All @@ -74,7 +84,7 @@ describe('PivotTable drill-down', () => {
const onDrillDown = vi.fn();
render(
<PivotTable
schema={{ ...baseSchema, drillDown: { enabled: true } }}
schema={baseSchema}
onDrillDown={onDrillDown}
/>,
);
Expand All @@ -86,7 +96,7 @@ describe('PivotTable drill-down', () => {
const onDrillDown = vi.fn();
render(
<PivotTable
schema={{ ...baseSchema, drillDown: { enabled: true } }}
schema={baseSchema}
onDrillDown={onDrillDown}
/>,
);
Expand All @@ -100,7 +110,7 @@ describe('PivotTable drill-down', () => {
const onDrillDown = vi.fn();
render(
<PivotTable
schema={{ ...baseSchema, drillDown: { enabled: true } }}
schema={baseSchema}
rowLabels={{ won: 'Won', lost: 'Lost' }}
columnLabels={{ web: 'Web', event: 'Event' }}
onDrillDown={onDrillDown}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* 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.
*/

/**
* A `pivot` NODE drills nowhere, whatever `drillDown` it carries
* (objectui#10932): the runtime half of the retirement, read through the real
* `SchemaRenderer` and the real `pivot` registration rather than by calling
* `PivotTable` directly.
*
* The route matters. `SchemaRenderer` spreads a node's keys as React props, so
* a component can consume a key it never reads off `schema`. This render is
* what an author's document goes through, so it sees both channels: `schema`
* and the spread. `drillDown` on a `pivot` node is refused at validation (the
* `@object-ui/types` pin is `pivot-drilldown-retired-10932.test.ts`); this
* file pins that a document which reaches the renderer anyway draws a plain
* cross-tab with no drill affordance, rather than a clickable cell that does
* nothing.
*/

import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { SchemaRenderer } from '@object-ui/react';
// Registers `pivot` → `PivotTable`, the registration every `pivot` path uses.
import '../index';
import { PivotTable } from '../PivotTable';

afterEach(cleanup);

const PIVOT = {
type: 'pivot' as const,
rowField: 'stage',
columnField: 'source',
valueField: 'amount',
data: [
{ stage: 'won', source: 'web', amount: 100 },
{ stage: 'lost', source: 'event', amount: 25 },
],
};

/** What an author's document carries: the retired key, reaching the renderer anyway. */
const NODE = { ...PIVOT, drillDown: { enabled: true } };

describe('a `pivot` node authored with `drillDown` draws no drill affordance (objectui#10932)', () => {
it('renders the cross-tab through SchemaRenderer, with no interactive cell or header', () => {
render(<SchemaRenderer schema={NODE} />);
// Lit control: the pivot itself rendered, cells and headers included.
expect(screen.getByText('won')).toBeTruthy();
expect(screen.getByText('100')).toBeTruthy();
expect(screen.queryAllByRole('button')).toHaveLength(0);
expect(screen.queryByLabelText('Drill into stage=won, source=web')).toBeNull();
});

it('CONTROL — the query can see a drill affordance when a host passes `onDrillDown`', () => {
render(<PivotTable schema={PIVOT} onDrillDown={vi.fn()} />);
expect(screen.getByLabelText('Drill into stage=won, source=web')).toBeTruthy();
expect(screen.queryAllByRole('button').length).toBeGreaterThan(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
* ledger side (both `UnmirroredDeclared` rows gone, the new pair registered)
* is pinned by the parity file's own ratchet.
*
* ⚠️ `PivotTableSchema.drillDown` is NOT covered here: that declaration has no
* zod mirror at all, so it sits in no ledger. The mirror minted here is the home
* that key will use whenever the pivot pair is mirrored (a separate card).
* ⚠️ `PivotTableSchema.drillDown` is NOT covered here. The pivot pair gained a
* zod mirror in objectui#10859 (batch 2), which judged that key by the mirror
* minted here, and objectui#10932 then retired it on the `pivot` node, which
* nothing drills: `pivot-drilldown-retired-10932.test.ts` pins the refusal.
*
* ⚠️ Not the spec's `ChartDrillDownSchema`, deliberately: `@objectstack/spec/ui`
* models the CHART-ONLY subset (`enabled` / `filter` / `title` / `target` /
Expand Down
Loading
Loading