Compare commits

...
Author SHA1 Message Date
Claude Code 6a029736be fix(examples): migrate Life Expectancy VS Rural % example off the removed legacy bubble viz_type
PR #41714 removed the legacy nvd3 chart pipeline and documented that
saved nvd3 Bubble charts auto-migrate to the ECharts Bubble Chart
(bubble_v2). That auto-migration runs against existing rows in an
already-populated metadata database (superset/migrations/shared/migrate_viz/processors.py),
not against the example YAML fixtures loaded fresh by `superset load-examples` --
so this example chart was left with `viz_type: bubble`, which has had zero
registered plugin since #41714 landed. Every fresh install loading examples
got a broken chart on the "World Bank's Data" dashboard.

Migrated params to bubble_v2's shape (adhoc-metric x/y/size instead of
legacy sum__ column-name shorthand, dropped legacy-only fields), matching
the working bubble_v2 example in featured_charts/charts/Bubble.yaml.
Verified against ImportV1ChartSchema and a full `superset db upgrade`.

Audited all 103 example chart YAMLs across superset/examples/ for any
other viz_type without a currently-registered plugin; this was the only one.
2026-08-13 22:52:33 -07:00
a188e9473a feat(chart): let Drill By scope to the clicked x-axis value (#42296)
Co-authored-by: Jacob Hartmann (BUVM-STIL) <jacob.hartmann@stil.dk>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-13 17:02:27 -07:00
10 changed files with 922 additions and 33 deletions
@@ -45,6 +45,13 @@ export interface ContextMenuFilters {
filters: BinaryQueryObjectFilterClause[];
groupbyFieldName: string;
adhocFilterFieldName?: string;
/**
* Filters scoped to the clicked x-axis value (category or time bucket),
* as opposed to `filters`, which are scoped to the clicked series.
* When both are present, the Drill By UI lets the user choose which
* of the two (or both) to apply to the drilled chart.
*/
xAxisFilters?: BinaryQueryObjectFilterClause[];
};
}
@@ -21,7 +21,8 @@ import {
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import { AxisType, TimeGranularity } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
@@ -655,3 +656,99 @@ test('context menu cross-filter uses the category value for a horizontal categor
]);
}
});
// A category axis can still sit on a temporal column when the axis is
// forced categorical (xAxisForceCategorical); the drillBy x-axis filter must
// then bucket by the configured time grain rather than doing an exact match.
test('drillBy filters by time bucket when a categorical axis is forced onto a temporal column', async () => {
const onContextMenuMock = jest.fn();
const propsWithForcedCategoricalTemporalAxis: TimeseriesChartTransformedProps =
{
...defaultProps,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
xAxisForceCategorical: true,
timeGrainSqla: TimeGranularity.MONTH,
},
coltypeMapping: { order_date: GenericDataType.Temporal },
xAxis: {
label: 'order_date',
type: AxisType.Category,
},
};
render(<EchartsTimeseries {...propsWithForcedCategoricalTemporalAxis} />);
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
expect(contextMenuHandler).toBeDefined();
await contextMenuHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: ['2021-02-01T00:00:00', 100],
name: '2021-02-01T00:00:00',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenuMock).toHaveBeenCalled();
});
const { drillBy } = onContextMenuMock.mock.calls[0][2];
expect(drillBy.xAxisFilters).toEqual([
{
col: 'order_date',
op: 'TEMPORAL_RANGE',
val: '2021-02-01T00:00:00 : 2021-03-01T00:00:00',
formattedVal: '2021-02-01T00:00:00',
},
]);
});
// For horizontal orientation the [x, value] pair reported by ECharts is
// swapped, so the drillBy x-axis filter must read the clicked time value
// from the second element of the data tuple rather than the first.
test('drillBy uses the swapped data index for a horizontal time-based axis', async () => {
const onContextMenuMock = jest.fn();
const propsWithHorizontalTimeAxis: TimeseriesChartTransformedProps = {
...defaultProps,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
xAxis: {
label: 'order_date',
type: AxisType.Time,
},
};
render(<EchartsTimeseries {...propsWithHorizontalTimeAxis} />);
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
expect(contextMenuHandler).toBeDefined();
await contextMenuHandler?.({
componentType: 'series',
seriesName: 'Sales',
// Horizontal: value first, x (time) value second
data: [100, '2021-02-01T00:00:00'],
name: '2021-02-01T00:00:00',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenuMock).toHaveBeenCalled();
});
const { drillBy } = onContextMenuMock.mock.calls[0][2];
expect(drillBy.xAxisFilters).toEqual([
{
col: 'order_date',
op: '==',
val: '2021-02-01T00:00:00',
formattedVal: '2021-02-01T00:00:00',
},
]);
});
@@ -28,6 +28,7 @@ import {
ensureIsArray,
} from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import type {
ECElementEvent,
ViewRootGroup,
@@ -43,6 +44,7 @@ import {
} from './percentChange';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
const TIMER_DURATION = 300;
@@ -507,6 +509,55 @@ export default function EchartsTimeseries({
});
});
// Filters for the clicked x-axis value, so Drill By can subset the
// drilled data to the clicked bar/point rather than only the series
const xAxisFilters: BinaryQueryObjectFilterClause[] = [];
const xAxisCol =
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
if (data && xAxis.type === AxisType.Time && xAxisCol) {
// For horizontal orientation the [x, value] pair is swapped
const xValue = Array.isArray(data)
? data[categoryAxisValueIndex]
: data;
const xAxisFilter = getTemporalXAxisDrillByFilter(
xAxisCol,
xValue,
formData.timeGrainSqla,
String(xValueFormatter(xValue as number)),
);
if (xAxisFilter) {
xAxisFilters.push(xAxisFilter);
}
} else if (xAxis.type === AxisType.Category && xAxisCol) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
if (categoryAxisValue !== undefined) {
// A category axis can still sit on a temporal column when the
// axis is forced categorical; filter by time bucket in that case
const xAxisFilter =
coltypeMapping?.[getColumnLabel(xAxis.label)] ===
GenericDataType.Temporal
? getTemporalXAxisDrillByFilter(
xAxisCol,
categoryAxisValue,
formData.timeGrainSqla,
String(eventParams.name ?? categoryAxisValue),
)
: {
col: xAxisCol,
op: '==' as const,
val: categoryAxisValue,
formattedVal: String(categoryAxisValue),
};
if (xAxisFilter) {
xAxisFilters.push(xAxisFilter);
}
}
}
// Provide cross-filter for dimensions OR categorical X-axis (issue #25334)
let crossFilter;
if (hasDimensions) {
@@ -526,7 +577,11 @@ export default function EchartsTimeseries({
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' },
drillBy: {
filters: drillByFilters,
groupbyFieldName: 'groupby',
...(xAxisFilters.length > 0 && { xAxisFilters }),
},
crossFilter,
});
}
@@ -0,0 +1,164 @@
/**
* 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 {
BinaryQueryObjectFilterClause,
QueryFormColumn,
TimeGranularity,
} from '@superset-ui/core';
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Format a Date as a naive ISO datetime string (UTC, no timezone suffix),
* the format Superset's time range parser expects, e.g. "2021-01-01T00:00:00".
* Sub-second precision is preserved when present (e.g.
* "2021-01-01T00:00:00.123") so exact-match filters on high-frequency
* timestamps don't get truncated to the containing second.
*/
export const formatNaiveDateTime = (date: Date): string => {
const iso = date.toISOString();
return date.getUTCMilliseconds() === 0 ? iso.slice(0, 19) : iso.slice(0, 23);
};
/**
* Given the start (label) of a time bucket and its time grain, return the
* [since, until) range covering the bucket, using calendar-aware UTC
* arithmetic. Week-ending grains are labeled by the last day of the bucket,
* so their range extends backwards from the label. Returns undefined for
* unknown grains.
*/
export const getTimeBucketRange = (
bucketLabel: Date,
grain: TimeGranularity,
): { since: Date; until: Date } | undefined => {
const until = new Date(bucketLabel.getTime());
switch (grain) {
case TimeGranularity.SECOND:
until.setUTCSeconds(until.getUTCSeconds() + 1);
break;
case TimeGranularity.MINUTE:
until.setUTCMinutes(until.getUTCMinutes() + 1);
break;
case TimeGranularity.FIVE_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 5);
break;
case TimeGranularity.TEN_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 10);
break;
case TimeGranularity.FIFTEEN_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 15);
break;
case TimeGranularity.THIRTY_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 30);
break;
case TimeGranularity.HOUR:
until.setUTCHours(until.getUTCHours() + 1);
break;
case TimeGranularity.DATE:
case TimeGranularity.DAY:
until.setUTCDate(until.getUTCDate() + 1);
break;
case TimeGranularity.WEEK:
case TimeGranularity.WEEK_STARTING_SUNDAY:
case TimeGranularity.WEEK_STARTING_MONDAY:
until.setUTCDate(until.getUTCDate() + 7);
break;
case TimeGranularity.WEEK_ENDING_SATURDAY:
case TimeGranularity.WEEK_ENDING_SUNDAY:
// These buckets are labeled with their last day: the bucket spans
// the 6 days before the label plus the label day itself.
until.setUTCDate(until.getUTCDate() + 1);
return { since: new Date(bucketLabel.getTime() - 6 * DAY_MS), until };
case TimeGranularity.MONTH:
until.setUTCMonth(until.getUTCMonth() + 1);
break;
case TimeGranularity.QUARTER:
until.setUTCMonth(until.getUTCMonth() + 3);
break;
case TimeGranularity.YEAR:
until.setUTCFullYear(until.getUTCFullYear() + 1);
break;
default:
return undefined;
}
return { since: new Date(bucketLabel.getTime()), until };
};
// Matches an explicit timezone designator (Z or ±HH:MM/±HHMM) at the end of
// a datetime string.
const TIMEZONE_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
/**
* Parse a datetime string the same way regardless of the host's local
* timezone. Superset's backend returns naive datetime strings (no timezone
* designator) that represent UTC instants; passing those directly to `new
* Date()` would interpret them as local wall-clock time and shift the
* result by the browser's UTC offset. Strings that already carry an
* explicit timezone designator are parsed as-is.
*/
const parseAsUtc = (value: string): Date =>
new Date(TIMEZONE_SUFFIX_RE.test(value) ? value : `${value}Z`);
/**
* Build a drill-by filter clause matching the clicked value on a temporal
* x-axis. When a known time grain is active, the clause is a TEMPORAL_RANGE
* covering the clicked bucket; otherwise it falls back to an exact match on
* the timestamp.
*/
export const getTemporalXAxisDrillByFilter = (
col: QueryFormColumn,
value: unknown,
grain?: TimeGranularity,
formattedVal?: string,
): BinaryQueryObjectFilterClause | undefined => {
if (
!col ||
(typeof value !== 'number' &&
typeof value !== 'string' &&
!(value instanceof Date))
) {
return undefined;
}
let bucketLabel: Date;
if (value instanceof Date) {
bucketLabel = value;
} else if (typeof value === 'string') {
bucketLabel = parseAsUtc(value);
} else {
bucketLabel = new Date(value);
}
if (Number.isNaN(bucketLabel.getTime())) {
return undefined;
}
const range = grain ? getTimeBucketRange(bucketLabel, grain) : undefined;
if (!range) {
return {
col,
op: '==',
val: formatNaiveDateTime(bucketLabel),
formattedVal,
};
}
return {
col,
op: 'TEMPORAL_RANGE',
val: `${formatNaiveDateTime(range.since)} : ${formatNaiveDateTime(range.until)}`,
formattedVal,
};
};
@@ -0,0 +1,238 @@
/**
* 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 { TimeGranularity } from '@superset-ui/core';
import {
formatNaiveDateTime,
getTemporalXAxisDrillByFilter,
getTimeBucketRange,
} from '../../src/utils/xAxisDrillByFilter';
const utc = (dateString: string) => new Date(`${dateString}Z`);
const expectRange = (
bucketLabel: string,
grain: TimeGranularity,
since: string,
until: string,
) => {
const range = getTimeBucketRange(utc(bucketLabel), grain);
expect(range).toBeDefined();
expect(formatNaiveDateTime(range!.since)).toEqual(since);
expect(formatNaiveDateTime(range!.until)).toEqual(until);
};
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
test('getTimeBucketRange computes sub-daily buckets', () => {
expectRange(
'2021-03-14T01:59:00',
TimeGranularity.MINUTE,
'2021-03-14T01:59:00',
'2021-03-14T02:00:00',
);
expectRange(
'2021-03-14T01:30:00',
TimeGranularity.THIRTY_MINUTES,
'2021-03-14T01:30:00',
'2021-03-14T02:00:00',
);
expectRange(
'2021-03-14T23:00:00',
TimeGranularity.HOUR,
'2021-03-14T23:00:00',
'2021-03-15T00:00:00',
);
});
test('getTimeBucketRange computes daily and weekly buckets', () => {
expectRange(
'2021-12-31T00:00:00',
TimeGranularity.DAY,
'2021-12-31T00:00:00',
'2022-01-01T00:00:00',
);
expectRange(
'2021-04-26T00:00:00',
TimeGranularity.WEEK,
'2021-04-26T00:00:00',
'2021-05-03T00:00:00',
);
expectRange(
'2021-04-25T00:00:00',
TimeGranularity.WEEK_STARTING_SUNDAY,
'2021-04-25T00:00:00',
'2021-05-02T00:00:00',
);
});
test('getTimeBucketRange extends week-ending buckets backwards from their label', () => {
expectRange(
'2021-05-01T00:00:00',
TimeGranularity.WEEK_ENDING_SATURDAY,
'2021-04-25T00:00:00',
'2021-05-02T00:00:00',
);
expectRange(
'2021-05-02T00:00:00',
TimeGranularity.WEEK_ENDING_SUNDAY,
'2021-04-26T00:00:00',
'2021-05-03T00:00:00',
);
});
test('getTimeBucketRange respects calendar month lengths', () => {
expectRange(
'2021-01-01T00:00:00',
TimeGranularity.MONTH,
'2021-01-01T00:00:00',
'2021-02-01T00:00:00',
);
expectRange(
'2021-02-01T00:00:00',
TimeGranularity.MONTH,
'2021-02-01T00:00:00',
'2021-03-01T00:00:00',
);
expectRange(
'2024-02-01T00:00:00',
TimeGranularity.MONTH,
'2024-02-01T00:00:00',
'2024-03-01T00:00:00',
);
expectRange(
'2021-12-01T00:00:00',
TimeGranularity.MONTH,
'2021-12-01T00:00:00',
'2022-01-01T00:00:00',
);
});
test('getTimeBucketRange computes quarter and year buckets', () => {
expectRange(
'2021-10-01T00:00:00',
TimeGranularity.QUARTER,
'2021-10-01T00:00:00',
'2022-01-01T00:00:00',
);
expectRange(
'2024-01-01T00:00:00',
TimeGranularity.YEAR,
'2024-01-01T00:00:00',
'2025-01-01T00:00:00',
);
});
test('getTimeBucketRange returns undefined for unknown grains', () => {
expect(
getTimeBucketRange(utc('2021-01-01T00:00:00'), 'P1D2H' as TimeGranularity),
).toBeUndefined();
});
test('getTemporalXAxisDrillByFilter builds a temporal range for known grains', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T00:00:00').getTime(),
TimeGranularity.MONTH,
'Jan 2021',
),
).toEqual({
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
formattedVal: 'Jan 2021',
});
});
test('getTemporalXAxisDrillByFilter falls back to exact match without a grain', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T12:34:56').getTime(),
undefined,
'2021-01-01 12:34:56',
),
).toEqual({
col: 'ds',
op: '==',
val: '2021-01-01T12:34:56',
formattedVal: '2021-01-01 12:34:56',
});
});
test('getTemporalXAxisDrillByFilter preserves sub-second precision in the exact-match fallback', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
new Date('2021-01-01T12:34:56.123Z').getTime(),
undefined,
'2021-01-01 12:34:56.123',
),
).toEqual({
col: 'ds',
op: '==',
val: '2021-01-01T12:34:56.123',
formattedVal: '2021-01-01 12:34:56.123',
});
});
test('getTemporalXAxisDrillByFilter falls back to exact match for unknown grains', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T00:00:00').getTime(),
'P1D2H' as TimeGranularity,
),
).toMatchObject({ op: '==', val: '2021-01-01T00:00:00' });
});
test('getTemporalXAxisDrillByFilter accepts parseable date strings and Dates', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
'2021-01-01T00:00:00Z',
TimeGranularity.DAY,
),
).toMatchObject({
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
});
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T00:00:00'),
TimeGranularity.DAY,
),
).toMatchObject({
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
});
});
test('getTemporalXAxisDrillByFilter returns undefined for unusable input', () => {
expect(
getTemporalXAxisDrillByFilter('ds', 'not a date', TimeGranularity.DAY),
).toBeUndefined();
expect(
getTemporalXAxisDrillByFilter('ds', null, TimeGranularity.DAY),
).toBeUndefined();
expect(
getTemporalXAxisDrillByFilter('', 1609459200000, TimeGranularity.DAY),
).toBeUndefined();
});
@@ -17,7 +17,7 @@
* under the License.
*/
import { useRef, useState } from 'react';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { ContextMenuFilters, FeatureFlag, VizType } from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import mockState from 'spec/fixtures/mockState';
@@ -30,6 +30,35 @@ import ChartContextMenu, {
jest.mock('src/utils/cachedSupersetGet');
// The scope-selector behavior within the submenu (which filters get built
// for x-axis/series/both) is covered by DrillBySubmenu.test.tsx. Here we
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
// config, so we can assert ChartContextMenu wires it into the modal.
jest.mock('../DrillBy/DrillBySubmenu', () => ({
DrillBySubmenu: ({ onDrillBy }: any) => (
<button
type="button"
data-test="fake-drill-by-submenu"
onClick={() =>
onDrillBy(
{ column_name: 'city', groupby: true },
{ id: 1, columns: [], metrics: [] },
{ filters: [{ col: 'selected_scope' }], groupbyFieldName: 'groupby' },
)
}
>
Fake Drill By
</button>
),
}));
jest.mock('src/components/Chart/DrillBy/DrillByModal', () => ({
__esModule: true,
default: ({ drillByConfig }: any) => (
<div data-test="drill-by-modal">{JSON.stringify(drillByConfig)}</div>
),
}));
const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction<
typeof cachedSupersetGet
>;
@@ -39,7 +68,11 @@ const defaultFormData = {
viz_type: VizType.Pie,
};
const TestWrapper = () => {
const TestWrapper = ({
openFilters = {},
}: {
openFilters?: ContextMenuFilters;
}) => {
const contextMenuRef = useRef<ChartContextMenuRef>(null);
const [isTooltipVisible, setIsTooltipVisible] = useState(true);
@@ -51,7 +84,7 @@ const TestWrapper = () => {
<>
<button
type="button"
onClick={() => contextMenuRef.current?.open(100, 100, {})}
onClick={() => contextMenuRef.current?.open(100, 100, openFilters)}
data-test="open-context-menu"
>
Open Context Menu
@@ -71,8 +104,8 @@ const TestWrapper = () => {
);
};
const setup = () =>
render(<TestWrapper />, {
const setup = (openFilters?: ContextMenuFilters) =>
render(<TestWrapper openFilters={openFilters} />, {
useRedux: true,
initialState: {
...mockState,
@@ -150,3 +183,30 @@ test('tooltip is restored when user selects a menu item', async () => {
expect(screen.getByTestId('tooltip-visible')).toBeInTheDocument();
});
});
test('drill by modal uses the scope selected in the submenu over the raw context filters', async () => {
setup({
drillBy: {
filters: [{ col: 'raw_scope', op: '==', val: 'raw' }],
groupbyFieldName: 'groupby',
},
});
userEvent.click(screen.getByTestId('open-context-menu'));
await waitFor(() => {
expect(screen.getByTestId('chart-context-menu')).toBeInTheDocument();
});
const submenuButton = await screen.findByTestId('fake-drill-by-submenu');
userEvent.click(submenuButton);
await waitFor(() => {
expect(screen.getByTestId('drill-by-modal')).toBeInTheDocument();
});
const modalConfig = JSON.parse(
screen.getByTestId('drill-by-modal').textContent || '{}',
);
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
});
@@ -55,6 +55,7 @@ import { getMenuAdjustedY } from '../utils';
import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu';
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { Dataset } from '../types';
export enum ContextMenuItem {
CrossFilter,
@@ -155,6 +156,10 @@ const ChartContextMenu = (
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [drillByColumn, setDrillByColumn] = useState<Column>();
// Drill by config as selected in the submenu (e.g. with the chosen
// x-axis/series filter scope applied), used over the raw context filters
const [selectedDrillByConfig, setSelectedDrillByConfig] =
useState<ContextMenuFilters['drillBy']>();
const [showDrillByModal, setShowDrillByModal] = useState(false);
const closeContextMenu = useCallback(() => {
@@ -162,10 +167,18 @@ const ChartContextMenu = (
onClose();
}, [onClose]);
const handleDrillBy = useCallback((column: Column) => {
setDrillByColumn(column);
setShowDrillByModal(true);
}, []);
const handleDrillBy = useCallback(
(
column: Column,
_dataset: Dataset,
drillByConfig?: ContextMenuFilters['drillBy'],
) => {
setDrillByColumn(column);
setSelectedDrillByConfig(drillByConfig);
setShowDrillByModal(true);
},
[],
);
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
'load.drillby.options',
@@ -175,6 +188,8 @@ const ChartContextMenu = (
setShowDrillByModal(false);
}, []);
const drillByModalConfig = selectedDrillByConfig ?? enhancedFilters?.drillBy;
const menuItems: MenuItem[] = [];
const showDrillToDetail =
@@ -459,10 +474,10 @@ const ChartContextMenu = (
{showDrillByModal &&
drillByColumn &&
filteredDataset &&
enhancedFilters?.drillBy && (
drillByModalConfig && (
<DrillByModal
column={drillByColumn}
drillByConfig={enhancedFilters?.drillBy}
drillByConfig={drillByModalConfig}
formData={formData}
onHideModal={handleCloseDrillByModal}
dataset={filteredDataset}
@@ -274,6 +274,140 @@ test('When menu item is clicked, call onSelection with clicked column and drill
);
});
const xAxisFilters = [
{
col: 'ds',
op: 'TEMPORAL_RANGE' as const,
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
formattedVal: 'Jan 2021',
},
];
test('do not display scope selector without x-axis filters', async () => {
renderSubmenu({});
await expectDrillByEnabled();
await screen.findByText('col1');
expect(
screen.queryByTestId('drill-by-scope-selector'),
).not.toBeInTheDocument();
});
test('do not display scope selector with only x-axis filters', async () => {
renderSubmenu({
drillByConfig: { filters: [], xAxisFilters, groupbyFieldName: 'groupby' },
});
await expectDrillByEnabled();
await screen.findByText('col1');
expect(
screen.queryByTestId('drill-by-scope-selector'),
).not.toBeInTheDocument();
});
test('display scope selector when x-axis and series filters are present', async () => {
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
});
await expectDrillByEnabled();
await screen.findByText('col1');
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
expect(scopeSelector).toBeInTheDocument();
expect(within(scopeSelector).getByText('Jan 2021')).toBeInTheDocument();
expect(within(scopeSelector).getByText('val')).toBeInTheDocument();
expect(within(scopeSelector).getByText('Both')).toBeInTheDocument();
});
test('apply both x-axis and series filters by default', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
const col1Element = await screen.findByText('col1');
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{
filters: [...xAxisFilters, ...defaultFilters],
groupbyFieldName: 'groupby',
},
);
});
test('apply only x-axis filters when x-axis scope is selected', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
await screen.findByText('col1');
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
userEvent.click(within(scopeSelector).getByText('Jan 2021'));
userEvent.click(screen.getByText('col1'));
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{ filters: xAxisFilters, groupbyFieldName: 'groupby' },
);
});
test('apply only series filters when series scope is selected', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
await screen.findByText('col1');
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
userEvent.click(within(scopeSelector).getByText('val'));
userEvent.click(screen.getByText('col1'));
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
);
});
test('apply x-axis filters when only x-axis filters are present', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: { filters: [], xAxisFilters, groupbyFieldName: 'groupby' },
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
const col1Element = await screen.findByText('col1');
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{ filters: xAxisFilters, groupbyFieldName: 'groupby' },
);
});
test('matrixify_mode_rows enabled should not render component', () => {
const { container } = renderSubmenu({
formData: {
@@ -29,6 +29,7 @@ import { t } from '@apache-superset/core/translation';
import {
BaseFormData,
Behavior,
BinaryQueryObjectFilterClause,
Column,
ContextMenuFilters,
ensureIsArray,
@@ -42,6 +43,7 @@ import {
Popover,
Icons,
} from '@superset-ui/core/components';
import { Radio } from '@superset-ui/core/components/Radio';
import { debounce } from 'lodash-es';
import { List, type RowComponentProps } from 'react-window';
import { InputRef } from 'antd';
@@ -80,6 +82,15 @@ function DrillByColumnRow({
);
}
enum DrillByFilterScope {
XAxis = 'x-axis',
Series = 'series',
All = 'all',
}
const formatFilterValues = (filters: BinaryQueryObjectFilterClause[]) =>
filters.map(filter => filter.formattedVal ?? String(filter.val)).join(', ');
export interface DrillBySubmenuProps {
drillByConfig?: ContextMenuFilters['drillBy'];
formData: BaseFormData & { [key: string]: any };
@@ -88,7 +99,11 @@ export interface DrillBySubmenuProps {
onCloseMenu?: () => void;
openNewModal?: boolean;
excludedColumns?: Column[];
onDrillBy?: (column: Column, dataset: Dataset) => void;
onDrillBy?: (
column: Column,
dataset: Dataset,
drillByConfig?: ContextMenuFilters['drillBy'],
) => void;
dataset?: Dataset;
isLoadingDataset?: boolean;
}
@@ -110,6 +125,7 @@ export const DrillBySubmenu = ({
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [popoverOpen, setPopoverOpen] = useState(false);
const [filterScope, setFilterScope] = useState(DrillByFilterScope.All);
const ref = useRef<InputRef>(null);
const menuItemRef = useRef<HTMLButtonElement>(null);
@@ -119,18 +135,54 @@ export const DrillBySubmenu = ({
);
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const seriesFilters = useMemo(
() => ensureIsArray(drillByConfig?.filters),
[drillByConfig?.filters],
);
const xAxisFilters = useMemo(
() => ensureIsArray(drillByConfig?.xAxisFilters),
[drillByConfig?.xAxisFilters],
);
// Both the clicked x-axis value and the clicked series can scope the
// drilled data; when both are available the user picks which to apply
const showScopeSelector = seriesFilters.length > 0 && xAxisFilters.length > 0;
const effectiveDrillByConfig = useMemo(():
| ContextMenuFilters['drillBy']
| undefined => {
if (!drillByConfig) {
return undefined;
}
let filters = [...xAxisFilters, ...seriesFilters];
if (showScopeSelector && filterScope === DrillByFilterScope.XAxis) {
filters = xAxisFilters;
} else if (showScopeSelector && filterScope === DrillByFilterScope.Series) {
filters = seriesFilters;
}
const config = { ...drillByConfig, filters };
// the x-axis filters have been folded into `filters` above
delete config.xAxisFilters;
return config;
}, [
drillByConfig,
filterScope,
seriesFilters,
showScopeSelector,
xAxisFilters,
]);
const handleSelection = useCallback(
(event: React.MouseEvent, column: Column) => {
onClick(event);
onSelection(column, drillByConfig);
onSelection(column, effectiveDrillByConfig);
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
onDrillBy(column, dataset, effectiveDrillByConfig);
}
setPopoverOpen(false);
onCloseMenu();
},
[
drillByConfig,
effectiveDrillByConfig,
onClick,
onSelection,
openNewModal,
@@ -148,9 +200,10 @@ export const DrillBySubmenu = ({
ref.current?.input?.focus({ preventScroll: true });
}, 100);
} else {
// Reset search input when menu is closed
// Reset search input and filter scope when menu is closed
setSearchInput('');
setDebouncedSearchInput('');
setFilterScope(DrillByFilterScope.All);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
@@ -237,6 +290,55 @@ export const DrillBySubmenu = ({
`}
onClick={e => e.stopPropagation()}
>
{showScopeSelector && (
<div
data-test="drill-by-scope-selector"
css={css`
margin-bottom: ${theme.sizeUnit * 2}px;
padding-bottom: ${theme.sizeUnit * 2}px;
border-bottom: 1px solid ${theme.colorSplit};
`}
>
<div
css={css`
color: ${theme.colorTextSecondary};
margin-bottom: ${theme.sizeUnit}px;
`}
>
{t('Filter by')}
</div>
<Radio.Group
value={filterScope}
onChange={e => setFilterScope(e.target.value)}
css={css`
display: flex;
flex-direction: column;
.ant-radio-wrapper {
margin-inline-end: 0;
span:last-of-type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
`}
>
<Radio
value={DrillByFilterScope.XAxis}
title={formatFilterValues(xAxisFilters)}
>
{formatFilterValues(xAxisFilters)}
</Radio>
<Radio
value={DrillByFilterScope.Series}
title={formatFilterValues(seriesFilters)}
>
{formatFilterValues(seriesFilters)}
</Radio>
<Radio value={DrillByFilterScope.All}>{t('Both')}</Radio>
</Radio.Group>
</div>
)}
{showSearch && (
<Input
ref={ref}
@@ -41,27 +41,44 @@ params:
filterOptionName: 2745eae5
operator: NOT IN
subject: country_code
compare_lag: '10'
compare_suffix: o10Y
country_fieldtype: cca3
color_scheme: supersetColors
entity: country_name
granularity_sqla: year
groupby: []
limit: 0
markup_type: markdown
legendOrientation: top
legendType: scroll
max_bubble_size: '50'
row_limit: 50000
opacity: 0.6
order_desc: true
row_limit: 500
series: region
show_bubbles: true
since: '2011-01-01'
size: sum__SP_POP_TOTL
show_legend: true
size:
aggregate: SUM
column:
column_name: SP_POP_TOTL
expressionType: SIMPLE
label: SUM(SP_POP_TOTL)
optionName: metric_size_life_expectancy_vs_rural
time_range: '2014-01-01 : 2014-01-02'
until: '2011-01-02'
viz_type: bubble
x: sum__SP_RUR_TOTL_ZS
y: sum__SP_DYN_LE00_IN
tooltipSizeFormat: SMART_NUMBER
truncateXAxis: true
viz_type: bubble_v2
x:
aggregate: SUM
column:
column_name: SP_RUR_TOTL_ZS
expressionType: SIMPLE
label: SUM(SP_RUR_TOTL_ZS)
optionName: metric_x_life_expectancy_vs_rural
y:
aggregate: SUM
column:
column_name: SP_DYN_LE00_IN
expressionType: SIMPLE
label: SUM(SP_DYN_LE00_IN)
optionName: metric_y_life_expectancy_vs_rural
query_context: null
slice_name: Life Expectancy VS Rural %
uuid: c18faec9-ec43-4d36-8b66-4c8b1372020f
version: 1.0.0
viz_type: bubble
viz_type: bubble_v2