Skip to content

Dashboard widgets: options.stageOrder is declared for every chart type, documented for a chart type that does not exist, and silently dropped in every non-en locale #17344

Description

@zhuangjianguo

Summary

DashboardWidgetOptionsSchema.stageOrder is an ungated member of the generic widget options object. Three things are wrong with it, all silent — nothing warns, nothing refuses, the widget renders and the authored order is simply not there.

  1. Only the funnel branch of the renderer reads it. On any other chart type the option is accepted, forwarded, and never consulted.
  2. Its own documentation names pyramid as a primary case. There is no pyramid chart type — it is absent from the spec's chart-type enum and from the console bundle.
  3. Even on a funnel, the order is matched on rendered category values, and analytics rows carry unlocalized dimension labels — so an authored stageOrder is silently discarded in every locale that is not en. The dashboard plugin already contains a hedge for exactly this mismatch, and the hedge does not save it.

Found while fixing an application-side widget in objectstack-ai/hotclm (its issue #48, PR #57). Everything below is read out of the published 17.4.0 packages@objectstack/spec@17.4.0 and @objectstack/console@17.4.0 — plus browser measurements from that app.

1. Honoured by the funnel branch only

@objectstack/console@17.4.0, dist/assets/plugin-charts-*.js. The chart component destructures the prop as categoryOrder: c, and the only place c is read is inside the funnel guard:

if (h === `funnel`) {
  
  let d = ce(c),
      f = d ? [..._].sort((e,t) => (d.get(String(e?.[r] ?? ``)) ?? 2**53-1)
                                 - (d.get(String(t?.[r] ?? ``)) ?? 2**53-1))
            : [..._].sort((t,n) => Number(n?.[e] ?? 0) - Number(t?.[e] ?? 0));

h is the resolved chart type. Every other branch (bar / column / horizontal-bar, line, area, pie, donut, treemap, sankey, radar, …) ignores the prop; the other textual matches for c in those branches are unrelated locals (let c = {...n} in the pie branch, c = o.map(…) in sankey).

Meanwhile @objectstack/spec@17.4.0 declares it with no chart-type gate at all, in the same generic object as sortBy / sortOrder / limit:

options: {
  dateGranularity: ZodOptional<ZodEnum<{year,month,day,week,quarter}>>
  sortBy:          ZodOptional<ZodString>
  sortOrder:       ZodOptional<ZodEnum<{desc,asc}>>
  limit:           ZodOptional<ZodNumber>
  stageOrder:      ZodOptional<ZodArray<…>>      ← no gate
}

Authoring a horizontal-bar widget carrying stageOrder passes validate and boots. Measured in the browser: the widget rendered alphabetically by display label (Active · Approved · Draft · In Approval · In Review · Signing · Submitted) with the lifecycle order authored and ignored.

This is ADR-0049's shape — enforce or remove. Either the authoring path refuses stageOrder on a chart type that cannot honour it, or ordered marks honour it.

2. The documentation names a chart type that does not exist

packages/spec/src/ui/dashboard.zod.ts, the JSDoc and the .describe() string that reaches the authoring UI and the generated reference:

Explicit category order for ordered-sequence charts — funnel / pyramid stages above all.

.describe('Explicit category order for funnel/pyramid stages (stored values)')

pyramid appears zero times in @objectstack/spec@17.4.0's emitted types and zero times in the console charts bundle. The widget type enum at 17.4.0 is:

column · metric · kpi · line · bar · horizontal-bar · area · pie · donut · funnel · scatter · treemap · sankey · combo · gauge · solid-gauge · bullet · radar · table · pivot

So the one sentence an author reads before using this option names two chart types, of which one is the only one that works and the other does not exist. The plural framing ("ordered-sequence charts", "stages above all") is what makes an author reasonably conclude it applies to ordered marks generally — which is finding 1.

3. On a funnel, the order is matched on labels, and analytics labels are not localized

dist/assets/plugin-dashboard-*.js builds the prop like this:

Ue = (Array.isArray(h.stageOrder) ? h.stageOrder : void 0)?.flatMap(e => {
       let t = String(e), n = K?.[r[0]]?.[t];     // K = dimensionLabels
       return n && n !== t ? [t, n] : [t];        // emit BOTH stored value and label
     });
We = Ue?.length ? Ue : J;                          // J = order derived from picklist meta

The plugin emits both the authored stored value and its resolved display label into categoryOrder, because the chart sorts on String(row[xAxisKey]) and it cannot know which spelling the row will carry. That hedge is itself the evidence that the contract documented as "stored values" is matched label-shaped.

The hedge fails as soon as the two label sources disagree. Measured in the hotclm app, same widget, same build, two consoles:

console authored stageOrder rendered order
en lifecycle lifecycleDraft · Submitted · In Review · In Approval · Approved · Signing · Active
zh-CN lifecycle alphabetical by the English labelActive · Approved · Draft · In Approval · In Review · Signing · Submitted

The mechanism: dimensionLabels resolves through the app's i18n bundle, so on zh-CN the order array carries [stored_value, 中文标签] pairs, while the rows arriving from POST /api/v1/analytics/dataset/query carry the English labels (the unlocalized-analytics-label behaviour of #5076, closed not_planned). Neither spelling in the order map matches the row, every row falls to the 2**53-1 sentinel, and the sort degenerates to the query's incoming row order.

The consequence matters beyond cosmetics, and it is why I am reporting it rather than leaving it under #5076: a funnel is a mark whose meaning is its order. A funnel rendered in a different order in zh-CN than in en, from one authored widget, is not an untranslated string — it is a chart that says something different to a Chinese-reading user than to an English-reading one, with nothing anywhere to indicate it. #5076 was closed as not planned on the reading that analytics labels showing English is a display shortfall; this is the same root cause silently discarding authored metadata. Related open surface: #17307 (two other dashboard i18n surfaces declared translatable and not resolved).

What the app did in the meantime

Nothing that patches the platform, per its own policy. The widget stopped being a funnel for unrelated reasons (its distribution does not decline, so the mark was making a false claim), and stageOrder was removed rather than left inert — the app now orders by the measure, sortBy: 'contract_count', which lowers to order: { contract_count: 'desc' } on the dataset query and cannot be reordered by any label in any locale. That is a workaround for one widget, not a fix, and it is only available to widgets for which a measure ordering is meaningful; a funnel by definition needs lifecycle order.

Suggested remedies, in the order I would take them

  1. Fix the doc string first — it is the cheapest and it is currently pointing authors at a nonexistent chart type. Say funnel only, and say plainly that no other chart type reads it.
  2. Enforce it at author time. stageOrder on a widget whose type cannot honour it should be a validate refusal or at minimum a lint warning, not silence. Same treatment options.sortBy got in A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148.
  3. Match on stored values, not labels. The row payload already carries enough to identify the category (the funnel branch reads a categoryId off the payload for click handling — se(t?.payload)), so the order map has a locale-independent key available to it. Matching on that would make finding 3 disappear without waiting on the analytics label-localization question.
  4. Then decide finding 1 on its merits — either honour categoryOrder on the ordered marks where an author would reasonably expect it (bar / column / horizontal-bar / line / area), or keep it funnel-only and let step 2 enforce that.

Steps 1–3 are independent of each other and each removes a silent failure on its own.

Prior art / related

Environment

  • @objectstack/spec@17.4.0, @objectstack/console@17.4.0 (published tarballs, read directly)
  • Application: objectstack-ai/hotclm @ 2807b3b, pnpm demo, Chromium, en and zh-CN consoles, four passes, screenshotted
  • Widget: legal_dashboardstage_funnel, dataset contract_metrics, dimension clm_contract.status (a picklist with lifecycle option order declared on the object)

Activity

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions