feat(dashboard): Add cross filter from context menu (#23141)
This commit is contained in:
parent
95eb8d79d0
commit
ee1952e488
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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.',
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue