mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(dashboard): Add cross filter from context menu (#23141)
This commit is contained in:
committed by
GitHub
parent
95eb8d79d0
commit
ee1952e488
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user