feat(dashboard): Add cross filter from context menu (#23141)

This commit is contained in:
Kamil Gabryjelski 2023-02-23 17:05:41 +01:00 committed by GitHub
parent 95eb8d79d0
commit ee1952e488
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 896 additions and 753 deletions

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { ExtraFormData } from '../../query';
import { BinaryQueryObjectFilterClause, ExtraFormData } from '../../query';
import { JsonObject } from '../..';
export type HandlerFunction = (...args: unknown[]) => void;
@ -33,6 +33,14 @@ export enum Behavior {
DRILL_TO_DETAIL = 'DRILL_TO_DETAIL',
}
export interface ContextMenuFilters {
crossFilter?: {
dataMask: DataMask;
isCurrentValueSelected?: boolean;
};
drillToDetail?: BinaryQueryObjectFilterClause[];
}
export enum AppSection {
EXPLORE = 'EXPLORE',
DASHBOARD = 'DASHBOARD',

View File

@ -24,8 +24,6 @@ import {
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
logging,
t,
} from '@superset-ui/core';
import Datamap from 'datamaps/dist/datamaps.world.min';
import { ColorBy } from './utils';
@ -114,39 +112,57 @@ function WorldMap(element, props) {
mapData[d.country] = d;
});
const getCrossFilterDataMask = source => {
const selected = Object.values(filterState.selectedValues || {});
const key = source.id || source.country;
const country =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
if (!country) {
return undefined;
}
let values;
if (selected.includes(key)) {
values = [];
} else {
values = [country];
}
return {
dataMask: {
extraFormData: {
filters: values.length
? [
{
col: entity,
op: 'IN',
val: values,
},
]
: [],
},
filterState: {
value: values.length ? values : null,
selectedValues: values.length ? [key] : null,
},
},
isCurrentValueSelected: selected.includes(key),
};
};
const handleClick = source => {
if (!emitCrossFilters) {
return;
}
const pointerEvent = d3.event;
pointerEvent.preventDefault();
const key = source.id || source.country;
let val =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
if (!val) {
return;
}
if (val === filterState.value) {
val = null;
}
getCrossFilterDataMask(source);
setDataMask({
extraFormData: {
filters: val
? [
{
col: entity,
op: 'IN',
val: [val],
},
]
: [],
},
filterState: {
value: val ?? null,
selectedValues: val ? [key] : [],
},
});
const dataMask = getCrossFilterDataMask(source)?.dataMask;
if (dataMask) {
setDataMask(dataMask);
}
};
const handleContextMenu = source => {
@ -155,8 +171,9 @@ function WorldMap(element, props) {
const key = source.id || source.country;
const val =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
let drillToDetailFilters;
if (val) {
const filters = [
drillToDetailFilters = [
{
col: entity,
op: '==',
@ -164,15 +181,11 @@ function WorldMap(element, props) {
formattedVal: val,
},
];
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} else {
logging.warn(
t(
`Unable to process right-click on %s. Check you chart configuration.`,
),
key,
);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(source),
});
};
const map = new Datamap({

View File

@ -220,8 +220,8 @@ class BigNumberVis extends React.PureComponent<BigNumberVizProps> {
const { data } = eventParams;
if (data) {
const pointerEvent = eventParams.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
filters.push({
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
drillToDetailFilters.push({
col: this.props.formData?.granularitySqla,
grain: this.props.formData?.timeGrainSqla,
op: '==',
@ -231,7 +231,7 @@ class BigNumberVis extends React.PureComponent<BigNumberVizProps> {
this.props.onContextMenu(
pointerEvent.clientX,
pointerEvent.clientY,
filters,
{ drillToDetail: drillToDetailFilters },
);
}
}

View File

@ -19,8 +19,8 @@
import { EChartsCoreOption } from 'echarts';
import {
BinaryQueryObjectFilterClause,
ChartDataResponseResult,
ContextMenuFilters,
DataRecordValue,
NumberFormatter,
QueryFormData,
@ -89,7 +89,7 @@ export type BigNumberVizProps = {
onContextMenu?: (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
filters?: ContextMenuFilters,
) => void;
xValueFormatter?: TimeFormatter;
formData?: BigNumberWithTrendlineFormData;

View File

@ -16,60 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React from 'react';
import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
import { BoxPlotChartTransformedProps } from './types';
export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
groupby,
selectedValues,
refs,
emitCrossFilters,
} = props;
const handleChange = useCallback(
(values: string[]) => {
if (!emitCrossFilters) {
return;
}
const { height, width, echartOptions, selectedValues, refs } = props;
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
},
[groupby, labelMap, setDataMask, selectedValues],
);
const eventHandlers = allEventHandlers(props, handleChange);
const eventHandlers = allEventHandlers(props);
return (
<Echart

View File

@ -16,60 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React from 'react';
import { FunnelChartTransformedProps } from './types';
import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsFunnel(props: FunnelChartTransformedProps) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
groupby,
selectedValues,
emitCrossFilters,
refs,
} = props;
const handleChange = useCallback(
(values: string[]) => {
if (!emitCrossFilters) {
return;
}
const { height, width, echartOptions, selectedValues, refs } = props;
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
},
[groupby, labelMap, setDataMask, selectedValues],
);
const eventHandlers = allEventHandlers(props, handleChange);
const eventHandlers = allEventHandlers(props);
return (
<Echart

View File

@ -16,60 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React from 'react';
import { GaugeChartTransformedProps } from './types';
import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsGauge(props: GaugeChartTransformedProps) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
groupby,
selectedValues,
emitCrossFilters,
refs,
} = props;
const handleChange = useCallback(
(values: string[]) => {
if (!emitCrossFilters) {
return;
}
const { height, width, echartOptions, selectedValues, refs } = props;
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
},
[groupby, labelMap, setDataMask, selectedValues],
);
const eventHandlers = allEventHandlers(props, handleChange);
const eventHandlers = allEventHandlers(props);
return (
<Echart

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo } from 'react';
import React from 'react';
import { EventHandlers } from '../types';
import Echart from '../components/Echart';
import { GraphChartTransformedProps } from './types';
@ -36,127 +36,123 @@ type Event = {
dataType: 'node' | 'edge';
};
const EchartsGraph = React.memo(
({
height,
width,
echartOptions,
formData,
onContextMenu,
setDataMask,
filterState,
refs,
emitCrossFilters,
}: GraphChartTransformedProps) => {
const eventHandlers: EventHandlers = useMemo(
() => ({
click: (e: Event) => {
if (!emitCrossFilters || !setDataMask) {
return;
}
e.event.stop();
const data = (echartOptions as any).series[0].data as Data;
const node = data.find(item => item.id === e.data.id);
const val = filterState?.value === node?.name ? null : node?.name;
if (node?.col) {
setDataMask({
extraFormData: {
filters: val
? [
{
col: node.col,
op: '==',
val,
},
]
: [],
},
filterState: {
value: val,
selectedValues: [val],
},
});
}
export default function EchartsGraph({
height,
width,
echartOptions,
formData,
onContextMenu,
setDataMask,
filterState,
emitCrossFilters,
refs,
}: GraphChartTransformedProps) {
const getCrossFilterDataMask = (node: DataRow | undefined) => {
if (!node?.name || !node?.col) {
return undefined;
}
const { name, col } = node;
const selected = Object.values(
filterState?.selectedValues || {},
) as string[];
let values: string[];
if (selected.includes(name)) {
values = selected.filter(v => v !== name);
} else {
values = [name];
}
return {
dataMask: {
extraFormData: {
filters: values.length
? [
{
col,
op: 'IN' as const,
val: values,
},
]
: [],
},
contextmenu: (e: Event) => {
const handleNodeClick = (data: Data) => {
const node = data.find(item => item.id === e.data.id);
if (node?.name) {
return [
{
col: node.col,
op: '==' as const,
val: node.name,
formattedVal: node.name,
},
];
}
return undefined;
};
const handleEdgeClick = (data: Data) => {
const sourceValue = data.find(
item => item.id === e.data.source,
)?.name;
const targetValue = data.find(
item => item.id === e.data.target,
)?.name;
if (sourceValue && targetValue) {
return [
{
col: formData.source,
op: '==' as const,
val: sourceValue,
formattedVal: sourceValue,
},
{
col: formData.target,
op: '==' as const,
val: targetValue,
formattedVal: targetValue,
},
];
}
return undefined;
};
if (onContextMenu) {
e.event.stop();
const pointerEvent = e.event.event;
const data = (echartOptions as any).series[0].data as Data;
const filters =
e.dataType === 'node'
? handleNodeClick(data)
: handleEdgeClick(data);
if (filters) {
onContextMenu(
pointerEvent.clientX,
pointerEvent.clientY,
filters,
);
}
}
filterState: {
value: values.length ? values : null,
selectedValues: values.length ? values : null,
},
}),
[
echartOptions,
emitCrossFilters,
filterState?.value,
formData.source,
formData.target,
onContextMenu,
setDataMask,
],
);
return (
<Echart
refs={refs}
height={height}
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
/>
);
},
);
export default EchartsGraph;
},
isCurrentValueSelected: selected.includes(name),
};
};
const eventHandlers: EventHandlers = {
click: (e: Event) => {
if (!emitCrossFilters || !setDataMask) {
return;
}
e.event.stop();
const data = (echartOptions as any).series[0].data as Data;
const node = data.find(item => item.id === e.data.id);
const dataMask = getCrossFilterDataMask(node)?.dataMask;
if (dataMask) {
setDataMask(dataMask);
}
},
contextmenu: (e: Event) => {
const handleNodeClick = (data: Data) => {
const node = data.find(item => item.id === e.data.id);
if (node?.name) {
return [
{
col: node.col,
op: '==' as const,
val: node.name,
formattedVal: node.name,
},
];
}
return undefined;
};
const handleEdgeClick = (data: Data) => {
const sourceValue = data.find(item => item.id === e.data.source)?.name;
const targetValue = data.find(item => item.id === e.data.target)?.name;
if (sourceValue && targetValue) {
return [
{
col: formData.source,
op: '==' as const,
val: sourceValue,
formattedVal: sourceValue,
},
{
col: formData.target,
op: '==' as const,
val: targetValue,
formattedVal: targetValue,
},
];
}
return undefined;
};
if (onContextMenu) {
e.event.stop();
const pointerEvent = e.event.event;
const data = (echartOptions as any).series[0].data as Data;
const drillToDetailFilters =
e.dataType === 'node' ? handleNodeClick(data) : handleEdgeClick(data);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(
data.find(item => item.id === e.data.id),
),
});
}
},
};
return (
<Echart
refs={refs}
height={height}
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
/>
);
}

View File

@ -51,10 +51,14 @@ export default function EchartsMixedTimeseries({
[seriesBreakdown],
);
const handleChange = useCallback(
(values: string[], seriesIndex: number) => {
if (!emitCrossFilters) {
return;
const getCrossFilterDataMask = useCallback(
(seriesName, seriesIndex) => {
const selected: string[] = Object.values(selectedValues || {});
let values: string[];
if (selected.includes(seriesName)) {
values = selected.filter(v => v !== seriesName);
} else {
values = [seriesName];
}
const currentGroupBy = isFirstQuery(seriesIndex) ? groupby : groupbyB;
@ -63,51 +67,57 @@ export default function EchartsMixedTimeseries({
.map(value => currentLabelMap?.[value])
.filter(value => !!value);
setDataMask({
extraFormData: {
// @ts-ignore
filters:
values.length === 0
? []
: [
...currentGroupBy.map((col, idx) => {
const val: DataRecordValue[] = groupbyValues.map(
v => v[idx],
);
if (val === null || val === undefined)
return {
dataMask: {
extraFormData: {
// @ts-ignore
filters:
values.length === 0
? []
: [
...currentGroupBy.map((col, idx) => {
const val: DataRecordValue[] = groupbyValues.map(
v => v[idx],
);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IS NULL',
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
],
}),
],
},
filterState: {
value: !groupbyValues.length ? null : groupbyValues,
selectedValues: values.length ? values : null,
},
},
filterState: {
value: !groupbyValues.length ? null : groupbyValues,
selectedValues: values.length ? values : null,
},
});
isCurrentValueSelected: selected.includes(seriesName),
};
},
[groupby, groupbyB, labelMap, labelMapB, setDataMask, selectedValues],
[groupby, groupbyB, isFirstQuery, labelMap, labelMapB, selectedValues],
);
const handleChange = useCallback(
(seriesName: string, seriesIndex: number) => {
if (!emitCrossFilters) {
return;
}
setDataMask(getCrossFilterDataMask(seriesName, seriesIndex).dataMask);
},
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
);
const eventHandlers: EventHandlers = {
click: props => {
const { seriesName, seriesIndex } = props;
const values: string[] = Object.values(selectedValues || {});
if (values.includes(seriesName)) {
handleChange(
values.filter(v => v !== seriesName),
seriesIndex,
);
} else {
handleChange([seriesName], seriesIndex);
}
handleChange(seriesName, seriesIndex);
},
mouseout: () => {
currentSeries.name = '';
@ -118,18 +128,18 @@ export default function EchartsMixedTimeseries({
contextmenu: eventParams => {
if (onContextMenu) {
eventParams.event.stop();
const { data, seriesIndex } = eventParams;
const { data, seriesName, seriesIndex } = eventParams;
const pointerEvent = eventParams.event.event;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
if (data) {
const pointerEvent = eventParams.event.event;
const values = [
...(eventParams.name ? [eventParams.name] : []),
...(isFirstQuery(seriesIndex) ? labelMap : labelMapB)[
eventParams.seriesName
],
];
const filters: BinaryQueryObjectFilterClause[] = [];
if (xAxis.type === AxisType.time) {
filters.push({
drillToDetailFilters.push({
col:
xAxis.label === DTTM_ALIAS
? formData.granularitySqla
@ -146,15 +156,18 @@ export default function EchartsMixedTimeseries({
? formData.groupby
: formData.groupbyB),
].forEach((dimension, i) =>
filters.push({
drillToDetailFilters.push({
col: dimension,
op: '==',
val: values[i],
formattedVal: String(values[i]),
}),
);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(seriesName, seriesIndex),
});
}
},
};

View File

@ -16,60 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React from 'react';
import { PieChartTransformedProps } from './types';
import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsPie(props: PieChartTransformedProps) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
groupby,
selectedValues,
refs,
emitCrossFilters,
} = props;
const handleChange = useCallback(
(values: string[]) => {
if (!emitCrossFilters) {
return;
}
const { height, width, echartOptions, selectedValues, refs } = props;
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
},
[groupby, labelMap, setDataMask, selectedValues],
);
const eventHandlers = allEventHandlers(props, handleChange);
const eventHandlers = allEventHandlers(props);
return (
<Echart

View File

@ -16,60 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React from 'react';
import { RadarChartTransformedProps } from './types';
import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsRadar(props: RadarChartTransformedProps) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
groupby,
selectedValues,
emitCrossFilters,
refs,
} = props;
const handleChange = useCallback(
(values: string[]) => {
if (!emitCrossFilters) {
return;
}
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
},
[groupby, labelMap, setDataMask, selectedValues],
);
const eventHandlers = allEventHandlers(props, handleChange);
const { height, width, echartOptions, selectedValues, refs } = props;
const eventHandlers = allEventHandlers(props);
return (
<Echart

View File

@ -43,65 +43,77 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
const { columns } = formData;
const getCrossFilterDataMask = useCallback(
(treePathInfo: TreePathInfo[]) => {
const treePath = extractTreePathInfo(treePathInfo);
const name = treePath.join(',');
const selected = Object.values(selectedValues);
let values: string[];
if (selected.includes(name)) {
values = selected.filter(v => v !== name);
} else {
values = [name];
}
const labels = values.map(value => labelMap[value]);
return {
dataMask: {
extraFormData: {
filters:
values.length === 0 || !columns
? []
: columns.map((col, idx) => {
const val = labels.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: labels.length ? labels : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected: selected.includes(name),
};
},
[columns, labelMap, selectedValues],
);
const handleChange = useCallback(
(values: string[]) => {
(treePathInfo: TreePathInfo[]) => {
if (!emitCrossFilters) {
return;
}
const labels = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0 || !columns
? []
: columns.map((col, idx) => {
const val = labels.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: labels.length ? labels : null,
selectedValues: values.length ? values : null,
},
});
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
},
[emitCrossFilters, setDataMask, columns, labelMap],
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
);
const eventHandlers: EventHandlers = {
click: props => {
const { treePathInfo } = props;
const treePath = extractTreePathInfo(treePathInfo);
const name = treePath.join(',');
const values = Object.values(selectedValues);
if (values.includes(name)) {
handleChange(values.filter(v => v !== name));
} else {
handleChange([name]);
}
handleChange(treePathInfo);
},
contextmenu: eventParams => {
if (onContextMenu) {
eventParams.event.stop();
const { data } = eventParams;
const { data, treePathInfo } = eventParams;
const { records } = data;
const treePath = extractTreePathInfo(eventParams.treePathInfo);
const pointerEvent = eventParams.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
if (columns?.length) {
treePath.forEach((path, i) =>
filters.push({
drillToDetailFilters.push({
col: columns[i],
op: '==',
val: records[i],
@ -109,7 +121,10 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
}),
);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(treePathInfo),
});
}
},
};

View File

@ -108,40 +108,56 @@ export default function EchartsTimeseries({
return model;
};
const getCrossFilterDataMask = useCallback(
(value: string) => {
const selected: string[] = Object.values(selectedValues);
let values: string[];
if (selected.includes(value)) {
values = selected.filter(v => v !== value);
} else {
values = [value];
}
const groupbyValues = values.map(value => labelMap[value]);
return {
dataMask: {
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
label: groupbyValues.length ? groupbyValues : undefined,
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected: selected.includes(value),
};
},
[groupby, labelMap, selectedValues],
);
const handleChange = useCallback(
(values: string[]) => {
(value: string) => {
if (!emitCrossFilters) {
return;
}
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
label: groupbyValues.length ? groupbyValues : undefined,
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
setDataMask(getCrossFilterDataMask(value).dataMask);
},
[groupby, labelMap, setDataMask, emitCrossFilters],
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
);
const eventHandlers: EventHandlers = {
@ -152,12 +168,7 @@ export default function EchartsTimeseries({
// Ensure that double-click events do not trigger single click event. So we put it in the timer.
clickTimer.current = setTimeout(() => {
const { seriesName: name } = props;
const values = Object.values(selectedValues);
if (values.includes(name)) {
handleChange(values.filter(v => v !== name));
} else {
handleChange([name]);
}
handleChange(name);
}, TIMER_DURATION);
},
mouseout: () => {
@ -188,16 +199,16 @@ export default function EchartsTimeseries({
contextmenu: eventParams => {
if (onContextMenu) {
eventParams.event.stop();
const { data } = eventParams;
const { data, seriesName } = eventParams;
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
const pointerEvent = eventParams.event.event;
const values = [
...(eventParams.name ? [eventParams.name] : []),
...labelMap[eventParams.seriesName],
];
if (data) {
const pointerEvent = eventParams.event.event;
const values = [
...(eventParams.name ? [eventParams.name] : []),
...labelMap[eventParams.seriesName],
];
const filters: BinaryQueryObjectFilterClause[] = [];
if (xAxis.type === AxisType.time) {
filters.push({
drillToDetailFilters.push({
col:
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS
@ -213,15 +224,18 @@ export default function EchartsTimeseries({
...(xAxis.type === AxisType.category ? [xAxis.label] : []),
...formData.groupby,
].forEach((dimension, i) =>
filters.push({
drillToDetailFilters.push({
col: dimension,
op: '==',
val: values[i],
formattedVal: String(values[i]),
}),
);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(seriesName),
});
}
},
};

View File

@ -39,74 +39,95 @@ export default function EchartsTreemap({
selectedValues,
width,
}: TreemapTransformedProps) {
const handleChange = useCallback(
(values: string[]) => {
if (!emitCrossFilters) {
return;
const getCrossFilterDataMask = useCallback(
(data, treePathInfo) => {
if (data?.children) {
return undefined;
}
const { treePath } = extractTreePathInfo(treePathInfo);
const name = treePath.join(',');
const selected = Object.values(selectedValues);
let values: string[];
if (selected.includes(name)) {
values = selected.filter(v => v !== name);
} else {
values = [name];
}
const groupbyValues = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val: DataRecordValue[] = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
dataMask: {
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val: DataRecordValue[] = groupbyValues.map(
v => v[idx],
);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IS NULL',
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
isCurrentValueSelected: selected.includes(name),
};
},
[groupby, labelMap, setDataMask, selectedValues],
[groupby, labelMap, selectedValues],
);
const handleChange = useCallback(
(data, treePathInfo) => {
if (!emitCrossFilters) {
return;
}
const dataMask = getCrossFilterDataMask(data, treePathInfo)?.dataMask;
if (dataMask) {
setDataMask(dataMask);
}
},
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
);
const eventHandlers: EventHandlers = {
click: props => {
const { data, treePathInfo } = props;
// do nothing when clicking on the parent node
if (data?.children) {
return;
}
const { treePath } = extractTreePathInfo(treePathInfo);
const name = treePath.join(',');
const values = Object.values(selectedValues);
if (values.includes(name)) {
handleChange(values.filter(v => v !== name));
} else {
handleChange([name]);
}
handleChange(data, treePathInfo);
},
contextmenu: eventParams => {
if (onContextMenu) {
eventParams.event.stop();
const { treePath } = extractTreePathInfo(eventParams.treePathInfo);
const { data, treePathInfo } = eventParams;
const { treePath } = extractTreePathInfo(treePathInfo);
if (treePath.length > 0) {
const pointerEvent = eventParams.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
treePath.forEach((path, i) =>
filters.push({
drillToDetailFilters.push({
col: groupby[i],
op: '==',
val: path === 'null' ? NULL_STRING : path,
formattedVal: path,
}),
);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(data, treePathInfo),
});
}
}
},

View File

@ -18,9 +18,9 @@
*/
import React, { RefObject } from 'react';
import {
BinaryQueryObjectFilterClause,
ChartDataResponseResult,
ChartProps,
ContextMenuFilters,
FilterState,
HandlerFunction,
PlainObject,
@ -124,7 +124,7 @@ export interface BaseTransformedProps<F> {
onContextMenu?: (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
filters?: ContextMenuFilters,
) => void;
setDataMask?: SetDataMaskHook;
filterState?: FilterState;
@ -146,7 +146,7 @@ export type ContextMenuTransformedProps = {
onContextMenu?: (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
filters?: ContextMenuFilters,
) => void;
setDataMask?: SetDataMaskHook;
};

View File

@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { BinaryQueryObjectFilterClause } from '@superset-ui/core';
import {
BinaryQueryObjectFilterClause,
ContextMenuFilters,
DataMask,
QueryFormColumn,
} from '@superset-ui/core';
import {
BaseTransformedProps,
CrossFilterTransformedProps,
@ -28,17 +33,67 @@ export type Event = {
event: { stop: () => void; event: PointerEvent };
};
export const clickEventHandler =
const getCrossFilterDataMask =
(
selectedValues: Record<number, string>,
handleChange: (values: string[]) => void,
groupby: QueryFormColumn[],
labelMap: Record<string, string[]>,
) =>
(value: string) => {
const selected = Object.values(selectedValues);
let values: string[];
if (selected.includes(value)) {
values = selected.filter(v => v !== value);
} else {
values = [value];
}
const groupbyValues = values.map(value => labelMap[value]);
return {
dataMask: {
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected: selected.includes(value),
};
};
export const clickEventHandler =
(
getCrossFilterDataMask: (
value: string,
) => ContextMenuFilters['crossFilter'],
setDataMask: (dataMask: DataMask) => void,
emitCrossFilters?: boolean,
) =>
({ name }: { name: string }) => {
const values = Object.values(selectedValues);
if (values.includes(name)) {
handleChange(values.filter(v => v !== name));
} else {
handleChange([name]);
if (!emitCrossFilters) {
return;
}
const dataMask = getCrossFilterDataMask(name)?.dataMask;
if (dataMask) {
setDataMask(dataMask);
}
};
@ -48,16 +103,19 @@ export const contextMenuEventHandler =
CrossFilterTransformedProps)['groupby'],
onContextMenu: BaseTransformedProps<any>['onContextMenu'],
labelMap: Record<string, string[]>,
getCrossFilterDataMask: (
value: string,
) => ContextMenuFilters['crossFilter'],
) =>
(e: Event) => {
if (onContextMenu) {
e.event.stop();
const pointerEvent = e.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
if (groupby.length > 0) {
const values = labelMap[e.name];
groupby.forEach((dimension, i) =>
filters.push({
drillToDetailFilters.push({
col: dimension,
op: '==',
val: values[i],
@ -65,18 +123,36 @@ export const contextMenuEventHandler =
}),
);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(e.name),
});
}
};
export const allEventHandlers = (
transformedProps: BaseTransformedProps<any> & CrossFilterTransformedProps,
handleChange: (values: string[]) => void,
) => {
const { groupby, selectedValues, onContextMenu, labelMap } = transformedProps;
const {
groupby,
onContextMenu,
setDataMask,
labelMap,
emitCrossFilters,
selectedValues,
} = transformedProps;
const eventHandlers: EventHandlers = {
click: clickEventHandler(selectedValues, handleChange),
contextmenu: contextMenuEventHandler(groupby, onContextMenu, labelMap),
click: clickEventHandler(
getCrossFilterDataMask(selectedValues, groupby, labelMap),
setDataMask,
emitCrossFilters,
),
contextmenu: contextMenuEventHandler(
groupby,
onContextMenu,
labelMap,
getCrossFilterDataMask(selectedValues, groupby, labelMap),
),
};
return eventHandlers;
};

View File

@ -279,6 +279,70 @@ export default function PivotTableChart(props: PivotTableProps) {
[groupbyColumnsRaw, groupbyRowsRaw, setDataMask],
);
const getCrossFilterDataMask = useCallback(
(value: { [key: string]: string }) => {
const isActiveFilterValue = (key: string, val: DataRecordValue) =>
!!selectedFilters && selectedFilters[key]?.includes(val);
if (!value) {
return undefined;
}
const [key, val] = Object.entries(value)[0];
let values = { ...selectedFilters };
if (isActiveFilterValue(key, val)) {
values = {};
} else {
values = { [key]: [val] };
}
const filterKeys = Object.keys(values);
const groupby = [...groupbyRowsRaw, ...groupbyColumnsRaw];
return {
dataMask: {
extraFormData: {
filters:
filterKeys.length === 0
? undefined
: filterKeys.map(key => {
const val = values?.[key];
const col =
groupby.find(item => {
if (isPhysicalColumn(item)) {
return item === key;
}
if (isAdhocColumn(item)) {
return item.label === key;
}
return false;
}) ?? '';
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value:
values && Object.keys(values).length
? Object.values(values)
: null,
selectedFilters:
values && Object.keys(values).length ? values : null,
},
},
isCurrentValueSelected: isActiveFilterValue(key, val),
};
},
[groupbyColumnsRaw, groupbyRowsRaw, selectedFilters],
);
const toggleFilter = useCallback(
(
e: MouseEvent,
@ -369,18 +433,19 @@ export default function PivotTableChart(props: PivotTableProps) {
e: MouseEvent,
colKey: (string | number | boolean)[] | undefined,
rowKey: (string | number | boolean)[] | undefined,
dataPoint: { [key: string]: string },
) => {
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
const filters: BinaryQueryObjectFilterClause[] = [];
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
if (colKey && colKey.length > 1) {
colKey.forEach((val, i) => {
const col = cols[i];
const formatter = dateFormatters[col];
const formattedVal = formatter?.(val as number) || String(val);
if (i > 0) {
filters.push({
drillToDetailFilters.push({
col,
op: '==',
val,
@ -395,7 +460,7 @@ export default function PivotTableChart(props: PivotTableProps) {
const col = rows[i];
const formatter = dateFormatters[col];
const formattedVal = formatter?.(val as number) || String(val);
filters.push({
drillToDetailFilters.push({
col,
op: '==',
val,
@ -404,7 +469,10 @@ export default function PivotTableChart(props: PivotTableProps) {
});
});
}
onContextMenu(e.clientX, e.clientY, filters);
onContextMenu(e.clientX, e.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(dataPoint),
});
}
},
[cols, dateFormatters, onContextMenu, rows, timeGrainSqla],

View File

@ -393,6 +393,7 @@ export class TableRenderer extends React.Component {
// Iterate through columns. Jump over duplicate values.
let i = 0;
while (i < visibleColKeys.length) {
let handleContextMenu;
const colKey = visibleColKeys[i];
const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1;
let colLabelClass = 'pvtColLabel';
@ -402,6 +403,10 @@ export class TableRenderer extends React.Component {
!omittedHighlightHeaderGroups.includes(colAttrs[attrIdx])
) {
colLabelClass += ' hoverable';
handleContextMenu = e =>
this.props.onContextMenu(e, colKey, undefined, {
[attrName]: colKey[attrIdx],
});
}
if (
highlightedHeaderCells &&
@ -434,6 +439,7 @@ export class TableRenderer extends React.Component {
attrIdx,
this.props.tableOptions.clickColumnHeaderCallback,
)}
onContextMenu={handleContextMenu}
>
{displayHeaderCell(
needToggle,
@ -590,12 +596,17 @@ export class TableRenderer extends React.Component {
const colIncrSpan = colAttrs.length !== 0 ? 1 : 0;
const attrValueCells = rowKey.map((r, i) => {
let handleContextMenu;
let valueCellClassName = 'pvtRowLabel';
if (
highlightHeaderCellsOnHover &&
!omittedHighlightHeaderGroups.includes(rowAttrs[i])
) {
valueCellClassName += ' hoverable';
handleContextMenu = e =>
this.props.onContextMenu(e, undefined, rowKey, {
[rowAttrs[i]]: r,
});
}
if (
highlightedHeaderCells &&
@ -631,6 +642,7 @@ export class TableRenderer extends React.Component {
i,
this.props.tableOptions.clickRowHeaderCallback,
)}
onContextMenu={handleContextMenu}
>
{displayHeaderCell(
needRowToggle,

View File

@ -26,8 +26,8 @@ import {
NumberFormatter,
QueryFormMetric,
QueryFormColumn,
BinaryQueryObjectFilterClause,
TimeGranularity,
ContextMenuFilters,
} from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls';
@ -77,7 +77,7 @@ interface PivotTableCustomizeProps {
onContextMenu?: (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
filters?: ContextMenuFilters,
) => void;
timeGrainSqla?: TimeGranularity;
}

View File

@ -23,7 +23,6 @@ import React, {
HTMLProps,
MutableRefObject,
CSSProperties,
MouseEvent,
} from 'react';
import {
useTable,
@ -67,7 +66,6 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
rowCount: number;
wrapperRef?: MutableRefObject<HTMLDivElement>;
onColumnOrderChange: () => void;
onContextMenu?: (value: D, clientX: number, clientY: number) => void;
}
export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
@ -100,7 +98,6 @@ export default typedMemo(function DataTable<D extends object>({
serverPagination,
wrapperRef: userWrapperRef,
onColumnOrderChange,
onContextMenu,
...moreUseTableOptions
}: DataTableProps<D>): JSX.Element {
const tableHooks: PluginHook<D>[] = [
@ -273,21 +270,7 @@ export default typedMemo(function DataTable<D extends object>({
prepareRow(row);
const { key: rowKey, ...rowProps } = row.getRowProps();
return (
<tr
key={rowKey || row.id}
{...rowProps}
onContextMenu={(e: MouseEvent) => {
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
onContextMenu(
row.original,
e.nativeEvent.clientX,
e.nativeEvent.clientY,
);
}
}}
>
<tr key={rowKey || row.id} {...rowProps}>
{row.cells.map(cell =>
cell.render('Cell', { key: cell.column.id }),
)}

View File

@ -22,11 +22,13 @@ import React, {
useLayoutEffect,
useMemo,
useState,
MouseEvent,
} from 'react';
import {
ColumnInstance,
ColumnWithLooseAccessor,
DefaultSortTypes,
Row,
} from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import { FaSort } from '@react-icons/all-files/fa/FaSort';
@ -241,57 +243,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// keep track of whether column order changed, so that column widths can too
const [columnOrderToggle, setColumnOrderToggle] = useState(false);
const handleChange = useCallback(
(filters: { [x: string]: DataRecordValue[] }) => {
if (!emitCrossFilters) {
return;
}
const groupBy = Object.keys(filters);
const groupByValues = Object.values(filters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(filters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
}
});
setDataMask({
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(filters?.[col]);
if (!val.length)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
};
}),
},
filterState: {
label: labelElements.join(', '),
value: groupByValues.length ? groupByValues : null,
filters: filters && Object.keys(filters).length ? filters : null,
},
});
},
[emitCrossFilters, setDataMask],
);
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) => n <= rowCount;
@ -322,25 +273,80 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[filters],
);
const getCrossFilterDataMask = (key: string, value: DataRecordValue) => {
let updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, value)) {
updatedFilters = {};
} else {
updatedFilters = {
[key]: [value],
};
}
if (
Array.isArray(updatedFilters[key]) &&
updatedFilters[key].length === 0
) {
delete updatedFilters[key];
}
const groupBy = Object.keys(updatedFilters);
const groupByValues = Object.values(updatedFilters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(updatedFilters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
}
});
return {
dataMask: {
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(updatedFilters?.[col]);
if (!val.length)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
};
}),
},
filterState: {
label: labelElements.join(', '),
value: groupByValues.length ? groupByValues : null,
filters:
updatedFilters && Object.keys(updatedFilters).length
? updatedFilters
: null,
},
},
isCurrentValueSelected: isActiveFilterValue(key, value),
};
};
const toggleFilter = useCallback(
function toggleFilter(key: string, val: DataRecordValue) {
let updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, val)) {
updatedFilters = {};
} else {
updatedFilters = {
[key]: [val],
};
if (!emitCrossFilters) {
return;
}
if (
Array.isArray(updatedFilters[key]) &&
updatedFilters[key].length === 0
) {
delete updatedFilters[key];
}
handleChange(updatedFilters);
setDataMask(getCrossFilterDataMask(key, val).dataMask);
},
[filters, handleChange, isActiveFilterValue],
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
);
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
@ -355,6 +361,39 @@ export default function TableChart<D extends DataRecord = DataRecord>(
};
};
const handleContextMenu =
onContextMenu && !isRawRecords
? (
value: D,
cellPoint: {
key: string;
value: DataRecordValue;
isMetric?: boolean;
},
clientX: number,
clientY: number,
) => {
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
columnsMeta.forEach(col => {
if (!col.isMetric) {
const dataRecordValue = value[col.key];
drillToDetailFilters.push({
col: col.key,
op: '==',
val: dataRecordValue as string | number | boolean,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
});
onContextMenu(clientX, clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: cellPoint.isMetric
? undefined
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
});
}
: undefined;
const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const { key, label, isNumeric, dataType, isMetric, config = {} } = column;
@ -390,7 +429,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
getValueRange(key, alignPositiveNegative);
let className = '';
if (emitCrossFilters) {
if (emitCrossFilters && !isMetric) {
className += ' dt-is-filter';
}
@ -400,7 +439,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// typing is incorrect in current version of `@types/react-table`
// so we ask TS not to check.
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value }: { value: DataRecordValue }) => {
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
const [isHtml, text] = formatColumnValue(column, value);
const html = isHtml ? { __html: text } : undefined;
@ -453,9 +492,21 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// show raw number in title in case of numeric values
title: typeof value === 'number' ? String(value) : undefined,
onClick:
emitCrossFilters && !valueRange
emitCrossFilters && !valueRange && !isMetric
? () => toggleFilter(key, value)
: undefined,
onContextMenu: (e: MouseEvent) => {
if (handleContextMenu) {
e.preventDefault();
e.stopPropagation();
handleContextMenu(
row.original,
{ key, value, isMetric },
e.nativeEvent.clientX,
e.nativeEvent.clientY,
);
}
},
className: [
className,
value == null ? 'dt-is-null' : '',
@ -621,25 +672,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const { width: widthFromState, height: heightFromState } = tableSize;
const handleContextMenu =
onContextMenu && !isRawRecords
? (value: D, clientX: number, clientY: number) => {
const filters: BinaryQueryObjectFilterClause[] = [];
columnsMeta.forEach(col => {
if (!col.isMetric) {
const dataRecordValue = value[col.key];
filters.push({
col: col.key,
op: '==',
val: dataRecordValue as string | number | boolean,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
});
onContextMenu(clientX, clientY, filters);
}
: undefined;
return (
<Styles>
<DataTable<D>
@ -662,7 +694,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
selectPageSize={pageSize !== null && SelectPageSize}
// not in use in Superset, but needed for unit tests
sticky={sticky}
onContextMenu={handleContextMenu}
/>
</Styles>
);

View File

@ -30,7 +30,7 @@ import {
ChartDataResponseResult,
QueryFormData,
SetDataMaskHook,
BinaryQueryObjectFilterClause,
ContextMenuFilters,
} from '@superset-ui/core';
import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls';
@ -114,7 +114,7 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
onContextMenu?: (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
filters?: ContextMenuFilters,
) => void;
}

View File

@ -18,18 +18,23 @@
*/
import React, {
forwardRef,
ReactNode,
RefObject,
useCallback,
useImperativeHandle,
useState,
} from 'react';
import ReactDOM from 'react-dom';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import {
BinaryQueryObjectFilterClause,
Behavior,
ContextMenuFilters,
FeatureFlag,
getChartMetadataRegistry,
isFeatureEnabled,
QueryFormData,
t,
useTheme,
} from '@superset-ui/core';
import { RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
@ -37,6 +42,8 @@ import { Menu } from 'src/components/Menu';
import { AntdDropdown as Dropdown } from 'src/components';
import { DrillDetailMenuItems } from './DrillDetail';
import { getMenuAdjustedY } from './utils';
import { updateDataMask } from '../../dataMask/actions';
import { MenuItemTooltip } from './DisabledMenuItemTooltip';
export interface ChartContextMenuProps {
id: number;
@ -49,7 +56,7 @@ export interface Ref {
open: (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
filters?: ContextMenuFilters,
) => void;
}
@ -57,26 +64,119 @@ const ChartContextMenu = (
{ id, formData, onSelection, onClose }: ChartContextMenuProps,
ref: RefObject<Ref>,
) => {
const theme = useTheme();
const dispatch = useDispatch();
const canExplore = useSelector((state: RootState) =>
findPermission('can_explore', 'Superset', state.user?.roles),
);
const crossFiltersEnabled = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
);
const [{ filters, clientX, clientY }, setState] = useState<{
clientX: number;
clientY: number;
filters?: BinaryQueryObjectFilterClause[];
filters?: ContextMenuFilters;
}>({ clientX: 0, clientY: 0 });
const menuItems = [];
const showDrillToDetail =
isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore;
const isCrossFilteringSupportedByChart = getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors?.includes(Behavior.INTERACTIVE_CHART);
let itemsCount = 0;
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
itemsCount += 1;
}
if (showDrillToDetail) {
itemsCount += 2; // Drill to detail always has 2 top-level menu items
}
if (itemsCount === 0) {
itemsCount = 1; // "No actions" appears if no actions in menu
}
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
const isCrossFilterDisabled =
!isCrossFilteringSupportedByChart ||
!crossFiltersEnabled ||
!filters?.crossFilter;
let crossFilteringTooltipTitle: ReactNode = null;
if (!isCrossFilterDisabled) {
crossFilteringTooltipTitle = (
<>
<div>
{t(
'Cross-filter will be applied to all of the charts that use this dataset.',
)}
</div>
<div>
{t('You can also just click on the chart to apply cross-filter.')}
</div>
</>
);
} else if (!crossFiltersEnabled) {
crossFilteringTooltipTitle = (
<>
<div>{t('Cross-filtering is not enabled for this dashboard.')}</div>
</>
);
} else if (!isCrossFilteringSupportedByChart) {
crossFilteringTooltipTitle = (
<>
<div>
{t('This visualization type does not support cross-filtering.')}
</div>
</>
);
} else if (!filters?.crossFilter) {
crossFilteringTooltipTitle = (
<>
<div>{t(`You can't apply cross-filter on this data point.`)}</div>
</>
);
}
menuItems.push(
<>
<Menu.Item
key="cross-filtering-menu-item"
disabled={isCrossFilterDisabled}
onClick={() => {
if (filters?.crossFilter) {
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
}
}}
>
{filters?.crossFilter?.isCurrentValueSelected ? (
t('Remove cross-filter')
) : (
<div>
{t('Add cross-filter')}
<MenuItemTooltip
title={crossFilteringTooltipTitle}
color={
!isCrossFilterDisabled
? theme.colors.grayscale.base
: undefined
}
/>
</div>
)}
</Menu.Item>
{itemsCount > 1 && <Menu.Divider />}
</>,
);
}
if (showDrillToDetail) {
menuItems.push(
<DrillDetailMenuItems
chartId={id}
formData={formData}
filters={filters}
filters={filters?.drillToDetail}
isContextMenu
contextMenuY={clientY}
onSelection={onSelection}
@ -85,16 +185,7 @@ const ChartContextMenu = (
}
const open = useCallback(
(
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => {
const itemsCount =
[
showDrillToDetail ? 2 : 0, // Drill to detail always has 2 top-level menu items
].reduce((a, b) => a + b, 0) || 1; // "No actions" appears if no actions in menu
(clientX: number, clientY: number, filters?: ContextMenuFilters) => {
const adjustedY = getMenuAdjustedY(clientY, itemsCount);
setState({
clientX,
@ -108,7 +199,7 @@ const ChartContextMenu = (
// from the charts.
document.getElementById(`hidden-span-${id}`)?.click();
},
[id, showDrillToDetail],
[id, itemsCount],
);
useImperativeHandle(

View File

@ -87,7 +87,8 @@ class ChartRenderer extends React.Component {
this.state = {
showContextMenu:
props.source === ChartSource.Dashboard &&
isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL),
(isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) ||
isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)),
inContextMenu: false,
};
this.hasQueryResponseChange = false;

View File

@ -0,0 +1,48 @@
/**
* 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, SupersetTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
export const MenuItemTooltip = ({
title,
color,
}: {
title: ReactNode;
color?: string;
}) => (
<Tooltip title={title} placement="top">
<Icons.InfoCircleOutlined
data-test="tooltip-trigger"
css={(theme: SupersetTheme) => css`
color: ${color || theme.colors.text.label};
margin-left: ${theme.gridUnit * 2}px;
&.anticon {
font-size: unset;
.anticon {
line-height: unset;
vertical-align: unset;
}
}
`}
/>
</Tooltip>
);

View File

@ -27,37 +27,16 @@ import {
getChartMetadataRegistry,
QueryFormData,
styled,
SupersetTheme,
t,
} from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
import DrillDetailModal from './DrillDetailModal';
import { getMenuAdjustedY, MENU_ITEM_HEIGHT } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
const MENU_PADDING = 4;
const DRILL_TO_DETAIL_TEXT = t('Drill to detail by');
const DisabledMenuItemTooltip = ({ title }: { title: ReactNode }) => (
<Tooltip title={title} placement="top">
<Icons.InfoCircleOutlined
data-test="tooltip-trigger"
css={(theme: SupersetTheme) => css`
color: ${theme.colors.text.label};
margin-left: ${theme.gridUnit * 2}px;
&.anticon {
font-size: unset;
.anticon {
line-height: unset;
vertical-align: unset;
}
}
`}
/>
</Tooltip>
);
const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => (
<Menu.Item disabled {...props}>
<div
@ -141,7 +120,7 @@ const DrillDetailMenuItems = ({
drillToDetailMenuItem = (
<DisabledMenuItem {...props} key="drill-detail-no-aggregations">
{t('Drill to detail')}
<DisabledMenuItemTooltip
<MenuItemTooltip
title={t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
)}
@ -165,7 +144,7 @@ const DrillDetailMenuItems = ({
drillToDetailByMenuItem = (
<DisabledMenuItem {...props} key="drill-detail-by-chart-not-supported">
{DRILL_TO_DETAIL_TEXT}
<DisabledMenuItemTooltip
<MenuItemTooltip
title={t(
'Drill to detail by value is not yet supported for this chart type.',
)}
@ -228,7 +207,7 @@ const DrillDetailMenuItems = ({
drillToDetailByMenuItem = (
<DisabledMenuItem {...props} key="drill-detail-by-select-aggregation">
{DRILL_TO_DETAIL_TEXT}
<DisabledMenuItemTooltip
<MenuItemTooltip
title={t(
'Right-click on a dimension value to drill to detail by that value.',
)}