feat: Implement context menu for drill by (#23454)

This commit is contained in:
Kamil Gabryjelski 2023-03-29 15:01:51 +02:00 committed by GitHub
parent 542bf25729
commit 9fbfd1c1d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 860 additions and 129 deletions

View File

@ -31,6 +31,7 @@ export enum Behavior {
* when dimensions are right-clicked on. * when dimensions are right-clicked on.
*/ */
DRILL_TO_DETAIL = 'DRILL_TO_DETAIL', DRILL_TO_DETAIL = 'DRILL_TO_DETAIL',
DRILL_BY = 'DRILL_BY',
} }
export interface ContextMenuFilters { export interface ContextMenuFilters {
@ -39,6 +40,11 @@ export interface ContextMenuFilters {
isCurrentValueSelected?: boolean; isCurrentValueSelected?: boolean;
}; };
drillToDetail?: BinaryQueryObjectFilterClause[]; drillToDetail?: BinaryQueryObjectFilterClause[];
drillBy?: {
filters: BinaryQueryObjectFilterClause[];
groupbyFieldName: string;
adhocFilterFieldName?: string;
};
} }
export enum AppSection { export enum AppSection {

View File

@ -172,6 +172,7 @@ function WorldMap(element, props) {
const val = const val =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country; countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
let drillToDetailFilters; let drillToDetailFilters;
let drillByFilters;
if (val) { if (val) {
drillToDetailFilters = [ drillToDetailFilters = [
{ {
@ -181,10 +182,18 @@ function WorldMap(element, props) {
formattedVal: val, formattedVal: val,
}, },
]; ];
drillByFilters = [
{
col: entity,
op: '==',
val,
},
];
} }
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(source), crossFilter: getCrossFilterDataMask(source),
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
}); });
}; };

View File

@ -45,7 +45,11 @@ const metadata = new ChartMetadata({
], ],
thumbnail, thumbnail,
useLegacyApi: true, useLegacyApi: true,
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
}); });
export default class WorldMapChartPlugin extends ChartPlugin { export default class WorldMapChartPlugin extends ChartPlugin {

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core';
import buildQuery from './buildQuery'; import buildQuery from './buildQuery';
import controlPanel from './controlPanel'; import controlPanel from './controlPanel';
import transformProps from './transformProps'; import transformProps from './transformProps';
@ -44,7 +44,11 @@ export default class EchartsBoxPlotChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsBoxPlot'), loadChart: () => import('./EchartsBoxPlot'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Distribution'), category: t('Distribution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -44,7 +44,11 @@ export default class EchartsFunnelChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsFunnel'), loadChart: () => import('./EchartsFunnel'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('KPI'), category: t('KPI'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -35,7 +35,11 @@ export default class EchartsGaugeChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsGauge'), loadChart: () => import('./EchartsGauge'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('KPI'), category: t('KPI'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -137,11 +137,16 @@ export default function EchartsGraph({
const data = (echartOptions as any).series[0].data as Data; const data = (echartOptions as any).series[0].data as Data;
const drillToDetailFilters = const drillToDetailFilters =
e.dataType === 'node' ? handleNodeClick(data) : handleEdgeClick(data); e.dataType === 'node' ? handleNodeClick(data) : handleEdgeClick(data);
const node = data.find(item => item.id === e.data.id);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask( crossFilter: getCrossFilterDataMask(node),
data.find(item => item.id === e.data.id), drillBy: node && {
), filters: [{ col: node.col, op: '==', val: node.name }],
groupbyFieldName:
node.col === formData.source ? 'source' : 'target',
},
}); });
} }
}, },

View File

@ -48,7 +48,11 @@ export default class EchartsGraphChartPlugin extends ChartPlugin {
t('Transformable'), t('Transformable'),
], ],
thumbnail, thumbnail,
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
}), }),
transformProps, transformProps,
}); });

View File

@ -131,42 +131,52 @@ export default function EchartsMixedTimeseries({
const { data, seriesName, seriesIndex } = eventParams; const { data, seriesName, seriesIndex } = eventParams;
const pointerEvent = eventParams.event.event; const pointerEvent = eventParams.event.event;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
if (data) { const drillByFilters: BinaryQueryObjectFilterClause[] = [];
const values = [ const isFirst = isFirstQuery(seriesIndex);
...(eventParams.name ? [eventParams.name] : []), const values = [
...(isFirstQuery(seriesIndex) ? labelMap : labelMapB)[ ...(eventParams.name ? [eventParams.name] : []),
eventParams.seriesName ...(isFirst ? labelMap : labelMapB)[eventParams.seriesName],
], ];
]; if (data && xAxis.type === AxisType.time) {
if (xAxis.type === AxisType.time) { drillToDetailFilters.push({
drillToDetailFilters.push({ col:
col: xAxis.label === DTTM_ALIAS
xAxis.label === DTTM_ALIAS ? formData.granularitySqla
? formData.granularitySqla : xAxis.label,
: xAxis.label, grain: formData.timeGrainSqla,
grain: formData.timeGrainSqla, op: '==',
op: '==', val: data[0],
val: data[0], formattedVal: xValueFormatter(data[0]),
formattedVal: xValueFormatter(data[0]), });
}); }
} [
[ ...(data && xAxis.type === AxisType.category ? [xAxis.label] : []),
...(xAxis.type === AxisType.category ? [xAxis.label] : []), ...(isFirst ? formData.groupby : formData.groupbyB),
...(isFirstQuery(seriesIndex) ].forEach((dimension, i) =>
? formData.groupby drillToDetailFilters.push({
: formData.groupbyB), col: dimension,
].forEach((dimension, i) => op: '==',
drillToDetailFilters.push({ val: values[i],
formattedVal: String(values[i]),
}),
);
[...(isFirst ? formData.groupby : formData.groupbyB)].forEach(
(dimension, i) =>
drillByFilters.push({
col: dimension, col: dimension,
op: '==', op: '==',
val: values[i], val: values[i],
formattedVal: String(values[i]),
}), }),
); );
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(seriesName, seriesIndex), crossFilter: getCrossFilterDataMask(seriesName, seriesIndex),
drillBy: {
filters: drillByFilters,
groupbyFieldName: isFirst ? 'groupby' : 'groupby_b',
adhocFilterFieldName: isFirst ? 'adhoc_filters' : 'adhoc_filters_b',
},
}); });
} }
}, },

View File

@ -54,7 +54,11 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsMixedTimeseries'), loadChart: () => import('./EchartsMixedTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -47,7 +47,11 @@ export default class EchartsPieChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsPie'), loadChart: () => import('./EchartsPie'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Part of a Whole'), category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: description:

View File

@ -46,7 +46,11 @@ export default class EchartsRadarChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsRadar'), loadChart: () => import('./EchartsRadar'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Ranking'), category: t('Ranking'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -40,7 +40,6 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
refs, refs,
emitCrossFilters, emitCrossFilters,
} = props; } = props;
const { columns } = formData; const { columns } = formData;
const getCrossFilterDataMask = useCallback( const getCrossFilterDataMask = useCallback(
@ -62,7 +61,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
filters: filters:
values.length === 0 || !columns values.length === 0 || !columns
? [] ? []
: columns.map((col, idx) => { : columns.slice(0, treePath.length).map((col, idx) => {
const val = labels.map(v => v[idx]); const val = labels.map(v => v[idx]);
if (val === null || val === undefined) if (val === null || val === undefined)
return { return {
@ -111,6 +110,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
const treePath = extractTreePathInfo(eventParams.treePathInfo); const treePath = extractTreePathInfo(eventParams.treePathInfo);
const pointerEvent = eventParams.event.event; const pointerEvent = eventParams.event.event;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
if (columns?.length) { if (columns?.length) {
treePath.forEach((path, i) => treePath.forEach((path, i) =>
drillToDetailFilters.push({ drillToDetailFilters.push({
@ -120,10 +120,16 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
formattedVal: path, formattedVal: path,
}), }),
); );
drillByFilters.push({
col: columns[treePath.length - 1],
op: '==',
val: treePath[treePath.length - 1],
});
} }
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(treePathInfo), crossFilter: getCrossFilterDataMask(treePathInfo),
drillBy: { filters: drillByFilters, groupbyFieldName: 'columns' },
}); });
} }
}, },

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core';
import transformProps from './transformProps'; import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png'; import thumbnail from './images/thumbnail.png';
import controlPanel from './controlPanel'; import controlPanel from './controlPanel';
@ -31,7 +31,11 @@ export default class EchartsSunburstChartPlugin extends ChartPlugin {
controlPanel, controlPanel,
loadChart: () => import('./EchartsSunburst'), loadChart: () => import('./EchartsSunburst'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Part of a Whole'), category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -50,7 +50,11 @@ export default class EchartsAreaChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../EchartsTimeseries'), loadChart: () => import('../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -201,40 +201,48 @@ export default function EchartsTimeseries({
eventParams.event.stop(); eventParams.event.stop();
const { data, seriesName } = eventParams; const { data, seriesName } = eventParams;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
const pointerEvent = eventParams.event.event; const pointerEvent = eventParams.event.event;
const values = [ const values = [
...(eventParams.name ? [eventParams.name] : []), ...(eventParams.name ? [eventParams.name] : []),
...labelMap[eventParams.seriesName], ...labelMap[seriesName],
]; ];
if (data) { if (data && xAxis.type === AxisType.time) {
if (xAxis.type === AxisType.time) { drillToDetailFilters.push({
drillToDetailFilters.push({ col:
col: // if the xAxis is '__timestamp', granularity_sqla will be the column of filter
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter xAxis.label === DTTM_ALIAS
xAxis.label === DTTM_ALIAS ? formData.granularitySqla
? formData.granularitySqla : xAxis.label,
: xAxis.label, grain: formData.timeGrainSqla,
grain: formData.timeGrainSqla, op: '==',
op: '==', val: data[0],
val: data[0], formattedVal: xValueFormatter(data[0]),
formattedVal: xValueFormatter(data[0]), });
});
}
[
...(xAxis.type === AxisType.category ? [xAxis.label] : []),
...formData.groupby,
].forEach((dimension, i) =>
drillToDetailFilters.push({
col: dimension,
op: '==',
val: values[i],
formattedVal: String(values[i]),
}),
);
} }
[
...(xAxis.type === AxisType.category && data ? [xAxis.label] : []),
...formData.groupby,
].forEach((dimension, i) =>
drillToDetailFilters.push({
col: dimension,
op: '==',
val: values[i],
formattedVal: String(values[i]),
}),
);
formData.groupby.forEach((dimension, i) => {
drillByFilters.push({
col: dimension,
op: '==',
val: labelMap[seriesName][i],
});
});
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(seriesName), crossFilter: getCrossFilterDataMask(seriesName),
drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' },
}); });
} }
}, },

View File

@ -56,7 +56,11 @@ export default class EchartsTimeseriesBarChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -55,7 +55,11 @@ export default class EchartsTimeseriesLineChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -54,7 +54,11 @@ export default class EchartsTimeseriesScatterChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -54,7 +54,11 @@ export default class EchartsTimeseriesSmoothLineChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -45,7 +45,11 @@ export default class EchartsTimeseriesStepChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../EchartsTimeseries'), loadChart: () => import('../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -44,7 +44,11 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsTimeseries'), loadChart: () => import('./EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -116,17 +116,25 @@ export default function EchartsTreemap({
if (treePath.length > 0) { if (treePath.length > 0) {
const pointerEvent = eventParams.event.event; const pointerEvent = eventParams.event.event;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
treePath.forEach((path, i) => const drillByFilters: BinaryQueryObjectFilterClause[] = [];
treePath.forEach((path, i) => {
const val = path === 'null' ? NULL_STRING : path;
drillToDetailFilters.push({ drillToDetailFilters.push({
col: groupby[i], col: groupby[i],
op: '==', op: '==',
val: path === 'null' ? NULL_STRING : path, val,
formattedVal: path, formattedVal: path,
}), });
); drillByFilters.push({
col: groupby[i],
op: '==',
val,
});
});
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(data, treePathInfo), crossFilter: getCrossFilterDataMask(data, treePathInfo),
drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' },
}); });
} }
} }

View File

@ -46,7 +46,11 @@ export default class EchartsTreemapChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsTreemap'), loadChart: () => import('./EchartsTreemap'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Part of a Whole'), category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -111,11 +111,11 @@ export const contextMenuEventHandler =
if (onContextMenu) { if (onContextMenu) {
e.event.stop(); e.event.stop();
const pointerEvent = e.event.event; const pointerEvent = e.event.event;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; const drillFilters: BinaryQueryObjectFilterClause[] = [];
if (groupby.length > 0) { if (groupby.length > 0) {
const values = labelMap[e.name]; const values = labelMap[e.name];
groupby.forEach((dimension, i) => groupby.forEach((dimension, i) =>
drillToDetailFilters.push({ drillFilters.push({
col: dimension, col: dimension,
op: '==', op: '==',
val: values[i], val: values[i],
@ -124,8 +124,9 @@ export const contextMenuEventHandler =
); );
} }
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillFilters,
crossFilter: getCrossFilterDataMask(e.name), crossFilter: getCrossFilterDataMask(e.name),
drillBy: { filters: drillFilters, groupbyFieldName: 'groupby' },
}); });
} }
}; };

View File

@ -478,10 +478,27 @@ export default function PivotTableChart(props: PivotTableProps) {
onContextMenu(e.clientX, e.clientY, { onContextMenu(e.clientX, e.clientY, {
drillToDetail: drillToDetailFilters, drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(dataPoint), crossFilter: getCrossFilterDataMask(dataPoint),
drillBy: dataPoint && {
filters: [
{
col: Object.keys(dataPoint)[0],
op: '==',
val: Object.values(dataPoint)[0],
},
],
groupbyFieldName: rowKey ? 'groupbyRows' : 'groupbyColumns',
},
}); });
} }
}, },
[cols, dateFormatters, onContextMenu, rows, timeGrainSqla], [
cols,
dateFormatters,
getCrossFilterDataMask,
onContextMenu,
rows,
timeGrainSqla,
],
); );
return ( return (

View File

@ -17,12 +17,12 @@
* under the License. * under the License.
*/ */
import { import {
t, Behavior,
ChartMetadata, ChartMetadata,
ChartPlugin, ChartPlugin,
Behavior,
ChartProps, ChartProps,
QueryFormData, QueryFormData,
t,
} from '@superset-ui/core'; } from '@superset-ui/core';
import buildQuery from './buildQuery'; import buildQuery from './buildQuery';
import controlPanel from './controlPanel'; import controlPanel from './controlPanel';
@ -47,7 +47,11 @@ export default class PivotTableChartPlugin extends ChartPlugin<
*/ */
constructor() { constructor() {
const metadata = new ChartMetadata({ const metadata = new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Table'), category: t('Table'),
description: t( description: t(
'Used to summarize a set of data by grouping together multiple statistics along two axes. Examples: Sales numbers by region and month, tasks by status and assignee, active users by age and location. Not the most visually stunning visualization, but highly informative and versatile.', 'Used to summarize a set of data by grouping together multiple statistics along two axes. Examples: Sales numbers by region and month, tasks by status and assignee, active users by age and location. Not the most visually stunning visualization, but highly informative and versatile.',

View File

@ -391,6 +391,18 @@ export default function TableChart<D extends DataRecord = DataRecord>(
crossFilter: cellPoint.isMetric crossFilter: cellPoint.isMetric
? undefined ? undefined
: getCrossFilterDataMask(cellPoint.key, cellPoint.value), : getCrossFilterDataMask(cellPoint.key, cellPoint.value),
drillBy: cellPoint.isMetric
? undefined
: {
filters: [
{
col: cellPoint.key,
op: '==',
val: cellPoint.value as string | number | boolean,
},
],
groupbyFieldName: 'groupby',
},
}); });
} }
: undefined; : undefined;

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core';
import transformProps from './transformProps'; import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png'; import thumbnail from './images/thumbnail.png';
import example1 from './images/Table.jpg'; import example1 from './images/Table.jpg';
@ -31,7 +31,11 @@ export { default as __hack__ } from './types';
export * from './types'; export * from './types';
const metadata = new ChartMetadata({ const metadata = new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], behaviors: [
Behavior.INTERACTIVE_CHART,
Behavior.DRILL_TO_DETAIL,
Behavior.DRILL_BY,
],
category: t('Table'), category: t('Table'),
canBeAnnotationTypes: ['EVENT', 'INTERVAL'], canBeAnnotationTypes: ['EVENT', 'INTERVAL'],
description: t( description: t(

View File

@ -44,6 +44,7 @@ import { DrillDetailMenuItems } from './DrillDetail';
import { getMenuAdjustedY } from './utils'; import { getMenuAdjustedY } from './utils';
import { updateDataMask } from '../../dataMask/actions'; import { updateDataMask } from '../../dataMask/actions';
import { MenuItemTooltip } from './DisabledMenuItemTooltip'; import { MenuItemTooltip } from './DisabledMenuItemTooltip';
import { DrillByMenuItems } from './DrillBy/DrillByMenuItems';
export interface ChartContextMenuProps { export interface ChartContextMenuProps {
id: number; id: number;
@ -84,17 +85,25 @@ const ChartContextMenu = (
const showDrillToDetail = const showDrillToDetail =
isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore; isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore;
const showDrillBy = isFeatureEnabled(FeatureFlag.DRILL_BY) && canExplore;
const showCrossFilters = isFeatureEnabled(
FeatureFlag.DASHBOARD_CROSS_FILTERS,
);
const isCrossFilteringSupportedByChart = getChartMetadataRegistry() const isCrossFilteringSupportedByChart = getChartMetadataRegistry()
.get(formData.viz_type) .get(formData.viz_type)
?.behaviors?.includes(Behavior.INTERACTIVE_CHART); ?.behaviors?.includes(Behavior.INTERACTIVE_CHART);
let itemsCount = 0; let itemsCount = 0;
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { if (showCrossFilters) {
itemsCount += 1; itemsCount += 1;
} }
if (showDrillToDetail) { if (showDrillToDetail) {
itemsCount += 2; // Drill to detail always has 2 top-level menu items itemsCount += 2; // Drill to detail always has 2 top-level menu items
} }
if (showDrillBy) {
itemsCount += 1;
}
if (itemsCount === 0) { if (itemsCount === 0) {
itemsCount = 1; // "No actions" appears if no actions in menu itemsCount = 1; // "No actions" appears if no actions in menu
} }
@ -180,6 +189,25 @@ const ChartContextMenu = (
isContextMenu isContextMenu
contextMenuY={clientY} contextMenuY={clientY}
onSelection={onSelection} onSelection={onSelection}
submenuIndex={showCrossFilters ? 2 : 1}
/>,
);
}
if (showDrillBy) {
let submenuIndex = 0;
if (showCrossFilters) {
submenuIndex += 1;
}
if (showDrillToDetail) {
submenuIndex += 2;
}
menuItems.push(
<DrillByMenuItems
filters={filters?.drillBy?.filters}
groupbyFieldName={filters?.drillBy?.groupbyFieldName}
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
/>, />,
); );
} }

View File

@ -0,0 +1,190 @@
/**
* 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 React from 'react';
import userEvent from '@testing-library/user-event';
import {
Behavior,
ChartMetadata,
getChartMetadataRegistry,
} from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { Menu } from 'src/components/Menu';
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
const datasetEndpointMatcher = 'glob:*/api/v1/dataset/7';
const { form_data: defaultFormData } = chartQueries[sliceId];
const defaultColumns = [
{ column_name: 'col1', groupby: true },
{ column_name: 'col2', groupby: true },
{ column_name: 'col3', groupby: true },
{ column_name: 'col4', groupby: true },
{ column_name: 'col5', groupby: true },
{ column_name: 'col6', groupby: true },
{ column_name: 'col7', groupby: true },
{ column_name: 'col8', groupby: true },
{ column_name: 'col9', groupby: true },
{ column_name: 'col10', groupby: true },
{ column_name: 'col11', groupby: true },
];
const defaultFilters = [
{
col: 'filter_col',
op: '==' as const,
val: 'val',
},
];
const renderMenu = ({
formData = defaultFormData,
filters = defaultFilters,
}: Partial<DrillByMenuItemsProps>) =>
render(
<Menu>
<DrillByMenuItems
formData={formData ?? defaultFormData}
filters={filters}
groupbyFieldName="groupby"
/>
</Menu>,
{ useRouter: true, useRedux: true },
);
const expectDrillByDisabled = async (tooltipContent: string) => {
const drillByMenuItem = screen.getByRole('menuitem', {
name: 'Drill by',
});
expect(drillByMenuItem).toBeVisible();
expect(drillByMenuItem).toHaveAttribute('aria-disabled', 'true');
const tooltipTrigger = within(drillByMenuItem).getByTestId('tooltip-trigger');
userEvent.hover(tooltipTrigger as HTMLElement);
const tooltip = await screen.findByRole('tooltip', { name: tooltipContent });
expect(tooltip).toBeInTheDocument();
};
const expectDrillByEnabled = async () => {
const drillByMenuItem = screen.getByRole('menuitem', {
name: 'Drill by',
});
expect(drillByMenuItem).toBeInTheDocument();
await waitFor(() =>
expect(drillByMenuItem).not.toHaveAttribute('aria-disabled'),
);
const tooltipTrigger =
within(drillByMenuItem).queryByTestId('tooltip-trigger');
expect(tooltipTrigger).not.toBeInTheDocument();
userEvent.hover(
within(drillByMenuItem).getByRole('button', { name: 'Drill by' }),
);
expect(await screen.findByTestId('drill-by-submenu')).toBeInTheDocument();
};
getChartMetadataRegistry().registerValue(
'pie',
new ChartMetadata({
name: 'fake pie',
thumbnail: '.png',
useLegacyApi: false,
behaviors: [Behavior.DRILL_BY],
}),
);
describe('Drill by menu items', () => {
afterEach(() => {
supersetGetCache.clear();
fetchMock.restore();
});
test('render disabled menu item for unsupported chart', async () => {
renderMenu({
formData: { ...defaultFormData, viz_type: 'unsupported_viz' },
});
await expectDrillByDisabled(
'Drill by is not yet supported for this chart type',
);
});
test('render disabled menu item for supported chart, no filters', async () => {
renderMenu({ filters: [] });
await expectDrillByDisabled(
'Drill by is not available for this data point',
);
});
test('render disabled menu item for supported chart, no columns', async () => {
fetchMock.get(datasetEndpointMatcher, { result: { columns: [] } });
renderMenu({});
await waitFor(() => fetchMock.called(datasetEndpointMatcher));
await expectDrillByDisabled('No dimensions available for drill by');
});
test('render menu item with submenu without searchbox', async () => {
const slicedColumns = defaultColumns.slice(0, 9);
fetchMock.get(datasetEndpointMatcher, {
result: { columns: slicedColumns },
});
renderMenu({});
await waitFor(() => fetchMock.called(datasetEndpointMatcher));
await expectDrillByEnabled();
slicedColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
test('render menu item with submenu and searchbox', async () => {
fetchMock.get(datasetEndpointMatcher, {
result: { columns: defaultColumns },
});
renderMenu({});
await waitFor(() => fetchMock.called(datasetEndpointMatcher));
await expectDrillByEnabled();
defaultColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
const searchbox = screen.getByRole('textbox');
expect(searchbox).toBeInTheDocument();
userEvent.type(searchbox, 'col1');
await screen.findByText('col1');
const expectedFilteredColumnNames = ['col1', 'col10', 'col11'];
defaultColumns
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
.forEach(col => {
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
});
expectedFilteredColumnNames.forEach(colName => {
expect(screen.getByText(colName)).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,221 @@
/**
* 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 React, {
ChangeEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Menu } from 'src/components/Menu';
import {
BaseFormData,
Behavior,
BinaryQueryObjectFilterClause,
Column,
css,
ensureIsArray,
getChartMetadataRegistry,
t,
useTheme,
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Input } from 'src/components/Input';
import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
const MAX_SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
const SEARCH_INPUT_HEIGHT = 48;
export interface DrillByMenuItemsProps {
filters?: BinaryQueryObjectFilterClause[];
formData: BaseFormData & { [key: string]: any };
contextMenuY?: number;
submenuIndex?: number;
groupbyFieldName?: string;
}
export const DrillByMenuItems = ({
filters,
groupbyFieldName,
formData,
contextMenuY = 0,
submenuIndex = 0,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
const [searchInput, setSearchInput] = useState('');
const [columns, setColumns] = useState<Column[]>([]);
useEffect(() => {
// Input is displayed only when columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD
// Reset search input in case Input gets removed
setSearchInput('');
}, [columns.length]);
const hasDrillBy = ensureIsArray(filters).length && groupbyFieldName;
const handlesDimensionContextMenu = useMemo(
() =>
getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DRILL_BY),
[formData.viz_type],
);
useEffect(() => {
if (handlesDimensionContextMenu && hasDrillBy) {
const datasetId = formData.datasource.split('__')[0];
cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`,
})
.then(({ json: { result } }) => {
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(formData[groupbyFieldName]).includes(
column.column_name,
),
),
);
})
.catch(() => {
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
});
}
}, [formData, groupbyFieldName, handlesDimensionContextMenu, hasDrillBy]);
const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
const input = e?.target?.value;
setSearchInput(input);
}, []);
const filteredColumns = useMemo(
() =>
columns.filter(column =>
(column.verbose_name || column.column_name)
.toLowerCase()
.includes(searchInput.toLowerCase()),
),
[columns, searchInput],
);
const submenuYOffset = useMemo(
() =>
getSubmenuYOffset(
contextMenuY,
filteredColumns.length || 1,
submenuIndex,
MAX_SUBMENU_HEIGHT,
columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD
? SEARCH_INPUT_HEIGHT
: 0,
),
[contextMenuY, filteredColumns.length, submenuIndex, columns.length],
);
let tooltip: ReactNode;
if (!handlesDimensionContextMenu) {
tooltip = t('Drill by is not yet supported for this chart type');
} else if (!hasDrillBy) {
tooltip = t('Drill by is not available for this data point');
} else if (columns.length === 0) {
tooltip = t('No dimensions available for drill by');
}
if (!handlesDimensionContextMenu || !hasDrillBy || columns.length === 0) {
return (
<Menu.Item key="drill-by-disabled" disabled {...rest}>
<div>
{t('Drill by')}
<MenuItemTooltip title={tooltip} />
</div>
</Menu.Item>
);
}
return (
<Menu.SubMenu
title={t('Drill by')}
key="drill-by-submenu"
popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]}
{...rest}
>
<div data-test="drill-by-submenu">
{columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD && (
<Input
prefix={
<Icons.Search
iconSize="l"
iconColor={theme.colors.grayscale.light1}
/>
}
onChange={handleInput}
placeholder={t('Search columns')}
value={searchInput}
onClick={e => {
// prevent closing menu when clicking on input
e.nativeEvent.stopImmediatePropagation();
}}
allowClear
css={css`
width: auto;
max-width: 100%;
margin: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px;
box-shadow: none;
`}
/>
)}
{filteredColumns.length ? (
<div
css={css`
max-height: ${MAX_SUBMENU_HEIGHT}px;
overflow: auto;
`}
>
{filteredColumns.map(column => (
<MenuItemWithTruncation
key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
))}
</div>
) : (
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
{t('No columns found')}
</Menu.Item>
)}
</div>
</Menu.SubMenu>
);
};

View File

@ -31,10 +31,10 @@ import {
} from '@superset-ui/core'; } from '@superset-ui/core';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import DrillDetailModal from './DrillDetailModal'; import DrillDetailModal from './DrillDetailModal';
import { getMenuAdjustedY, MENU_ITEM_HEIGHT } from '../utils'; import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
const MENU_PADDING = 4;
const DRILL_TO_DETAIL_TEXT = t('Drill to detail by'); const DRILL_TO_DETAIL_TEXT = t('Drill to detail by');
const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => ( const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => (
@ -65,6 +65,7 @@ export type DrillDetailMenuItemsProps = {
contextMenuY?: number; contextMenuY?: number;
onSelection?: () => void; onSelection?: () => void;
onClick?: (event: MouseEvent) => void; onClick?: (event: MouseEvent) => void;
submenuIndex?: number;
}; };
const DrillDetailMenuItems = ({ const DrillDetailMenuItems = ({
@ -75,6 +76,7 @@ const DrillDetailMenuItems = ({
contextMenuY = 0, contextMenuY = 0,
onSelection = () => null, onSelection = () => null,
onClick = () => null, onClick = () => null,
submenuIndex = 0,
...props ...props
}: DrillDetailMenuItemsProps) => { }: DrillDetailMenuItemsProps) => {
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>( const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
@ -162,31 +164,35 @@ const DrillDetailMenuItems = ({
} }
// Ensure submenu doesn't appear offscreen // Ensure submenu doesn't appear offscreen
const submenuYOffset = useMemo(() => { const submenuYOffset = useMemo(
const itemsCount = filters.length > 1 ? filters.length + 1 : filters.length; () =>
const submenuY = getSubmenuYOffset(
contextMenuY + MENU_PADDING + MENU_ITEM_HEIGHT + MENU_PADDING; contextMenuY,
filters.length > 1 ? filters.length + 1 : filters.length,
return getMenuAdjustedY(submenuY, itemsCount) - submenuY; submenuIndex,
}, [contextMenuY, filters.length]); ),
[contextMenuY, filters.length, submenuIndex],
);
if (handlesDimensionContextMenu && !noAggregations && filters?.length) { if (handlesDimensionContextMenu && !noAggregations && filters?.length) {
drillToDetailByMenuItem = ( drillToDetailByMenuItem = (
<Menu.SubMenu <Menu.SubMenu
{...props} {...props}
popupOffset={[0, submenuYOffset]} popupOffset={[0, submenuYOffset]}
popupClassName="chart-context-submenu"
title={DRILL_TO_DETAIL_TEXT} title={DRILL_TO_DETAIL_TEXT}
> >
<div data-test="drill-to-detail-by-submenu"> <div data-test="drill-to-detail-by-submenu">
{filters.map((filter, i) => ( {filters.map((filter, i) => (
<Menu.Item <MenuItemWithTruncation
{...props} {...props}
tooltipText={`${DRILL_TO_DETAIL_TEXT} ${filter.formattedVal}`}
key={`drill-detail-filter-${i}`} key={`drill-detail-filter-${i}`}
onClick={openModal.bind(null, [filter])} onClick={openModal.bind(null, [filter])}
> >
{`${DRILL_TO_DETAIL_TEXT} `} {`${DRILL_TO_DETAIL_TEXT} `}
<Filter>{filter.formattedVal}</Filter> <Filter>{filter.formattedVal}</Filter>
</Menu.Item> </MenuItemWithTruncation>
))} ))}
{filters.length > 1 && ( {filters.length > 1 && (
<Menu.Item <Menu.Item
@ -194,8 +200,10 @@ const DrillDetailMenuItems = ({
key="drill-detail-filter-all" key="drill-detail-filter-all"
onClick={openModal.bind(null, filters)} onClick={openModal.bind(null, filters)}
> >
{`${DRILL_TO_DETAIL_TEXT} `} <div>
<Filter>{t('all')}</Filter> {`${DRILL_TO_DETAIL_TEXT} `}
<Filter>{t('all')}</Filter>
</div>
</Menu.Item> </Menu.Item>
)} )}
</div> </div>

View File

@ -0,0 +1,58 @@
/**
* 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 React, { ReactNode } from 'react';
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import { Tooltip } from 'src/components/Tooltip';
export type MenuItemWithTruncationProps = {
tooltipText: ReactNode;
children: ReactNode;
onClick?: () => void;
};
export const MenuItemWithTruncation = ({
tooltipText,
children,
...props
}: MenuItemWithTruncationProps) => {
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
return (
<Menu.Item
css={css`
display: flex;
`}
{...props}
>
<Tooltip title={itemIsTruncated ? tooltipText : null}>
<div
ref={itemRef}
css={css`
max-width: 100%;
${truncationCSS};
`}
>
{children}
</div>
</Tooltip>
</Menu.Item>
);
};

View File

@ -39,4 +39,7 @@ test('correctly positions at lower edge of screen', () => {
expect(getMenuAdjustedY(425, 1)).toEqual(425); // No adjustment expect(getMenuAdjustedY(425, 1)).toEqual(425); // No adjustment
expect(getMenuAdjustedY(425, 2)).toEqual(404); // Adjustment expect(getMenuAdjustedY(425, 2)).toEqual(404); // Adjustment
expect(getMenuAdjustedY(425, 3)).toEqual(372); // Adjustment expect(getMenuAdjustedY(425, 3)).toEqual(372); // Adjustment
expect(getMenuAdjustedY(425, 8, 200)).toEqual(268);
expect(getMenuAdjustedY(425, 8, 200, 48)).toEqual(220);
}); });

View File

@ -18,6 +18,7 @@
*/ */
export const MENU_ITEM_HEIGHT = 32; export const MENU_ITEM_HEIGHT = 32;
const MENU_PADDING = 4;
const MENU_VERTICAL_SPACING = 32; const MENU_VERTICAL_SPACING = 32;
/** /**
@ -27,14 +28,45 @@ const MENU_VERTICAL_SPACING = 32;
* @param clientY The original Y-offset * @param clientY The original Y-offset
* @param itemsCount The number of menu items * @param itemsCount The number of menu items
*/ */
export function getMenuAdjustedY(clientY: number, itemsCount: number) { export const getMenuAdjustedY = (
clientY: number,
itemsCount: number,
maxItemsContainerHeight = Number.MAX_SAFE_INTEGER,
additionalItemsHeight = 0,
) => {
// Viewport height // Viewport height
const vh = Math.max( const vh = Math.max(
document.documentElement.clientHeight || 0, document.documentElement.clientHeight || 0,
window.innerHeight || 0, window.innerHeight || 0,
); );
const menuHeight = MENU_ITEM_HEIGHT * itemsCount + MENU_VERTICAL_SPACING; const menuHeight =
Math.min(MENU_ITEM_HEIGHT * itemsCount, maxItemsContainerHeight) +
MENU_VERTICAL_SPACING +
additionalItemsHeight;
// Always show the context menu inside the viewport // Always show the context menu inside the viewport
return vh - clientY < menuHeight ? vh - menuHeight : clientY; return vh - clientY < menuHeight ? vh - menuHeight : clientY;
} };
export const getSubmenuYOffset = (
contextMenuY: number,
itemsCount: number,
submenuIndex = 0,
maxItemsContainerHeight = Number.MAX_SAFE_INTEGER,
additionalItemsHeight = 0,
) => {
const submenuY =
contextMenuY +
MENU_PADDING +
MENU_ITEM_HEIGHT * submenuIndex +
MENU_PADDING;
return (
getMenuAdjustedY(
submenuY,
itemsCount,
maxItemsContainerHeight,
additionalItemsHeight,
) - submenuY
);
};

View File

@ -22,8 +22,8 @@ import { Column, ensureIsArray, t, useChangeEffect } from '@superset-ui/core';
import { Select, FormInstance } from 'src/components'; import { Select, FormInstance } from 'src/components';
import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useToasts } from 'src/components/MessageToasts/withToasts';
import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import { NativeFiltersForm } from '../types'; import { NativeFiltersForm } from '../types';
import { cachedSupersetGet } from './utils';
interface ColumnSelectProps { interface ColumnSelectProps {
allowClear?: boolean; allowClear?: boolean;

View File

@ -24,7 +24,8 @@ import {
ClientErrorObject, ClientErrorObject,
getClientErrorObject, getClientErrorObject,
} from 'src/utils/getClientErrorObject'; } from 'src/utils/getClientErrorObject';
import { cachedSupersetGet, datasetToSelectOption } from './utils'; import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import { datasetToSelectOption } from './utils';
interface DatasetSelectProps { interface DatasetSelectProps {
onChange: (value: { label: string; value: number }) => void; onChange: (value: { label: string; value: number }) => void;

View File

@ -60,6 +60,7 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
import { Radio } from 'src/components/Radio'; import { Radio } from 'src/components/Radio';
import Tabs from 'src/components/Tabs'; import Tabs from 'src/components/Tabs';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import { import {
Chart, Chart,
ChartsState, ChartsState,
@ -90,7 +91,6 @@ import getControlItemsMap from './getControlItemsMap';
import RemovedFilter from './RemovedFilter'; import RemovedFilter from './RemovedFilter';
import { useBackendFormUpdate, useDefaultValue } from './state'; import { useBackendFormUpdate, useDefaultValue } from './state';
import { import {
cachedSupersetGet,
hasTemporalColumns, hasTemporalColumns,
mostUsedDataset, mostUsedDataset,
setNativeFilterFieldValues, setNativeFilterFieldValues,

View File

@ -20,14 +20,8 @@ import { flatMapDeep } from 'lodash';
import { FormInstance } from 'src/components'; import { FormInstance } from 'src/components';
import React from 'react'; import React from 'react';
import { CustomControlItem, Dataset } from '@superset-ui/chart-controls'; import { CustomControlItem, Dataset } from '@superset-ui/chart-controls';
import { import { Column, ensureIsArray, GenericDataType } from '@superset-ui/core';
Column,
ensureIsArray,
GenericDataType,
SupersetClient,
} from '@superset-ui/core';
import { DatasourcesState, ChartsState } from 'src/dashboard/types'; import { DatasourcesState, ChartsState } from 'src/dashboard/types';
import { cacheWrapper } from 'src/utils/cacheWrapper';
import { FILTER_SUPPORTED_TYPES } from './constants'; import { FILTER_SUPPORTED_TYPES } from './constants';
const FILTERS_FIELD_NAME = 'filters'; const FILTERS_FIELD_NAME = 'filters';
@ -124,11 +118,3 @@ export const mostUsedDataset = (
return datasets[mostUsedDataset]?.id; return datasets[mostUsedDataset]?.id;
}; };
const localCache = new Map<string, any>();
export const cachedSupersetGet = cacheWrapper(
SupersetClient.get,
localCache,
({ endpoint }) => endpoint || '',
);

View File

@ -59,7 +59,11 @@ import { DashboardContextForExplore } from 'src/types/DashboardContextForExplore
import shortid from 'shortid'; import shortid from 'shortid';
import { RootState } from '../types'; import { RootState } from '../types';
import { getActiveFilters } from '../util/activeDashboardFilters'; import { getActiveFilters } from '../util/activeDashboardFilters';
import { filterCardPopoverStyle, headerStyles } from '../styles'; import {
chartContextMenuStyles,
filterCardPopoverStyle,
headerStyles,
} from '../styles';
export const DashboardPageIdContext = React.createContext(''); export const DashboardPageIdContext = React.createContext('');
@ -279,7 +283,13 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
return ( return (
<> <>
<Global styles={[filterCardPopoverStyle(theme), headerStyles(theme)]} /> <Global
styles={[
filterCardPopoverStyle(theme),
headerStyles(theme),
chartContextMenuStyles(theme),
]}
/>
<DashboardPageIdContext.Provider value={dashboardPageId}> <DashboardPageIdContext.Provider value={dashboardPageId}>
<DashboardContainer /> <DashboardContainer />
</DashboardPageIdContext.Provider> </DashboardPageIdContext.Provider>

View File

@ -87,3 +87,10 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css`
} }
} }
`; `;
export const chartContextMenuStyles = (theme: SupersetTheme) => css`
.ant-dropdown-menu-submenu.chart-context-submenu {
max-width: ${theme.gridUnit * 60}px;
min-width: ${theme.gridUnit * 40}px;
}
`;

View File

@ -0,0 +1,29 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
import { cacheWrapper } from './cacheWrapper';
export const supersetGetCache = new Map<string, any>();
export const cachedSupersetGet = cacheWrapper(
SupersetClient.get,
supersetGetCache,
({ endpoint }) => endpoint || '',
);