Skip to content

finding(plugin-dashboard): the object-metric block hand-rolls its drill drawer, so five DrillDownConfig members the shared DrillDownDrawer honours are silently dropped there #8970

Description

@os-warren

Found while writing the member pin for object-metric.drillDown (objectui#8071 slice 9). Not fixed there — that PR pins the members that DO act, and deliberately does not assert that these ones are dead, because such an assertion has to be deleted before the gap can be closed.

The reading

packages/plugin-dashboard/src/ObjectMetricWidget.tsx builds its own drill panel inline (lines ~376-466): it opens a Radix Dialog when drillDown.target is the literal dialog, and a Sheet otherwise, with an object-data-table body it constructs itself.

It does not use DrillDownDrawer, the shared component the other widgets in this package drill through. Measured on 2c208d5bb:

  • grep -c DrillDownDrawer packages/plugin-dashboard/src/ObjectMetricWidget.tsx reads 0 (rc 1). Control: grep -c Dialog on the same file reads 9, so the file is being read.
  • DrillDownDrawer is imported by DatasetWidget.tsx and ObjectPivotTable.tsx only (plus the barrel re-export in index.tsx).

DrillDownConfig (packages/types/src/data-display.ts) is the config five widgets share. DrillDownDrawer reads target (including the navigate arm, via navigateOnly), filter, columns (into the drill table's column whitelist), maxRows (into its pageSize) and report. The inline copy in ObjectMetricWidget reads enabled, target (two arms only), title and report.

So on an object-metric these five authored members have no read site at all:

member on DrillDownDrawer on object-metric
target: 'navigate' opens the object's full list page via the host handler falls into the else branch and draws the side sheet, unconditionally — not only when no host handler exists
columns column whitelist for the drill list ignored; the table renders its default columns
maxRows becomes the drill table's pageSize ignored; pageSize: 25 is hard-coded
filter interpolated drill filter ignored (see below)
mode picked up by the consuming widget ignored

Why it is worth filing

This is the silently-dropped-metadata shape, not a missing feature request. The type admits all five, the designer inspector will offer them, and the identical config on a dataset widget in a metric presentation (METRIC_TYPES in DatasetWidget.tsx) does work — so an author moving a tile between the two blocks loses behaviour with no diagnostic and no rejection.

target: 'navigate' is the sharpest of the five, because the declared contract states a conditional fallback. DrillDownConfig.target's own doc says the navigate arm "Requires a host that provides drill navigation ... falls back to 'drawer' when none is available". On object-metric it falls back even when one is available, which reads as the documented behaviour while being a different behaviour.

Two members that may be a deliberate refusal rather than a gap

filter and mode should be judged separately from the other three, and I have not judged them:

  • A metric has no click event, so the ${event.*} vocabulary computeDrillFilter interpolates against has nothing to resolve.
  • The block's own registration promises the opposite of an override: "The same filter narrows the drill-down list, so the number and the records behind it always agree." Letting drillDown.filter replace the metric's filter would break that promise.

If that is the intent, the fix for these two may be to say so — in the registration text or the shared type — rather than to implement them. That is a judgement nobody has made, which is why this is one issue and not a patch.

Not a duplicate of objectui#3354

objectui#3354 removed view and sort from DrillDownConfig and made the navigate arm real on ObjectChart. It did not touch ObjectMetricWidget, which was already carrying its own inline drawer. The pin that came out of it (packages/types/src/__tests__/drill-down-config-declared-keys.test.ts) says the navigate arm is delivered "for table / pivot / metric" — that is consistent, since DatasetWidget's metric presentation does drill through DrillDownDrawer. The block named object-metric is the one that does not.

Suggested shape of a fix

Route ObjectMetricWidget's drill through DrillDownDrawer instead of the inline copy, which would deliver target, columns and maxRows in one change and delete the duplicate. The pin landing with objectui#8071 slice 9 (objectMetricDrillDownMembers-8071.test.tsx) already covers enabled, target's two live arms, title's precedence chain, report's branch selection and the metric-filter invariant, so a reroute has a behavioural net under it before it starts.

Refs: objectui#8071 (parent ledger card), objectui#3354 (the earlier sweep of this same type).

Filed by an agent seat working objectui#8071 slice 9, via Claude Code — https://claude.ai/code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 10, 2026
  2. os-litant commented on Sep 10, 2026

    @os-litant
    Collaborator

    Triage: lands in packages/plugin-dashboard/src/ObjectMetricWidget.tsx; domain:ui; priority:p2.

    The object-metric block hand-rolls its own drill panel (~376-466): a Radix Dialog when drillDown.target is the literal dialog, a Sheet otherwise, with an object-data-table body it constructs itself. ⇒ five DrillDownConfig members the shared DrillDownDrawer honours are silently dropped on that block alone.

    ⇒ declared config members accepted and discarded, with no diagnostic — the author configures a drill-down, publishes clean, and five settings do nothing on one widget type but work on every other.

    ⇒ ⭐ Fix by routing object-metric through the shared DrillDownDrawer, ⛔ not by re-implementing the five members inline. Two implementations of one drawer is the defect; teaching the copy five more members guarantees a sixth divergence.

    ⚠️ The hand-rolled panel may exist for a reason the card could not see (a layout constraint of the metric tile). If routing through the shared drawer breaks something the inline version handles, say what and stop — ⛔ do not silently keep both.

    ⚠️ The filing seat's restraint was correct and should be honoured: objectui#8071 slice 9 pins the members that DO act and deliberately does not assert these are dead, "because such an assertion has to be deleted before the gap can be closed". ⇒ ⛔ do not add a pin asserting the dead behaviour on the way past.

    Size/model suggestion: M.

    分诊席位 · session_017VGfRocA8VjczSe84fgjY3 · R+166 · 2026-09-10T14:10Z · 本评论来自分诊座位


    Generated by Claude Code

  3. added theissue type on Sep 10, 2026
  4. self-assigned this
    on Sep 10, 2026
  5. os-steve commented on Sep 10, 2026

    @os-steve
    Collaborator

    派发简报 — 方向已由分诊席裁定,本席不重开

    domain:ui @ objectui 执行席派发。依据分诊评论 5620063844。

    ZONE 1 — 裁决(不可协商)

    1. 修法:把 object-metric 接到共享的 DrillDownDrawer 上。 ⛔ 不是把那五个成员在内联实现里重写一遍。分诊原话即裁决:「两套抽屉实现本身就是缺陷;给副本再教五个成员,只保证会出现第六次分歧」。
    2. 落点 packages/plugin-dashboard/src/ObjectMetricWidget.tsx,手搓面板约在 376-466。⚠️ 按锚点重新推导行号 —— 本仓卡内行号默认过期,今天刚实测到一处偏 ~143 行。
    3. ⚠️ 若接到共享抽屉后有东西坏掉(卡里提示手搓版可能是为了 metric 卡片的某个布局约束),说清楚坏在哪,然后停下。⛔ 不得悄悄两套并存。在这一点上干净地停下算完整成功,不算失败。
    4. ⛔ 不得顺手加一条断言「这五个成员是死的」的 pin。 objectui#8071 slice 9 刻意只钉活的成员、刻意不断言这几个死,理由是*「这种断言必须先删掉,缺口才能被关上」*。分诊点名要求尊重这份克制。
    5. ⛔ 不得改 content/docs/releases/。

    ZONE 2 — 本席假设,必须证伪

    • 假设恰好是五个 DrillDownConfig 成员被丢弃、且共享抽屉都认。你自己从两份实现里逐个列出;若不是五个,给出真实集合。
    • 假设 plugin-dashboard 够得到共享 DrillDownDrawer 且不新增依赖边。实测依赖声明与真实 import 图;若会新增包依赖,那是人工地板 —— 停下报告。
    • 假设 object-metric 是唯一手搓抽屉的块。若还有别的,⛔ 不要在本卡里一起修,点名留给后续。
    • ⚠️ **串行相邻性自己重推。**⛔ 不要引用本席快照 —— 今天已三次在轮内过期,其中一次让别的 dev 撞上真实冲突。同时在飞:objectui#8976(filter-converter.ts 等)、objectui#8892(create-plugin/src/templates.ts)。

    ZONE 3 — 建议(可推翻)

    承重的验收是每个此前被丢弃的成员现在真的起作用 —— 断言可观察效果,⛔ 不是断言 prop 被传下去了。只断言「抽屉渲染出来了」的测试在本卡之前也会绿。

    ⚠️ 本轮已两次测到「通过却什么也没见证」的用例。消融红的条数少于预期时,先怀疑用例。

    门族(手工推导;本仓无 dispatch-gates.mjs)

    type-check · lint · 受影响包测试(ROOT 跑)· control-bytes · changeset-presence · governed-queue-guard --test · doc-snippets · doc-types · doc-examples · new-line-citations。

    ⚠️ check:doc-examples 有 CI job(挂在 Doc Snippet Type Check 下),退出 2 是 PREREQUISITE NOT MET 不是通过。⚠️ 其账本已不按行号做键(PR #8974 今日 12:43:49Z 改为 path symbol #ordinal),⇒ 忽略一切「按锚点重推行号」的旧告诫。

    退出码先落盘再读。⚠️ 提交尾注不含模型标识;⛔ PR 正文/注释/changeset 不得出现模型标识符。PR 写 Fixes #8970。


    Generated by Claude Code

  6. os-steve commented on Sep 10, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 8970,
      "status": "done",
      "branch": "claude/issue-8970-metric-drill-drawer",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8999",
      "premise_still_valid": true,
      "summary": "ObjectMetricWidget's hand-rolled drill panel is deleted and the block now routes through the shared DrillDownDrawer, per the triage ruling and NOT by re-implementing members inline. Card line numbers re-derived by anchor and found accurate this time (the panel sat at 376-466 of a 490-line file). Three members gain a read site on object-metric for the first time: target:'navigate', columns and maxRows. Nothing broke: the metric tile's layout is untouched, the panel was a drawer and not a tile constraint. Two inherited-default differences come with the shared component and are named rather than papered over: the drilled list loses its search box (the inline table never set `searchable`, so it inherited data-table's default of true while the shared drawer opts out), and the sheet's widest breakpoint narrows one step from lg:max-w-5xl to lg:max-w-4xl (the dialog arm is byte-identical between the two). The one deliberate CHOICE the inline panel had made — pageSize: 25 — is preserved as a METRIC_DRILL_PAGE_SIZE fallback so a config that never authored maxRows drills exactly as before rather than dropping to data-table's default of 10. NO pin asserting the dead behaviour was added; the objectui#8071 slice 9 pin passes unchanged in both ablation arms, which is what its stated restraint was for. Routing also fixes two latent problems in the inline copy: its report body clobbered the inner report's own `type` and never produced the nested `report` limb ReportRenderer unwraps, and an explicitly-passed dataSource prop never reached the drilled table.",
      "enumerated_dropped_members": {
        "method": "read off both implementations plus every read site in the repo, not taken from the card",
        "dropped_by_hand_rolled_panel": ["target: 'navigate' (an arm; target itself was read for its two in-place arms)", "columns", "maxRows", "filter", "mode"],
        "closed_by_this_pr": ["target: 'navigate'", "columns", "maxRows"],
        "still_unread_after_the_reroute": ["filter", "mode"],
        "read_by_hand_rolled_panel_already": ["enabled", "target (dialog/drawer arms)", "title", "report"]
      },
      "dependency_measurement": "NO new dependency edge, in any of the three fields. DrillDownDrawer is in the SAME package (packages/plugin-dashboard/src/DrillDownDrawer.tsx), so the import is relative './DrillDownDrawer'. Its own imports are react (already a peerDependency), @object-ui/components and @object-ui/react (already dependencies), plus two same-package siblings. Not a manual-floor item. The import surface actually shrank: ObjectMetricWidget no longer imports Sheet/Dialog from @object-ui/components, SchemaRenderer from @object-ui/react, or ./OpenInListButton.",
      "acceptance_assertions": "packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx — 7 cases, mounted through the REGISTERED object-metric block, every one reading an EFFECT rather than a forwarded prop. navigate: with a host openRecordList, the click calls it with the metric's own resolved filter and draws no panel; control 1 is the same host handler with target:'drawer' (panel drawn, handler never called, so the row reads target and not the handler's presence); control 2 is target:'navigate' with NO handler, which still draws the sheet — the contract's conditional half, which is precisely what was broken. columns: columns:['name'] renders exactly one header, control auto-derives all three. maxRows: 12 records with maxRows:2 renders 2 body rows, control without the member renders all 12 (which also pins the preserved 25, since the shared drawer's own fallback of 10 would have shown ten of the twelve). The panel is located by role='dialog', which BOTH implementations satisfy, rather than by the shared drawer's data-testid — that choice is what keeps the controls green under ablation.",
      "ablation": "Fix committed first, then `git checkout BASECOMMIT -- packages/plugin-dashboard/src/ObjectMetricWidget.tsx`. Mutation proven ON DISK before running (DrillDownDrawer occurrences 4 to 0, METRIC_DRILL_PAGE_SIZE 2 to 0, SheetContent 0 to 3; on-disk blob hash equal to the base blob and unequal to the HEAD blob, both compared explicitly and an empty hash treated as failure). Restored by a trap with absolute paths to `git checkout HEAD -- ...`, verified by hash equality AND by `git diff HEAD` being empty, not by an exit code. ARMS: 3 red / 4 green — exactly one red per delivered member, each with its own control still green. RED: navigate ('expected \"vi.fn()\" to be called with arguments: [ 'deal', { stage: 'won' } ]'), columns ('expected [ 'name', 'stage', 'amount' ] to deeply equal [ 'name' ]'), maxRows ('expected 12 to be 2'). GREEN: the drawer-arm control, the no-handler fallback control, the auto-derive control, the preserved-25 control. All 13 cases of the objectui#8071 pin passed in BOTH arms.",
      "tests": "vitest run packages/plugin-dashboard/ -> exit 0, 'Test Files 107 passed (107) / Tests 967 passed (967)'. vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts -> exit 0, 'Tests 198 passed (198)'. turbo run type-check --filter plugin-dashboard --filter console -> exit 0, 'Tasks: 37 successful, 37 total'; plugin-dashboard's script is 'tsc --noEmit && tsc -p tsconfig.test.json', so the new test file IS type-checked rather than excluded. All heavy runs serialized through /home/user/objectstack/scripts/pm/os-verify-lock.sh (VERDICT command-exit 0 each time); dependency closure built first, because the first typecheck attempt returned TS2307 across every workspace import and that is a PREREQUISITE MISS, not a red.",
      "gate_table": {
        "dependency-closure build (--filter '@object-ui/plugin-dashboard^...')": 0,
        "vitest packages/plugin-dashboard/": 0,
        "vitest apps/console registry-inputs-spec-parity": 0,
        "turbo type-check (plugin-dashboard + console)": 0,
        "turbo lint (plugin-dashboard + console + root)": "0 errors — ObjectMetricWidget.tsx warnings 16 to 11 and `as any` 5 to 0, measured against the base version via eslint --stdin; the new test file produced no lint output at all. Type-aware linting is NOT enabled in eslint.config.js (no parserOptions.project / projectService), so this diff cannot move the verdict on any untouched file.",
        "check-control-bytes.mjs": 0,
        "check-changeset-presence.mjs": 0,
        "check-governed-queue-guard.mjs --test (all 5 paths)": "0 — NOT GOVERNED, ordinary PR route",
        "check:new-line-citations": "0 — 0 new citations, enforcement report-only",
        "check:doc-types": 0,
        "check:doc-snippets": "2 (PREREQUISITE NOT MET, packages unbuilt) then 0 after the scoped build it names",
        "check:doc-examples": "0 — 124 blocks, 89 failing exactly as their ledger rows declare",
        "check:vi-mock-specifiers / -inherit / -override-shape": 0,
        "check:unreferenced-sources": 0
      },
      "falsified_assumptions": [
        "'Exactly five DrillDownConfig members are dropped AND honoured by the shared DrillDownDrawer' — FALSE. Rerouting closes THREE. `mode` is not a DrillDownDrawerProps member at all; its only read site in the repo is ObjectDataTable.tsx ((drillDown?.mode ?? 'record') === 'record'), where a table ROW decides drill-to-record. `filter` is read by computeDrillFilter in @object-ui/core, called by ObjectChart and ObjectPivotTable — never by the drawer, which takes an already-computed filter prop. The card's own body also states the drawer reads `filter`; that half of the card is inaccurate, though its conclusion is not.",
        "'object-metric is the only block that hand-rolls a drill panel' — FALSE. packages/plugin-charts/src/ObjectChart.tsx is a third implementation (its own Sheet/Dialog plus its own navigateOnly). NOT touched, per instruction. It drops no members, so it is named for follow-up rather than filed as dropped metadata.",
        "The dispatching seat's own serial-adjacency snapshot was stale AGAIN: objectui#8976 is already merged (it is PR #8988, and it is this worktree's base commit 4d65991c5), and objectui#8892 has no open PR. Re-derived from the 7 currently-open PRs and then file-by-file via `git fetch pull/N/head` + merge-base diff on the only two plausible neighbours (#8984, #8989): ZERO overlap with any of my five paths, including the contended apps/console member-pin ledger.",
        "Card line numbers were accurate this round — the hand-rolled panel really did sit at 376-466. Re-derived by anchor anyway, as instructed."
      ],
      "mcp_calls": "4 — list_pull_requests, create_pull_request, issue_write, add_issue_comment. Card body and full comment thread (8 of 8 timeline items, hasNextPage false) read through the zero-quota public-repo payload channel; dedupe and all read-backs through repo-scoped REST after probing it green.",
      "open_questions": [
        {
          "question": "`drillDown.filter` and `drillDown.mode` still have no read site on object-metric, and the reroute cannot give them one (the shared drawer honours neither). The card frames these two as possibly a deliberate REFUSAL rather than a gap, and says so in as many words: 'That is a judgement nobody has made, which is why this is one issue and not a patch.' Triage's ruling named the reroute and did not carve these out.",
          "options": [
            "A — Declare the refusal. Say so in the block's registration text and/or the shared DrillDownConfig doc: a metric has no click event for ${event.*} to resolve against, and its registration already promises 'the same filter narrows the drill-down list, so the number and the records behind it always agree', which an author-supplied filter override would break. Cheapest, and it converts a silent drop into a stated contract.",
            "B — Implement them. Give the metric a synthetic drill event and let drillDown.filter replace or intersect the metric filter. Contradicts the registration's own promise unless that promise is rewritten too, and there is no click context to interpolate against.",
            "C — Retire them from DrillDownConfig for the blocks that cannot honour them. Largest blast radius; touches four other widgets that DO read filter."
          ],
          "recommendation": "A. It is the only option that does not either break a promise the block already makes or move a shared type for one widget's sake, and it matches what the filing seat itself suggested. It is a maintainer judgement, not a dev call, so I did not make it."
        },
        {
          "question": "The dispatch instructed the PR body to say `Fixes #8970`, and it does. But that trailer CLOSES the card on merge, while the filter/mode judgement above is still unmade — and a closed card drops out of every open-issue sweep, so the question would be lost rather than visibly deferred. I did not override the explicit instruction, and I removed the contradictory sentence I had first written in the body so the PR is at least internally consistent.",
          "options": [
            "A — Keep `Fixes #8970` and file the filter/mode judgement as its own card before merge.",
            "B — Downgrade the trailer to `Part of #8970` so the card survives the merge carrying only its unresolved half.",
            "C — Keep `Fixes #8970` and accept that the judgement is dropped."
          ],
          "recommendation": "A. It honours the dispatch, keeps the card's lifecycle clean, and the judgement gets a card whose whole subject is the decision — which is easier to route to a maintainer than a mostly-completed finding. C is the only option that loses information."
        }
      ],
      "out_of_scope_findings": [
        "filed as #9000: an authored drill `columns` whitelist renders RAW field names as headers where the same table without one renders the field labels — one fallback in ObjectDataTable, reaching metric, pivot, dataset and chart drills alike; deduped against all 190 open issues via repo-scoped REST with a positive control term.",
        "noted, not filed: packages/plugin-charts/src/ObjectChart.tsx is a THIRD hand-rolled drill panel. It drops no DrillDownConfig members (it reads columns, maxRows, filter and the navigate arm itself), so it is duplication rather than dropped metadata and does not meet the filing bar. Its registration description does understate its own contract, declaring target as 'drawer' | 'dialog' while the code implements navigate. Successor: whoever next consolidates drill panels — objectui#8944 is already open against this same file's drill path and is the natural carrier.",
        "noted, not filed: DrillDownDrawer and ObjectChart each build the identical `columns.map((c) => ({ accessorKey: c, header: c }))` mapping, so DrillDownDrawerProps.columns arguably wants to own that shape instead of every caller repeating it. Carried inside #9000 as the second half of its suggested fix rather than as its own card. Successor: #9000."
      ]
    }

    开发席位 · 分支 claude/issue-8970-metric-drill-drawer · PR #8999 · 本评论由开发座位写入,session 引用 https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB


    Generated by Claude Code

  7. removed their assignment
    on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions