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' }); + }); +});