diff --git a/.changeset/chart-drilldown-navigate-target.md b/.changeset/chart-drilldown-navigate-target.md new file mode 100644 index 0000000000..34d2603d99 --- /dev/null +++ b/.changeset/chart-drilldown-navigate-target.md @@ -0,0 +1,51 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `drillDown.target` 补上 `'navigate'` —— 渲染器已经兑现的第三个 arm (#5435) + +`` 现在接受 `'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 + +``` + +- `'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` 仍然按值被拒。 diff --git a/content/docs/references/ui/chart.mdx b/content/docs/references/ui/chart.mdx index be285390e2..f9f6ddaacc 100644 --- a/content/docs/references/ui/chart.mdx +++ b/content/docs/references/ui/chart.mdx @@ -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` | 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 | diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 2e48a76d23..940c63a8fa 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -781,8 +781,8 @@ describe('validateReactPageProps — 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 — (#5022)', () => { @@ -816,11 +816,26 @@ describe('validateReactPageProps — (#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', () => { diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 8193830042..8cae99ffd9 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -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 diff --git a/packages/spec/src/ui/chart.test.ts b/packages/spec/src/ui/chart.test.ts index da5242ba16..29311e8a1c 100644 --- a/packages/spec/src/ui/chart.test.ts +++ b/packages/spec/src/ui/chart.test.ts @@ -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 } } })._zod.def.shape, ); @@ -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', { @@ -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) ------- diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 274094b8aa..06dc9936ed 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -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( { @@ -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: `` 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 `` 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. diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 4695263af8..4f9eb7bb9d 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -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; 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; 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: `` / `` / `` / diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index 0445227600..8810b642e5 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -315,10 +315,10 @@ }, { "name": "drillDown", - "type": "{ enabled?: boolean; filter?: Record; title?: string; target?: 'drawer' | 'dialog'; columns?: string[]; maxRows?: number }", + "type": "{ enabled?: boolean; filter?: Record; 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", diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index d6ae7e1427..49eed81c3a 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -72,7 +72,7 @@ Chart over an object’s aggregated data. Bind objectName + aggregate; the axes | `objectName` | `string` | binding | ✓ | The object this block binds to (server-connected). | | `aggregate` | `{ field?: string; function: 'count' \| 'sum' \| 'avg' \| 'min' \| 'max'; groupBy: string \| { field: string; dateGranularity?: 'day' \| 'week' \| 'month' \| 'quarter' \| 'year' } }` | binding | | Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names. | | `data` | `any[]` | binding | | Static/precomputed data to chart directly instead of binding via objectName + aggregate. | -| `drillDown` | `{ enabled?: boolean; filter?: Record; title?: string; target?: 'drawer' \| 'dialog'; columns?: string[]; maxRows?: number }` | binding | | 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). | +| `drillDown` | `{ enabled?: boolean; filter?: Record; title?: string; target?: 'drawer' \| 'dialog' \| 'navigate'; columns?: string[]; maxRows?: number }` | binding | | 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). | | `filter` | `FilterArray` | controlled | | ObjectQL filter scoping the data; drive from React state. | | `type` | `'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'` | data | ✓ | | | `title` | `string` | data | | Chart title |