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
51 changes: 51 additions & 0 deletions .changeset/chart-drilldown-navigate-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@objectstack/spec": minor
---

feat(spec): `drillDown.target` 补上 `'navigate'` —— 渲染器已经兑现的第三个 arm (#5435)

`<ObjectChart drillDown={{ target }}>` 现在接受 `'navigate'`,联合从
`'drawer' | 'dialog'` 扩成 `'drawer' | 'dialog' | 'navigate'`。**纯 additive**:
之前能解析的一律照旧解析。

## 为什么现在才加

#5022 当初把 `'navigate'` 排除在外,依据是一条**测量**而不是设计偏好 ——
当时 objectui 的 `ObjectChart` 自绘抽屉只分支 `'dialog'`,`'navigate'` 会静默
落进 Sheet。声明一个渲染器不兑现的值,等于用协议承诺一次永远不会发生的跳转。

objectui#3382 把这条测量改掉了:`ObjectChart` 现在真正兑现 `'navigate'`,语义
对齐 `DrillDownDrawer.navigateOnly` —— 也就是 table / pivot / metric 三个 widget
在共享的 `DrillDownConfig` 上一直以来的行为。测量失效,联合随之跟上。

顺序不可颠倒:**先有渲染器兑现,协议才声明**。在此之前(objectui#3382 合并前)
拒绝 `'navigate'` 是正确的。

## 写法与兑现条件

```jsx
<ObjectChart objectName="opportunity"
aggregate={{ function: 'sum', field: 'amount', groupBy: 'stage' }}
drillDown={{ target: 'navigate' }} />
```

- `'drawer'`(默认)—— 就地侧边抽屉;
- `'dialog'` —— 居中模态,适合图表本身已经在抽屉里、再叠一层 Sheet 会很别扭的场合;
- `'navigate'` —— **跳过就地视图**,直接打开该对象的完整列表页,带上抽屉本会用的
同一套过滤条件(widget filter ∧ 点击段的上下文)。适合「钻取结果是目的地」而不是
「瞄一眼」的场景。

`'navigate'` 是唯一带 **host 前提**的 arm:宿主应用必须提供 drill navigation
(objectui 侧是 `DrillNavigationContext.openRecordList`)。宿主没提供时无处可跳,
渲染器**文档化回落**到 `'drawer'` —— 这是既定行为而非故障,点击照样打开记录,
只是就地打开。

注意 escape hatch 与本键无关:只要宿主接了 drill navigation,抽屉里就一直有
"Open in list" 动作,所以 `'drawer'` 的图表也能按需到达列表页。`'navigate'` 的
意义是把这次跳转变成**默认**的点击行为。

## 影响面

`packages/lint` 的 `validate-react-page-props` 直接 parse `ChartDrillDownSchema`,
所以发布闸门随联合一起放行 —— 该 gate 本身一行未改。收窄未发生:三个 arm 以外的
`target` 仍然按值被拒。
2 changes: 1 addition & 1 deletion content/docs/references/ui/chart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Inline aggregation for an object-bound chart
| **enabled** | `boolean` | optional | Turn the segment drill on/off; the block being present already means on, so this is only needed to force it off |
| **filter** | `Record<string, any>` | optional | Filter for the drilled list; values support $`{event.*}`. Omit to derive it from the clicked category |
| **title** | `string` | optional | Drill drawer/dialog heading; supports $`{event.*}` interpolation |
| **target** | `Enum<'drawer' \| 'dialog'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal) |
| **target** | `Enum<'drawer' \| 'dialog' \| 'navigate'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet), 'dialog' (centered modal), or 'navigate' (skip the in-place view and open the object's full list page; needs host drill navigation, else falls back to 'drawer') |
| **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) |
| **maxRows** | `integer` | optional | Rows per page in the drilled list |

Expand Down
23 changes: 19 additions & 4 deletions packages/lint/src/validate-react-page-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,8 +781,8 @@ describe('validateReactPageProps — <ObjectForm subforms> resolve per child obj
// this gate nothing on the react surface called one, which is exactly the
// `no gate` verdict the strictness ledger records for `aggregate` two props
// over. The rule parses instead of re-deriving, so the surface name, the
// near-key guidance and the `target: 'navigate'` prescription all arrive
// without being restated here.
// near-key guidance and the `target` union all arrive without being restated
// here — which is why #5435's widening needed no edit to the rule itself.
// ─────────────────────────────────────────────────────────────────────────

describe('validateReactPageProps — <ObjectChart drillDown> (#5022)', () => {
Expand Down Expand Up @@ -816,11 +816,26 @@ describe('validateReactPageProps — <ObjectChart drillDown> (#5022)', () => {
expect(hit!.severity).toBe('error');
});

it("rejects target: 'navigate' with the chart-specific reason", () => {
it("passes target: 'navigate' — the renderer delivers it since objectui#3382 (#5435)", () => {
// This assertion was the exact inverse until #5435. #5022 excluded
// `'navigate'` on a MEASUREMENT — ObjectChart's hand-rolled drawer only
// branched on `'dialog'` and let `'navigate'` fall through to the Sheet —
// and objectui#3382 changed that measurement by implementing the arm.
// The gate parses `ChartDrillDownSchema`, so this case is what proves the
// widened union actually reaches the author-facing publish gate rather
// than only the type.
const f = validateReactPageProps(drill(`{ target: 'navigate' }`));
expect(f).toEqual([]);
});

it('still rejects a target outside the three declared arms — widening is not loosening', () => {
// The companion to the case above: `'navigate'` became legal because a
// renderer delivers it, NOT because `target` stopped being checked.
const f = validateReactPageProps(drill(`{ target: 'sidebar' }`));
const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID);
expect(hit!.message).toContain('objectui#3354');
expect(hit, 'an undeclared target must still be reported').toBeTruthy();
expect(hit!.message).toContain('drillDown.target');
expect(hit!.severity).toBe('error');
});

it('rejects a key that belongs to another widget, with the reason rather than a rename', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/lint/src/validate-react-page-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,10 @@ function checkObjectChart(
// and the strictness ledger's `chart.zod.ts` row already names it as the
// weakness: a gate that re-derives the rules cannot inherit the schema's
// unknown-key handling, so `groupby` sails through it. Parsing inherits all
// of it for free — the surface name, the near-key guidance, the
// `target: 'navigate'` prescription — which is why #5022 declared the shape
// as Zod rather than as another list here.
// of it for free — the surface name, the near-key guidance, the `target`
// union — which is why #5022 declared the shape as Zod rather than as
// another list here. #5435 is the dividend: widening `target` to admit
// `'navigate'` moved this gate with it, with nothing to edit in this file.
checkChartDrillDown(values.get('drillDown'), push);

// Inline `data` wins over the aggregate query: the columns then come from
Expand Down
57 changes: 38 additions & 19 deletions packages/spec/src/ui/chart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,12 +588,16 @@ describe('#5022 — ChartDrillDownSchema', () => {

it('declares exactly the six keys ObjectChart was measured to read — no more', () => {
// The honest subset. objectui's renderer-side `DrillDownConfig` is wider
// (`mode` / `report` / `view` / `sort`, and a `navigate` target) because it
// is shared with the table / pivot / metric widgets. A chart reads none of
// those, so copying the union would have promoted four keys a chart ignores
// — two of which NO widget reads (objectui#3354) — into protocol-declared
// capabilities. This assertion is what stops the next sweep "completing"
// the shape from the objectui type.
// (`mode` / `report`, and — until objectui#3354 removed them — `view` /
// `sort`) because it is shared with the table / pivot / metric widgets. A
// chart reads none of those, so copying the union would have promoted keys
// a chart ignores into protocol-declared capabilities. This assertion is
// what stops the next sweep "completing" the shape from the objectui type.
//
// The KEY set is what this pins. `target`'s VALUE union is a separate
// question with a separate answer: #5435 widened it to include `'navigate'`
// once objectui#3382 made ObjectChart honour that arm — declared because
// delivered, which is the same rule as this assertion, not an exception.
const shape = Object.keys(
(ChartDrillDownSchema as unknown as { _zod: { def: { shape: Record<string, unknown> } } })._zod.def.shape,
);
Expand All @@ -606,6 +610,7 @@ describe('#5022 — ChartDrillDownSchema', () => {
['title', { title: '${event.categoryLabel} deals' }],
['target drawer', { target: 'drawer' }],
['target dialog', { target: 'dialog' }],
['target navigate', { target: 'navigate' }],
['columns', { columns: ['name', 'amount'] }],
['maxRows', { maxRows: 50 }],
['everything at once', {
Expand Down Expand Up @@ -664,22 +669,36 @@ describe('#5022 — ChartDrillDownSchema', () => {
expect(msg, 'a guidance entry suppresses the rename suggestion').not.toContain(`\`${key}\` → `);
});

it("target: 'navigate' is rejected with the reason a CHART cannot honor it", () => {
// The one arm of objectui's shared `target` union that ObjectChart does not
// implement: it falls through to the Sheet, so declaring it would promise a
// jump that never happens. A bare enum error would say only "invalid
// option" and leave the author to discover that by clicking.
const msg = reject({ target: 'navigate' });
expect(msg).toContain('objectui#3354');
expect(msg, 'and points at the arms that do work').toContain("'dialog'");
expect(msg, 'and at the affordance that replaces it').toContain('Open in list');
it("target: 'navigate' is ACCEPTED — objectui#3382 made the renderer deliver it (#5435)", () => {
// This test asserted the exact opposite until #5435, and the flip is the
// point: #5022 excluded `'navigate'` on a MEASUREMENT ("ObjectChart falls
// through to the Sheet"), not on a design preference. objectui#3382
// implemented the arm, the measurement expired, and the union followed.
//
// Kept as a NAMED case rather than folded into the `accepts` table above
// so that a future sweep re-narrowing the union has to delete a test whose
// title states why the arm exists, instead of quietly dropping a row.
expect(ChartDrillDownSchema.safeParse({ target: 'navigate' }).success).toBe(true);

// The prescription that used to fire for this value must be GONE, not
// merely unreachable — a rejection message asserting a chart "does not
// implement that arm" is now false, and #5046's lesson is that a dead limb
// left in place reads as live to the next author.
const msg = reject({ target: 'sidebar' });
expect(msg, 'the retired navigate prescription must not survive').not.toContain('objectui#3354');
expect(msg, 'nor its claim about what a chart cannot do').not.toContain('not supported by a chart');
});

it('a plain wrong VALUE still gets zod\'s own message — the navigate text is not sprayed over everything', () => {
// The `previosPeriod` lesson from #5011: a targeted prescription must not
// fire for every wrong input, or it misinforms.
it('a target outside the three declared arms is still rejected — widening is not loosening', () => {
// The companion to the case above. `'navigate'` became legal because a
// renderer delivers it; `target` did not stop being an enum. Without this,
// deleting the union entirely would leave the suite green.
const msg = reject({ target: 'sidebar' });
expect(msg).not.toContain('objectui#3354');
expect(msg, 'rejected as a value, not swallowed').toContain('invalid_value');
// Zod's own enum message enumerates the legal arms rather than echoing the
// bad input, so THIS is the string that proves the union still has exactly
// three members — and it fails loudly if a fourth is ever slipped in.
expect(msg, 'and the three arms that do work are named').toContain('"values":["drawer","dialog","navigate"]');
});

// ---- the near-key, both directions (the 2026-08-04 ruling, item 3) -------
Expand Down
47 changes: 32 additions & 15 deletions packages/spec/src/ui/chart.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,16 @@ export const ChartInteractionSchema = lazySchema(() => strictObject(
* ## Keys that belong to other widgets, not to a chart
*
* objectui's renderer carries a wider drill config shared by its table / pivot /
* metric widgets (`mode`, `report`, and a `target: 'navigate'` arm). A chart
* reads none of them, so they are not declared here — see the `guidance`
* entries, which name each one and where it does apply.
* metric widgets (`mode`, `report`). A chart reads neither, so they are not
* declared here — see the `guidance` entries, which name each one and where it
* does apply.
*
* `target: 'navigate'` used to be on that list and no longer is: #5022 excluded
* it on a MEASUREMENT (ObjectChart's hand-rolled drawer only branched on
* `'dialog'`, so `'navigate'` fell through to the Sheet), and objectui#3382
* implemented the arm, which retired the measurement. #5435 widened the union
* to match what the renderer now delivers. The ordering matters and is not
* reversible: the protocol declares an arm only once a renderer honours it.
*/
export const ChartDrillDownSchema = lazySchema(() => strictObject(
{
Expand Down Expand Up @@ -462,19 +469,29 @@ export const ChartDrillDownSchema = lazySchema(() => strictObject(
* side sheet; `'dialog'` is a centered modal, for when the chart is already
* inside a drawer and a second sheet would stack badly.
*
* There is no `'navigate'` arm here even though objectui's shared renderer
* type has one: `<ObjectChart>` does not implement it and silently renders
* the drawer instead (objectui#3354). Escalating to the object's full list
* page is available anyway, and needs no config — the drill drawer shows an
* "Open in list" action whenever the host app provides drill navigation.
* `'navigate'` skips the in-place view entirely and sends the user to the
* object's full list page, carrying the same filter the drawer would have
* used (the widget filter ∧ the clicked segment's context). Reach for it
* when the drilled list is a destination rather than a peek.
*
* ## What `'navigate'` requires, and what happens without it
*
* It is the one arm with a HOST PRECONDITION: the app must provide drill
* navigation — in objectui that is `DrillNavigationContext.openRecordList`.
* When the host does not provide it there is nowhere to navigate to, and
* the renderer falls back to `'drawer'`. That fallback is DOCUMENTED
* behaviour, not a failure: the click still opens the records, just in
* place. Semantics match `DrillDownDrawer.navigateOnly`, which is how the
* table / pivot / metric widgets on objectui's shared `DrillDownConfig`
* have always honoured this arm.
*
* Note the escape hatch is independent of this key: the drill drawer shows
* an "Open in list" action whenever the host wires drill navigation, so a
* `'drawer'` chart can still reach the list page on demand. `'navigate'`
* is for making that jump the DEFAULT click behaviour.
*/
target: z.enum(['drawer', 'dialog'], {
error: (issue) =>
issue.code === 'invalid_value' && issue.input === 'navigate'
? "`drillDown.target: 'navigate'` is not supported by a chart. objectui's shared drill type offers it for the table/pivot/metric widgets, but `<ObjectChart>` does not implement that arm — it renders the drawer regardless (objectui#3354), so declaring it here would promise a jump that never happens. Use 'drawer' (the default) or 'dialog'; the drawer already offers an \"Open in list\" action when the host app wires drill navigation."
: undefined,
}).optional()
.describe("Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal)"),
target: z.enum(['drawer', 'dialog', 'navigate']).optional()
.describe("Where the drilled list opens: 'drawer' (default, side sheet), 'dialog' (centered modal), or 'navigate' (skip the in-place view and open the object's full list page; needs host drill navigation, else falls back to 'drawer')"),

/**
* Whitelist of field names shown as columns in the drilled list, in order.
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/ui/react-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export const REACT_BLOCKS: ReactBlockDef[] = [
// drifts from it. `renderType` would flatten the nested object to the
// useless `'object'` anyway, which is why `aggregate` above is spelled out
// the same way.
{ name: 'drillDown', type: "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog'; columns?: string[]; maxRows?: number }", kind: 'binding', description: "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog'). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)." },
{ name: 'drillDown', type: "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog' | 'navigate'; columns?: string[]; maxRows?: number }", kind: 'binding', description: "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog', or 'navigate' to open the object's full list page instead — that arm needs host drill navigation and falls back to the drawer without it). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)." },
],
},
// NOTE: `<RecordDetails>` / `<RecordHighlights>` / `<RecordRelatedList>` /
Expand Down
4 changes: 2 additions & 2 deletions skills/objectstack-ui/contracts/react-blocks.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,10 @@
},
{
"name": "drillDown",
"type": "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog'; columns?: string[]; maxRows?: number }",
"type": "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog' | 'navigate'; columns?: string[]; maxRows?: number }",
"kind": "binding",
"required": false,
"description": "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog'). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)."
"description": "Click a segment to open the underlying records, filtered by the clicked category, in a drawer (or 'dialog', or 'navigate' to open the object's full list page instead — that arm needs host drill navigation and falls back to the drawer without it). Present = on; {} is enough. `filter`/`title` support ${event.*} interpolation; omit `filter` to derive it from aggregate.groupBy. Declared by ChartDrillDownSchema — NOT a dashboard widget key (a dataset-bound widget drills through the semantic layer instead), and not ReportSchema.drilldown (that is lowercase, boolean, report-only)."
},
{
"name": "filter",
Expand Down
Loading
Loading