Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions core/src/user-components/common/cross-filter-attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { UserComponentAttribute } from '../types';

/**
* Shared cross-filter attributes for chart components.
*/
export const CROSS_FILTER_ATTRIBUTES: Record<string, UserComponentAttribute> = {
cross_filter: {
type: [Boolean, String],
required: false,
description:
'Enable cross-filtering on click. Pass `true` to filter on the primary dimension, or a string to specify the filter ID/name.',
supportsVariables: true
},
cross_filter_column: {
type: String,
required: false,
description: 'Explicit column name to filter on when cross-filtering is enabled.',
suggestionType: 'column',
supportsVariables: true
},
cross_filter_multiple: {
type: Boolean,
required: false,
default: false,
description: 'Allow selecting multiple values simultaneously during cross-filtering.'
}
};
110 changes: 110 additions & 0 deletions core/src/user-components/common/cross-filter.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { ECharts } from 'echarts';
import type { Filters } from '../../Filters.svelte';
import type { Filter } from '../../Filter.svelte';

export interface CrossFilterConfig {
chart: () => ECharts | undefined;
pageFilters: Filters | undefined;
crossFilter: boolean | string | undefined;
crossFilterColumn: string | undefined;
crossFilterMultiple?: boolean;
id?: string;
}

/**
* Manages cross-filtering interaction on a chart component.
* Allows clicking chart elements (bars, slices, points) to filter the page.
*/
export function setupCrossFilter(config: CrossFilterConfig) {
const isEnabled = () => Boolean(config.crossFilter);

const targetColumn = () => {
if (config.crossFilterColumn) return config.crossFilterColumn;
if (typeof config.crossFilter === 'string' && config.crossFilter !== 'true') {
return config.crossFilter;
}
return undefined;
};

const filterId = () => {
if (typeof config.crossFilter === 'string' && config.crossFilter !== 'true') {
return config.crossFilter;
}
if (config.id) return config.id;
return targetColumn();
};

const getOrCreateFilter = (): Filter | undefined => {
if (!isEnabled() || !config.pageFilters) return undefined;
const id = filterId();
if (!id) return undefined;
const existing = config.pageFilters.get(id);
if (existing) return existing;
return config.pageFilters.createExternal(id, undefined, targetColumn());
};

const handleChartClick = (params: {
name?: string;
value?: unknown;
data?: unknown;
seriesName?: string;
}) => {
if (!isEnabled() || !config.pageFilters) return;
const id = filterId();
if (!id) return;
const filter = getOrCreateFilter();
if (!filter) return;

// Resolve clicked raw value:
let clickedValue: unknown = params.name;
if (clickedValue === undefined || clickedValue === '' || clickedValue === null) {
if (Array.isArray(params.value)) {
clickedValue = params.value[0];
} else if (params.value !== undefined && params.value !== null) {
clickedValue = params.value;
} else if (typeof params.data === 'object' && params.data !== null && 'name' in params.data) {
clickedValue = (params.data as { name: unknown }).name;
}
}

if (clickedValue === undefined || clickedValue === null || clickedValue === '') return;
const strValue = String(clickedValue);

if (config.crossFilterMultiple) {
const currentList: string[] = Array.isArray(filter.value)
? [...(filter.value as string[])]
: filter.value != null && filter.value !== ''
? [String(filter.value)]
: [];

const idx = currentList.indexOf(strValue);
if (idx > -1) {
currentList.splice(idx, 1);
} else {
currentList.push(strValue);
}
filter.value = currentList.length > 0 ? currentList : undefined;
} else {
const isCurrentlySelected =
filter.value === strValue ||
filter.value === clickedValue ||
(Array.isArray(filter.value) &&
filter.value.length === 1 &&
String(filter.value[0]) === strValue);

if (isCurrentlySelected) {
filter.value = undefined;
} else {
filter.value = strValue;
}
}
};

return {
isEnabled,
filterId,
targetColumn,
getFilter: getOrCreateFilter,
handleChartClick
};
}
176 changes: 176 additions & 0 deletions core/src/user-components/common/cross-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect, vi } from 'vitest';
import { setupCrossFilter } from './cross-filter.svelte';
import { schema as barChartSchema } from '../tags/series_charts/bar_chart/schema';
import { schema as lineChartSchema } from '../tags/series_charts/line_chart/schema';
import { schema as pieChartSchema } from '../tags/pie_chart/schema';
import { schema as funnelChartSchema } from '../tags/funnel_chart/schema';
import { schema as treemapSchema } from '../tags/treemap/schema';
import { schema as horizontalBarChartSchema } from '../tags/series_charts/horizontal_bar_chart/schema';

describe('Cross-filtering (#1111)', () => {
describe('Schema Attributes', () => {
it('includes cross_filter attributes in bar_chart schema', () => {
expect(barChartSchema.attributes.cross_filter).toBeDefined();
expect(barChartSchema.attributes.cross_filter_column).toBeDefined();
expect(barChartSchema.attributes.cross_filter_multiple).toBeDefined();
});

it('includes cross_filter attributes in line_chart schema', () => {
expect(lineChartSchema.attributes.cross_filter).toBeDefined();
expect(lineChartSchema.attributes.cross_filter_column).toBeDefined();
expect(lineChartSchema.attributes.cross_filter_multiple).toBeDefined();
});

it('includes cross_filter attributes in pie_chart schema', () => {
expect(pieChartSchema.attributes.cross_filter).toBeDefined();
expect(pieChartSchema.attributes.cross_filter_column).toBeDefined();
expect(pieChartSchema.attributes.cross_filter_multiple).toBeDefined();
});

it('includes cross_filter attributes in funnel_chart and treemap schemas', () => {
expect(funnelChartSchema.attributes.cross_filter).toBeDefined();
expect(treemapSchema.attributes.cross_filter).toBeDefined();
});

it('includes cross_filter attributes in horizontal_bar_chart schema', () => {
expect(horizontalBarChartSchema.attributes.cross_filter).toBeDefined();
});
});

describe('setupCrossFilter Logic', () => {
it('reports isEnabled correctly', () => {
const helperDisabled = setupCrossFilter({
chart: () => undefined,
pageFilters: undefined,
crossFilter: false,
crossFilterColumn: 'category'
});
expect(helperDisabled.isEnabled()).toBe(false);

const helperEnabled = setupCrossFilter({
chart: () => undefined,
pageFilters: undefined,
crossFilter: true,
crossFilterColumn: 'category'
});
expect(helperEnabled.isEnabled()).toBe(true);
expect(helperEnabled.targetColumn()).toBe('category');
expect(helperEnabled.filterId()).toBe('category');
});

it('supports custom cross_filter filter name / id', () => {
const helper = setupCrossFilter({
chart: () => undefined,
pageFilters: undefined,
crossFilter: 'selected_category',
crossFilterColumn: 'category'
});
expect(helper.isEnabled()).toBe(true);
expect(helper.filterId()).toBe('selected_category');
});

it('handles single-select chart element click and toggle off', () => {
let storedValue: unknown = undefined;
const mockFilter = {
get value() {
return storedValue;
},
set value(v: unknown) {
storedValue = v;
}
};

const mockPageFilters = {
get: vi.fn().mockReturnValue(mockFilter),
createExternal: vi.fn().mockReturnValue(mockFilter)
} as any;

const helper = setupCrossFilter({
chart: () => undefined,
pageFilters: mockPageFilters,
crossFilter: true,
crossFilterColumn: 'country'
});

// First click: selects Canada
helper.handleChartClick({ name: 'Canada' });
expect(storedValue).toBe('Canada');

// Second click on same item: toggles off (undefined)
helper.handleChartClick({ name: 'Canada' });
expect(storedValue).toBeUndefined();

// Click on another item: selects USA
helper.handleChartClick({ name: 'USA' });
expect(storedValue).toBe('USA');
});

it('handles multi-select chart element clicks', () => {
let storedValue: unknown = undefined;
const mockFilter = {
get value() {
return storedValue;
},
set value(v: unknown) {
storedValue = v;
}
};

const mockPageFilters = {
get: vi.fn().mockReturnValue(mockFilter),
createExternal: vi.fn().mockReturnValue(mockFilter)
} as any;

const helper = setupCrossFilter({
chart: () => undefined,
pageFilters: mockPageFilters,
crossFilter: true,
crossFilterColumn: 'region',
crossFilterMultiple: true
});

// Select North
helper.handleChartClick({ name: 'North' });
expect(storedValue).toEqual(['North']);

// Select South
helper.handleChartClick({ name: 'South' });
expect(storedValue).toEqual(['North', 'South']);

// Deselect North
helper.handleChartClick({ name: 'North' });
expect(storedValue).toEqual(['South']);

// Deselect South -> becomes undefined
helper.handleChartClick({ name: 'South' });
expect(storedValue).toBeUndefined();
});

it('extracts value from series data or value arrays when name is absent', () => {
let storedValue: unknown = undefined;
const mockFilter = {
get value() {
return storedValue;
},
set value(v: unknown) {
storedValue = v;
}
};

const mockPageFilters = {
get: vi.fn().mockReturnValue(mockFilter),
createExternal: vi.fn().mockReturnValue(mockFilter)
} as any;

const helper = setupCrossFilter({
chart: () => undefined,
pageFilters: mockPageFilters,
crossFilter: true,
crossFilterColumn: 'date'
});

helper.handleChartClick({ value: ['2026-01-01', 100] });
expect(storedValue).toBe('2026-01-01');
});
});
});
44 changes: 43 additions & 1 deletion core/src/user-components/tags/funnel_chart/FunnelChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import { getThemeToken } from '../../../theme/get-theme-token';
import { getCardContext } from '../../common/card-context.svelte';
import { colorPalettes } from '../echarts/echarts-themes';
import { setupCrossFilter } from '../../common/cross-filter.svelte';
import {
FUNNEL_LABEL_INSIDE_PADDING,
FUNNEL_LABEL_SINGLE_LINE_TOP_NUDGE,
Expand Down Expand Up @@ -184,6 +185,32 @@
const categoryColumn = $derived(categoryProcessed.alias);
const valueColumn = $derived(valueProcessed.alias);

const cross_filter = $derived(props.cross_filter);
const cross_filter_column = $derived(resolveColumn(props.cross_filter_column));
const cross_filter_multiple = $derived(props.cross_filter_multiple ?? false);

const crossFilterHelper = $derived.by(() => {
return setupCrossFilter({
chart: () => chart,
pageFilters,
crossFilter: cross_filter,
crossFilterColumn: cross_filter_column ?? resolvedCategory,
crossFilterMultiple: cross_filter_multiple,
id: props.id
});
});

const effectiveFilterIds = $derived.by(() => {
const fIds = filterIds ?? [];
if (crossFilterHelper.isEnabled()) {
const selfId = crossFilterHelper.filterId();
if (selfId) {
return fIds.filter((id) => id !== selfId);
}
}
return fIds;
});

const queryConfig = $derived.by(() => {
if (hasValidationErrors || !resolvedTableName) {
return;
Expand All @@ -193,7 +220,7 @@
data: resolvedTableName,
category: resolvedCategory,
value: resolvedValue,
filters: filterIds,
filters: effectiveFilterIds,
where,
date_range: resolvedDateRange,
having,
Expand Down Expand Up @@ -537,6 +564,21 @@
});

let chart: EChartsInstance | undefined = $state(undefined);

// Handle cross-filtering on chart element click
$effect(() => {
if (!chart || !crossFilterHelper.isEnabled()) return;

const handleCrossFilterClick = (params: any) => {
crossFilterHelper.handleChartClick(params);
};

chart.on('click', handleCrossFilterClick);

return () => {
chart?.off('click', handleCrossFilterClick);
};
});
</script>

<div
Expand Down
Loading