Skip to content

Commit e900015

Browse files
os-zhuangclaude
andauthored
feat(spec): drillDown.target 补上 'navigate' —— 渲染器已兑现的第三个 arm (#5435) (#5556)
ChartDrillDownSchema.target 从 'drawer' | 'dialog' 扩成 'drawer' | 'dialog' | 'navigate',additive(minor),之前能解析的一律照旧。 #5022 当初排除 'navigate' 依据的是一条**测量**而非设计偏好:当时 ObjectChart 自绘抽屉只分支 'dialog','navigate' 静默落进 Sheet,声明它等于用协议承诺一次 永不发生的跳转。objectui#3382 把这条测量改掉了 —— ObjectChart 现在真正兑现 'navigate',语义对齐 DrillDownDrawer.navigateOnly(table / pivot / metric 三个 widget 在共享 DrillDownConfig 上一直以来的行为)。测量失效,联合随之跟上。 顺序不可颠倒:先有渲染器兑现,协议才声明。 - target 的 JSDoc 写明兑现条件与回退:'navigate' 是唯一带 host 前提的 arm, 宿主须提供 drill navigation(objectui 侧 DrillNavigationContext.openRecordList), 缺失时文档化回落 'drawer'(既定行为,非故障)。 - 删除 z.enum 上那条只为 'navigate' 定制的 error 回调 —— 它的判词 (「chart 不实现该 arm」)现在是假的,留着就是一条对下一个作者撒谎的死枝。 - 生成物一律走生成器(gen:react-blocks / gen:schema / gen:docs): react-blocks 类型串、contract.json、references/react-blocks.md、chart.mdx。 测试(先证红):改 schema 前先在 validate-react-page-props 写下「'navigate' 应被接受」,跑出红(闸门带着 #5022 的判词拒绝),再改 schema 转绿。 反向验证方向先判后跑:把联合改回两成员,chart.test.ts 恰好 3 例红 (accepts 行、接受用例、三 arm 命名断言),不多不少。 spec 8011 例、lint 1282 例全绿;两包 typecheck 通过。 Fixes #5435 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5543020 commit e900015

9 files changed

Lines changed: 149 additions & 46 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `drillDown.target` 补上 `'navigate'` —— 渲染器已经兑现的第三个 arm (#5435)
6+
7+
`<ObjectChart drillDown={{ target }}>` 现在接受 `'navigate'`,联合从
8+
`'drawer' | 'dialog'` 扩成 `'drawer' | 'dialog' | 'navigate'`**纯 additive**:
9+
之前能解析的一律照旧解析。
10+
11+
## 为什么现在才加
12+
13+
#5022 当初把 `'navigate'` 排除在外,依据是一条**测量**而不是设计偏好 ——
14+
当时 objectui 的 `ObjectChart` 自绘抽屉只分支 `'dialog'`,`'navigate'` 会静默
15+
落进 Sheet。声明一个渲染器不兑现的值,等于用协议承诺一次永远不会发生的跳转。
16+
17+
objectui#3382 把这条测量改掉了:`ObjectChart` 现在真正兑现 `'navigate'`,语义
18+
对齐 `DrillDownDrawer.navigateOnly` —— 也就是 table / pivot / metric 三个 widget
19+
在共享的 `DrillDownConfig` 上一直以来的行为。测量失效,联合随之跟上。
20+
21+
顺序不可颠倒:**先有渲染器兑现,协议才声明**。在此之前(objectui#3382 合并前)
22+
拒绝 `'navigate'` 是正确的。
23+
24+
## 写法与兑现条件
25+
26+
```jsx
27+
<ObjectChart objectName="opportunity"
28+
aggregate={{ function: 'sum', field: 'amount', groupBy: 'stage' }}
29+
drillDown={{ target: 'navigate' }} />
30+
```
31+
32+
- `'drawer'`(默认)—— 就地侧边抽屉;
33+
- `'dialog'` —— 居中模态,适合图表本身已经在抽屉里、再叠一层 Sheet 会很别扭的场合;
34+
- `'navigate'` —— **跳过就地视图**,直接打开该对象的完整列表页,带上抽屉本会用的
35+
同一套过滤条件(widget filter ∧ 点击段的上下文)。适合「钻取结果是目的地」而不是
36+
「瞄一眼」的场景。
37+
38+
`'navigate'` 是唯一带 **host 前提**的 arm:宿主应用必须提供 drill navigation
39+
(objectui 侧是 `DrillNavigationContext.openRecordList`)。宿主没提供时无处可跳,
40+
渲染器**文档化回落**`'drawer'` —— 这是既定行为而非故障,点击照样打开记录,
41+
只是就地打开。
42+
43+
注意 escape hatch 与本键无关:只要宿主接了 drill navigation,抽屉里就一直有
44+
"Open in list" 动作,所以 `'drawer'` 的图表也能按需到达列表页。`'navigate'`
45+
意义是把这次跳转变成**默认**的点击行为。
46+
47+
## 影响面
48+
49+
`packages/lint``validate-react-page-props` 直接 parse `ChartDrillDownSchema`,
50+
所以发布闸门随联合一起放行 —— 该 gate 本身一行未改。收窄未发生:三个 arm 以外的
51+
`target` 仍然按值被拒。

content/docs/references/ui/chart.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Inline aggregation for an object-bound chart
124124
| **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 |
125125
| **filter** | `Record<string, any>` | optional | Filter for the drilled list; values support $`{event.*}`. Omit to derive it from the clicked category |
126126
| **title** | `string` | optional | Drill drawer/dialog heading; supports $`{event.*}` interpolation |
127-
| **target** | `Enum<'drawer' \| 'dialog'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal) |
127+
| **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') |
128128
| **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) |
129129
| **maxRows** | `integer` | optional | Rows per page in the drilled list |
130130

packages/lint/src/validate-react-page-props.test.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -781,8 +781,8 @@ describe('validateReactPageProps — <ObjectForm subforms> resolve per child obj
781781
// this gate nothing on the react surface called one, which is exactly the
782782
// `no gate` verdict the strictness ledger records for `aggregate` two props
783783
// over. The rule parses instead of re-deriving, so the surface name, the
784-
// near-key guidance and the `target: 'navigate'` prescription all arrive
785-
// without being restated here.
784+
// near-key guidance and the `target` union all arrive without being restated
785+
// here — which is why #5435's widening needed no edit to the rule itself.
786786
// ─────────────────────────────────────────────────────────────────────────
787787

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

819-
it("rejects target: 'navigate' with the chart-specific reason", () => {
819+
it("passes target: 'navigate' — the renderer delivers it since objectui#3382 (#5435)", () => {
820+
// This assertion was the exact inverse until #5435. #5022 excluded
821+
// `'navigate'` on a MEASUREMENT — ObjectChart's hand-rolled drawer only
822+
// branched on `'dialog'` and let `'navigate'` fall through to the Sheet —
823+
// and objectui#3382 changed that measurement by implementing the arm.
824+
// The gate parses `ChartDrillDownSchema`, so this case is what proves the
825+
// widened union actually reaches the author-facing publish gate rather
826+
// than only the type.
820827
const f = validateReactPageProps(drill(`{ target: 'navigate' }`));
828+
expect(f).toEqual([]);
829+
});
830+
831+
it('still rejects a target outside the three declared arms — widening is not loosening', () => {
832+
// The companion to the case above: `'navigate'` became legal because a
833+
// renderer delivers it, NOT because `target` stopped being checked.
834+
const f = validateReactPageProps(drill(`{ target: 'sidebar' }`));
821835
const hit = f.find((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID);
822-
expect(hit!.message).toContain('objectui#3354');
836+
expect(hit, 'an undeclared target must still be reported').toBeTruthy();
823837
expect(hit!.message).toContain('drillDown.target');
838+
expect(hit!.severity).toBe('error');
824839
});
825840

826841
it('rejects a key that belongs to another widget, with the reason rather than a rename', () => {

packages/lint/src/validate-react-page-props.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,10 @@ function checkObjectChart(
333333
// and the strictness ledger's `chart.zod.ts` row already names it as the
334334
// weakness: a gate that re-derives the rules cannot inherit the schema's
335335
// unknown-key handling, so `groupby` sails through it. Parsing inherits all
336-
// of it for free — the surface name, the near-key guidance, the
337-
// `target: 'navigate'` prescription — which is why #5022 declared the shape
338-
// as Zod rather than as another list here.
336+
// of it for free — the surface name, the near-key guidance, the `target`
337+
// union — which is why #5022 declared the shape as Zod rather than as
338+
// another list here. #5435 is the dividend: widening `target` to admit
339+
// `'navigate'` moved this gate with it, with nothing to edit in this file.
339340
checkChartDrillDown(values.get('drillDown'), push);
340341

341342
// Inline `data` wins over the aggregate query: the columns then come from

packages/spec/src/ui/chart.test.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -588,12 +588,16 @@ describe('#5022 — ChartDrillDownSchema', () => {
588588

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

667-
it("target: 'navigate' is rejected with the reason a CHART cannot honor it", () => {
668-
// The one arm of objectui's shared `target` union that ObjectChart does not
669-
// implement: it falls through to the Sheet, so declaring it would promise a
670-
// jump that never happens. A bare enum error would say only "invalid
671-
// option" and leave the author to discover that by clicking.
672-
const msg = reject({ target: 'navigate' });
673-
expect(msg).toContain('objectui#3354');
674-
expect(msg, 'and points at the arms that do work').toContain("'dialog'");
675-
expect(msg, 'and at the affordance that replaces it').toContain('Open in list');
672+
it("target: 'navigate' is ACCEPTED — objectui#3382 made the renderer deliver it (#5435)", () => {
673+
// This test asserted the exact opposite until #5435, and the flip is the
674+
// point: #5022 excluded `'navigate'` on a MEASUREMENT ("ObjectChart falls
675+
// through to the Sheet"), not on a design preference. objectui#3382
676+
// implemented the arm, the measurement expired, and the union followed.
677+
//
678+
// Kept as a NAMED case rather than folded into the `accepts` table above
679+
// so that a future sweep re-narrowing the union has to delete a test whose
680+
// title states why the arm exists, instead of quietly dropping a row.
681+
expect(ChartDrillDownSchema.safeParse({ target: 'navigate' }).success).toBe(true);
682+
683+
// The prescription that used to fire for this value must be GONE, not
684+
// merely unreachable — a rejection message asserting a chart "does not
685+
// implement that arm" is now false, and #5046's lesson is that a dead limb
686+
// left in place reads as live to the next author.
687+
const msg = reject({ target: 'sidebar' });
688+
expect(msg, 'the retired navigate prescription must not survive').not.toContain('objectui#3354');
689+
expect(msg, 'nor its claim about what a chart cannot do').not.toContain('not supported by a chart');
676690
});
677691

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

685704
// ---- the near-key, both directions (the 2026-08-04 ruling, item 3) -------

packages/spec/src/ui/chart.zod.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,16 @@ export const ChartInteractionSchema = lazySchema(() => strictObject(
392392
* ## Keys that belong to other widgets, not to a chart
393393
*
394394
* objectui's renderer carries a wider drill config shared by its table / pivot /
395-
* metric widgets (`mode`, `report`, and a `target: 'navigate'` arm). A chart
396-
* reads none of them, so they are not declared here — see the `guidance`
397-
* entries, which name each one and where it does apply.
395+
* metric widgets (`mode`, `report`). A chart reads neither, so they are not
396+
* declared here — see the `guidance` entries, which name each one and where it
397+
* does apply.
398+
*
399+
* `target: 'navigate'` used to be on that list and no longer is: #5022 excluded
400+
* it on a MEASUREMENT (ObjectChart's hand-rolled drawer only branched on
401+
* `'dialog'`, so `'navigate'` fell through to the Sheet), and objectui#3382
402+
* implemented the arm, which retired the measurement. #5435 widened the union
403+
* to match what the renderer now delivers. The ordering matters and is not
404+
* reversible: the protocol declares an arm only once a renderer honours it.
398405
*/
399406
export const ChartDrillDownSchema = lazySchema(() => strictObject(
400407
{
@@ -462,19 +469,29 @@ export const ChartDrillDownSchema = lazySchema(() => strictObject(
462469
* side sheet; `'dialog'` is a centered modal, for when the chart is already
463470
* inside a drawer and a second sheet would stack badly.
464471
*
465-
* There is no `'navigate'` arm here even though objectui's shared renderer
466-
* type has one: `<ObjectChart>` does not implement it and silently renders
467-
* the drawer instead (objectui#3354). Escalating to the object's full list
468-
* page is available anyway, and needs no config — the drill drawer shows an
469-
* "Open in list" action whenever the host app provides drill navigation.
472+
* `'navigate'` skips the in-place view entirely and sends the user to the
473+
* object's full list page, carrying the same filter the drawer would have
474+
* used (the widget filter ∧ the clicked segment's context). Reach for it
475+
* when the drilled list is a destination rather than a peek.
476+
*
477+
* ## What `'navigate'` requires, and what happens without it
478+
*
479+
* It is the one arm with a HOST PRECONDITION: the app must provide drill
480+
* navigation — in objectui that is `DrillNavigationContext.openRecordList`.
481+
* When the host does not provide it there is nowhere to navigate to, and
482+
* the renderer falls back to `'drawer'`. That fallback is DOCUMENTED
483+
* behaviour, not a failure: the click still opens the records, just in
484+
* place. Semantics match `DrillDownDrawer.navigateOnly`, which is how the
485+
* table / pivot / metric widgets on objectui's shared `DrillDownConfig`
486+
* have always honoured this arm.
487+
*
488+
* Note the escape hatch is independent of this key: the drill drawer shows
489+
* an "Open in list" action whenever the host wires drill navigation, so a
490+
* `'drawer'` chart can still reach the list page on demand. `'navigate'`
491+
* is for making that jump the DEFAULT click behaviour.
470492
*/
471-
target: z.enum(['drawer', 'dialog'], {
472-
error: (issue) =>
473-
issue.code === 'invalid_value' && issue.input === 'navigate'
474-
? "`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."
475-
: undefined,
476-
}).optional()
477-
.describe("Where the drilled list opens: 'drawer' (default, side sheet) or 'dialog' (centered modal)"),
493+
target: z.enum(['drawer', 'dialog', 'navigate']).optional()
494+
.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')"),
478495

479496
/**
480497
* Whitelist of field names shown as columns in the drilled list, in order.

packages/spec/src/ui/react-blocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export const REACT_BLOCKS: ReactBlockDef[] = [
260260
// drifts from it. `renderType` would flatten the nested object to the
261261
// useless `'object'` anyway, which is why `aggregate` above is spelled out
262262
// the same way.
263-
{ 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)." },
263+
{ 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)." },
264264
],
265265
},
266266
// NOTE: `<RecordDetails>` / `<RecordHighlights>` / `<RecordRelatedList>` /

skills/objectstack-ui/contracts/react-blocks.contract.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,10 @@
315315
},
316316
{
317317
"name": "drillDown",
318-
"type": "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog'; columns?: string[]; maxRows?: number }",
318+
"type": "{ enabled?: boolean; filter?: Record<string, unknown>; title?: string; target?: 'drawer' | 'dialog' | 'navigate'; columns?: string[]; maxRows?: number }",
319319
"kind": "binding",
320320
"required": false,
321-
"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)."
321+
"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)."
322322
},
323323
{
324324
"name": "filter",

0 commit comments

Comments
 (0)