Compare commits

...
Author SHA1 Message Date
Enzo Martellucci 23b72d31ca fix(datasets): address remaining review comments on count/percent warning
- Declare d3-format as a direct superset-frontend dependency: it was only
  resolving via workspace hoisting from superset-ui-core, with no runtime
  entry of its own in this package's manifest.
- Allow whitespace between COUNT and its opening paren (COUNT (*)) in
  isCountExpression so the warning isn't silently skipped.
2026-08-28 11:05:58 +02:00
Enzo Martellucci 7aa94b53cc Merge branch 'master' into enxdev/fix/explore-count-metric 2026-08-28 11:02:46 +02:00
Enzo Martellucci a59b96c4f5 fix(explore): keep the row count label visible when the row limit is reached (#43296) 2026-08-28 10:51:27 +02:00
Enzo Martellucci 90a5e0074b chore: adress comments 2026-08-25 10:49:40 +02:00
Enzo Martellucci 2e5d3b8c73 Merge branch 'master' into enxdev/fix/explore-count-metric 2026-08-25 09:29:11 +02:00
Enzo Martellucci 046de3121c Merge branch 'master' into enxdev/fix/explore-count-metric 2026-08-24 17:53:41 +02:00
Enzo Martellucci 3b1398d6e3 Merge branch 'master' into enxdev/fix/explore-count-metric 2026-08-24 17:15:09 +02:00
Enzo Martellucci 9895bb0d10 Merge branch 'master' into enxdev/fix/explore-count-metric 2026-08-24 15:34:05 +02:00
Enzo Martellucci 3658d62f01 fix: adress findings 2026-08-24 15:32:48 +02:00
Enzo Martellucci 7ad5726717 fix(datasets): warn when a percent D3 format is set on a count metric 2026-08-24 11:54:02 +02:00
15 changed files with 457 additions and 14 deletions
+1
View File
@@ -162,6 +162,7 @@
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-format": "^3.1.2",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
| 'onOpenChange'
| 'optionRender'
| 'placeholder'
| 'prefix'
| 'showArrow'
| 'showSearch'
| 'tokenSeparators'
@@ -355,25 +355,31 @@ describe('SqlEditor', () => {
}),
);
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
// compute the accessible name, which can crash on an unrelated antd Tabs
// "more" button style; findByLabelText matches the same aria-label without
// that traversal.
test('enables the save dataset button when the latest query succeeded', async () => {
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
});
expect(await findByLabelText('Save dataset')).toBeEnabled();
});
test('disables the save dataset button when the latest query failed', async () => {
const { findByRole } = setupWithLatestQuery({
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Failed,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
expect(await findByLabelText('Save dataset')).toBeDisabled();
});
test('disables the save dataset button when the results are not loaded', async () => {
const { findByRole } = setupWithLatestQuery({
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
expect(await findByLabelText('Save dataset')).toBeDisabled();
});
test('renders an Extension if provided', async () => {
@@ -30,6 +30,7 @@ import { connect, ConnectedProps } from 'react-redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import { Radio } from '@superset-ui/core/components/Radio';
import { formatSpecifier } from 'd3-format';
import {
isFeatureEnabled,
FeatureFlag,
@@ -827,6 +828,64 @@ function EditorsSelector({
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
// D3's '%' and 'p' types both multiply by 100; parsed via d3-format's own
// grammar so garbage like "foo%" is rejected rather than matched by suffix.
export const isPercentD3Format = (d3format?: string): boolean => {
const trimmed = d3format?.trim();
if (!trimmed) {
return false;
}
try {
const { type } = formatSpecifier(trimmed);
return type === '%' || type === 'p';
} catch {
return false;
}
};
// Matches the outermost COUNT(...) call's parens by depth, so a ratio like
// `COUNT(*) / COUNT(*)` isn't misclassified but a nested call like
// `COUNT(DISTINCT COALESCE(a, b))` is still recognized.
export const isCountExpression = (expression?: string): boolean => {
const trimmed = expression?.trim();
if (!trimmed || !/^count\s*\(/i.test(trimmed) || !trimmed.endsWith(')')) {
return false;
}
let depth = 0;
for (let i = trimmed.indexOf('('); i < trimmed.length; i += 1) {
if (trimmed[i] === '(') {
depth += 1;
} else if (trimmed[i] === ')') {
depth -= 1;
if (depth === 0) {
return i === trimmed.length - 1;
}
}
}
return false;
};
function renderMetricFormatWarning(item: Record<string, any>): ReactNode {
if (
!isCountExpression(item.expression) ||
!isPercentD3Format(item.d3format)
) {
return null;
}
return (
<Alert
css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })}
type="warning"
showIcon
message={t(
'This metric is a count, but its D3 format is a percentage. ' +
'Percent formats multiply the value by 100, which will make a ' +
'raw count render as a misleadingly large number.',
)}
/>
);
}
// Redux connector types
interface QueryPayload {
client_id?: string;
@@ -2170,7 +2229,7 @@ function DatasourceEditor({
}}
expandFieldset={
<FormContainer>
<Fieldset compact>
<Fieldset compact renderWarning={renderMetricFormatWarning}>
<Field
fieldKey="expression"
label={t('SQL expression')}
@@ -0,0 +1,168 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { screen, userEvent } from 'spec/helpers/testing-library';
import { Constants } from '@superset-ui/core/components';
import { isCountExpression, isPercentD3Format } from '../DatasourceEditor';
import {
createProps,
DATASOURCE_ENDPOINT,
setupDatasourceEditorMocks,
cleanupAsyncOperations,
fastRender,
dismissDatasourceWarning,
} from './DatasourceEditor.test.utils';
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
setupDatasourceEditorMocks();
});
afterEach(async () => {
await cleanupAsyncOperations();
fetchMock.clearHistory().removeRoutes();
});
const WARNING_TEXT = /D3 format is a percentage/i;
// Negative assertions must wait past TextControl's debounce, or they pass
// before the value even commits.
const waitPastDebounce = () =>
new Promise(resolve => {
setTimeout(resolve, Constants.FAST_DEBOUNCE + 50);
});
test('isCountExpression matches a COUNT(...) call, including nested calls', () => {
expect(isCountExpression('COUNT(*)')).toBe(true);
expect(isCountExpression('count( * )')).toBe(true);
expect(isCountExpression('COUNT (*)')).toBe(true);
expect(isCountExpression('COUNT(DISTINCT name)')).toBe(true);
expect(isCountExpression('COUNT(DISTINCT COALESCE(a, b))')).toBe(true);
expect(isCountExpression('COUNT(*) / COUNT(*)')).toBe(false);
expect(isCountExpression('COUNT(*) * 100')).toBe(false);
expect(isCountExpression('SUM(num)')).toBe(false);
expect(isCountExpression(undefined)).toBe(false);
});
test('isPercentD3Format accepts only a valid D3 percent/p spec', () => {
expect(isPercentD3Format('.0%')).toBe(true);
expect(isPercentD3Format(',.2%')).toBe(true);
expect(isPercentD3Format('.1p')).toBe(true);
expect(isPercentD3Format('foo%')).toBe(false);
expect(isPercentD3Format('.0%garbage%')).toBe(false);
expect(isPercentD3Format(',.0f')).toBe(false);
expect(isPercentD3Format(undefined)).toBe(false);
});
// A '%' format is valid syntax, so it never hits the "Invalid format" fallback.
test('warns when a percent D3 format is set on a COUNT metric', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
const expandToggles = await screen.findAllByLabelText(/expand row/i);
// Rows sort by metric id descending, so `COUNT(*)` (id 7) is first.
await userEvent.click(expandToggles[0]);
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
expect(await screen.findByText(WARNING_TEXT)).toBeInTheDocument();
});
test('does not warn for a non-percent format on a COUNT metric', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), ',.0f');
await waitPastDebounce();
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
test('does not warn for a percent format on a non-COUNT metric', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
const expandToggles = await screen.findAllByLabelText(/expand row/i);
// Rows sort by metric id descending, so id 1 (`SUM(...)`) sorts last.
await userEvent.click(expandToggles[6]);
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
await waitPastDebounce();
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
test('does not warn for a ratio built from COUNT, e.g. COUNT(*) / COUNT(*)', async () => {
const baseProps = createProps();
const testProps = {
...baseProps,
datasource: {
...baseProps.datasource,
metrics: [
...baseProps.datasource.metrics,
{
id: 99,
uuid: 'metric-99-uuid',
expression: 'COUNT(*) / COUNT(*)',
verbose_name: 'ratio',
metric_name: 'ratio',
metric_type: 'count',
},
],
},
};
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
const expandToggles = await screen.findAllByLabelText(/expand row/i);
// The appended metric (id 99) sorts first.
await userEvent.click(expandToggles[0]);
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
await waitPastDebounce();
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
test('does not warn for a garbage format string that merely ends in %', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), 'foo%');
await waitPastDebounce();
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
});
@@ -28,6 +28,7 @@ export interface FieldsetProps {
item?: Record<string, any>;
title?: ReactNode;
compact?: boolean;
renderWarning?: (item: Record<string, any>) => ReactNode;
}
type fieldKeyType = string | number;
@@ -38,6 +39,7 @@ export default function Fieldset({
item = {},
title = null,
compact = false,
renderWarning,
}: FieldsetProps) {
// Controls report their edits asynchronously - TextControl debounces by
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
@@ -78,6 +80,7 @@ export default function Fieldset({
</Typography.Title>
)}
{renderWarning?.(item)}
{recurseReactClone(children, Field, propExtender)}
</Form>
);
@@ -53,6 +53,22 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
expect(tooltip).toHaveTextContent('The row limit');
});
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
render(
<RowCountLabel
rowcount={100}
limit={100}
limitReachedMessage="Custom limit message"
/>,
);
const expectedText = '100 rows';
expect(screen.getByText(expectedText)).toBeInTheDocument();
userEvent.hover(screen.getByText(expectedText));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent('Custom limit message');
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
});
test('RowCountLabel renders loading', () => {
render(<RowCountLabel loading />);
const expectedText = 'Loading...';
@@ -26,6 +26,9 @@ type RowCountLabelProps = {
limit?: number;
loading?: boolean;
label?: JSX.Element;
// Overrides the default "chart" wording for panes (e.g. samples) where the
// limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
};
const limitReachedMsg = t(
@@ -33,7 +36,13 @@ const limitReachedMsg = t(
);
export default function RowCountLabel(props: RowCountLabelProps) {
const { rowcount = 0, limit = null, loading, label } = props;
const {
rowcount = 0,
limit = null,
loading,
label,
limitReachedMessage,
} = props;
const limitReached = limit && rowcount >= limit;
const type =
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
@@ -50,7 +59,10 @@ export default function RowCountLabel(props: RowCountLabelProps) {
</Label>
);
return limitReached ? (
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
<Tooltip
id="tt-rowcount-tooltip"
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
>
{label || labelText}
</Tooltip>
) : (
@@ -68,6 +68,8 @@ export const TableControls = ({
canDownload,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
limitReachedMessage,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -111,14 +113,19 @@ export const TableControls = ({
value={rowLimit}
onChange={onRowLimitChange}
options={rowLimitOptions ?? []}
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
prefix={t('Limit')}
css={css`
min-width: 110px;
min-width: 160px;
`}
/>
)}
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
<RowCountLabel
rowcount={rowcount}
limit={effectiveRowLimit ?? rowLimit}
limitReachedMessage={limitReachedMessage}
loading={isLoading}
/>
{canDownload && onDownloadCSV && onDownloadXLSX && (
<DownloadDropdown
onDownloadCSV={onDownloadCSV}
@@ -136,6 +136,12 @@ export const SamplesPane = ({
const columns = useGridColumns(colnames, coltypes, data);
const keywordFilter = useKeywordFilter(filterText);
// Samples aren't capped by a chart's row_limit, just this pane's own
// page-size selector, so RowCountLabel's default "chart" wording is wrong here.
const limitReachedMessage = t(
'The sample row limit was reached. This %s may contain more rows.',
datasetLabelLower(),
);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
@@ -161,6 +167,7 @@ export const SamplesPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
<ErrorAlertWrapper>
@@ -197,6 +204,7 @@ export const SamplesPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
<GridContainer>
@@ -56,6 +56,8 @@ export const SingleQueryResultPane = ({
columnDisplayNames,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
limitReachedMessage,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -86,6 +88,8 @@ export const SingleQueryResultPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={rowLimitOptions}
effectiveRowLimit={effectiveRowLimit}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={onRowLimitChange}
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
@@ -84,6 +84,17 @@ export const useResultsPane = ({
// Never exceed the chart's own row_limit
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
// When this pane's own row-limit selector is stricter than the chart's
// row_limit, it - not the chart - is what caps the result, so
// RowCountLabel's default "chart" wording would be misleading (the chart's
// configured row_limit was never actually reached).
const limitReachedMessage =
rowLimit < chartRowLimit
? t(
'The row limit selected for this pane was reached. There may be more matching rows.',
)
: undefined;
const cappedFormData = useMemo(
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
[queryFormData, effectiveRowLimit],
@@ -236,6 +247,8 @@ export const useResultsPane = ({
columnDisplayNames={columnDisplayNames}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
effectiveRowLimit={effectiveRowLimit}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
</StyledDiv>
@@ -0,0 +1,94 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
act,
render,
screen,
sleep,
userEvent,
} from 'spec/helpers/testing-library';
import { GenericDataType } from '@apache-superset/core/common';
import {
TableControls,
ROW_LIMIT_OPTIONS,
} from '../components/DataTableControls';
import { TableControlsProps } from '../types';
const setup = (overrides: Partial<TableControlsProps> = {}) =>
render(
<TableControls
data={[]}
columnNames={['name']}
columnTypes={[GenericDataType.String]}
rowcount={0}
onInputChange={jest.fn()}
isLoading={false}
canDownload
rowLimit={100}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={jest.fn()}
{...overrides}
/>,
{ useRedux: true },
);
test('shows the row count when the result fills the selected row limit', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
});
test('warns that the row limit was reached when the result fills it', async () => {
setup({ rowcount: 100, rowLimit: 100 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('does not warn when the result is smaller than the selected row limit', async () => {
setup({ rowcount: 42, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
userEvent.hover(screen.getByTestId('row-count-label'));
// Wait past antd's 0.1s mouseEnterDelay so a regression that made the
// tooltip appear would be caught here instead of racing the delay.
await act(() => sleep(150));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('labels the row limit selector so it is not read as a second row count', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByText('Limit')).toBeInTheDocument();
});
@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { screen, render, waitFor } from 'spec/helpers/testing-library';
import {
screen,
render,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneOnDashboard } from '../components';
@@ -157,6 +162,42 @@ describe('useResultsPane query data reuse', () => {
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
expect(screen.getByText('2 rows')).toBeVisible();
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
userEvent.hover(screen.getByText('2 rows'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test("warns about this pane's own row limit, not the chart's, when the pane's selector is what caps the result", async () => {
// chart row_limit (2000) is well above this pane's default 1000-row
// selector, so the selector - not the chart - is what truncates here.
const props = createResultsPaneOnDashboardProps({
sliceId: 208,
rowLimit: 2000,
queriesResponse: [
{
colnames: ['genre'],
coltypes: [1],
data: Array.from({ length: 1500 }, (_, i) => ({
genre: `genre-${i}`,
})),
rowcount: 1500,
},
],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
const rowCountLabel = await screen.findByTestId('row-count-label');
expect(rowCountLabel).toHaveTextContent('1k rows');
userEvent.hover(rowCountLabel);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(
'The row limit selected for this pane was reached',
);
expect(tooltip).not.toHaveTextContent('for the chart');
});
test('renders an empty (0 rows) result from reused data without an API call', async () => {
@@ -84,6 +84,12 @@ export interface TableControlsProps extends DrillControlsProps {
canDownload: boolean;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
// Effective result limit, capped by the chart's row limit.
// Defaults to `rowLimit` and controls the "row limit reached" warning.
effectiveRowLimit?: number;
// Overrides RowCountLabel's default "chart" wording for panes (e.g.
// samples) where the limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
onRowLimitChange?: (limit: number) => void;
}
@@ -104,5 +110,9 @@ export interface SingleQueryResultPaneProp
columnDisplayNames?: Record<string, string>;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
effectiveRowLimit?: number;
// Overrides RowCountLabel's default "chart" wording when the pane's own
// row-limit selector, not the chart's row_limit, is what capped the result.
limitReachedMessage?: React.ReactNode;
onRowLimitChange?: (limit: number) => void;
}