diff --git a/core/src/user-components/common/cross-filter-attributes.ts b/core/src/user-components/common/cross-filter-attributes.ts new file mode 100644 index 0000000000..b98fc95cb7 --- /dev/null +++ b/core/src/user-components/common/cross-filter-attributes.ts @@ -0,0 +1,27 @@ +import type { UserComponentAttribute } from '../types'; + +/** + * Shared cross-filter attributes for chart components. + */ +export const CROSS_FILTER_ATTRIBUTES: Record = { + 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.' + } +}; diff --git a/core/src/user-components/common/cross-filter.svelte.ts b/core/src/user-components/common/cross-filter.svelte.ts new file mode 100644 index 0000000000..7c233279cf --- /dev/null +++ b/core/src/user-components/common/cross-filter.svelte.ts @@ -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 + }; +} diff --git a/core/src/user-components/common/cross-filter.test.ts b/core/src/user-components/common/cross-filter.test.ts new file mode 100644 index 0000000000..62625eefb2 --- /dev/null +++ b/core/src/user-components/common/cross-filter.test.ts @@ -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'); + }); + }); +}); diff --git a/core/src/user-components/tags/funnel_chart/FunnelChart.svelte b/core/src/user-components/tags/funnel_chart/FunnelChart.svelte index 533ec107bd..7bea9a0107 100644 --- a/core/src/user-components/tags/funnel_chart/FunnelChart.svelte +++ b/core/src/user-components/tags/funnel_chart/FunnelChart.svelte @@ -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, @@ -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; @@ -193,7 +220,7 @@ data: resolvedTableName, category: resolvedCategory, value: resolvedValue, - filters: filterIds, + filters: effectiveFilterIds, where, date_range: resolvedDateRange, having, @@ -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); + }; + });
{ + return setupCrossFilter({ + chart: () => chart, + pageFilters, + crossFilter: cross_filter, + crossFilterColumn: cross_filter_column ?? category, + 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 || !tableName) { return; @@ -143,7 +170,7 @@ data: tableName, category, value, - filters: filterIds, + filters: effectiveFilterIds, where, date_range: resolvedDateRange, having, @@ -325,6 +352,21 @@ }); let chart: EChartsInstance | undefined = $state(undefined); + + // Cross-filtering click listener + $effect(() => { + if (!chart || !crossFilterHelper.isEnabled()) return; + + const handleCrossFilterClick = (params: any) => { + crossFilterHelper.handleChartClick(params); + }; + + chart.on('click', handleCrossFilterClick); + + return () => { + chart?.off('click', handleCrossFilterClick); + }; + });
; export type ComboChartInternalProps = { @@ -314,6 +315,32 @@ // resolves its own base + aggregate independently of the combo_chart's `data=`. const metricsCatalog = getMetricsCatalogContext(); + 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 ?? x, + 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; + }); + // Raw shared context that children (SeriesModels) forward to buildChartSQLConfig const sharedQueryContext = $derived.by(() => { return { @@ -329,7 +356,7 @@ dateGrain: date_grain, // Same story for grain — author's explicit `date_grain=` vs the // inherited one from child #1's view. - filters: filterIds, + filters: effectiveFilterIds, where, dateRange: resolvedDateRange, having, @@ -1068,6 +1095,21 @@ }; }); + // 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); + }; + }); + // Activate zoom mode automatically $effect(() => { if (!chart || !zoom) { diff --git a/core/src/user-components/tags/series_charts/combo_chart/schema.ts b/core/src/user-components/tags/series_charts/combo_chart/schema.ts index 8d7d0f365b..9d92a803b6 100644 --- a/core/src/user-components/tags/series_charts/combo_chart/schema.ts +++ b/core/src/user-components/tags/series_charts/combo_chart/schema.ts @@ -31,6 +31,8 @@ import { HANDLE_MISSING_ATTRIBUTE } from '../../../common/handle-missing-attribu import { colorPaletteSchema, seriesColorsSchema } from '../../../common/chart-options-schema'; import { setZodMetadata } from '../../../common/zod-metadata'; +import { CROSS_FILTER_ATTRIBUTES } from '../../../common/cross-filter-attributes'; + const attributes = { data: { type: String, @@ -55,6 +57,7 @@ const attributes = { suggestionType: 'filter', affectsQuery: true }, + ...CROSS_FILTER_ATTRIBUTES, ...DATE_RANGE_ATTRIBUTE, ...DATE_GRAIN_ATTRIBUTE, ...HANDLE_MISSING_ATTRIBUTE, diff --git a/core/src/user-components/tags/series_charts/horizontal_bar_chart/HorizontalBarChart.svelte b/core/src/user-components/tags/series_charts/horizontal_bar_chart/HorizontalBarChart.svelte index 1c01d2cbd3..8f47654c1a 100644 --- a/core/src/user-components/tags/series_charts/horizontal_bar_chart/HorizontalBarChart.svelte +++ b/core/src/user-components/tags/series_charts/horizontal_bar_chart/HorizontalBarChart.svelte @@ -48,6 +48,7 @@ renderTooltipExtras, type TooltipField } from '../../../common/tooltip-fields'; + import { setupCrossFilter } from '../../../common/cross-filter.svelte'; const props: UserComponentProps = $props(); const children = $derived(props.children); @@ -166,6 +167,32 @@ const y_sort = $derived(props.y_sort); const series_order = $derived(props.series_order); + 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 ?? resolvedY, + crossFilterMultiple: cross_filter_multiple, + id: props.id + }); + }); + + const effectiveFilterIds = $derived.by(() => { + const fIds = props.filters ?? []; + if (crossFilterHelper.isEnabled()) { + const selfId = crossFilterHelper.filterId(); + if (selfId) { + return fIds.filter((id) => id !== selfId); + } + } + return fIds; + }); + // Build query config const queryConfig = $derived.by(() => { if (hasValidationErrors || !resolvedX || !resolvedY) return; @@ -179,7 +206,7 @@ y: resolvedY, series: resolvedSeries, date_grain, - filters: props.filters, + filters: effectiveFilterIds, where, date_range: resolvedDateRange, having, @@ -713,6 +740,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); + }; + });
{ + 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) { return; @@ -149,7 +176,7 @@ category: resolvedCategory, value: resolvedValue, group: resolvedGroup, - filters: filterIds, + filters: effectiveFilterIds, where, date_range: resolvedDateRange, having, @@ -319,6 +346,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); + }; + });