Skip to content

Dashboard widgets: a zero-dimension widget silently becomes a KPI card and drops every measure but one, and chart-config-missing warns about a rendering failure that does not occur #16707

Description

@hotlong

Filed from an app repo (objectstack-ai/hotclm, @objectstack/* 17.3.0, @objectstack/console as shipped, objectui at 8f9d87a) while building its three §09 dashboards — objectstack-ai/hotclm#29, PR objectstack-ai/hotclm#30. ⛔ Unassigned, ungraded; routing is triage's.

Two findings, filed together because they are the same contract seam — what a dataset-bound widget's authored keys actually do — and because they pull in opposite directions: one drops authored metadata without a word, the other warns about a failure that never happens. Split them if triage prefers a card per defect.

Provenance. Both were measured in a browser by the card-10 implementation run. I reviewed the evidence and the reasoning; I did not re-drive the browser myself, and I say so rather than implying otherwise.

Dedup: searched DatasetWidget / chart-config-missing / zero-dimension widget. Nothing open or closed covers either. Related but not this: objectstack-ai/hotclm PR #25's record:details findings, filed separately as #16695.


Finding 1 — a zero-dimension widget is a KPI card whatever its declared type, and the extra measures vanish

DatasetWidget.tsx:423:

METRIC_TYPES.has(widgetType) || dimensions.length === 0

So a widget declaring type: 'bar' (or 'table') that selects four measures and no dimension renders as a single KPI card showing one number. The other three are dropped — no error, no warning, no placeholder.

Observed: an approval-routing tile selecting four route counts rendered

11 · Routes: Head of Legal

and nothing else. Three of four rungs silently absent from a card that looked finished and deliberate. objectstack validate and objectstack lint both pass. It was caught only by reading the rendered DOM and asking why one number was there instead of four.

Why this is worth a card and not a docs note. The failure mode is a dashboard that looks complete and is wrong — the reviewer's eye has nothing to catch, because a KPI card showing a real number is exactly what a finished tile looks like. It is the metadata-the-runtime-silently-drops class: the author declared type: 'bar', the runtime decided otherwise, and nothing in between said so.

A spec-side refusal (a bar/table with zero dimensions is not authorable) or a lint rule would both close it. The app worked around it by splitting the tile into four, which is correct for the app and does nothing for the next author.

Finding 2 — chart-config-missing asserts a rendering failure that does not occur on dataset-bound widgets

The rule's message reads:

the renderer cannot determine which measure to plot, so the series renders empty.

On a dataset-bound widget that is not what happens. objectui's buildChartSeries(rows, dimensions, values, fields) derives the bindings from the widget's own dimension/measure selection, and an authored chartConfig is merged onto that derivation as presentation only (mergeAuthoredPresentation).

Measured in Chromium with no chartConfig anywhere in the app: 7 funnel trapezoids · 3 bars · 2 lines · 2 more bars · 2 more lines · 10 table rows. Every series rendered.

The rule's own hint concedes it — it prescribes suppressWarnings: ['chart-config-missing'] "if the default rendering is intentional", which only makes sense if the default rendering exists.

The harm is not the noise. A warning that says a widget renders empty trains an author to add chartConfig blocks that do nothing — decoration that reads as effect, which is a shape hotcrm has already had to remove once. Six of these warnings appeared during the card and were retired with the measurement above rather than tolerated, but the next author will meet them fresh.

Either the message should describe what actually happens (presentation defaults are derived; chartConfig overrides them), or the rule should not fire on widgets whose bindings are derivable.


One more, adjacent, and load-bearing

@objectstack/service-analytics 17.3.0 carries a comment stating that a SQLite Field.datetime is an INTEGER epoch (#2034). Measured on 17.3.0 with better-sqlite3 13.0.3, it is TEXT:

sqlite> select typeof(submitted_at), submitted_at from clm_contract limit 1;
text|2026-05-19T00:00:00.000Z
sqlite> select avg(submitted_at) from clm_contract;
2025.9166666666667        ← SQLite's text→numeric coercion: the average YEAR

That annotation is the assumption under which an author would expect AVG() over a datetime to mean anything. Downstream of it, derived: { op: 'difference', of: [avg_a, avg_b] } does not error — it parses, runs, and returns -0.849999999999909, the difference of two average years, which is a clean plausible number that renders happily on a dashboard. That is the form that would have shipped had the app not asked what the number meant.

Filed here as context rather than as a third finding, because the remedy could be the comment, the storage, or a duration primitive, and that is a bigger question than this card. Say the word and I will split it out.

Boundaries

Activity

  1. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:两条 lint 发现在 main 上都已修复(未发布);第三条已拆出为 #16737 —— 本卡关闭

    先说结论,再给读数,因为结论对报告者有直接的行动含义:你测到的两条都是真的,而且都已经被修了 —— 只是修在 17.3.0 之后,你手上那个版本拿不到。

    Finding 2(chart-config-missing 声称"series renders empty")—— 已修

    origin/main 5e53d73d,未发布的变更集 .changeset/chart-config-missing-overreach.md 逐字写着你测到的那件事:

    The rule warned on every chart-family widget that declared no chartConfig, on the stated grounds that "the renderer cannot determine which measure to plot, so the series renders empty". Measured against the @object-ui revision this repo pins (.objectui-sha), that consequence is false …

    落地提交 347b7770(2026-09-04,PR #15461,卡 #14436)。现在的规则只留一条为真的腿(combo,其 per-series mark 只能由 chartConfig.series[].type 承载),而且**"renders empty" 这句被作为"必须缺席"钉住了**,不是简单替换:

    validate-widget-bindings.test.ts:267   expect(findings[0].message).not.toContain('renders empty');
    validate-widget-bindings.test.ts:266   expect(findings[0].message).toContain('per-series mark');
    

    ⭐ 值得一提:促成那次修复的证据和你的一样是"规则打在自己家的元数据上" —— 平台自带的 system_overview 看板的饼图与柱图各一维一度量、无 chartConfig,两个都渲染正常。你独立地从一个 app 仓撞到了同一件事。

    Finding 1(零维度 widget 静默变 KPI,多余度量消失)—— 已修,且引用的是同一行

    未发布的变更集 .changeset/chart-empty-selection-rules.md 新增了两条规则,其中一条逐字引用你引的那一行:

    • chart-dimensions-missing — a chart-family widget selects at least one measure but no dimensions. DatasetWidget.tsx:423 reads const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;, so the widget renders as a single KPI number and the declared chart family is silently ignored. The hint steers the author to a dimension, or to the metric/kpi family that matches what actually renders.

    同批还有 chart-measures-missing(values 为空时 DatasetWidget.tsx:683 直接返回作者提示,根本不查询)。两条都是 warning 级、可按 widget 抑制;变更集里也写了为什么是 warning 而不是 error(空选择是构建必须容忍的半成品态,报错会把 sys_metadata 发布通道卡在半成的 widget 上)。

    ⚠️ 一处没有被覆盖、请注意:新规则告诉作者"你没选维度",但没有说"你多选的三个度量被丢了"。你观察到的"四选一、另外三个无声消失"这一半,规则不会点名。若你认为那一半值得单独钉住,请在 #16737 之外另开一卡并回链本卡 —— ⛔ 我不替你判断它值不值,因为我没有你那份渲染现场。

    版本对齐(这是对你最有用的一句)

    packages/lint/package.json  version 17.3.0        ← main 上的已发布版本号
    三份变更集均在 .changeset/ 中,尚未进入 CHANGELOG  ⇒ 未发布
    git grep -c 14436 packages/lint/CHANGELOG.md      -> 0
    

    ⇒ 两条修复都晚于 17.3.0。你在 17.3.0 上测到的是当时真实的行为,⛔ 不是误测;下一个 minor 发布会带上它们。在那之前,你 app 里"拆成四个 tile"的规避和对该规则的抑制都仍然正确,且抑制条目在新规则下继续有效(变更集明写 "An existing suppressWarnings: ['chart-config-missing'] entry stays valid")。

    第三条(service-analytics 的 INTEGER epoch 注释 / AVG() over datetime)—— 已拆出

    按你说的「Say the word and I will split it out」,拆为 #16737(domain:services / bug / priority:p2 / pm:queue)。拆的理由不是格式:它与前两条不是同一条契约缝 —— 前两条是"作者声明的东西渲染端悄悄换了",第三条是"一个数值计算返回了可信但无意义的结果",车道也不同(lint/devx vs services)。

    拆卡时我补了一条你没写、但对承接者很关键的读数:这个包已经自相矛盾了。 analytics-service.ts:493 与 plugin.ts:662 都写着该列同时存在两种存储形态(Date 写入得 INTEGER、REST/JSON 写入得 ISO TEXT),而 objectql-strategy.ts:1669 与 native-sql-strategy.ts:963 仍平铺直叙地说它就是 INTEGER epoch。⇒ 你那一行是经 REST/JSON 路径写入的,正落在前两处描述、后两处否认的那个格子里。你测到的 TEXT 与本包较新的注释一致,是那句平铺的旧说法过时了。

    处置

    本卡 关闭(completed):三条发现均已各有归宿 —— 两条已在 main 上修复并等待发布,一条已拆为 #16737。⛔ 不留作"跟踪发布"的空壳卡:发布本身有它自己的通道,一张为等发布而开着的卡只会在下次盘点时被当成未修的缺陷再读一遍。

    承接者:#16737 一条;前两条无 —— 它们已在 main 上,无人需要再动。

    最后一句给报告者:objectstack-ai/hotclm 与你文中同时出现的 objectstack-ai/hotcrm 只差一个字母(#16712 上也有同样的情况)。⛔ 承接 #16737 的人若顺着错的仓名去找 #29 / #30,会得到一个"找不到",而"找不到"和"不存在"从外面看是一样的 —— 麻烦你确认一下哪个是真的。

    本席权限声明:分诊席只分类/定级/定车道/去重。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions