From c2dcaba82d993978b3d152ae1187d8ca4b283d0c Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:50:41 -0400 Subject: [PATCH 1/7] fix(dashboards): use null instead of undefined for widget limits on TABLE/BIG_NUMBER TABLE and BIG_NUMBER widgets should never have limits, but changing a chart widget to TABLE via the builder set limit to undefined, which JSON.stringify drops. The backend then kept the stale DB value, causing "Maximum limit for this display type" errors on subsequent saves. Use null so the value survives serialization and the backend clears it. Also strengthen _enforceWidgetLimit to clear limits for TABLE/BIG_NUMBER and cap chart limits to their display-type maximum. Fixes DAIN-1330 --- static/app/actionCreators/dashboards.tsx | 32 +++++++++++++------ static/app/views/dashboards/types.tsx | 2 +- .../convertBuilderStateToWidget.spec.tsx | 8 ++--- .../utils/convertBuilderStateToWidget.ts | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/static/app/actionCreators/dashboards.tsx b/static/app/actionCreators/dashboards.tsx index 3be7ed6fc4a958..ac2275e56b903c 100644 --- a/static/app/actionCreators/dashboards.tsx +++ b/static/app/actionCreators/dashboards.tsx @@ -15,6 +15,7 @@ import {getStarredDashboardsQueryKey} from 'sentry/views/dashboards/hooks/useGet import { DashboardFilter, DisplayType, + MAX_CATEGORICAL_BAR_LIMIT, type DashboardDetails, type DashboardListItem, type Widget, @@ -354,34 +355,47 @@ export function validateWidget( } /** - * Enforces a limit on the widget if it is a chart and has a grouping + * Enforces valid limits on widgets before saving to the backend. * - * This ensures that widgets from previously created dashboards will have - * a limit applied properly when editing old dashboards that did not have - * this validation in place. + * - TABLE and BIG_NUMBER widgets should never have limits — clear any stale ones. + * - Chart widgets with grouping must have a limit, capped to the display type's max. + * + * Uses `null` (not `undefined`) so the value survives JSON.stringify and + * reaches the backend, which will clear the stale DB value. */ function _enforceWidgetLimit(widget: Widget) { if ( widget.displayType === DisplayType.TABLE || widget.displayType === DisplayType.BIG_NUMBER ) { - return widget; + return {...widget, limit: null}; + } + + let maxLimit: number; + if (widget.displayType === DisplayType.CATEGORICAL_BAR) { + maxLimit = MAX_CATEGORICAL_BAR_LIMIT; + } else { + maxLimit = getResultsLimit( + widget.queries.length, + widget.queries[0]!.aggregates.length + ); } const hasColumns = widget.queries.some(query => query.columns.length > 0); + if (hasColumns && !defined(widget.limit)) { // The default we historically assign for charts with a grouping is 5, // continue using that default unless there are conditions which make 5 // too large to automatically apply. - const maxLimit = getResultsLimit( - widget.queries.length, - widget.queries[0]!.aggregates.length - ); return { ...widget, limit: Math.min(maxLimit, TOP_N), }; } + if (defined(widget.limit) && widget.limit > maxLimit) { + return {...widget, limit: maxLimit}; + } + return widget; } diff --git a/static/app/views/dashboards/types.tsx b/static/app/views/dashboards/types.tsx index 8de3739854d693..dea8325a54f046 100644 --- a/static/app/views/dashboards/types.tsx +++ b/static/app/views/dashboards/types.tsx @@ -168,7 +168,7 @@ export type Widget = { layout?: WidgetLayout | null; legendType?: LegendType | null; // Used to define 'topEvents' when fetching time-series data for a widget - limit?: number; + limit?: number | null; // Used for table widget column widths, currently is not saved tableWidths?: number[]; tempId?: string; diff --git a/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.spec.tsx b/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.spec.tsx index e9af15be4bacb6..0d511983d2e2bd 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.spec.tsx @@ -200,7 +200,7 @@ describe('convertBuilderStateToWidget', () => { expect(widget.queries[0]!.orderby).toBe(''); }); - it('sets limit to undefined for table widgets', () => { + it('sets limit to null for table widgets so it survives JSON serialization', () => { const mockState: WidgetBuilderState = { displayType: DisplayType.TABLE, fields: [ @@ -213,10 +213,10 @@ describe('convertBuilderStateToWidget', () => { const widget = convertBuilderStateToWidget(mockState); - expect(widget.limit).toBeUndefined(); + expect(widget.limit).toBeNull(); }); - it('sets limit to undefined for big number widgets', () => { + it('sets limit to null for big number widgets so it survives JSON serialization', () => { const mockState: WidgetBuilderState = { displayType: DisplayType.BIG_NUMBER, fields: [ @@ -228,7 +228,7 @@ describe('convertBuilderStateToWidget', () => { const widget = convertBuilderStateToWidget(mockState); - expect(widget.limit).toBeUndefined(); + expect(widget.limit).toBeNull(); }); it('uses explicit axisRange from state', () => { diff --git a/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts b/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts index f2241a082c7d5d..c43393f77c1a09 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts +++ b/static/app/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget.ts @@ -136,7 +136,7 @@ export function convertBuilderStateToWidget(state: WidgetBuilderState): Widget { const limit = [DisplayType.BIG_NUMBER, DisplayType.TABLE].includes( state.displayType ?? DisplayType.TABLE ) - ? undefined + ? null : state.limit; return { From c69b44d4765133416033cd14c7950a1dd57389de Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:02:59 -0400 Subject: [PATCH 2/7] fix(dashboards): coerce null widget limits to undefined at call sites Follow-up to the Widget.limit type widening (number | null). Places that pass widget.limit to components or functions expecting number | undefined now use ?? undefined to coerce null. --- static/app/components/modals/dataWidgetViewerModal.tsx | 4 ++-- .../dashboards/widgetBuilder/components/widgetPreview.tsx | 2 +- .../widgetBuilder/utils/convertWidgetToBuilderStateParams.ts | 2 +- static/app/views/dashboards/widgetCard/chart.tsx | 2 +- .../dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/static/app/components/modals/dataWidgetViewerModal.tsx b/static/app/components/modals/dataWidgetViewerModal.tsx index 3f889cd5ae6f53..4cec0db065e55a 100644 --- a/static/app/components/modals/dataWidgetViewerModal.tsx +++ b/static/app/components/modals/dataWidgetViewerModal.tsx @@ -658,7 +658,7 @@ function DataWidgetViewerModal(props: Props) { selection={modalSelection} dashboardFilters={dashboardFilters} widget={primaryWidget} - tableItemLimit={widget.limit} + tableItemLimit={widget.limit ?? undefined} onZoom={onZoom} isFullScreen showConfidenceWarning={ @@ -675,7 +675,7 @@ function DataWidgetViewerModal(props: Props) { dashboardFilters={dashboardFilters} // Top N charts rely on the orderby of the table widget={primaryWidget} - tableItemLimit={widget.limit} + tableItemLimit={widget.limit ?? undefined} onZoom={onZoom} onLegendSelectChanged={onLegendSelectChanged} legendOptions={{ diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx index 5f4909a8c4b49f..fa77e307dc8784 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx @@ -129,7 +129,7 @@ export function WidgetPreview({ // dashboard state to be added onWidgetSplitDecision={() => {}} // onWidgetSplitDecision={onWidgetSplitDecision} - tableItemLimit={widget.limit} + tableItemLimit={widget.limit ?? undefined} showConfidenceWarning={ widget.widgetType === WidgetType.SPANS || widget.widgetType === WidgetType.TRACEMETRICS || diff --git a/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts b/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts index 0dbd86a8f41dc6..f72087d54f7e38 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts +++ b/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.ts @@ -62,7 +62,7 @@ export function convertWidgetToQueryParams( description, dataset, displayType: widget.displayType ?? DisplayType.TABLE, - limit: widget.limit, + limit: widget.limit ?? undefined, field, yAxis, query, diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx index 408d7164831e22..267adbf722a294 100644 --- a/static/app/views/dashboards/widgetCard/chart.tsx +++ b/static/app/views/dashboards/widgetCard/chart.tsx @@ -263,7 +263,7 @@ function WidgetCardChart(props: WidgetCardChartProps) { return ( Date: Tue, 14 Apr 2026 11:59:33 -0400 Subject: [PATCH 3/7] fix(dashboards): handle TEXT widgets in _enforceWidgetLimit TEXT widgets have no queries, so accessing queries[0].aggregates would crash. Add TEXT to the early-return alongside TABLE/BIG_NUMBER. --- static/app/actionCreators/dashboards.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/app/actionCreators/dashboards.tsx b/static/app/actionCreators/dashboards.tsx index ac2275e56b903c..a6b462cccfc35a 100644 --- a/static/app/actionCreators/dashboards.tsx +++ b/static/app/actionCreators/dashboards.tsx @@ -366,7 +366,8 @@ export function validateWidget( function _enforceWidgetLimit(widget: Widget) { if ( widget.displayType === DisplayType.TABLE || - widget.displayType === DisplayType.BIG_NUMBER + widget.displayType === DisplayType.BIG_NUMBER || + widget.displayType === DisplayType.TEXT ) { return {...widget, limit: null}; } From 85782e69a0efbde166db8a8e142c760ffba9f435 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:48:56 -0400 Subject: [PATCH 4/7] fix(dashboards): handle AGENTS_TRACES_TABLE, empty queries, and null limit check - Add AGENTS_TRACES_TABLE to the early-return list in _enforceWidgetLimit (table-like widget was falling through to chart capping logic) - Guard against empty queries array before accessing queries[0] - Use defined() instead of !== undefined for limit check in dataWidgetViewerModal (null !== undefined is true) --- static/app/actionCreators/dashboards.tsx | 7 ++++++- static/app/components/modals/dataWidgetViewerModal.tsx | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/static/app/actionCreators/dashboards.tsx b/static/app/actionCreators/dashboards.tsx index a6b462cccfc35a..f283cf2ca85954 100644 --- a/static/app/actionCreators/dashboards.tsx +++ b/static/app/actionCreators/dashboards.tsx @@ -367,11 +367,16 @@ function _enforceWidgetLimit(widget: Widget) { if ( widget.displayType === DisplayType.TABLE || widget.displayType === DisplayType.BIG_NUMBER || - widget.displayType === DisplayType.TEXT + widget.displayType === DisplayType.TEXT || + widget.displayType === DisplayType.AGENTS_TRACES_TABLE ) { return {...widget, limit: null}; } + if (widget.queries.length === 0) { + return widget; + } + let maxLimit: number; if (widget.displayType === DisplayType.CATEGORICAL_BAR) { maxLimit = MAX_CATEGORICAL_BAR_LIMIT; diff --git a/static/app/components/modals/dataWidgetViewerModal.tsx b/static/app/components/modals/dataWidgetViewerModal.tsx index 4cec0db065e55a..ca55660d5eacc3 100644 --- a/static/app/components/modals/dataWidgetViewerModal.tsx +++ b/static/app/components/modals/dataWidgetViewerModal.tsx @@ -288,7 +288,7 @@ function DataWidgetViewerModal(props: Props) { // Top N widget charts (including widgets with limits) results rely on the sorting of the query // Set the orderby of the widget chart to match the location query params const primaryWidget = - widget.displayType === DisplayType.TOP_N || widget.limit !== undefined + widget.displayType === DisplayType.TOP_N || defined(widget.limit) ? {...widget, queries: sortedQueries} : widget; const api = useApi(); From 112b9e14210f876ac82e8016d0e9f8a2575e6870 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:57:57 -0400 Subject: [PATCH 5/7] fix(dashboards): scope limit cap to widgets with grouping columns The limit cap in _enforceWidgetLimit should only apply to chart widgets that have grouping columns, since limits are only relevant for Top N behavior. Non-grouped chart widgets should pass through unchanged. --- static/app/actionCreators/dashboards.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/actionCreators/dashboards.tsx b/static/app/actionCreators/dashboards.tsx index f283cf2ca85954..481a2da25088ab 100644 --- a/static/app/actionCreators/dashboards.tsx +++ b/static/app/actionCreators/dashboards.tsx @@ -399,7 +399,7 @@ function _enforceWidgetLimit(widget: Widget) { }; } - if (defined(widget.limit) && widget.limit > maxLimit) { + if (hasColumns && defined(widget.limit) && widget.limit > maxLimit) { return {...widget, limit: maxLimit}; } From 87a5578158e6222fdf693f391d8da20a7da76d2e Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:05:39 -0400 Subject: [PATCH 6/7] fix(dashboards): use allowlist for display types that use limits Instead of maintaining a growing exclusion list of display types that don't use limits (TABLE, BIG_NUMBER, TEXT, AGENTS_TRACES_TABLE, WHEEL, DETAILS, SERVER_TREE, RAGE_AND_DEAD_CLICKS...), flip to an allowlist of the chart types that actually use limits: AREA, BAR, LINE, TOP_N, CATEGORICAL_BAR. Everything else gets limit cleared to null. --- static/app/actionCreators/dashboards.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/static/app/actionCreators/dashboards.tsx b/static/app/actionCreators/dashboards.tsx index 481a2da25088ab..ab619e6fa914c0 100644 --- a/static/app/actionCreators/dashboards.tsx +++ b/static/app/actionCreators/dashboards.tsx @@ -364,12 +364,7 @@ export function validateWidget( * reaches the backend, which will clear the stale DB value. */ function _enforceWidgetLimit(widget: Widget) { - if ( - widget.displayType === DisplayType.TABLE || - widget.displayType === DisplayType.BIG_NUMBER || - widget.displayType === DisplayType.TEXT || - widget.displayType === DisplayType.AGENTS_TRACES_TABLE - ) { + if (!DISPLAY_TYPES_WITH_LIMITS.has(widget.displayType)) { return {...widget, limit: null}; } @@ -405,3 +400,11 @@ function _enforceWidgetLimit(widget: Widget) { return widget; } + +const DISPLAY_TYPES_WITH_LIMITS = new Set([ + DisplayType.AREA, + DisplayType.BAR, + DisplayType.LINE, + DisplayType.TOP_N, + DisplayType.CATEGORICAL_BAR, +]); From 5a783f27c9cf93b043af47e7f570c32ac61b177c Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:06:39 -0400 Subject: [PATCH 7/7] fix(dashboards): add comment referencing widgetFetchesOwnData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document why DISPLAY_TYPES_WITH_LIMITS is an allowlist — other types either fetch their own data (see widgetFetchesOwnData) or don't use limits (TABLE, BIG_NUMBER, WHEEL, DETAILS). --- static/app/actionCreators/dashboards.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/app/actionCreators/dashboards.tsx b/static/app/actionCreators/dashboards.tsx index ab619e6fa914c0..4b3f77744f57c1 100644 --- a/static/app/actionCreators/dashboards.tsx +++ b/static/app/actionCreators/dashboards.tsx @@ -401,6 +401,9 @@ function _enforceWidgetLimit(widget: Widget) { return widget; } +// Chart types where `limit` controls the Top N grouping cap. +// Other display types either fetch their own data (see `widgetFetchesOwnData`) +// or don't use limits (TABLE, BIG_NUMBER, WHEEL, DETAILS). const DISPLAY_TYPES_WITH_LIMITS = new Set([ DisplayType.AREA, DisplayType.BAR,