From 50ab05b69cdc3ad4606849b2b9601d1accf315a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:53:18 +0000 Subject: [PATCH] fix(dashboard,charts): send widget query options to the server, order funnel stages by the pipeline (framework#3588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DatasetWidget` never read `widget.options`. Four keys an author writes there change the query the server compiles, so a widget declaring `options: { dateGranularity: 'month' }` grouped by the raw timestamp and drew one bar per record, and `sortBy`/`sortOrder` produced no ordering at all. - `DatasetWidget` lowers `options.dateGranularity`, `options.sortBy` + `options.sortOrder`, and `options.limit` into the `DatasetSelection` it posts. A `sortBy` naming something the widget does not project is dropped rather than sent, so a stale sort key left by an edit degrades to "unordered" instead of failing the widget against the server's stricter validation. These keys join the refetch signature, so editing one in the designer refetches instead of re-rendering the previous grid. - Funnel stages follow a declared order. `AdvancedChartImpl` sorted funnel segments by value descending, unconditionally — overriding any server ordering and rendering a sales pipeline as a tidy narrowing shape whatever the stages' real sequence, which hides the very anomaly (a bulge at Proposal) the chart exists to show. It now honours `categoryOrder`, derived from the dimension field's picklist option order — the pipeline order already declared on the object — or from an explicit `options.stageOrder`. With no declared order the value-descending default is unchanged, and a category missing from the order is kept after the declared ones rather than dropped. - New `@object-ui/core` helpers `buildCategoryOrder` / `buildCategoryRank`, keyed by both stored value and display label like `buildOptionColorMap`, so ordering works whether or not the server resolved the dimension's labels. Requires the framework-side fix in objectstack#3588 for the selection keys to take effect server-side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SEZLBGyNoJMLvtGPqsMPRC --- .changeset/dataset-widget-query-options.md | 37 +++++ .../utils/__tests__/category-order.test.ts | 78 ++++++++++ packages/core/src/utils/chart-series.ts | 52 +++++++ .../AdvancedChartImpl.funnelOrder.test.tsx | 141 +++++++++++++++++ .../plugin-charts/src/AdvancedChartImpl.tsx | 51 ++++-- packages/plugin-charts/src/ChartRenderer.tsx | 6 + .../plugin-dashboard/src/DatasetWidget.tsx | 66 +++++++- .../DatasetWidget.queryOptions.test.tsx | 145 ++++++++++++++++++ 8 files changed, 560 insertions(+), 16 deletions(-) create mode 100644 .changeset/dataset-widget-query-options.md create mode 100644 packages/core/src/utils/__tests__/category-order.test.ts create mode 100644 packages/plugin-charts/src/AdvancedChartImpl.funnelOrder.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/DatasetWidget.queryOptions.test.tsx diff --git a/.changeset/dataset-widget-query-options.md b/.changeset/dataset-widget-query-options.md new file mode 100644 index 000000000..dee90aabc --- /dev/null +++ b/.changeset/dataset-widget-query-options.md @@ -0,0 +1,37 @@ +--- +"@object-ui/core": patch +"@object-ui/plugin-charts": patch +"@object-ui/plugin-dashboard": patch +--- + +fix(dashboard,charts): send widget `dateGranularity`/`sortBy`/`limit` to the query, and give funnels a real stage order (framework#3588) + +`DatasetWidget` never read `widget.options`. Four keys an author writes there +change the query the server compiles, so a widget declaring +`options: { dateGranularity: 'month' }` grouped by the raw timestamp and drew +one bar per record, and `sortBy`/`sortOrder` produced no ordering at all. + +- `DatasetWidget` lowers `options.dateGranularity`, `options.sortBy` + + `options.sortOrder`, and `options.limit` into the `DatasetSelection` it posts. + A `sortBy` naming something the widget does not project is dropped rather than + sent, so a stale sort key left by an edit degrades to "unordered" instead of + failing the widget against the server's stricter validation. These keys also + join the refetch signature, so editing one in the designer refetches instead + of re-rendering the previous grid. +- Funnel stages follow a **declared order**. `AdvancedChartImpl` sorted funnel + segments by value descending, unconditionally — which overrode any server + ordering and rendered a sales pipeline as a tidy narrowing shape whatever the + stages' real sequence, hiding the very anomaly (a bulge at Proposal) the chart + exists to show. It now honours a `categoryOrder`, which `DatasetWidget` + derives from the dimension field's picklist option order — the pipeline order + an author already declared on the object — or from an explicit + `options.stageOrder`. With no declared order the value-descending default is + unchanged, and a category missing from the order is kept (after the declared + ones), never dropped. +- New `@object-ui/core` helpers `buildCategoryOrder` / `buildCategoryRank`, + keyed by both the stored value and the display label like the existing + `buildOptionColorMap`, so ordering works whether or not the server resolved + the dimension's labels. + +Requires the framework-side fix in objectstack#3588 for the selection keys to +take effect server-side. diff --git a/packages/core/src/utils/__tests__/category-order.test.ts b/packages/core/src/utils/__tests__/category-order.test.ts new file mode 100644 index 000000000..9097abbeb --- /dev/null +++ b/packages/core/src/utils/__tests__/category-order.test.ts @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * framework#3588 — a picklist's declared option order is the domain order, and + * ordered-sequence charts (funnel/pyramid) must render along it rather than + * alphabetically or by value. + */ + +import { describe, it, expect } from 'vitest'; +import { buildCategoryOrder, buildCategoryRank } from '../chart-series'; + +const STAGES = [ + { value: 'qualification', label: 'Qualification' }, + { value: 'needs_analysis', label: 'Needs Analysis' }, + { value: 'proposal', label: 'Proposal' }, + { value: 'negotiation', label: 'Negotiation' }, +]; + +describe('buildCategoryOrder', () => { + it('keeps the declared order and keys by BOTH value and label', () => { + expect(buildCategoryOrder(STAGES)).toEqual([ + 'qualification', 'Qualification', + 'needs_analysis', 'Needs Analysis', + 'proposal', 'Proposal', + 'negotiation', 'Negotiation', + ]); + }); + + it('accepts bare string options', () => { + expect(buildCategoryOrder(['a', 'b'])).toEqual(['a', 'b']); + }); + + it('does not duplicate a label identical to its value', () => { + expect(buildCategoryOrder([{ value: 'open', label: 'open' }])).toEqual(['open']); + }); + + it('returns null for a field with no options, so callers keep their default', () => { + expect(buildCategoryOrder(undefined)).toBeNull(); + expect(buildCategoryOrder([])).toBeNull(); + expect(buildCategoryOrder('nope')).toBeNull(); + }); +}); + +describe('buildCategoryRank', () => { + it('ranks value and label of the same option identically enough to sort together', () => { + const rank = buildCategoryRank(buildCategoryOrder(STAGES))!; + // A row keyed by the stored value and one keyed by the display label both + // sort into the same slot relative to the other stages. + expect(rank.get('qualification')).toBeLessThan(rank.get('Needs Analysis')!); + expect(rank.get('Proposal')).toBeLessThan(rank.get('negotiation')!); + }); + + it('sorts the real pipeline out of alphabetical order', () => { + const rank = buildCategoryRank(buildCategoryOrder(STAGES))!; + // Alphabetical is the order analytics returns; the declared order is not it. + const alphabetical = ['Needs Analysis', 'Negotiation', 'Proposal', 'Qualification']; + const sorted = [...alphabetical].sort( + (a, b) => (rank.get(a) ?? Infinity) - (rank.get(b) ?? Infinity), + ); + expect(sorted).toEqual(['Qualification', 'Needs Analysis', 'Proposal', 'Negotiation']); + }); + + it('first occurrence wins for a repeated key', () => { + const rank = buildCategoryRank(['a', 'b', 'a'])!; + expect(rank.get('a')).toBe(0); + }); + + it('returns null for an empty/absent order', () => { + expect(buildCategoryRank(null)).toBeNull(); + expect(buildCategoryRank([])).toBeNull(); + }); +}); diff --git a/packages/core/src/utils/chart-series.ts b/packages/core/src/utils/chart-series.ts index 21497ff50..9f4980e53 100644 --- a/packages/core/src/utils/chart-series.ts +++ b/packages/core/src/utils/chart-series.ts @@ -197,6 +197,58 @@ export function buildOptionColorMap(options: unknown): Record | return Object.keys(map).length > 0 ? map : null; } +/** + * Build the DECLARED category order from a select field's `options` — the + * sequence the author wrote them in on the object (framework#3588). + * + * A picklist's option order is already the domain order: a sales `stage` field + * lists Qualification → Needs Analysis → Proposal → Negotiation because that IS + * the pipeline. Analytics groups by that field and returns buckets in whatever + * order the GROUP BY produced (usually alphabetical), which for an + * ordered-sequence chart — a funnel above all — draws a shape that reads as a + * pipeline but isn't one. + * + * Emits BOTH the stored `value` and its display `label` per option, adjacent + * and in declared order, for the same reason {@link buildOptionColorMap} keys + * by both: a chart row's category may carry either, depending on whether the + * server resolved the dimension's labels. Rank is "index of first match", so + * either key ranks the option identically. + * + * Returns `null` for a field with no options, so callers keep their existing + * ordering (a funnel falls back to descending by value). + */ +export function buildCategoryOrder(options: unknown): string[] | null { + if (!Array.isArray(options) || options.length === 0) return null; + const keys: string[] = []; + for (const opt of options) { + if (typeof opt === 'string' || typeof opt === 'number' || typeof opt === 'boolean') { + keys.push(String(opt)); + continue; + } + if (opt && typeof opt === 'object') { + const o = opt as { value?: unknown; label?: unknown }; + if (o.value != null) keys.push(String(o.value)); + if (o.label != null && String(o.label) !== String(o.value)) keys.push(String(o.label)); + } + } + return keys.length > 0 ? keys : null; +} + +/** + * Rank map for {@link buildCategoryOrder} keys — `category → position`, first + * occurrence wins. Categories absent from the declared order get no entry; the + * caller decides where those sort (see `AdvancedChartImpl`, which keeps them + * after the declared ones rather than dropping them). + */ +export function buildCategoryRank(order: string[] | null | undefined): Map | null { + if (!Array.isArray(order) || order.length === 0) return null; + const rank = new Map(); + order.forEach((key, i) => { + if (!rank.has(key)) rank.set(key, i); + }); + return rank.size > 0 ? rank : null; +} + /** * Build a `{ value → label }` map from a select/enum field's `options`, for * resolving a grouped dimension's stored value to its display label (fed to diff --git a/packages/plugin-charts/src/AdvancedChartImpl.funnelOrder.test.tsx b/packages/plugin-charts/src/AdvancedChartImpl.funnelOrder.test.tsx new file mode 100644 index 000000000..c0f8077af --- /dev/null +++ b/packages/plugin-charts/src/AdvancedChartImpl.funnelOrder.test.tsx @@ -0,0 +1,141 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * framework#3588 — a funnel's shape asserts a sequence. + * + * The renderer used to sort funnel segments by value descending, + * unconditionally — so a sales pipeline rendered as a tidy narrowing shape + * regardless of the stages' real order, and any server-side ordering was + * silently overridden. With a declared `categoryOrder` the pipeline order wins; + * without one the value-descending default is preserved. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; + +// Recharts' ResponsiveContainer measures its parent via ResizeObserver, which +// reports 0×0 under the headless DOM — so the funnel never paints. Replace it +// with a fixed-size passthrough so segments actually render. +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => + React.cloneElement(children, { width: 320, height: 320 }), + }; +}); + +import AdvancedChartImpl from './AdvancedChartImpl'; + +afterEach(cleanup); + +/** + * Rows as analytics returns them: alphabetical, and deliberately NOT + * monotonically narrowing — Proposal (200) is the largest, so a value sort and + * the declared pipeline order produce visibly different funnels. + */ +const PIPELINE = [ + { stage: 'Needs Analysis', total_amount: 120 }, + { stage: 'Negotiation', total_amount: 90 }, + { stage: 'Proposal', total_amount: 200 }, + { stage: 'Qualification', total_amount: 150 }, +]; + +/** As `buildCategoryOrder` emits it: value and label adjacent, in declared order. */ +const DECLARED = [ + 'qualification', 'Qualification', + 'needs_analysis', 'Needs Analysis', + 'proposal', 'Proposal', + 'negotiation', 'Negotiation', +]; + +/** + * The segment labels in the order Recharts drew them. `` renders one + * `` label per trapezoid in source order, which is exactly the ordering + * under test. + */ +function drawnOrder(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('text')) + .map((t) => t.textContent?.trim() ?? '') + .filter(Boolean); +} + +function renderFunnel(extra: Record) { + return render( + , + ); +} + +describe('funnel stage order (#3588)', () => { + it('draws every stage (guards the assertions below against an empty render)', () => { + const { container } = renderFunnel({ categoryOrder: DECLARED }); + expect(container.querySelectorAll('.recharts-funnel-trapezoid')).toHaveLength(4); + expect(drawnOrder(container)).toHaveLength(4); + }); + + it('draws in the DECLARED pipeline order, not by value', () => { + const { container } = renderFunnel({ categoryOrder: DECLARED }); + expect(drawnOrder(container)).toEqual([ + 'Qualification', 'Needs Analysis', 'Proposal', 'Negotiation', + ]); + }); + + it('keeps the value-descending default when no order is declared', () => { + const { container } = renderFunnel({}); + expect(drawnOrder(container)).toEqual([ + 'Proposal', 'Qualification', 'Needs Analysis', 'Negotiation', + ]); + }); + + it('keeps a category missing from the declared order, after the declared ones', () => { + const { container } = render( + , + ); + // Never dropped — an unlisted stage still renders, and lands last. + expect(drawnOrder(container)).toEqual([ + 'Qualification', 'Needs Analysis', 'Proposal', 'Negotiation', 'Closed Won', + ]); + }); + + it('orders by the stored VALUE too, for rows the server left unlabeled', () => { + const { container } = render( + , + ); + expect(drawnOrder(container)).toEqual(['qualification', 'proposal', 'negotiation']); + }); +}); diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index 06953658b..0e2feddfc 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -44,6 +44,7 @@ import { ChartConfig } from './ChartContainerImpl'; import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents'; +import { buildCategoryRank } from '@object-ui/core'; // Default color fallback for chart series const DEFAULT_CHART_COLOR = 'hsl(var(--primary))'; @@ -109,6 +110,20 @@ export interface AdvancedChartImplProps { * categories absent here fall back to the positional palette, then the theme. */ categoryColors?: Record; + /** + * Declared category order for ordered-sequence charts (funnel / pyramid), + * keyed like {@link categoryColors} by the category VALUE or its display + * LABEL, listed in domain order. + * + * A funnel's shape asserts a sequence, so without this the renderer can only + * guess at one and sorts by value descending. That is right for a generic + * "biggest first" funnel and wrong for a sales pipeline, where the stages + * have a declared order (Qualification → Needs Analysis → Proposal → + * Negotiation) that a healthy pipeline happens to narrow along — and an + * unhealthy one does not. When supplied, this order wins; categories not + * listed keep their incoming relative order after the listed ones. + */ + categoryOrder?: string[]; /** * Optional drill-down click handler. Fires when a chart segment is clicked * with `{ category, series, value }`. Wired for bar/horizontal-bar/line/ @@ -160,6 +175,7 @@ export default function AdvancedChartImpl({ className = '', colors, categoryColors, + categoryOrder, onChartClick, isAnimationActive, }: AdvancedChartImplProps) { @@ -410,15 +426,32 @@ export default function AdvancedChartImpl({ const funnelClickProps = handleFunnelClick ? { onClick: handleFunnelClick, style: { cursor: 'pointer' as const } } : {}; - // Recharts draws segments in source order. For a visually - // correct funnel (largest at top, narrowing down) we sort descending - // by the numeric value of `dataKey` so authors don't have to pre-sort - // their dashboard data. - const funnelData = [...data].sort((a, b) => { - const av = Number(a?.[dataKey] ?? 0); - const bv = Number(b?.[dataKey] ?? 0); - return bv - av; - }); + // Recharts draws segments in source order, so this decides the + // sequence the funnel asserts. + // + // With a DECLARED order (framework#3588) — a stage picklist's own option + // order, or an explicit `stageOrder` — that order wins: a sales funnel must + // read Qualification → Needs Analysis → Proposal → Negotiation whether or + // not each stage happens to hold more value than the next. Sorting such a + // pipeline by value would manufacture a tidy narrowing shape and hide the + // very anomaly (a bulge at Proposal) the chart exists to reveal. Categories + // missing from the declared order keep their incoming relative order, + // after the declared ones — never dropped. + // + // Without one, keep the long-standing default: descending by value, so a + // generic funnel still narrows downward without authors pre-sorting. + const categoryRank = buildCategoryRank(categoryOrder); + const funnelData = categoryRank + ? [...data].sort((a, b) => { + const ar = categoryRank.get(String(a?.[xAxisKey] ?? '')) ?? Number.MAX_SAFE_INTEGER; + const br = categoryRank.get(String(b?.[xAxisKey] ?? '')) ?? Number.MAX_SAFE_INTEGER; + return ar - br; + }) + : [...data].sort((a, b) => { + const av = Number(a?.[dataKey] ?? 0); + const bv = Number(b?.[dataKey] ?? 0); + return bv - av; + }); return ( diff --git a/packages/plugin-charts/src/ChartRenderer.tsx b/packages/plugin-charts/src/ChartRenderer.tsx index c36fa97b6..88b546bb5 100644 --- a/packages/plugin-charts/src/ChartRenderer.tsx +++ b/packages/plugin-charts/src/ChartRenderer.tsx @@ -53,6 +53,11 @@ export interface ChartRendererProps { * category; see AdvancedChartImpl. Set by ObjectChart from select/lookup * dimension option colours and/or an explicit author map. */ categoryColors?: Record; + /** Declared category order (value/label keys, in domain order) for + * ordered-sequence charts — funnel/pyramid stages. Set by DatasetWidget + * from the dimension's picklist order or an explicit `stageOrder`; see + * AdvancedChartImpl. Omitted → the funnel's value-descending default. */ + categoryOrder?: string[]; /** Pass `false` for a deterministic, export-safe render with no entrance * animation (see AdvancedChartImpl). Omitted → animated default. */ isAnimationActive?: boolean; @@ -118,6 +123,7 @@ export const ChartRenderer: React.FC = ({ schema, onChartCli className={props.className} colors={Array.isArray((schema as any).colors) ? (schema as any).colors : undefined} categoryColors={(schema as any).categoryColors} + categoryOrder={(schema as any).categoryOrder} isAnimationActive={schema.isAnimationActive} onChartClick={onChartClick} /> diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index e9b7db260..6156b4a9c 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -32,6 +32,7 @@ import { buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, + buildCategoryOrder, relabelDimensions, findChartSeriesRow, formatMeasure, @@ -178,6 +179,30 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // computes each with the measure's TRUE aggregate (never re-derived here). const totalsGroupings = isMatrix ? [rowDims, [colDim], []] : undefined; + // ── Query-affecting widget options (framework#3588) ────────────────────── + // `options` is the renderer-extras bag, but four of its keys are NOT + // presentation-only: they change the query the server compiles. They were + // accepted by the metadata layer and then dropped here — this component never + // read `options` at all — so `dateGranularity: 'month'` grouped by the raw + // timestamp (one bar per record) and `sortBy` produced no ordering. Lower + // them into the `DatasetSelection` the server already understands. + const options: Record = + widget?.options && typeof widget.options === 'object' && !Array.isArray(widget.options) + ? (widget.options as Record) + : {}; + const dateGranularity = typeof options.dateGranularity === 'string' ? options.dateGranularity : undefined; + const sortBy = typeof options.sortBy === 'string' && options.sortBy ? options.sortBy : undefined; + // Only order by something this widget actually projects — the server rejects + // an order key it can't resolve, and a stale `sortBy` left behind by an edit + // should degrade to "unordered", not fail the whole widget. + const sortable = sortBy && (dimensions.includes(sortBy) || values.includes(sortBy)) ? sortBy : undefined; + const order = sortable + ? { [sortable]: options.sortOrder === 'desc' ? ('desc' as const) : ('asc' as const) } + : undefined; + const limit = typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0 + ? Math.floor(options.limit) + : undefined; + const tt = useSafeTranslate(); const { fieldLabel } = useSafeFieldLabel(); @@ -216,10 +241,19 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // select whose options the analytics layer can't see comes back value-keyed, // so we resolve it here from the object field options (see relabelDimensions). const [dimensionLabels, setDimensionLabels] = useState> | null>(null); + // The first dimension's DECLARED picklist order (framework#3588) — the + // sequence the author wrote the options in on the object. For an + // ordered-sequence chart (funnel/pyramid) that order IS the domain order + // (Qualification → Needs Analysis → Proposal → Negotiation); grouping returns + // buckets alphabetically, which draws a shape that reads as a pipeline but + // isn't one. Fetched from the same object schema as the colours/labels below. + const [categoryOrder, setCategoryOrder] = useState(null); // Signature uses the RAW filter (stable) — the resolved one carries a - // render-time `now` and would otherwise force a refetch loop. - const signature = `${widgetType}|${datasetName}|${dimensions.join(',')}|${values.join(',')}|${JSON.stringify(rawFilter ?? null)}|${JSON.stringify(compareTo ?? null)}`; + // render-time `now` and would otherwise force a refetch loop. The + // query-affecting options join it so editing a widget's granularity/sort in + // the designer refetches instead of re-rendering the previous grid. + const signature = `${widgetType}|${datasetName}|${dimensions.join(',')}|${values.join(',')}|${JSON.stringify(rawFilter ?? null)}|${JSON.stringify(compareTo ?? null)}|${dateGranularity ?? ''}|${JSON.stringify(order ?? null)}|${limit ?? ''}`; useEffect(() => { const src = dataSource as DatasetCapableSource | undefined; if (!src || typeof src.queryDataset !== 'function') { @@ -235,6 +269,9 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: ...(runtimeFilter ? { runtimeFilter } : {}), ...(compareTo ? { compareTo } : {}), ...(totalsGroupings ? { totals: { groupings: totalsGroupings } } : {}), + ...(dateGranularity ? { dateGranularity } : {}), + ...(order ? { order } : {}), + ...(limit != null ? { limit } : {}), }) .then((res) => { if (!cancelled) setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [], fields: Array.isArray(res?.fields) ? res.fields : [], object: res?.object, dimensionFields: res?.dimensionFields, drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined, drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined, totals: Array.isArray(res?.totals) ? res.totals : undefined }); }) .catch((e) => { if (!cancelled) setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) }); }); @@ -250,9 +287,9 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // the axis/series display labels even when the server returned raw values. // Best-effort: any failure leaves both null (positional palette + raw values). useEffect(() => { - if (isMetric || isTable) { setCategoryColors(null); setDimensionLabels(null); return; } + if (isMetric || isTable) { setCategoryColors(null); setDimensionLabels(null); setCategoryOrder(null); return; } const object = state.object; - if (!object || dimensions.length === 0) { setCategoryColors(null); setDimensionLabels(null); return; } + if (!object || dimensions.length === 0) { setCategoryColors(null); setDimensionLabels(null); setCategoryOrder(null); return; } const fieldOf = (dim: string) => (state.dimensionFields && state.dimensionFields[dim]) || dim; let cancelled = false; (async () => { @@ -260,7 +297,8 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: const res = await fetch(`/api/v1/meta/object/${encodeURIComponent(object)}`, { headers: { accept: 'application/json' }, credentials: 'include' }); const j = await res.json().catch(() => null); const objSchema = j?.item ?? j?.data ?? j; - const colorMap = buildOptionColorMap(objSchema?.fields?.[fieldOf(dimensions[0])]?.options); + const firstDimOptions = objSchema?.fields?.[fieldOf(dimensions[0])]?.options; + const colorMap = buildOptionColorMap(firstDimOptions); const labels: Record> = {}; for (const dim of dimensions) { const m = buildDimensionLabelMap(objSchema?.fields?.[fieldOf(dim)]?.options); @@ -269,8 +307,9 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: if (!cancelled) { setCategoryColors(colorMap); setDimensionLabels(Object.keys(labels).length > 0 ? labels : null); + setCategoryOrder(buildCategoryOrder(firstDimOptions)); } - } catch { if (!cancelled) { setCategoryColors(null); setDimensionLabels(null); } } + } catch { if (!cancelled) { setCategoryColors(null); setDimensionLabels(null); setCategoryOrder(null); } } })(); return () => { cancelled = true; }; }, [state.object, state.dimensionFields, dimensions, isMetric, isTable]); @@ -540,6 +579,19 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // series so multi-dimension dataset widgets match the chart-view renderer. const { data: chartData, xAxisKey, series } = buildChartSeries(chartRows, dimensions, values, state.fields); + // Ordered-sequence charts (funnel/pyramid) need a DEFINED stage order. + // `options.stageOrder` wins when the author states one explicitly; otherwise + // the dimension field's declared picklist order does. Explicit values are + // expanded with their display labels because `chartRows` may already be + // relabeled — the same value-or-label keying `categoryColors` uses. + const stageOrder = Array.isArray(options.stageOrder) ? options.stageOrder : undefined; + const explicitOrder = stageOrder?.flatMap((v) => { + const key = String(v); + const label = dimensionLabels?.[dimensions[0]]?.[key]; + return label && label !== key ? [key, label] : [key]; + }); + const effectiveCategoryOrder = explicitOrder?.length ? explicitOrder : categoryOrder; + // Map a clicked chart segment back to its dataset row, then drill through to // the underlying records — same governed path the table/pivot rows use. const handleChartDrill = (ev: { category?: string; series?: string; value?: number }) => { @@ -565,7 +617,7 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // measurement churn, can freeze there — bars never draw until an unrelated // re-render (#2756, follow-up to #2727's ineffective settle re-mount). // Turning the tween off makes the first paint deterministic. - schema={{ type: 'chart', chartType, data: chartData, xAxisKey, series, isAnimationActive: false, ...(categoryColors ? { categoryColors } : {}) } as any} + schema={{ type: 'chart', chartType, data: chartData, xAxisKey, series, isAnimationActive: false, ...(categoryColors ? { categoryColors } : {}), ...(effectiveCategoryOrder ? { categoryOrder: effectiveCategoryOrder } : {}) } as any} onChartClick={chartDrill} onSegmentClick={chartDrill} /> diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.queryOptions.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.queryOptions.test.tsx new file mode 100644 index 000000000..ebd01bf6b --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.queryOptions.test.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { DatasetWidget } from '../DatasetWidget'; + +afterEach(cleanup); + +/** + * Regression cover for framework #3588. + * + * Three widget `options` keys were accepted by the metadata layer and then + * dropped: this component never read `options` at all, so a widget declaring + * `dateGranularity: 'month'` still grouped by the raw timestamp (one bar per + * record) and `sortBy`/`sortOrder` produced no ordering whatsoever. + * + * These assert on the SELECTION handed to `queryDataset`, because that is the + * seam the bug lived at — the widget rendered fine and the query ran fine; only + * the payload was missing the options the author wrote. + */ +describe('DatasetWidget — query-affecting widget options (#3588)', () => { + const makeSource = () => ({ queryDataset: vi.fn(async () => ({ rows: [], fields: [] })) }); + + const selectionOf = (src: { queryDataset: ReturnType }) => + src.queryDataset.mock.calls[0]?.[1] as Record; + + it('lowers options.dateGranularity into the selection', async () => { + const src = makeSource(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).toMatchObject({ + dimensions: ['created_at'], + measures: ['account_count'], + dateGranularity: 'month', + }); + }); + + it('lowers options.sortBy/sortOrder into an order, and limit alongside it', async () => { + const src = makeSource(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).toMatchObject({ + order: { annual_revenue_sum: 'desc' }, + limit: 10, + }); + }); + + it('defaults sortOrder to ascending', async () => { + const src = makeSource(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).toMatchObject({ order: { industry: 'asc' } }); + }); + + it('drops a sortBy the widget does not project, rather than failing the query', async () => { + // The server rejects an unresolvable order key. A stale `sortBy` left behind + // by an edit should degrade to "unordered", not blank the whole widget. + const src = makeSource(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).not.toHaveProperty('order'); + }); + + it('ignores non-query renderer extras and a non-numeric limit', async () => { + const src = makeSource(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + const sel = selectionOf(src); + expect(sel).not.toHaveProperty('limit'); + expect(sel).not.toHaveProperty('striped'); + expect(sel).not.toHaveProperty('columns'); + }); + + it('sends no query options at all when the widget declares none', async () => { + const src = makeSource(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'] }); + }); + + it('refetches when a query-affecting option changes', async () => { + const src = makeSource(); + const widget = (granularity: string) => ({ + type: 'bar', dataset: 'ds', dimensions: ['created_at'], values: ['n'], + options: { dateGranularity: granularity }, + }); + const { rerender } = render(); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalledTimes(1)); + + rerender(); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalledTimes(2)); + expect(src.queryDataset.mock.calls[1][1]).toMatchObject({ dateGranularity: 'quarter' }); + }); +});