|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import type { SemanticMigration } from '../../types.js'; |
| 4 | + |
| 5 | +export const entry: SemanticMigration = { |
| 6 | + id: 'dashboard-widget-stage-order-non-funnel-refused', |
| 7 | + surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` ' |
| 8 | + + '(`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything ' |
| 9 | + + 'other than `funnel`, INCLUDING a widget that declares no `type` at all and so ' |
| 10 | + + 'resolves to the `metric` default', |
| 11 | + replacement: 'either `type: \'funnel\'` on the widget that meant to declare a stage ' |
| 12 | + + 'order, or — for every other widget type — DELETE `stageOrder` and order the widget ' |
| 13 | + + 'with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as ' |
| 14 | + + '`order: { <name>: \'asc\' | \'desc\' }` instead of re-sorting what it returned. ' |
| 15 | + + 'There is no third spelling: no other widget type has ever read the key, so nothing ' |
| 16 | + + 'is lost by removing it that was not already absent from what rendered. The refusal ' |
| 17 | + + 'lands at `options.stageOrder` and names the type the widget carries, the one type ' |
| 18 | + + 'that reads the key, and the two keys to reach for instead.', |
| 19 | + reason: |
| 20 | + '#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' |
| 21 | + + 'whole content was SILENCE. `options` is the open renderer-extras bag, so ' |
| 22 | + + '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, ' |
| 23 | + + '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and ' |
| 24 | + + 'forwarded the array to the renderer, which never consulted it. Measured at this ' |
| 25 | + + 'repo\'s `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded ' |
| 26 | + + '`categoryOrder` prop has exactly one read in the charts plugin ' |
| 27 | + + '(`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits ' |
| 28 | + + 'inside the `chartType === \'funnel\'` guard opened at line 1473; the prop\'s other ' |
| 29 | + + 'two occurrences in that file are its declaration and its destructure. The producer ' |
| 30 | + + 'side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ' |
| 31 | + + 'ANY widget and forwards it whenever non-empty. So the authored order was accepted ' |
| 32 | + + 'by the metadata layer, carried all the way to the chart, and dropped there, with ' |
| 33 | + + 'nothing anywhere to say so: the widget rendered in whatever order the analytics ' |
| 34 | + + 'query returned and looked deliberate. The reporter measured exactly that in a live ' |
| 35 | + + 'app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered ' |
| 36 | + + 'alphabetically by display label. The four SIBLING members of the same bag are not ' |
| 37 | + + 'in this narrowing and were measured not to share the defect: `dateGranularity`, ' |
| 38 | + + '`sortBy`, `sortOrder` and `limit` are read unconditionally at the top of ' |
| 39 | + + '`DatasetWidget` (lines 443-455, outside every type branch) and lower into the ' |
| 40 | + + '`DatasetSelection` the server compiles, so they act on every widget type. ' |
| 41 | + + '`stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT ' |
| 42 | + + 'the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / ' |
| 43 | + + '`area` to sort by a category order is a renderer change in the objectui repo, and ' |
| 44 | + + 'widening the set of types that read the key can be done later WITHOUT a second ' |
| 45 | + + 'migration — a narrowing that is later relaxed costs an author nothing, while ' |
| 46 | + + 'leaving the key accepted-and-inert costs them a chart that silently lies. Ships at ' |
| 47 | + + 'once, no deprecation window: there is no window in which an inert key does ' |
| 48 | + + 'anything.', |
| 49 | + acceptanceCriteria: |
| 50 | + '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' |
| 51 | + + 'dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused ' |
| 52 | + + 'the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' |
| 53 | + + 'metadata publish path, and any server-side door that parses the spec schema — with one ' |
| 54 | + + '`custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT ' |
| 55 | + + 'refused by objectui\'s client-side authoring door: `@object-ui/types` builds its own ' |
| 56 | + + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' |
| 57 | + + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every ' |
| 58 | + + 'object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the ' |
| 59 | + + 'widget and reports zero checks, while `.extend({})` keeps the refusal). At the ' |
| 60 | + + '`.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE ' |
| 61 | + + 'of the spec\'s exported checks, so until it imports and chains ' |
| 62 | + + '`checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` ' |
| 63 | + + 'and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a ' |
| 64 | + + 'clean dashboard; re-parse through the spec. Fix each by writing ' |
| 65 | + + '`type: \'funnel\'` where a funnel was meant, and by deleting the key elsewhere — ' |
| 66 | + + 'check the rendered order afterwards, because a widget that was silently ignoring ' |
| 67 | + + 'the key renders EXACTLY as it did before once the key is gone, and `sortBy` / ' |
| 68 | + + '`sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses ' |
| 69 | + + 'byte-identically to before, a non-`funnel` widget carrying the other four ' |
| 70 | + + '`options` members is untouched, and a widget with no `options` at all is ' |
| 71 | + + 'untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete ' |
| 72 | + + '(the objectui door above is the first): a ' |
| 73 | + + 'widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone ' |
| 74 | + + '(zod treats that as aborting and skips object-level checks), so the stage-order ' |
| 75 | + + 'refusal arrives only on the next parse; and the array\'s CONTENTS are still ' |
| 76 | + + 'unconstrained, so a `funnel` carrying a stage value the dimension never declares ' |
| 77 | + + 'still parses and still renders that stage in the sentinel position; and a consumer ' |
| 78 | + + 'that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW ' |
| 79 | + + 'from zod rather than a schema, because zod 4 refuses all three on an object carrying ' |
| 80 | + + 'a refinement — latent rather than live (no consumer in either repo derives the widget ' |
| 81 | + + 'schema that way today), and `.extend()` is unaffected. Repo census at ' |
| 82 | + + 'the time of the change: zero authored widgets carry the key anywhere in the ' |
| 83 | + + 'monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated ' |
| 84 | + + 'reference pages, the sdui-parser census and the gate that derives it.', |
| 85 | +}; |
0 commit comments