From 54bec2e1b9d1f7e1960653211f6f31bac2d9e303 Mon Sep 17 00:00:00 2001 From: SBIN2010 Date: Sat, 31 Oct 2026 00:06:59 +0300 Subject: [PATCH 1/4] add cross filtering sankey --- .../src/Sankey/Sankey.tsx | 98 ++++- .../src/Sankey/crossFilter.ts | 358 ++++++++++++++++++ .../plugin-chart-echarts/src/Sankey/index.ts | 8 +- .../src/Sankey/transformProps.ts | 112 ++++-- .../test/Sankey/Sankey.test.tsx | 185 +++++++++ .../test/Sankey/crossFilter.test.ts | 204 ++++++++++ .../test/Sankey/transformProps.test.ts | 127 +++++++ 7 files changed, 1060 insertions(+), 32 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts create mode 100644 superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx create mode 100644 superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx index 88c5b14f93d0..b97d09ee84c2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx @@ -16,11 +16,106 @@ * specific language governing permissions and limitations * under the License. */ +import { useMemo } from 'react'; +import { ContextMenuFilters } from '@superset-ui/core'; import { SankeyTransformedProps } from './types'; import Echart from '../components/Echart'; +import { EventHandlers } from '../types'; +import { + getSankeyCrossFilterDataMask, + getSankeyDrillFilters, + getSelectedSankeyKeys, + isSankeyLinkDatum, + isSankeyNodeDatum, + SankeyLinkDatum, + SankeyNodeDatum, +} from './crossFilter'; + +const noop = () => {}; + +type SankeyEvent = { + data?: unknown; + event?: { stop: () => void; event: PointerEvent }; +}; + +function isSankeyDatum( + data: unknown, +): data is SankeyNodeDatum | SankeyLinkDatum { + return isSankeyNodeDatum(data) || isSankeyLinkDatum(data); +} export default function Sankey(props: SankeyTransformedProps) { - const { height, width, echartOptions, refs, formData } = props; + const { + height, + width, + echartOptions, + refs, + formData, + emitCrossFilters, + filterState, + setDataMask = noop, + onContextMenu, + coltypeMapping, + } = props; + const { source, target } = formData; + + const eventHandlers: EventHandlers = useMemo(() => { + const selectedKeys = getSelectedSankeyKeys(filterState?.selectedValues); + const crossFilterFor = (datum: SankeyNodeDatum | SankeyLinkDatum) => + getSankeyCrossFilterDataMask({ + source, + target, + datum, + selectedKeys, + coltypeMapping, + }); + + return { + click: (event: SankeyEvent) => { + if (!emitCrossFilters || !isSankeyDatum(event.data)) { + return; + } + setDataMask(crossFilterFor(event.data).dataMask); + }, + contextmenu: (event: SankeyEvent) => { + if (!onContextMenu || !isSankeyDatum(event.data)) { + return; + } + event.event?.stop(); + const pointerEvent = event.event?.event; + if (!pointerEvent) { + return; + } + const { data } = event; + // The context menu types only binary operators. IS NULL is unary, and + // both drill paths forward `op` to the query unchanged. + const drillFilters = getSankeyDrillFilters( + source, + target, + data, + coltypeMapping, + ) as NonNullable; + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillFilters, + crossFilter: crossFilterFor(data), + ...(isSankeyNodeDatum(data) && { + drillBy: { + filters: drillFilters, + groupbyFieldName: data.filterColumn, + }, + }), + }); + }, + }; + }, [ + coltypeMapping, + emitCrossFilters, + filterState?.selectedValues, + onContextMenu, + setDataMask, + source, + target, + ]); return ( ); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts new file mode 100644 index 000000000000..a4a8cfb3e1b3 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts @@ -0,0 +1,358 @@ +/** + * 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 { GenericDataType } from '@apache-superset/core/common'; +import { + DataRecordValue, + getColumnLabel, + getTimeFormatter, + QueryFormColumn, + QueryObjectFilterClause, +} from '@superset-ui/core'; +import { NULL_STRING } from '../constants'; +import { formatSeriesName } from '../utils/series'; + +/** + * Display text for an empty string. Matches `optionLabel` in + * `src/utils/common.ts`; this plugin cannot import that module. + */ +export const EMPTY_STRING = ''; + +export type SankeyFilterColumn = 'source' | 'target'; + +/** Fields ECharts keeps on a node and returns from a click. */ +export type SankeyNodeDatum = { + name: string; + filterColumn: SankeyFilterColumn; + rawValue: DataRecordValue; +}; + +/** Fields ECharts keeps on a link and returns from a click. */ +export type SankeyLinkDatum = { + source: string; + target: string; + value: number; + sourceValue: DataRecordValue; + targetValue: DataRecordValue; +}; + +type NamedLink = { + source: string; + target: string; +}; + +type ParsedSelection = + | { kind: 'node'; column: SankeyFilterColumn; name: string } + | { kind: 'link'; source: string; target: string }; + +/** + * Label used as the ECharts node name. `null` and `''` stay distinct so a + * click can emit `IS NULL` or `IN ['']`. + */ +export function sankeyNodeName(value: DataRecordValue): string { + if (value == null) { + return NULL_STRING; + } + if (typeof value === 'bigint') { + return value.toString(); + } + const text = String(value); + return text === '' ? EMPTY_STRING : text; +} + +export function sankeyNodeKey( + column: SankeyFilterColumn, + name: string, +): string { + return JSON.stringify(['node', column, name]); +} + +export function sankeyLinkKey(source: string, target: string): string { + return JSON.stringify(['link', source, target]); +} + +export function isSankeyLinkDatum(data: unknown): data is SankeyLinkDatum { + return ( + typeof data === 'object' && + data !== null && + 'sourceValue' in data && + 'targetValue' in data + ); +} + +export function isSankeyNodeDatum(data: unknown): data is SankeyNodeDatum { + return ( + typeof data === 'object' && + data !== null && + 'filterColumn' in data && + 'rawValue' in data && + !isSankeyLinkDatum(data) + ); +} + +export function getSelectedSankeyKeys(selectedValues: unknown): string[] { + if (!selectedValues) { + return []; + } + if (Array.isArray(selectedValues)) { + return selectedValues.map(value => String(value)); + } + if (typeof selectedValues === 'object') { + return Object.values(selectedValues as Record).map(value => + String(value), + ); + } + return []; +} + +function parseSelection(key: string): ParsedSelection | undefined { + try { + const parsed: unknown = JSON.parse(key); + if (!Array.isArray(parsed)) { + return undefined; + } + if ( + parsed[0] === 'node' && + (parsed[1] === 'source' || parsed[1] === 'target') && + typeof parsed[2] === 'string' + ) { + return { kind: 'node', column: parsed[1], name: parsed[2] }; + } + if ( + parsed[0] === 'link' && + typeof parsed[1] === 'string' && + typeof parsed[2] === 'string' + ) { + return { kind: 'link', source: parsed[1], target: parsed[2] }; + } + return undefined; + } catch { + return undefined; + } +} + +function nodeStaysBright( + name: string, + selection: ParsedSelection, + links: NamedLink[], +): boolean { + if (selection.kind === 'link') { + return name === selection.source || name === selection.target; + } + if (name === selection.name) { + return true; + } + // Keep the other end of each matching flow readable. + return links.some(link => + selection.column === 'source' + ? link.source === selection.name && link.target === name + : link.target === selection.name && link.source === name, + ); +} + +function linkStaysBright(link: NamedLink, selection: ParsedSelection): boolean { + if (selection.kind === 'link') { + return link.source === selection.source && link.target === selection.target; + } + return selection.column === 'source' + ? link.source === selection.name + : link.target === selection.name; +} + +function staysBright( + selectedKeys: string[], + matches: (selection: ParsedSelection) => boolean, +): boolean { + return selectedKeys.some(key => { + const selection = parseSelection(key); + return selection ? matches(selection) : false; + }); +} + +/** Dim when a selection exists and this node is outside it. */ +export function isSankeyNodeDimmed( + name: string, + selectedKeys: string[], + links: NamedLink[], +): boolean { + if (selectedKeys.length === 0) { + return false; + } + return !staysBright(selectedKeys, selection => + nodeStaysBright(name, selection, links), + ); +} + +/** Dim when a selection exists and this link is outside it. */ +export function isSankeyLinkDimmed( + link: NamedLink, + selectedKeys: string[], +): boolean { + if (selectedKeys.length === 0) { + return false; + } + return !staysBright(selectedKeys, selection => + linkStaysBright(link, selection), + ); +} + +/** + * `bigint` throws in `JSON.stringify`, and the data mask is serialized into + * dashboard state and the chart-data request. + */ +export function toJsonSafeFilterValue( + value: Exclude, +): Exclude { + return typeof value === 'bigint' ? value.toString() : value; +} + +function dimensionFilter( + col: QueryFormColumn, + value: DataRecordValue, +): QueryObjectFilterClause { + if (value == null) { + return { col, op: 'IS NULL' }; + } + return { + col, + op: 'IN', + val: [toJsonSafeFilterValue(value)], + }; +} + +/** + * Chip text for one column. An empty string is labeled before + * `formatSeriesName`, which would leave it blank. Numbers stay unformatted: + * the smart number formatter would abbreviate an id or a year. + */ +function formatPart( + col: QueryFormColumn, + value: DataRecordValue, + coltypeMapping?: Record, +): string { + if (value === '') { + return EMPTY_STRING; + } + return formatSeriesName(value, { + timeFormatter: getTimeFormatter(), + coltype: coltypeMapping?.[getColumnLabel(col)], + }); +} + +function selectionKey(datum: SankeyNodeDatum | SankeyLinkDatum): string { + return isSankeyLinkDatum(datum) + ? sankeyLinkKey(datum.source, datum.target) + : sankeyNodeKey(datum.filterColumn, datum.name); +} + +function selectionFilters( + source: QueryFormColumn, + target: QueryFormColumn, + datum: SankeyNodeDatum | SankeyLinkDatum, +): QueryObjectFilterClause[] { + if (isSankeyLinkDatum(datum)) { + return [ + dimensionFilter(source, datum.sourceValue), + dimensionFilter(target, datum.targetValue), + ]; + } + const column = datum.filterColumn === 'source' ? source : target; + return [dimensionFilter(column, datum.rawValue)]; +} + +function selectionLabel( + source: QueryFormColumn, + target: QueryFormColumn, + datum: SankeyNodeDatum | SankeyLinkDatum, + coltypeMapping?: Record, +): string { + if (isSankeyLinkDatum(datum)) { + const sourceLabel = formatPart(source, datum.sourceValue, coltypeMapping); + const targetLabel = formatPart(target, datum.targetValue, coltypeMapping); + return `${sourceLabel} → ${targetLabel}`; + } + const column = datum.filterColumn === 'source' ? source : target; + return formatPart(column, datum.rawValue, coltypeMapping); +} + +export function getSankeyCrossFilterDataMask({ + source, + target, + datum, + selectedKeys, + coltypeMapping, +}: { + source: QueryFormColumn; + target: QueryFormColumn; + datum: SankeyNodeDatum | SankeyLinkDatum; + selectedKeys: string[]; + coltypeMapping?: Record; +}) { + const key = selectionKey(datum); + const isCurrentValueSelected = selectedKeys.includes(key); + const nextKeys = isCurrentValueSelected ? [] : [key]; + const label = selectionLabel(source, target, datum, coltypeMapping); + const filters = + nextKeys.length === 0 ? [] : selectionFilters(source, target, datum); + + return { + dataMask: { + extraFormData: { + filters, + }, + filterState: { + label: nextKeys.length ? label : null, + value: nextKeys.length ? label : null, + selectedValues: nextKeys.length ? nextKeys : null, + }, + }, + isCurrentValueSelected, + }; +} + +export function getSankeyDrillFilters( + source: QueryFormColumn, + target: QueryFormColumn, + datum: SankeyNodeDatum | SankeyLinkDatum, + coltypeMapping?: Record, +): QueryObjectFilterClause[] { + const columns: Array<{ col: QueryFormColumn; value: DataRecordValue }> = + isSankeyLinkDatum(datum) + ? [ + { col: source, value: datum.sourceValue }, + { col: target, value: datum.targetValue }, + ] + : [ + { + col: datum.filterColumn === 'source' ? source : target, + value: datum.rawValue, + }, + ]; + return columns.map(({ col, value }) => { + const formattedVal = formatPart(col, value, coltypeMapping); + if (value == null) { + return { col, op: 'IS NULL' as const, formattedVal }; + } + return { + col, + op: '==' as const, + val: toJsonSafeFilterValue(value), + formattedVal, + }; + }); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/index.ts index e473bf096b45..66ca4c54c792 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/index.ts @@ -18,7 +18,7 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; -import { ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core'; import buildQuery from './buildQuery'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; @@ -30,7 +30,6 @@ import example2 from './images/example2.png'; import example2Dark from './images/example2-dark.png'; import { SankeyChartProps, SankeyFormData } from './types'; -// TODO: Implement cross filtering export default class EchartsSankeyChartPlugin extends ChartPlugin< SankeyFormData, SankeyChartProps @@ -51,6 +50,11 @@ export default class EchartsSankeyChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./Sankey'), metadata: new ChartMetadata({ + behaviors: [ + Behavior.InteractiveChart, + Behavior.DrillToDetail, + Behavior.DrillBy, + ], credits: ['https://echarts.apache.org'], category: t('Flow'), description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts index 3581492466f7..a87eccc03648 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts @@ -21,54 +21,94 @@ import type { SankeySeriesOption } from 'echarts/charts'; import type { CallbackDataParams } from 'echarts/types/src/util/types'; import { CategoricalColorNamespace, - NumberFormats, + DataRecordValue, getColumnLabel, getMetricLabel, getNumberFormatter, + NumberFormats, tooltipHtml, } from '@superset-ui/core'; import { SankeyChartProps, SankeyTransformedProps } from './types'; import { Refs } from '../types'; +import { OpacityEnum } from '../constants'; import { getDefaultTooltip } from '../utils/tooltip'; import { getPercentFormatter } from '../utils/formatters'; +import { getColtypesMapping } from '../utils/series'; +import { + isSankeyLinkDimmed, + isSankeyNodeDimmed, + SankeyFilterColumn, + SankeyLinkDatum, + sankeyNodeName, + getSelectedSankeyKeys, +} from './crossFilter'; + +type NodeMeta = { + filterColumn: SankeyFilterColumn; + value: DataRecordValue; +}; -type Link = { source: string; target: string; value: number }; type EChartsOption = ComposeOption; export default function transformProps( chartProps: SankeyChartProps, ): SankeyTransformedProps { const refs: Refs = {}; - const { formData, height, hooks, queriesData, width, theme } = chartProps; - const { onLegendStateChanged } = hooks; + const { + formData, + height, + hooks, + queriesData, + width, + theme, + filterState, + emitCrossFilters, + } = chartProps; + const { onContextMenu, onLegendStateChanged, setDataMask } = hooks; const { colorScheme, metric, source, target, sliceId } = formData; const { data } = queriesData[0]; + const coltypeMapping = getColtypesMapping(queriesData[0]); const colorFn = CategoricalColorNamespace.getScale(colorScheme); const metricLabel = getMetricLabel(metric); + const sourceLabel = getColumnLabel(source); + const targetLabel = getColumnLabel(target); const valueFormatter = getNumberFormatter(NumberFormats.FLOAT_2_POINT); const percentFormatter = getPercentFormatter(NumberFormats.PERCENT_2_POINT); + const selectedKeys = getSelectedSankeyKeys(filterState?.selectedValues); + + const links: SankeyLinkDatum[] = []; + // One ECharts node per label. A label that appears in both columns keeps + // the column of the first row that introduced it. + const nodes = new Map(); + const rememberNode = (column: SankeyFilterColumn, raw: DataRecordValue) => { + const name = sankeyNodeName(raw); + if (!nodes.has(name)) { + nodes.set(name, { filterColumn: column, value: raw }); + } + return name; + }; - const links: Link[] = []; - const set = new Set(); data.forEach(datum => { - const sourceName = String(datum[getColumnLabel(source)]); - const targetName = String(datum[getColumnLabel(target)]); - const value = datum[metricLabel] as number; - set.add(sourceName); - set.add(targetName); + const sourceName = rememberNode('source', datum[sourceLabel]); + const targetName = rememberNode('target', datum[targetLabel]); links.push({ source: sourceName, target: targetName, - value, + value: datum[metricLabel] as number, + sourceValue: datum[sourceLabel], + targetValue: datum[targetLabel], }); }); - const seriesData: NonNullable = Array.from( - set, - ).map(name => ({ + const seriesData = Array.from(nodes.entries()).map(([name, meta]) => ({ name, + filterColumn: meta.filterColumn, + rawValue: meta.value, itemStyle: { color: colorFn(name, sliceId), + ...(isSankeyNodeDimmed(name, selectedKeys, links) && { + opacity: OpacityEnum.SemiTransparent, + }), }, label: { color: theme.colorText, @@ -76,17 +116,16 @@ export default function transformProps( }, })); - // stores a map with the total values for each node considering the links const incomingFlows = new Map(); const outgoingFlows = new Map(); const allNodeNames = new Set(); links.forEach(link => { - const { source, target, value } = link; - allNodeNames.add(source); - allNodeNames.add(target); - incomingFlows.set(target, (incomingFlows.get(target) || 0) + value); - outgoingFlows.set(source, (outgoingFlows.get(source) || 0) + value); + const { source: linkSource, target: linkTarget, value } = link; + allNodeNames.add(linkSource); + allNodeNames.add(linkTarget); + incomingFlows.set(linkTarget, (incomingFlows.get(linkTarget) || 0) + value); + outgoingFlows.set(linkSource, (outgoingFlows.get(linkSource) || 0) + value); }); const nodeValues = new Map(); @@ -99,18 +138,19 @@ export default function transformProps( }); const tooltipFormatter = (params: CallbackDataParams) => { - const { name, data } = params; + const { name, data: tooltipData } = params; const value = params.value as number; const rows = [[metricLabel, valueFormatter.format(value)]]; - const { source, target } = data as Link; - if (source && target) { + const { source: linkSource, target: linkTarget } = + tooltipData as SankeyLinkDatum; + if (linkSource && linkTarget) { rows.push([ - `% (${source})`, - percentFormatter.format(value / nodeValues.get(source)!), + `% (${linkSource})`, + percentFormatter.format(value / nodeValues.get(linkSource)!), ]); rows.push([ - `% (${target})`, - percentFormatter.format(value / nodeValues.get(target)!), + `% (${linkTarget})`, + percentFormatter.format(value / nodeValues.get(linkTarget)!), ]); } return tooltipHtml(rows, name); @@ -119,11 +159,20 @@ export default function transformProps( const echartOptions: EChartsOption = { series: { animation: false, + cursor: emitCrossFilters ? 'pointer' : 'default', data: seriesData, lineStyle: { color: 'source', }, - links, + links: links.map(link => ({ + ...link, + ...(isSankeyLinkDimmed(link, selectedKeys) && { + lineStyle: { + color: 'source' as const, + opacity: OpacityEnum.SemiTransparent, + }, + }), + })), type: 'sankey', }, tooltip: { @@ -138,6 +187,11 @@ export default function transformProps( width, height, echartOptions, + onContextMenu, onLegendStateChanged, + setDataMask, + filterState, + emitCrossFilters, + coltypeMapping, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx new file mode 100644 index 000000000000..855611cd2c38 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx @@ -0,0 +1,185 @@ +/** + * 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 { render } from '@testing-library/react'; +import { ChartProps } from '@superset-ui/core'; +import { supersetTheme } from '@apache-superset/core/theme'; +import Sankey from '../../src/Sankey/Sankey'; +import transformProps from '../../src/Sankey/transformProps'; +import { SankeyChartProps } from '../../src/Sankey/types'; +import Echart from '../../src/components/Echart'; +import { EventHandlers } from '../../src/types'; +import { + sankeyLinkKey, + SankeyLinkDatum, + SankeyNodeDatum, +} from '../../src/Sankey/crossFilter'; + +jest.mock('../../src/components/Echart', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +const mockedEchart = jest.mocked(Echart); + +const data = [ + { source: 'A', target: 'B', value: 10 }, + { source: 'B', target: 'C', value: 5 }, +]; + +function setup( + overrides: { + emitCrossFilters?: boolean; + selectedValues?: string[]; + } = {}, +) { + const onContextMenu = jest.fn(); + const setDataMask = jest.fn(); + const chartProps = new ChartProps({ + formData: { + datasource: '1__table', + viz_type: 'sankey_v2', + source: 'source', + target: 'target', + metric: 'value', + }, + width: 800, + height: 600, + queriesData: [{ data }], + theme: supersetTheme, + hooks: { onContextMenu, setDataMask }, + emitCrossFilters: overrides.emitCrossFilters ?? true, + filterState: { selectedValues: overrides.selectedValues }, + }); + const transformed = transformProps(chartProps as SankeyChartProps); + render(); + const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1]; + const { eventHandlers } = lastCall[0] as { eventHandlers: EventHandlers }; + const series = transformed.echartOptions.series as { + data: SankeyNodeDatum[]; + links: SankeyLinkDatum[]; + }; + return { + eventHandlers, + onContextMenu, + setDataMask, + nodeB: series.data.find(node => node.name === 'B') as SankeyNodeDatum, + linkBC: series.links.find(link => link.target === 'C') as SankeyLinkDatum, + }; +} + +const pointerEvent = { + stop: jest.fn(), + event: { clientX: 10, clientY: 20 } as PointerEvent, +}; + +beforeEach(() => { + mockedEchart.mockClear(); +}); + +test('clicking a node emits a filter and a second click clears it', () => { + const { eventHandlers, setDataMask, nodeB } = setup(); + + eventHandlers.click({ data: nodeB }); + + expect(setDataMask).toHaveBeenCalledWith( + expect.objectContaining({ + extraFormData: { + filters: [{ col: 'target', op: 'IN', val: ['B'] }], + }, + }), + ); + + const selected = setDataMask.mock.calls[0][0].filterState + .selectedValues as string[]; + mockedEchart.mockClear(); + const next = setup({ selectedValues: selected }); + next.eventHandlers.click({ data: next.nodeB }); + + expect(next.setDataMask).toHaveBeenCalledWith( + expect.objectContaining({ + extraFormData: { filters: [] }, + filterState: { + label: null, + value: null, + selectedValues: null, + }, + }), + ); +}); + +test('clicking a link filters both of its columns', () => { + const { eventHandlers, setDataMask, linkBC } = setup(); + + eventHandlers.click({ data: linkBC }); + + expect(setDataMask).toHaveBeenCalledWith( + expect.objectContaining({ + extraFormData: { + filters: [ + { col: 'source', op: 'IN', val: ['B'] }, + { col: 'target', op: 'IN', val: ['C'] }, + ], + }, + filterState: expect.objectContaining({ + label: 'B → C', + selectedValues: [sankeyLinkKey('B', 'C')], + }), + }), + ); +}); + +test('click does nothing when the chart does not emit cross filters', () => { + const { eventHandlers, setDataMask, nodeB } = setup({ + emitCrossFilters: false, + }); + + eventHandlers.click({ data: nodeB }); + + expect(setDataMask).not.toHaveBeenCalled(); +}); + +test('the context menu drills a node by the column that owns it', () => { + const { eventHandlers, onContextMenu, nodeB } = setup(); + + eventHandlers.contextmenu({ data: nodeB, event: pointerEvent }); + + expect(onContextMenu).toHaveBeenCalledWith(10, 20, { + drillToDetail: [{ col: 'target', op: '==', val: 'B', formattedVal: 'B' }], + crossFilter: expect.objectContaining({ + isCurrentValueSelected: false, + }), + drillBy: { + filters: [{ col: 'target', op: '==', val: 'B', formattedVal: 'B' }], + groupbyFieldName: 'target', + }, + }); +}); + +test('the context menu drills a link to both columns', () => { + const { eventHandlers, onContextMenu, linkBC } = setup(); + + eventHandlers.contextmenu({ data: linkBC, event: pointerEvent }); + + const payload = onContextMenu.mock.calls[0][2]; + expect(payload.drillToDetail).toEqual([ + { col: 'source', op: '==', val: 'B', formattedVal: 'B' }, + { col: 'target', op: '==', val: 'C', formattedVal: 'C' }, + ]); + expect(payload.drillBy).toBeUndefined(); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts new file mode 100644 index 000000000000..717e35904b01 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts @@ -0,0 +1,204 @@ +/** + * 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 { NULL_STRING } from '../../src/constants'; +import { + EMPTY_STRING, + getSankeyCrossFilterDataMask, + getSankeyDrillFilters, + isSankeyLinkDimmed, + isSankeyNodeDimmed, + sankeyLinkKey, + sankeyNodeKey, + sankeyNodeName, + SankeyLinkDatum, + SankeyNodeDatum, +} from '../../src/Sankey/crossFilter'; + +const source = 'source'; +const target = 'target'; + +const nodeA: SankeyNodeDatum = { + name: 'A', + filterColumn: 'source', + rawValue: 'A', +}; + +const linkAB: SankeyLinkDatum = { + source: 'A', + target: 'B', + value: 10, + sourceValue: 'A', + targetValue: 'B', +}; + +test('node names keep null and an empty string distinct', () => { + expect(sankeyNodeName(null)).toBe(NULL_STRING); + expect(sankeyNodeName('')).toBe(EMPTY_STRING); + expect(sankeyNodeName(42n)).toBe('42'); + expect(sankeyNodeName(2024)).toBe('2024'); +}); + +test('a node click filters the column that introduced the label', () => { + const mask = getSankeyCrossFilterDataMask({ + source, + target, + datum: nodeA, + selectedKeys: [], + }); + + expect(mask.isCurrentValueSelected).toBe(false); + expect(mask.dataMask).toEqual({ + extraFormData: { + filters: [{ col: 'source', op: 'IN', val: ['A'] }], + }, + filterState: { + label: 'A', + value: 'A', + selectedValues: [sankeyNodeKey('source', 'A')], + }, + }); +}); + +test('a second click on the same node clears the filter', () => { + const mask = getSankeyCrossFilterDataMask({ + source, + target, + datum: nodeA, + selectedKeys: [sankeyNodeKey('source', 'A')], + }); + + expect(mask.isCurrentValueSelected).toBe(true); + expect(mask.dataMask.extraFormData.filters).toEqual([]); + expect(mask.dataMask.filterState.selectedValues).toBeNull(); +}); + +test('a link click filters source and target together', () => { + const mask = getSankeyCrossFilterDataMask({ + source, + target, + datum: linkAB, + selectedKeys: [], + }); + + expect(mask.dataMask.extraFormData.filters).toEqual([ + { col: 'source', op: 'IN', val: ['A'] }, + { col: 'target', op: 'IN', val: ['B'] }, + ]); + expect(mask.dataMask.filterState).toEqual({ + label: 'A → B', + value: 'A → B', + selectedValues: [sankeyLinkKey('A', 'B')], + }); +}); + +test('null becomes IS NULL and an empty string stays an empty value', () => { + const nullNode: SankeyNodeDatum = { + name: NULL_STRING, + filterColumn: 'target', + rawValue: null, + }; + const emptyLink: SankeyLinkDatum = { + source: 'A', + target: EMPTY_STRING, + value: 1, + sourceValue: 'A', + targetValue: '', + }; + + expect( + getSankeyCrossFilterDataMask({ + source, + target, + datum: nullNode, + selectedKeys: [], + }).dataMask.extraFormData.filters, + ).toEqual([{ col: 'target', op: 'IS NULL' }]); + expect( + getSankeyCrossFilterDataMask({ + source, + target, + datum: emptyLink, + selectedKeys: [], + }).dataMask.extraFormData.filters, + ).toEqual([ + { col: 'source', op: 'IN', val: ['A'] }, + { col: 'target', op: 'IN', val: [''] }, + ]); +}); + +test('a bigint filter value is serialized as a string', () => { + const node: SankeyNodeDatum = { + name: '42', + filterColumn: 'source', + rawValue: 42n, + }; + + expect( + getSankeyCrossFilterDataMask({ + source, + target, + datum: node, + selectedKeys: [], + }).dataMask.extraFormData.filters, + ).toEqual([{ col: 'source', op: 'IN', val: ['42'] }]); +}); + +test('drill filters use equality and keep null distinct', () => { + expect(getSankeyDrillFilters(source, target, linkAB)).toEqual([ + { col: 'source', op: '==', val: 'A', formattedVal: 'A' }, + { col: 'target', op: '==', val: 'B', formattedVal: 'B' }, + ]); + expect( + getSankeyDrillFilters(source, target, { + name: NULL_STRING, + filterColumn: 'source', + rawValue: null, + }), + ).toEqual([{ col: 'source', op: 'IS NULL', formattedVal: NULL_STRING }]); +}); + +test('a selected node keeps its matching flows and their other ends', () => { + const links = [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'D', target: 'E' }, + ]; + const keys = [sankeyNodeKey('target', 'B')]; + + expect(isSankeyNodeDimmed('B', keys, links)).toBe(false); + expect(isSankeyNodeDimmed('A', keys, links)).toBe(false); + expect(isSankeyNodeDimmed('C', keys, links)).toBe(true); + expect(isSankeyLinkDimmed(links[0], keys)).toBe(false); + expect(isSankeyLinkDimmed(links[1], keys)).toBe(true); + expect(isSankeyLinkDimmed(links[2], keys)).toBe(true); +}); + +test('a selected link keeps only that link and its endpoints', () => { + const links = [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + ]; + const keys = [sankeyLinkKey('B', 'C')]; + + expect(isSankeyLinkDimmed(links[1], keys)).toBe(false); + expect(isSankeyLinkDimmed(links[0], keys)).toBe(true); + expect(isSankeyNodeDimmed('B', keys, links)).toBe(false); + expect(isSankeyNodeDimmed('C', keys, links)).toBe(false); + expect(isSankeyNodeDimmed('A', keys, links)).toBe(true); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts new file mode 100644 index 000000000000..3c7789dce26e --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts @@ -0,0 +1,127 @@ +/** + * 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 { ChartProps, ChartPropsConfig } from '@superset-ui/core'; +import { supersetTheme } from '@apache-superset/core/theme'; +import { SankeyChartProps } from '../../src/Sankey/types'; +import transformProps from '../../src/Sankey/transformProps'; +import { NULL_STRING, OpacityEnum } from '../../src/constants'; +import { EMPTY_STRING, sankeyNodeKey } from '../../src/Sankey/crossFilter'; + +const formData = { + datasource: '1__table', + viz_type: 'sankey_v2', + source: 'source', + target: 'target', + metric: 'value', + color_scheme: 'supersetColors', +}; + +const data = [ + { source: 'A', target: 'B', value: 10 }, + { source: 'B', target: 'C', value: 5 }, + { source: 'D', target: 'E', value: 1 }, + { source: null, target: '', value: 2 }, +]; + +type NodeOption = { + name: string; + filterColumn: 'source' | 'target'; + rawValue: unknown; + itemStyle: { opacity?: number }; +}; + +type LinkOption = { + source: string; + target: string; + sourceValue: unknown; + targetValue: unknown; + lineStyle?: { opacity?: number }; +}; + +type SankeyTestOptions = { + series?: { + cursor?: string; + data?: NodeOption[]; + links?: LinkOption[]; + }; +}; + +function renderChart( + overrides: Partial = {}, +): SankeyTestOptions { + const chartProps = new ChartProps({ + formData, + width: 800, + height: 600, + queriesData: [{ data }], + theme: supersetTheme, + ...overrides, + }); + return transformProps(chartProps as SankeyChartProps) + .echartOptions as SankeyTestOptions; +} + +test('a repeated label keeps the column of the row that introduced it', () => { + const nodes = renderChart().series?.data ?? []; + const nodeB = nodes.find(node => node.name === 'B'); + + expect(nodeB).toEqual( + expect.objectContaining({ + filterColumn: 'target', + rawValue: 'B', + }), + ); + expect(nodes.find(node => node.name === 'A')?.filterColumn).toBe('source'); +}); + +test('null and an empty string get distinct node names', () => { + const links = renderChart().series?.links ?? []; + const missing = links.find(link => link.sourceValue === null); + + expect(missing?.source).toBe(NULL_STRING); + expect(missing?.target).toBe(EMPTY_STRING); + expect(missing?.targetValue).toBe(''); +}); + +test('selecting a node dims flows outside that column', () => { + const options = renderChart({ + filterState: { selectedValues: [sankeyNodeKey('target', 'B')] }, + emitCrossFilters: false, + }); + const nodes = options.series?.data ?? []; + const links = options.series?.links ?? []; + const opacityOf = (name: string) => + nodes.find(node => node.name === name)?.itemStyle.opacity; + + expect(opacityOf('B')).toBeUndefined(); + expect(opacityOf('A')).toBeUndefined(); + expect(opacityOf('C')).toBe(OpacityEnum.SemiTransparent); + expect(opacityOf('D')).toBe(OpacityEnum.SemiTransparent); + expect(links.find(link => link.source === 'A')?.lineStyle).toBeUndefined(); + expect(links.find(link => link.target === 'C')?.lineStyle?.opacity).toBe( + OpacityEnum.SemiTransparent, + ); + expect(options.series?.cursor).toBe('default'); +}); + +test('the pointer cursor is used when the chart emits cross filters', () => { + expect(renderChart({ emitCrossFilters: true }).series?.cursor).toBe( + 'pointer', + ); +}); From 69c1d0d275c1de7c29eb03a0038212eeb4781dba Mon Sep 17 00:00:00 2001 From: SBIN2010 Date: Sat, 31 Oct 2026 00:53:09 +0300 Subject: [PATCH 2/4] fix test and comment copilot --- .../src/Sankey/crossFilter.ts | 157 +++++++++++------- .../src/Sankey/transformProps.ts | 58 +++++-- .../test/Sankey/Sankey.test.tsx | 6 +- .../test/Sankey/crossFilter.test.ts | 76 ++++++--- .../test/Sankey/transformProps.test.ts | 74 +++++++-- .../useDrillDetailMenuItems.test.tsx | 4 +- 6 files changed, 252 insertions(+), 123 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts index a4a8cfb3e1b3..ff142b33b000 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/crossFilter.ts @@ -37,7 +37,9 @@ export type SankeyFilterColumn = 'source' | 'target'; /** Fields ECharts keeps on a node and returns from a click. */ export type SankeyNodeDatum = { + /** Typed value id. Link endpoints use the same id, not the display label. */ name: string; + displayName: string; filterColumn: SankeyFilterColumn; rawValue: DataRecordValue; }; @@ -57,12 +59,17 @@ type NamedLink = { }; type ParsedSelection = - | { kind: 'node'; column: SankeyFilterColumn; name: string } + | { kind: 'node'; column: SankeyFilterColumn; id: string } | { kind: 'link'; source: string; target: string }; +export type SankeyHighlight = { + nodeIds: ReadonlySet; + linkKeys: ReadonlySet; +}; + /** - * Label used as the ECharts node name. `null` and `''` stay distinct so a - * click can emit `IS NULL` or `IN ['']`. + * Label shown on the chart and the filter chip. `null` and `''` stay readable + * and distinct. This text is not the graph id: several values share one label. */ export function sankeyNodeName(value: DataRecordValue): string { if (value == null) { @@ -75,15 +82,36 @@ export function sankeyNodeName(value: DataRecordValue): string { return text === '' ? EMPTY_STRING : text; } +/** + * Identity of one source or target value. JSON keeps `null`, `''`, `42`, + * `"42"`, and `42n` apart, so they stay separate nodes and separate clicks. + */ +export function sankeyValueId(value: DataRecordValue): string { + if (value == null) { + return JSON.stringify(null); + } + if (typeof value === 'bigint') { + return JSON.stringify({ bigint: value.toString() }); + } + if (value instanceof Date) { + return JSON.stringify({ date: value.toISOString() }); + } + const encoded = JSON.stringify(value); + return encoded === undefined ? String(value) : encoded; +} + export function sankeyNodeKey( column: SankeyFilterColumn, - name: string, + value: DataRecordValue, ): string { - return JSON.stringify(['node', column, name]); + return JSON.stringify(['node', column, sankeyValueId(value)]); } -export function sankeyLinkKey(source: string, target: string): string { - return JSON.stringify(['link', source, target]); +export function sankeyLinkKey( + source: DataRecordValue, + target: DataRecordValue, +): string { + return JSON.stringify(['link', sankeyValueId(source), sankeyValueId(target)]); } export function isSankeyLinkDatum(data: unknown): data is SankeyLinkDatum { @@ -131,7 +159,7 @@ function parseSelection(key: string): ParsedSelection | undefined { (parsed[1] === 'source' || parsed[1] === 'target') && typeof parsed[2] === 'string' ) { - return { kind: 'node', column: parsed[1], name: parsed[2] }; + return { kind: 'node', column: parsed[1], id: parsed[2] }; } if ( parsed[0] === 'link' && @@ -146,69 +174,74 @@ function parseSelection(key: string): ParsedSelection | undefined { } } -function nodeStaysBright( - name: string, - selection: ParsedSelection, - links: NamedLink[], -): boolean { - if (selection.kind === 'link') { - return name === selection.source || name === selection.target; - } - if (name === selection.name) { - return true; - } - // Keep the other end of each matching flow readable. - return links.some(link => - selection.column === 'source' - ? link.source === selection.name && link.target === name - : link.target === selection.name && link.source === name, - ); -} - -function linkStaysBright(link: NamedLink, selection: ParsedSelection): boolean { - if (selection.kind === 'link') { - return link.source === selection.source && link.target === selection.target; - } - return selection.column === 'source' - ? link.source === selection.name - : link.target === selection.name; +function endpointKey(source: string, target: string): string { + return JSON.stringify([source, target]); } -function staysBright( - selectedKeys: string[], - matches: (selection: ParsedSelection) => boolean, -): boolean { - return selectedKeys.some(key => { - const selection = parseSelection(key); - return selection ? matches(selection) : false; +function linksByEndpoint(links: NamedLink[]): { + bySource: Map; + byTarget: Map; +} { + const bySource = new Map(); + const byTarget = new Map(); + links.forEach(link => { + const sources = bySource.get(link.source); + if (sources) { + sources.push(link); + } else { + bySource.set(link.source, [link]); + } + const targets = byTarget.get(link.target); + if (targets) { + targets.push(link); + } else { + byTarget.set(link.target, [link]); + } }); + return { bySource, byTarget }; } -/** Dim when a selection exists and this node is outside it. */ -export function isSankeyNodeDimmed( - name: string, +/** + * Nodes and links that stay bright for the current selection. Endpoints are + * indexed once so a click does not scan every link for every node. + */ +export function getSankeyHighlight( selectedKeys: string[], links: NamedLink[], -): boolean { +): SankeyHighlight | undefined { if (selectedKeys.length === 0) { - return false; + return undefined; } - return !staysBright(selectedKeys, selection => - nodeStaysBright(name, selection, links), - ); + const { bySource, byTarget } = linksByEndpoint(links); + const nodeIds = new Set(); + const linkKeys = new Set(); + selectedKeys.forEach(key => { + const selection = parseSelection(key); + if (!selection) { + return; + } + if (selection.kind === 'link') { + linkKeys.add(endpointKey(selection.source, selection.target)); + nodeIds.add(selection.source); + nodeIds.add(selection.target); + return; + } + nodeIds.add(selection.id); + const matched = + selection.column === 'source' + ? bySource.get(selection.id) + : byTarget.get(selection.id); + matched?.forEach(link => { + linkKeys.add(endpointKey(link.source, link.target)); + nodeIds.add(link.source); + nodeIds.add(link.target); + }); + }); + return { nodeIds, linkKeys }; } -/** Dim when a selection exists and this link is outside it. */ -export function isSankeyLinkDimmed( - link: NamedLink, - selectedKeys: string[], -): boolean { - if (selectedKeys.length === 0) { - return false; - } - return !staysBright(selectedKeys, selection => - linkStaysBright(link, selection), - ); +export function sankeyEndpointKey(source: string, target: string): string { + return endpointKey(source, target); } /** @@ -256,8 +289,8 @@ function formatPart( function selectionKey(datum: SankeyNodeDatum | SankeyLinkDatum): string { return isSankeyLinkDatum(datum) - ? sankeyLinkKey(datum.source, datum.target) - : sankeyNodeKey(datum.filterColumn, datum.name); + ? sankeyLinkKey(datum.sourceValue, datum.targetValue) + : sankeyNodeKey(datum.filterColumn, datum.rawValue); } function selectionFilters( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts index a87eccc03648..983fce386988 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts @@ -35,17 +35,26 @@ import { getDefaultTooltip } from '../utils/tooltip'; import { getPercentFormatter } from '../utils/formatters'; import { getColtypesMapping } from '../utils/series'; import { - isSankeyLinkDimmed, - isSankeyNodeDimmed, + getSankeyHighlight, + getSelectedSankeyKeys, + sankeyEndpointKey, SankeyFilterColumn, SankeyLinkDatum, sankeyNodeName, - getSelectedSankeyKeys, + sankeyValueId, } from './crossFilter'; +/** + * ECharts draws an unselected Sankey link at opacity 0.2. A selection has to + * raise the matching links above that and drop the others below it. + */ +export const SELECTED_LINK_OPACITY = 0.8; +export const DIMMED_LINK_OPACITY = 0.06; + type NodeMeta = { filterColumn: SankeyFilterColumn; value: DataRecordValue; + displayName: string; }; type EChartsOption = ComposeOption; @@ -77,13 +86,17 @@ export default function transformProps( const selectedKeys = getSelectedSankeyKeys(filterState?.selectedValues); const links: SankeyLinkDatum[] = []; - // One ECharts node per label. A label that appears in both columns keeps - // the column of the first row that introduced it. + // One ECharts node per value id. The same id in both columns keeps the + // column of the first row that introduced it. const nodes = new Map(); const rememberNode = (column: SankeyFilterColumn, raw: DataRecordValue) => { - const name = sankeyNodeName(raw); + const name = sankeyValueId(raw); if (!nodes.has(name)) { - nodes.set(name, { filterColumn: column, value: raw }); + nodes.set(name, { + filterColumn: column, + value: raw, + displayName: sankeyNodeName(raw), + }); } return name; }; @@ -100,17 +113,21 @@ export default function transformProps( }); }); + const highlight = getSankeyHighlight(selectedKeys, links); const seriesData = Array.from(nodes.entries()).map(([name, meta]) => ({ name, + displayName: meta.displayName, filterColumn: meta.filterColumn, rawValue: meta.value, itemStyle: { - color: colorFn(name, sliceId), - ...(isSankeyNodeDimmed(name, selectedKeys, links) && { - opacity: OpacityEnum.SemiTransparent, - }), + color: colorFn(meta.displayName, sliceId), + ...(highlight && + !highlight.nodeIds.has(name) && { + opacity: OpacityEnum.SemiTransparent, + }), }, label: { + formatter: () => meta.displayName, color: theme.colorText, textShadow: theme.colorBgBase, }, @@ -143,17 +160,20 @@ export default function transformProps( const rows = [[metricLabel, valueFormatter.format(value)]]; const { source: linkSource, target: linkTarget } = tooltipData as SankeyLinkDatum; - if (linkSource && linkTarget) { + const sourceName = nodes.get(linkSource)?.displayName; + const targetName = nodes.get(linkTarget)?.displayName; + if (sourceName && targetName) { rows.push([ - `% (${linkSource})`, + `% (${sourceName})`, percentFormatter.format(value / nodeValues.get(linkSource)!), ]); rows.push([ - `% (${linkTarget})`, + `% (${targetName})`, percentFormatter.format(value / nodeValues.get(linkTarget)!), ]); + return tooltipHtml(rows, `${sourceName} → ${targetName}`); } - return tooltipHtml(rows, name); + return tooltipHtml(rows, nodes.get(name)?.displayName ?? name); }; const echartOptions: EChartsOption = { @@ -166,10 +186,14 @@ export default function transformProps( }, links: links.map(link => ({ ...link, - ...(isSankeyLinkDimmed(link, selectedKeys) && { + ...(highlight && { lineStyle: { color: 'source' as const, - opacity: OpacityEnum.SemiTransparent, + opacity: highlight.linkKeys.has( + sankeyEndpointKey(link.source, link.target), + ) + ? SELECTED_LINK_OPACITY + : DIMMED_LINK_OPACITY, }, }), })), diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx index 855611cd2c38..22e8e6c9a3a4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx @@ -78,8 +78,10 @@ function setup( eventHandlers, onContextMenu, setDataMask, - nodeB: series.data.find(node => node.name === 'B') as SankeyNodeDatum, - linkBC: series.links.find(link => link.target === 'C') as SankeyLinkDatum, + nodeB: series.data.find(node => node.rawValue === 'B') as SankeyNodeDatum, + linkBC: series.links.find( + link => link.targetValue === 'C', + ) as SankeyLinkDatum, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts index 717e35904b01..be10cc459822 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts @@ -21,11 +21,12 @@ import { EMPTY_STRING, getSankeyCrossFilterDataMask, getSankeyDrillFilters, - isSankeyLinkDimmed, - isSankeyNodeDimmed, + getSankeyHighlight, + sankeyEndpointKey, sankeyLinkKey, sankeyNodeKey, sankeyNodeName, + sankeyValueId, SankeyLinkDatum, SankeyNodeDatum, } from '../../src/Sankey/crossFilter'; @@ -34,7 +35,8 @@ const source = 'source'; const target = 'target'; const nodeA: SankeyNodeDatum = { - name: 'A', + name: sankeyValueId('A'), + displayName: 'A', filterColumn: 'source', rawValue: 'A', }; @@ -54,6 +56,15 @@ test('node names keep null and an empty string distinct', () => { expect(sankeyNodeName(2024)).toBe('2024'); }); +test('value ids do not collapse labels that look the same', () => { + expect(sankeyValueId(null)).not.toBe(sankeyValueId(NULL_STRING)); + expect(sankeyValueId('')).not.toBe(sankeyValueId(EMPTY_STRING)); + expect(sankeyValueId(42)).not.toBe(sankeyValueId(42n)); + expect(sankeyValueId(42n)).not.toBe(sankeyValueId('42')); + expect(sankeyNodeKey('source', 42)).not.toBe(sankeyNodeKey('source', 42n)); + expect(sankeyLinkKey(42, '42')).not.toBe(sankeyLinkKey(42, 42)); +}); + test('a node click filters the column that introduced the label', () => { const mask = getSankeyCrossFilterDataMask({ source, @@ -109,13 +120,14 @@ test('a link click filters source and target together', () => { test('null becomes IS NULL and an empty string stays an empty value', () => { const nullNode: SankeyNodeDatum = { - name: NULL_STRING, + name: sankeyValueId(null), + displayName: NULL_STRING, filterColumn: 'target', rawValue: null, }; const emptyLink: SankeyLinkDatum = { - source: 'A', - target: EMPTY_STRING, + source: sankeyValueId('A'), + target: sankeyValueId(''), value: 1, sourceValue: 'A', targetValue: '', @@ -144,7 +156,8 @@ test('null becomes IS NULL and an empty string stays an empty value', () => { test('a bigint filter value is serialized as a string', () => { const node: SankeyNodeDatum = { - name: '42', + name: sankeyValueId(42n), + displayName: '42', filterColumn: 'source', rawValue: 42n, }; @@ -166,7 +179,8 @@ test('drill filters use equality and keep null distinct', () => { ]); expect( getSankeyDrillFilters(source, target, { - name: NULL_STRING, + name: sankeyValueId(null), + displayName: NULL_STRING, filterColumn: 'source', rawValue: null, }), @@ -174,31 +188,39 @@ test('drill filters use equality and keep null distinct', () => { }); test('a selected node keeps its matching flows and their other ends', () => { + const a = sankeyValueId('A'); + const b = sankeyValueId('B'); + const c = sankeyValueId('C'); + const d = sankeyValueId('D'); + const e = sankeyValueId('E'); const links = [ - { source: 'A', target: 'B' }, - { source: 'B', target: 'C' }, - { source: 'D', target: 'E' }, + { source: a, target: b }, + { source: b, target: c }, + { source: d, target: e }, ]; - const keys = [sankeyNodeKey('target', 'B')]; - - expect(isSankeyNodeDimmed('B', keys, links)).toBe(false); - expect(isSankeyNodeDimmed('A', keys, links)).toBe(false); - expect(isSankeyNodeDimmed('C', keys, links)).toBe(true); - expect(isSankeyLinkDimmed(links[0], keys)).toBe(false); - expect(isSankeyLinkDimmed(links[1], keys)).toBe(true); - expect(isSankeyLinkDimmed(links[2], keys)).toBe(true); + const highlight = getSankeyHighlight([sankeyNodeKey('target', 'B')], links); + + expect(highlight?.nodeIds.has(b)).toBe(true); + expect(highlight?.nodeIds.has(a)).toBe(true); + expect(highlight?.nodeIds.has(c)).toBe(false); + expect(highlight?.linkKeys.has(sankeyEndpointKey(a, b))).toBe(true); + expect(highlight?.linkKeys.has(sankeyEndpointKey(b, c))).toBe(false); + expect(highlight?.linkKeys.has(sankeyEndpointKey(d, e))).toBe(false); }); test('a selected link keeps only that link and its endpoints', () => { + const a = sankeyValueId('A'); + const b = sankeyValueId('B'); + const c = sankeyValueId('C'); const links = [ - { source: 'A', target: 'B' }, - { source: 'B', target: 'C' }, + { source: a, target: b }, + { source: b, target: c }, ]; - const keys = [sankeyLinkKey('B', 'C')]; + const highlight = getSankeyHighlight([sankeyLinkKey('B', 'C')], links); - expect(isSankeyLinkDimmed(links[1], keys)).toBe(false); - expect(isSankeyLinkDimmed(links[0], keys)).toBe(true); - expect(isSankeyNodeDimmed('B', keys, links)).toBe(false); - expect(isSankeyNodeDimmed('C', keys, links)).toBe(false); - expect(isSankeyNodeDimmed('A', keys, links)).toBe(true); + expect(highlight?.linkKeys.has(sankeyEndpointKey(b, c))).toBe(true); + expect(highlight?.linkKeys.has(sankeyEndpointKey(a, b))).toBe(false); + expect(highlight?.nodeIds.has(b)).toBe(true); + expect(highlight?.nodeIds.has(c)).toBe(true); + expect(highlight?.nodeIds.has(a)).toBe(false); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts index 3c7789dce26e..abe06266c2f8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts @@ -21,7 +21,15 @@ import { supersetTheme } from '@apache-superset/core/theme'; import { SankeyChartProps } from '../../src/Sankey/types'; import transformProps from '../../src/Sankey/transformProps'; import { NULL_STRING, OpacityEnum } from '../../src/constants'; -import { EMPTY_STRING, sankeyNodeKey } from '../../src/Sankey/crossFilter'; +import { + EMPTY_STRING, + sankeyNodeKey, + sankeyValueId, +} from '../../src/Sankey/crossFilter'; +import { + DIMMED_LINK_OPACITY, + SELECTED_LINK_OPACITY, +} from '../../src/Sankey/transformProps'; const formData = { datasource: '1__table', @@ -41,6 +49,7 @@ const data = [ type NodeOption = { name: string; + displayName: string; filterColumn: 'source' | 'target'; rawValue: unknown; itemStyle: { opacity?: number }; @@ -77,26 +86,54 @@ function renderChart( .echartOptions as SankeyTestOptions; } -test('a repeated label keeps the column of the row that introduced it', () => { +test('a repeated value keeps the column of the row that introduced it', () => { const nodes = renderChart().series?.data ?? []; - const nodeB = nodes.find(node => node.name === 'B'); + const nodeB = nodes.find(node => node.rawValue === 'B'); expect(nodeB).toEqual( expect.objectContaining({ + name: sankeyValueId('B'), + displayName: 'B', filterColumn: 'target', rawValue: 'B', }), ); - expect(nodes.find(node => node.name === 'A')?.filterColumn).toBe('source'); + expect(nodes.find(node => node.rawValue === 'A')?.filterColumn).toBe( + 'source', + ); }); -test('null and an empty string get distinct node names', () => { - const links = renderChart().series?.links ?? []; +test('null and an empty string stay separate nodes', () => { + const chart = renderChart(); + const links = chart.series?.links ?? []; + const nodes = chart.series?.data ?? []; const missing = links.find(link => link.sourceValue === null); - expect(missing?.source).toBe(NULL_STRING); - expect(missing?.target).toBe(EMPTY_STRING); - expect(missing?.targetValue).toBe(''); + expect(missing?.source).toBe(sankeyValueId(null)); + expect(missing?.target).toBe(sankeyValueId('')); + expect(missing?.source).not.toBe(sankeyValueId(NULL_STRING)); + expect(missing?.target).not.toBe(sankeyValueId(EMPTY_STRING)); + expect(nodes.find(node => node.rawValue === null)?.displayName).toBe( + NULL_STRING, + ); + expect(nodes.find(node => node.rawValue === '')?.displayName).toBe( + EMPTY_STRING, + ); +}); + +test('a number and the same bigint are different endpoints', () => { + const chart = renderChart({ + queriesData: [ + { + data: [{ source: 42, target: 42n, value: 1 }], + }, + ], + }); + const link = chart.series?.links?.[0]; + + expect(link?.source).toBe(sankeyValueId(42)); + expect(link?.target).toBe(sankeyValueId(42n)); + expect(link?.source).not.toBe(link?.target); }); test('selecting a node dims flows outside that column', () => { @@ -106,20 +143,29 @@ test('selecting a node dims flows outside that column', () => { }); const nodes = options.series?.data ?? []; const links = options.series?.links ?? []; - const opacityOf = (name: string) => - nodes.find(node => node.name === name)?.itemStyle.opacity; + const opacityOf = (value: string) => + nodes.find(node => node.rawValue === value)?.itemStyle.opacity; expect(opacityOf('B')).toBeUndefined(); expect(opacityOf('A')).toBeUndefined(); expect(opacityOf('C')).toBe(OpacityEnum.SemiTransparent); expect(opacityOf('D')).toBe(OpacityEnum.SemiTransparent); - expect(links.find(link => link.source === 'A')?.lineStyle).toBeUndefined(); - expect(links.find(link => link.target === 'C')?.lineStyle?.opacity).toBe( - OpacityEnum.SemiTransparent, + expect(links.find(link => link.sourceValue === 'A')?.lineStyle?.opacity).toBe( + SELECTED_LINK_OPACITY, + ); + expect(links.find(link => link.targetValue === 'C')?.lineStyle?.opacity).toBe( + DIMMED_LINK_OPACITY, ); + expect(SELECTED_LINK_OPACITY).toBeGreaterThan(DIMMED_LINK_OPACITY); expect(options.series?.cursor).toBe('default'); }); +test('links keep the default opacity when nothing is selected', () => { + const links = renderChart().series?.links ?? []; + + expect(links.every(link => link.lineStyle === undefined)).toBe(true); +}); + test('the pointer cursor is used when the chart emits cross filters', () => { expect(renderChart({ emitCrossFilters: true }).series?.cursor).toBe( 'pointer', diff --git a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx index 815ee9900539..0e2a9dc5bea7 100644 --- a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx +++ b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx @@ -54,9 +54,11 @@ const { id: defaultChartId, form_data: defaultFormData } = chartQueries[sliceId]; const { slice_name: chartName } = defaultFormData; +// Sankey handles Drill to detail. Histogram still does not, so it stands in +// for a chart whose context menu cannot drill by a clicked value. const unsupportedChartFormData = { ...defaultFormData, - viz_type: VizType.Sankey, + viz_type: VizType.Histogram, }; const noDimensionsFormData = { From b32daf9fa0efe927615235083173eefe826ac79e Mon Sep 17 00:00:00 2001 From: SBIN2010 Date: Sat, 31 Oct 2026 01:04:12 +0300 Subject: [PATCH 3/4] fix pre commit --- .../test/Sankey/transformProps.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts index abe06266c2f8..b4b852f4b4b9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts @@ -19,17 +19,16 @@ import { ChartProps, ChartPropsConfig } from '@superset-ui/core'; import { supersetTheme } from '@apache-superset/core/theme'; import { SankeyChartProps } from '../../src/Sankey/types'; -import transformProps from '../../src/Sankey/transformProps'; +import transformProps, { + DIMMED_LINK_OPACITY, + SELECTED_LINK_OPACITY, +} from '../../src/Sankey/transformProps'; import { NULL_STRING, OpacityEnum } from '../../src/constants'; import { EMPTY_STRING, sankeyNodeKey, sankeyValueId, } from '../../src/Sankey/crossFilter'; -import { - DIMMED_LINK_OPACITY, - SELECTED_LINK_OPACITY, -} from '../../src/Sankey/transformProps'; const formData = { datasource: '1__table', From 121fed65c4e9cb91738373155929acfa02639da9 Mon Sep 17 00:00:00 2001 From: SBIN2010 Date: Sat, 31 Oct 2026 01:35:33 +0300 Subject: [PATCH 4/4] add more test --- .../test/Sankey/Sankey.test.tsx | 53 ++++++++++++++++++- .../test/Sankey/crossFilter.test.ts | 48 +++++++++++++++++ .../test/Sankey/transformProps.test.ts | 37 +++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx index 22e8e6c9a3a4..55ca657c95a9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/Sankey.test.tsx @@ -46,6 +46,8 @@ function setup( overrides: { emitCrossFilters?: boolean; selectedValues?: string[]; + contextMenu?: boolean; + dataMask?: boolean; } = {}, ) { const onContextMenu = jest.fn(); @@ -62,7 +64,10 @@ function setup( height: 600, queriesData: [{ data }], theme: supersetTheme, - hooks: { onContextMenu, setDataMask }, + hooks: { + ...(overrides.dataMask === false ? {} : { setDataMask }), + ...(overrides.contextMenu === false ? {} : { onContextMenu }), + }, emitCrossFilters: overrides.emitCrossFilters ?? true, filterState: { selectedValues: overrides.selectedValues }, }); @@ -156,6 +161,22 @@ test('click does nothing when the chart does not emit cross filters', () => { expect(setDataMask).not.toHaveBeenCalled(); }); +test('a click without a data mask handler does nothing', () => { + const { eventHandlers, setDataMask, nodeB } = setup({ dataMask: false }); + + eventHandlers.click({ data: nodeB }); + + expect(setDataMask).not.toHaveBeenCalled(); +}); + +test('click ignores a point that is not a node or a link', () => { + const { eventHandlers, setDataMask } = setup(); + + eventHandlers.click({ data: { value: 1 } }); + + expect(setDataMask).not.toHaveBeenCalled(); +}); + test('the context menu drills a node by the column that owns it', () => { const { eventHandlers, onContextMenu, nodeB } = setup(); @@ -185,3 +206,33 @@ test('the context menu drills a link to both columns', () => { ]); expect(payload.drillBy).toBeUndefined(); }); + +test('the context menu ignores a point that is not a flow', () => { + const { eventHandlers, onContextMenu } = setup(); + + eventHandlers.contextmenu({ data: { value: 1 }, event: pointerEvent }); + + expect(onContextMenu).not.toHaveBeenCalled(); +}); + +test('the context menu ignores a click without a pointer', () => { + const { eventHandlers, onContextMenu, nodeB } = setup(); + + eventHandlers.contextmenu({ data: nodeB }); + eventHandlers.contextmenu({ + data: nodeB, + event: { stop: jest.fn() }, + }); + + expect(onContextMenu).not.toHaveBeenCalled(); +}); + +test('the context menu does nothing without a handler', () => { + const { eventHandlers, onContextMenu, nodeB } = setup({ + contextMenu: false, + }); + + eventHandlers.contextmenu({ data: nodeB, event: pointerEvent }); + + expect(onContextMenu).not.toHaveBeenCalled(); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts index be10cc459822..6ff495fecc95 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/crossFilter.test.ts @@ -22,6 +22,7 @@ import { getSankeyCrossFilterDataMask, getSankeyDrillFilters, getSankeyHighlight, + getSelectedSankeyKeys, sankeyEndpointKey, sankeyLinkKey, sankeyNodeKey, @@ -57,14 +58,31 @@ test('node names keep null and an empty string distinct', () => { }); test('value ids do not collapse labels that look the same', () => { + const date = new Date('2021-01-01T00:00:00.000Z'); + expect(sankeyValueId(null)).not.toBe(sankeyValueId(NULL_STRING)); expect(sankeyValueId('')).not.toBe(sankeyValueId(EMPTY_STRING)); expect(sankeyValueId(42)).not.toBe(sankeyValueId(42n)); expect(sankeyValueId(42n)).not.toBe(sankeyValueId('42')); + expect(sankeyValueId(date)).toBe( + JSON.stringify({ date: '2021-01-01T00:00:00.000Z' }), + ); + expect(sankeyValueId(date)).not.toBe(sankeyValueId(date.toISOString())); expect(sankeyNodeKey('source', 42)).not.toBe(sankeyNodeKey('source', 42n)); expect(sankeyLinkKey(42, '42')).not.toBe(sankeyLinkKey(42, 42)); }); +test('selected values stored as an object are read as keys', () => { + const key = sankeyNodeKey('source', 'A'); + + expect(getSelectedSankeyKeys({ first: key, second: key })).toEqual([ + key, + key, + ]); + expect(getSelectedSankeyKeys(1)).toEqual([]); + expect(getSelectedSankeyKeys(null)).toEqual([]); +}); + test('a node click filters the column that introduced the label', () => { const mask = getSankeyCrossFilterDataMask({ source, @@ -208,6 +226,36 @@ test('a selected node keeps its matching flows and their other ends', () => { expect(highlight?.linkKeys.has(sankeyEndpointKey(d, e))).toBe(false); }); +test('several flows through one node stay indexed', () => { + const a = sankeyValueId('A'); + const b = sankeyValueId('B'); + const c = sankeyValueId('C'); + const links = [ + { source: a, target: b }, + { source: a, target: c }, + { source: b, target: c }, + ]; + const highlight = getSankeyHighlight([sankeyNodeKey('source', 'A')], links); + + expect(highlight?.linkKeys.has(sankeyEndpointKey(a, b))).toBe(true); + expect(highlight?.linkKeys.has(sankeyEndpointKey(a, c))).toBe(true); + expect(highlight?.linkKeys.has(sankeyEndpointKey(b, c))).toBe(false); + expect(highlight?.nodeIds.has(b)).toBe(true); + expect(highlight?.nodeIds.has(c)).toBe(true); +}); + +test('a broken selection key highlights nothing', () => { + const a = sankeyValueId('A'); + const b = sankeyValueId('B'); + const highlight = getSankeyHighlight( + ['not-json', JSON.stringify({ bad: true }), JSON.stringify(['nope'])], + [{ source: a, target: b }], + ); + + expect(highlight?.nodeIds.size).toBe(0); + expect(highlight?.linkKeys.size).toBe(0); +}); + test('a selected link keeps only that link and its endpoints', () => { const a = sankeyValueId('A'); const b = sankeyValueId('B'); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts index b4b852f4b4b9..4404555af26e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sankey/transformProps.test.ts @@ -52,6 +52,13 @@ type NodeOption = { filterColumn: 'source' | 'target'; rawValue: unknown; itemStyle: { opacity?: number }; + label?: { formatter?: () => string }; +}; + +type TooltipParams = { + name?: string; + value?: number; + data?: { source?: string; target?: string }; }; type LinkOption = { @@ -68,6 +75,9 @@ type SankeyTestOptions = { data?: NodeOption[]; links?: LinkOption[]; }; + tooltip?: { + formatter?: (params: TooltipParams) => string; + }; }; function renderChart( @@ -170,3 +180,30 @@ test('the pointer cursor is used when the chart emits cross filters', () => { 'pointer', ); }); + +test('labels and tooltips use the display name', () => { + const chart = renderChart(); + const nodes = chart.series?.data ?? []; + const links = chart.series?.links ?? []; + const nodeA = nodes.find(node => node.rawValue === 'A'); + const link = links.find(link => link.sourceValue === 'A'); + const formatter = chart.tooltip?.formatter; + const linkTip = formatter?.({ + value: 10, + data: { source: link?.source, target: link?.target }, + }); + + expect(nodes.find(node => node.rawValue === null)?.label?.formatter?.()).toBe( + NULL_STRING, + ); + expect(linkTip).toContain('A → B'); + expect(linkTip).toContain('% (A)'); + expect(linkTip).toContain('% (B)'); + expect(linkTip).not.toContain(sankeyValueId('A')); + expect(formatter?.({ name: nodeA?.name, value: 10, data: {} })).toContain( + 'A', + ); + expect(formatter?.({ name: 'missing', value: 1, data: {} })).toContain( + 'missing', + ); +});