diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dbf90157f8..06ed3b146c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ 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 + with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx index 153a3e7917e..d11d5ae21fa 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx @@ -22,11 +22,15 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { isEqual } from 'lodash'; import { + AdhocFilter, Datasource, + ensureIsArray, HandlerFunction, + isDefined, JsonObject, JsonValue, QueryFormData, + QueryObjectFilterClause, SupersetClient, usePrevious, } from '@superset-ui/core'; @@ -107,70 +111,175 @@ const DeckMulti = (props: DeckMultiProps) => { } }, []); + const getLayerIndex = useCallback( + (sliceId: number, payloadIndex: number, deckSlices?: number[]): number => + deckSlices ? deckSlices.indexOf(sliceId) : payloadIndex, + [], + ); + + const processLayerFilters = useCallback( + ( + subslice: JsonObject, + formData: QueryFormData, + layerIndex: number, + ): { + extraFilters: (AdhocFilter | QueryObjectFilterClause)[]; + adhocFilters: AdhocFilter[]; + } => { + const layerFilterScope = formData.layer_filter_scope; + + const extraFilters: (AdhocFilter | QueryObjectFilterClause)[] = [ + ...(subslice.form_data.extra_filters || []), + ...(formData.extra_filters || []), + ]; + + const adhocFilters: AdhocFilter[] = [ + ...(subslice.form_data?.adhoc_filters || []), + ]; + + if (layerFilterScope) { + const filterDataMapping = formData.filter_data_mapping || {}; + let shouldAddDashboardAdhocFilters = false; + + Object.entries(layerFilterScope).forEach( + ([filterId, filterScope]: [string, number[]]) => { + const shouldApplyFilter = + ensureIsArray(filterScope).includes(layerIndex); + + if (shouldApplyFilter) { + shouldAddDashboardAdhocFilters = true; + const filtersFromThisFilter = filterDataMapping[filterId] || []; + extraFilters.push(...filtersFromThisFilter); + } + }, + ); + + if (shouldAddDashboardAdhocFilters) { + const dashboardAdhocFilters = formData.adhoc_filters || []; + adhocFilters.push(...dashboardAdhocFilters); + } + } else { + const originalExtraFormDataFilters = + formData.extra_form_data?.filters || []; + extraFilters.push(...originalExtraFormDataFilters); + + const dashboardAdhocFilters = formData.adhoc_filters || []; + adhocFilters.push(...dashboardAdhocFilters); + } + + return { extraFilters, adhocFilters }; + }, + [], + ); + + const createLayerFromData = useCallback( + (subslice: JsonObject, json: JsonObject): Layer => + // @ts-ignore TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators. + layerGenerators[subslice.form_data.viz_type]( + subslice.form_data, + json, + props.onAddFilter, + setTooltip, + props.datasource, + [], + props.onSelect, + ), + [props.onAddFilter, props.onSelect, props.datasource, setTooltip], + ); + + const loadSingleLayer = useCallback( + ( + subslice: JsonObject, + formData: QueryFormData, + payloadIndex: number, + ): void => { + const layerIndex = getLayerIndex( + subslice.slice_id, + payloadIndex, + formData.deck_slices, + ); + let extraFilters: (AdhocFilter | QueryObjectFilterClause)[] = []; + let adhocFilters: AdhocFilter[] = []; + const isExplore = (window.location.href || '').includes('explore'); + if (isExplore) { + // in explore all the filters are in the adhoc_filters + const adhocFiltersFromFormData = formData.adhoc_filters || []; + const finalAdhocFilters = adhocFiltersFromFormData + .map((filter: AdhocFilter & { layerFilterScope?: number[] }) => { + if (!isDefined(filter?.layerFilterScope)) { + return filter; + } + if ( + Array.isArray(filter.layerFilterScope) && + filter.layerFilterScope.length > 0 + ) { + if (filter.layerFilterScope.includes(-1)) { + return filter; + } + if (filter.layerFilterScope.includes(layerIndex)) { + return filter; + } + } + return undefined; + }) + .filter(filter => isDefined(filter)); + adhocFilters = finalAdhocFilters as AdhocFilter[]; + } else { + const { + extraFilters: processLayerFiltersResultExtraFilters, + adhocFilters: processLayerFiltersResultAdhocFilters, + } = processLayerFilters(subslice, formData, layerIndex); + extraFilters = processLayerFiltersResultExtraFilters; + adhocFilters = processLayerFiltersResultAdhocFilters; + } + + const subsliceCopy = { + ...subslice, + form_data: { + ...subslice.form_data, + extra_filters: extraFilters, + adhoc_filters: adhocFilters, + }, + } as any as JsonObject & { slice_id: number }; + + const url = getExploreLongUrl(subsliceCopy.form_data, 'json'); + + if (url) { + SupersetClient.get({ endpoint: url }) + .then(({ json }) => { + const layer = createLayerFromData(subsliceCopy, json); + setSubSlicesLayers(subSlicesLayers => ({ + ...subSlicesLayers, + [subsliceCopy.slice_id]: layer, + })); + }) + .catch(error => { + console.error( + `Error loading layer for slice ${subsliceCopy.slice_id}:`, + error, + ); + }); + } + }, + [getLayerIndex, processLayerFilters, createLayerFromData], + ); + const loadLayers = useCallback( - (formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => { + ( + formData: QueryFormData, + payload: JsonObject, + viewport?: Viewport, + ): void => { setViewport(getAdjustedViewport()); setSubSlicesLayers({}); + payload.data.slices.forEach( - (subslice: { slice_id: number } & JsonObject) => { - // Filters applied to multi_deck are passed down to underlying charts - // note that dashboard contextual information (filter_immune_slices and such) aren't - // taken into consideration here - const extra_filters = [ - ...(subslice.form_data.extra_filters || []), - ...(formData.extra_filters || []), - ...(formData.extra_form_data?.filters || []), - ]; - - const adhoc_filters = [ - ...(formData.adhoc_filters || []), - ...(subslice.formData?.adhoc_filters || []), - ...(formData.extra_form_data?.adhoc_filters || []), - ]; - - const subsliceCopy = { - ...subslice, - form_data: { - ...subslice.form_data, - extra_filters, - adhoc_filters, - }, - }; - - const url = getExploreLongUrl(subsliceCopy.form_data, 'json'); - - if (url) { - SupersetClient.get({ - endpoint: url, - }) - .then(({ json }) => { - // @ts-ignore TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators. - const layer = layerGenerators[subsliceCopy.form_data.viz_type]( - subsliceCopy.form_data, - json, - props.onAddFilter, - setTooltip, - props.datasource, - [], - props.onSelect, - ); - setSubSlicesLayers(subSlicesLayers => ({ - ...subSlicesLayers, - [subsliceCopy.slice_id]: layer, - })); - }) - .catch(() => {}); - } + (subslice: { slice_id: number } & JsonObject, payloadIndex: number) => { + loadSingleLayer(subslice, formData, payloadIndex); }, ); }, - [ - props.datasource, - props.onAddFilter, - props.onSelect, - setTooltip, - getAdjustedViewport, - ], + [getAdjustedViewport, loadSingleLayer], ); const prevDeckSlices = usePrevious(props.formData.deck_slices); diff --git a/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx b/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx index 27f649fb1fd..51655e057c2 100644 --- a/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx +++ b/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx @@ -29,6 +29,7 @@ import { } from 'src/utils/localStorageHelpers'; import { RootState } from 'src/dashboard/types'; import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; +import { getAllActiveFilters } from 'src/dashboard/util/activeAllDashboardFilters'; import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme'; import { Divider, Filter } from '@superset-ui/core'; @@ -65,8 +66,9 @@ const selectDashboardContextForExplore = createSelector( (state: RootState) => state.dashboardState?.colorScheme, (state: RootState) => state.nativeFilters?.filters, (state: RootState) => state.dataMask, + (state: RootState) => state.dashboardState?.sliceIds || [], ], - (metadata, dashboardId, colorScheme, filters, dataMask) => { + (metadata, dashboardId, colorScheme, filters, dataMask, sliceIds) => { const nativeFilters = Object.keys(filters).reduce< Record> >((acc, key) => { @@ -74,6 +76,13 @@ const selectDashboardContextForExplore = createSelector( return acc; }, {}); + const activeFilters = getAllActiveFilters({ + chartConfiguration: metadata?.chart_configuration || EMPTY_OBJECT, + nativeFilters: filters, + dataMask, + allSliceIds: sliceIds, + }); + return { labelsColor: metadata?.label_colors || EMPTY_OBJECT, labelsColorMap: metadata?.map_label_colors || EMPTY_OBJECT, @@ -86,6 +95,7 @@ const selectDashboardContextForExplore = createSelector( dataMask, dashboardId, filterBoxFilters: getActiveFilters(), + activeFilters, }; }, ); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index f69ff6fcefa..391a481f207 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -278,11 +278,11 @@ const FilterBar: FC = ({ const handleApply = useCallback(() => { dispatch(logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {})); - const filterIds = Object.keys(dataMaskSelected); setUpdateKey(1); - filterIds.forEach(filterId => { - if (dataMaskSelected[filterId]) { - dispatch(updateDataMask(filterId, dataMaskSelected[filterId])); + + Object.entries(dataMaskSelected).forEach(([filterId, dataMask]) => { + if (dataMask) { + dispatch(updateDataMask(filterId, dataMask)); } }); }, [dataMaskSelected, dispatch]); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx index 96a31d81704..72aa515e094 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx @@ -18,14 +18,22 @@ */ import { FC, useMemo, useState, memo } from 'react'; -import { NativeFilterScope } from '@superset-ui/core'; +import { NativeFilterScope, styled, css } from '@superset-ui/core'; import Tree from '@superset-ui/core/components/Tree'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; import { Tooltip } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; +import { Layout } from 'src/dashboard/types'; import { useFilterScopeTree } from './state'; import { findFilterScope, getTreeCheckedItems } from './utils'; +const StyledTree = styled(Tree)` + .ant-tree-title { + display: flex; + align-items: center; + } +`; + type ScopingTreeProps = { forceUpdate: Function; updateFormValues: (values: any) => void; @@ -40,8 +48,29 @@ const buildTreeLeafTitle = ( label: string, hasTooltip: boolean, tooltipTitle?: string, + isDeckMultiChart?: boolean, ) => { let title = {label}; + + if (isDeckMultiChart) { + title = ( + + + {label} + + ); + } + if (hasTooltip) { title = ( <> @@ -55,6 +84,68 @@ const buildTreeLeafTitle = ( return title; }; +const separateKeys = ( + checkedKeys: string[], +): { layerKeys: string[]; nonLayerKeys: string[] } => { + const layerKeys = checkedKeys.filter(key => key.includes('-layer-')); + const nonLayerKeys = checkedKeys.filter(key => !key.includes('-layer-')); + return { layerKeys, nonLayerKeys }; +}; + +const extractParentChartIds = (layerKeys: string[]): Set => { + const LAYER_KEY_REGEX = /^chart-(\d+)-layer-\d+$/; + const parentChartIds = new Set(); + + layerKeys.forEach(layerKey => { + const match = layerKey.match(LAYER_KEY_REGEX); + if (match) { + const chartId = parseInt(match[1], 10); + parentChartIds.add(chartId); + } + }); + return parentChartIds; +}; + +const updateScopeWithParentCharts = ( + scope: NativeFilterScope, + parentChartIds: Set, + nonLayerKeys: string[], + layout: Layout, +): NativeFilterScope => { + const updatedScope = { ...scope }; + parentChartIds.forEach(chartId => { + const chartLayoutKey = Object.keys(layout).find( + key => layout[key]?.meta?.chartId === chartId, + ); + if (chartLayoutKey && layout[chartLayoutKey]) { + const tempScope = findFilterScope( + [...nonLayerKeys, chartLayoutKey], + layout, + ); + updatedScope.rootPath = tempScope.rootPath; + updatedScope.excluded = tempScope.excluded; + } + }); + return updatedScope; +}; + +const createFormValues = ( + scope: NativeFilterScope, + layerKeys: string[], + chartId?: number, +): { scope: NativeFilterScope & { selectedLayers?: string[] } } => { + const finalScope = { ...scope }; + if (chartId !== undefined) { + finalScope.excluded = [...new Set([...finalScope.excluded, chartId])]; + } + return { + scope: + layerKeys.length > 0 + ? { ...finalScope, selectedLayers: layerKeys } + : finalScope, + }; +}; + const ScopingTree: FC = ({ formScope, initialScope, @@ -74,6 +165,7 @@ const ScopingTree: FC = ({ buildTreeLeafTitle, title, ); + const [autoExpandParent, setAutoExpandParent] = useState(true); const handleExpand = (expandedKeys: string[]) => { @@ -83,22 +175,30 @@ const ScopingTree: FC = ({ const handleCheck = (checkedKeys: string[]) => { forceUpdate(); - const scope = findFilterScope(checkedKeys, layout); - if (chartId !== undefined) { - scope.excluded = [...new Set([...scope.excluded, chartId])]; - } - updateFormValues({ + const { layerKeys, nonLayerKeys } = separateKeys(checkedKeys); + const scope = findFilterScope(nonLayerKeys, layout); + const parentChartIds = extractParentChartIds(layerKeys); + const updatedScope = updateScopeWithParentCharts( scope, - }); + parentChartIds, + nonLayerKeys, + layout, + ); + updateFormValues(createFormValues(updatedScope, layerKeys, chartId)); }; const checkedKeys = useMemo( - () => getTreeCheckedItems({ ...(formScope || initialScope) }, layout), + () => + getTreeCheckedItems( + { ...(formScope || initialScope) }, + layout, + ((formScope || initialScope) as any)?.selectedLayers, + ), [formScope, initialScope, layout], ); return ( - (({ charts }) => charts); + + const sliceEntities = useSelector(state => { + if (!state.sliceEntities) { + console.warn('sliceEntities not found in state'); + return {}; + } + return state.sliceEntities.slices || {}; + }); + const tree = { children: [], key: DASHBOARD_ROOT_ID, @@ -72,8 +81,16 @@ export function useFilterScopeTree( validNodes, initiallyExcludedCharts, buildTreeLeafTitle, + sliceEntities, ); - }, [layout, tree, charts, initiallyExcludedCharts, buildTreeLeafTitle]); + }, [ + layout, + tree, + charts, + initiallyExcludedCharts, + buildTreeLeafTitle, + sliceEntities, + ]); return { treeData: [tree], layout }; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts index c99c1d65e8b..fcb6e1d126d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts @@ -24,10 +24,14 @@ export type TreeItem = { children: TreeItem[]; key: string; title: ReactNode; + nodeType?: 'CHART' | 'TAB' | 'ROOT' | 'DECKGL_LAYER'; + chartId?: number; + layerType?: string; }; export type BuildTreeLeafTitle = ( label: string, hasTooltip: boolean, tooltipTitle?: string, + isDeckMultiChart?: boolean, ) => ReactNode; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts index 5e3079d3aee..0594e0fcc79 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Charts, Layout, LayoutItem } from 'src/dashboard/types'; +import { Charts, Layout, LayoutItem, Slice } from 'src/dashboard/types'; import { CHART_TYPE, DASHBOARD_ROOT_TYPE, @@ -37,6 +37,58 @@ export const getNodeTitle = (node: LayoutItem) => node?.id?.toString?.() ?? ''; +export const generateChartSubNodes = ( + chartId: number, + chart: { + form_data?: { + viz_type?: string; + deck_slices?: number[]; + }; + } & Partial>, + buildTreeLeafTitle: BuildTreeLeafTitle, + sliceEntities?: Record, +): TreeItem[] => { + const subNodes: TreeItem[] = []; + + if (chart?.form_data?.viz_type === 'deck_multi') { + const deckSliceIds = chart?.form_data?.deck_slices || []; + + deckSliceIds.forEach((sliceId: number, index: number) => { + let sliceName = `Slice ${sliceId}`; + + if (chart.queriesResponse?.[0]?.data?.slices) { + const slice = chart.queriesResponse[0].data.slices.find( + (s: { slice_id: number; slice_name?: string }) => + s.slice_id === sliceId, + ); + if (slice?.slice_name) { + sliceName = slice.slice_name; + } + } + + const sliceInfo = sliceEntities?.[sliceId]; + if (sliceInfo?.slice_name) { + sliceName = sliceInfo.slice_name; + } + + subNodes.push({ + key: `chart-${chartId}-layer-${index}`, + title: buildTreeLeafTitle( + sliceName, + false, + `Deck.gl layer: ${sliceName}`, + ), + children: [], + nodeType: 'DECKGL_LAYER', + chartId, + layerType: sliceInfo?.viz_type || 'deck_layer', + }); + }); + } + + return subNodes; +}; + export const buildTree = ( node: LayoutItem, treeItem: TreeItem, @@ -45,7 +97,12 @@ export const buildTree = ( validNodes: string[], initiallyExcludedCharts: number[], buildTreeLeafTitle: BuildTreeLeafTitle, + sliceEntities?: Record, ) => { + if (!node) { + return; + } + let itemToPass: TreeItem = treeItem; if ( node && @@ -60,34 +117,60 @@ export const buildTree = ( t( "This chart might be incompatible with the filter (datasets don't match)", ), + !!( + node.type === CHART_TYPE && + node.meta?.chartId && + charts && + charts[node.meta.chartId]?.form_data?.viz_type === 'deck_multi' + ), ); - const currentTreeItem = { + const currentTreeItem: TreeItem = { key: node.id, title, children: [], + nodeType: node.type === CHART_TYPE ? 'CHART' : 'TAB', + chartId: node.meta?.chartId, }; + + if (node.type === CHART_TYPE && node.meta?.chartId) { + const chart = charts?.[node.meta.chartId]; + if (chart) { + const subNodes = generateChartSubNodes( + node.meta.chartId, + chart, + buildTreeLeafTitle, + sliceEntities, + ); + currentTreeItem.children = subNodes; + } + } + treeItem.children.push(currentTreeItem); itemToPass = currentTreeItem; } - node?.children?.forEach?.(child => { - const node = layout?.[child]; - if (node) { - buildTree( - node, - itemToPass, - layout, - charts, - validNodes, - initiallyExcludedCharts, - buildTreeLeafTitle, - ); - } else { - logging.warn( - `Unable to find item with id: ${child} in the dashboard layout. This may indicate you have invalid references in your dashboard and the references to id: ${child} should be removed.`, - ); - } - }); + + if (node.type !== CHART_TYPE) { + node?.children?.forEach?.(child => { + const childNode = layout?.[child]; + if (childNode) { + buildTree( + childNode, + itemToPass, + layout, + charts, + validNodes, + initiallyExcludedCharts, + buildTreeLeafTitle, + sliceEntities, + ); + } else { + logging.warn( + `Unable to find item with id: ${child} in the dashboard layout. This may indicate you have invalid references in your dashboard and the references to id: ${child} should be removed.`, + ); + } + }); + } }; const addInvisibleParents = (layout: Layout, item: string) => [ @@ -102,7 +185,6 @@ const addInvisibleParents = (layout: Layout, item: string) => [ .map(({ id }) => id), ]; -// Generate checked options for Ant tree from redux scope const checkTreeItem = ( checkedItems: string[], layout: Layout, @@ -125,12 +207,39 @@ const checkTreeItem = ( }); }; +const LAYER_KEY_REGEX = /^chart-(\d+)-layer-\d+$/; + export const getTreeCheckedItems = ( scope: NativeFilterScope, layout: Layout, + selectedLayers?: string[], ) => { const checkedItems: string[] = []; checkTreeItem(checkedItems, layout, [...scope.rootPath], [...scope.excluded]); + + // If we have individual layer selections, exclude their parent charts from checkedItems + // to prevent Tree component from auto-checking all children + if (selectedLayers && selectedLayers.length > 0) { + const parentChartIds = new Set(); + selectedLayers.forEach(layerKey => { + const match = layerKey.match(LAYER_KEY_REGEX); + if (match) { + const chartId = parseInt(match[1], 10); + parentChartIds.add(chartId); + } + }); + + const filteredItems = checkedItems.filter(item => { + if (layout[item]?.type === CHART_TYPE) { + const chartId = layout[item]?.meta?.chartId; + return chartId ? !parentChartIds.has(chartId) : true; + } + return true; + }); + + return [...new Set([...filteredItems, ...selectedLayers])]; + } + return [...new Set(checkedItems)]; }; @@ -146,8 +255,32 @@ export const findFilterScope = ( }; } + const layerKeys = checkedKeys.filter(key => key.includes('-layer-')); + const chartKeys = checkedKeys.filter(key => { + if (layout[key]?.type === CHART_TYPE) { + return true; + } + if (key.includes('-layer-')) { + return false; + } + return true; + }); + + layerKeys.forEach(layerKey => { + const match = layerKey.match(LAYER_KEY_REGEX); + if (match) { + const chartId = parseInt(match[1], 10); + const chartLayoutKey = Object.keys(layout).find( + key => layout[key]?.meta?.chartId === chartId, + ); + if (chartLayoutKey && !chartKeys.includes(chartLayoutKey)) { + chartKeys.push(chartLayoutKey); + } + } + }); + // Get arrays of parents for selected charts - const checkedItemParents = checkedKeys + const checkedItemParents = chartKeys .filter(item => layout[item]?.type === CHART_TYPE) .map(key => { const parents = [DASHBOARD_ROOT_ID, ...(layout[key]?.parents || [])]; @@ -162,7 +295,7 @@ export const findFilterScope = ( const excluded: number[] = []; const isExcluded = (parent: string, item: string) => - rootPath.includes(parent) && !checkedKeys.includes(item); + rootPath.includes(parent) && !chartKeys.includes(item); // looking for charts to be excluded: iterate over all charts // and looking for charts that have one of their parents in `rootPath` and not in selected items Object.entries(layout).forEach(([key, value]) => { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts index c883ed04039..45bbe5b8269 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts @@ -192,3 +192,166 @@ export const getFilterValueForDisplay = ( } return t('Unknown value'); }; + +export interface FilterTarget { + type: 'CHART' | 'LAYER'; + chartId: string; + layerId?: string; +} + +export interface FilterScope { + filterId: string; + targets: FilterTarget[]; +} + +// Matches layer keys in format: 'chart-123-layer-456' where 123 is chartId and 456 is layerId +const LAYER_KEY_REGEX = /^chart-(\d+)-layer-(\d+)$/; +// Matches chart keys in format: 'chart-123' where 123 is chartId +const CHART_KEY_REGEX = /^chart-(\d+)$/; + +export function parseFilterTarget(scopeKey: string): FilterTarget | null { + const layerMatch = scopeKey.match(LAYER_KEY_REGEX); + if (layerMatch) { + return { + type: 'LAYER', + chartId: layerMatch[1], + layerId: layerMatch[2], + }; + } + + const chartMatch = scopeKey.match(CHART_KEY_REGEX); + if (chartMatch) { + return { + type: 'CHART', + chartId: chartMatch[1], + }; + } + + return null; +} + +export function getFilterScope( + filterId: string, + filterScopes: Record, +): FilterScope { + const scopeKeys = filterScopes[filterId] || []; + const targets: FilterTarget[] = []; + + scopeKeys.forEach(scopeKey => { + const target = parseFilterTarget(scopeKey); + if (target) { + targets.push(target); + } else { + console.warn(`Invalid filter scope key format: ${scopeKey}`); + } + }); + + return { + filterId, + targets, + }; +} + +export function aggregateFiltersForTarget( + dataMask: DataMaskStateWithId, + filterIds: string[], +): ExtraFormData { + let aggregatedFormData: ExtraFormData = {}; + + filterIds.forEach(filterId => { + const filterData = dataMask[filterId]; + if (filterData?.extraFormData) { + aggregatedFormData = mergeExtraFormData( + aggregatedFormData, + filterData.extraFormData, + ); + } + }); + + return aggregatedFormData; +} + +function createTargetKey(target: FilterTarget): string { + if (target.type === 'LAYER') { + return `${target.chartId}-${target.layerId}`; + } + return target.chartId; +} + +export function groupFiltersByTarget( + dataMask: DataMaskStateWithId, + filterScopes: Record, +): { + chartFilters: Map; + layerFilters: Map; +} { + const chartFilters = new Map(); + const layerFilters = new Map(); + + Object.keys(dataMask).forEach(filterId => { + const scope = getFilterScope(filterId, filterScopes); + + scope.targets.forEach(target => { + const filterData = dataMask[filterId]?.extraFormData || {}; + const targetKey = createTargetKey(target); + + if (target.type === 'CHART') { + const existing = chartFilters.get(targetKey) || {}; + chartFilters.set(targetKey, mergeExtraFormData(existing, filterData)); + } else if (target.type === 'LAYER') { + const existing = layerFilters.get(targetKey) || {}; + layerFilters.set(targetKey, mergeExtraFormData(existing, filterData)); + } + }); + }); + + return { chartFilters, layerFilters }; +} + +export function buildFilterScopesFromFilters( + filters: any, +): Record { + const filterScopes: Record = {}; + + Object.values(filters).forEach((filter: Filter) => { + if (filter.chartsInScope) { + filterScopes[filter.id] = filter.chartsInScope.map( + (chartId: number) => `chart-${chartId}`, + ); + } + }); + + return filterScopes; +} + +export function getLayerSpecificExtraFormData( + dataMask: DataMaskStateWithId, + filterIds: string[], + chartId: number, + layerId?: string, +): ExtraFormData { + let extraFormData: ExtraFormData = {}; + + filterIds.forEach(filterId => { + const filterData = dataMask[filterId]; + if (filterData?.extraFormData) { + extraFormData = mergeExtraFormData( + extraFormData, + filterData.extraFormData, + ); + } + }); + + if (layerId) { + const layerKey = `${chartId}-${layerId}`; + const layerFilterData = dataMask[layerKey]; + if (layerFilterData?.extraFormData) { + extraFormData = mergeExtraFormData( + extraFormData, + layerFilterData.extraFormData, + ); + } + } + + return extraFormData; +} diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 66fcf6418f8..43d67bd351f 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -39,6 +39,10 @@ import { ChartState } from '../explore/types'; export type { Dashboard } from 'src/types/Dashboard'; +export interface ExtendedNativeFilterScope extends NativeFilterScope { + selectedLayers?: string[]; +} + export type ChartReducerInitialState = typeof chart; // chart query built from initialState @@ -210,6 +214,9 @@ type ActiveFilter = { targets: number[] | [Partial]; scope: number[]; values: ExtraFormData; + layerScope?: { + [chartId: number]: number[]; + }; }; export type ActiveFilters = { diff --git a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts index 0d3ae30eb4a..bcc533780e8 100644 --- a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts +++ b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts @@ -16,27 +16,39 @@ * specific language governing permissions and limitations * under the License. */ -import { - DataMaskStateWithId, - PartialFilters, - JsonObject, - DataMaskWithId, -} from '@superset-ui/core'; +import { DataMaskStateWithId, PartialFilters } from '@superset-ui/core'; import { ActiveFilters, ChartConfiguration } from '../types'; export const getRelevantDataMask = ( dataMask: DataMaskStateWithId, - prop: string, -): JsonObject | DataMaskStateWithId => - Object.values(dataMask) - .filter(item => item[prop as keyof DataMaskWithId]) - .reduce( - (prev, next) => ({ - ...prev, - [next.id]: prop ? next[prop as keyof DataMaskWithId] : next, - }), - {}, - ); + filterId: string, +): DataMaskStateWithId => + dataMask[filterId] ? { [filterId]: dataMask[filterId] } : {}; + +interface LayerInfo { + layerMap: { [chartId: number]: number[] }; + chartIds: Set; +} + +const extractLayerIndicesFromKeys = (selectedLayers: string[]): LayerInfo => { + const layerMap: { [chartId: number]: number[] } = {}; + const chartIds = new Set(); + selectedLayers.forEach(layerKey => { + const match = layerKey.match(/^chart-(\d+)-layer-(\d+)$/); + if (match) { + const chartId = parseInt(match[1], 10); + const layerIndex = parseInt(match[2], 10); + if (!Number.isNaN(chartId)) { + if (!layerMap[chartId]) { + layerMap[chartId] = []; + } + layerMap[chartId].push(layerIndex); + chartIds.add(chartId); + } + } + }); + return { layerMap, chartIds }; +}; export const getAllActiveFilters = ({ chartConfiguration, @@ -51,23 +63,87 @@ export const getAllActiveFilters = ({ }): ActiveFilters => { const activeFilters: ActiveFilters = {}; - // Combine native filters with cross filters, because they have similar logic + const hasLayerSelectionsInAnyFilter = Object.values(dataMask).some( + ({ id: filterId }) => { + const selectedLayers = (nativeFilters?.[filterId]?.scope as any) + ?.selectedLayers; + return selectedLayers && selectedLayers.length > 0; + }, + ); + + let masterSelectedLayers: string[] = []; + let masterExcluded: number[] = []; + if (hasLayerSelectionsInAnyFilter) { + Object.values(dataMask).forEach(({ id: filterId }) => { + const selectedLayers = (nativeFilters?.[filterId]?.scope as any) + ?.selectedLayers; + const excluded = + (nativeFilters?.[filterId]?.scope as any)?.excluded || []; + if (selectedLayers && selectedLayers.length > 0) { + masterSelectedLayers = selectedLayers; + masterExcluded = excluded; + } + }); + } + Object.values(dataMask).forEach(({ id: filterId, extraFormData = {} }) => { - const scope = + let scope = nativeFilters?.[filterId]?.chartsInScope ?? chartConfiguration?.[parseInt(filterId, 10)]?.crossFilters ?.chartsInScope ?? allSliceIds ?? []; const filterType = nativeFilters?.[filterId]?.filterType; - const targets = nativeFilters?.[filterId]?.targets ?? scope; - // Iterate over all roots to find all affected charts + const targets = nativeFilters?.[filterId]?.targets; + + let selectedLayers = (nativeFilters?.[filterId]?.scope as any) + ?.selectedLayers; + let excludedCharts = + (nativeFilters?.[filterId]?.scope as any)?.excluded || []; + + if ( + hasLayerSelectionsInAnyFilter && + (!selectedLayers || selectedLayers.length === 0) + ) { + selectedLayers = masterSelectedLayers; + excludedCharts = masterExcluded; + } + + let layerScope; + if (selectedLayers && selectedLayers.length > 0) { + const layerInfo = extractLayerIndicesFromKeys(selectedLayers); + layerScope = layerInfo.layerMap; + + const explicitlyTargetedCharts = new Set(layerInfo.chartIds); + + const originalScope = scope; + originalScope.forEach((chartId: number) => { + if (!excludedCharts.includes(chartId)) { + const hasLayerSelections = selectedLayers.some((key: string) => + key.startsWith(`chart-${chartId}-layer-`), + ); + + if (!hasLayerSelections) { + explicitlyTargetedCharts.add(chartId); + } + } + }); + + scope = Array.from(explicitlyTargetedCharts); + } else { + scope = scope.filter( + (chartId: number) => !excludedCharts.includes(chartId), + ); + } + activeFilters[filterId] = { scope, - filterType, - targets, + targets: targets || [], values: extraFormData, + filterType, + ...(layerScope && { layerScope }), }; }); + return activeFilters; }; diff --git a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts index 56cc787b1dc..629bb74b95c 100644 --- a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts +++ b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts @@ -24,7 +24,11 @@ import { JsonObject, PartialFilters, } from '@superset-ui/core'; -import { ChartConfiguration, ChartQueryPayload } from 'src/dashboard/types'; +import { + ChartConfiguration, + ChartQueryPayload, + ActiveFilters, +} from 'src/dashboard/types'; import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils'; import { areObjectsEqual } from 'src/reduxUtils'; import { isEqual } from 'lodash'; @@ -45,14 +49,18 @@ interface CachedFormData { label_colors?: Record; shared_label_colors?: string[]; map_label_colors?: Record; + layer_filter_scope?: { + [filterId: string]: number[]; + }; + filter_data_mapping?: { + [filterId: string]: any[]; + }; } export type CachedFormDataWithExtraControls = CachedFormData & { [key: string]: any; }; -// We cache formData objects so that our connected container components don't always trigger -// render cascades. we cannot leverage the reselect library because our cache size is >1 const cachedFiltersByChart: Record = {}; const cachedFormdataByChart: Record< number, @@ -77,11 +85,25 @@ export interface GetFormDataWithExtraFiltersArguments { labelsColorMap?: Record; sharedLabelsColors?: string[]; allSliceIds: number[]; + activeFilters?: ActiveFilters; } -// this function merge chart's formData with dashboard filters value, -// and generate a new formData which will be used in the new query. -// filters param only contains those applicable to this chart. +const createFilterDataMapping = ( + dataMask: DataMaskStateWithId, + filterIdsAppliedOnChart: string[], +): { [filterId: string]: any[] } => { + const filterDataMapping: { [filterId: string]: any[] } = {}; + + filterIdsAppliedOnChart.forEach(filterId => { + const filterFormData = getExtraFormData(dataMask, [filterId]); + if (filterFormData.filters && filterFormData.filters.length > 0) { + filterDataMapping[filterId] = filterFormData.filters; + } + }); + + return filterDataMapping; +}; + export default function getFormDataWithExtraFilters({ chart, filters, @@ -97,8 +119,8 @@ export default function getFormDataWithExtraFilters({ labelsColorMap, sharedLabelsColors, allSliceIds, + activeFilters: passedActiveFilters, }: GetFormDataWithExtraFiltersArguments) { - // if dashboard metadata + filters have not changed, use cache if possible const cachedFormData = cachedFormdataByChart[sliceId]; if ( cachedFiltersByChart[sliceId] === filters && @@ -125,20 +147,62 @@ export default function getFormDataWithExtraFilters({ return cachedFormData; } - let extraData: { extra_form_data?: JsonObject } = {}; - const activeFilters = getAllActiveFilters({ - chartConfiguration, - dataMask, - nativeFilters, - allSliceIds, - }); + const activeFilters: ActiveFilters = + passedActiveFilters || + getAllActiveFilters({ + chartConfiguration, + nativeFilters, + dataMask, + allSliceIds, + }); + + let extraData: JsonObject = {}; const filterIdsAppliedOnChart = Object.entries(activeFilters) - .filter(([, { scope }]) => scope.includes(chart.id)) + .filter(([, activeFilter]) => activeFilter.scope.includes(chart.id)) .map(([filterId]) => filterId); + if (filterIdsAppliedOnChart.length) { + const aggregatedFormData = getExtraFormData( + dataMask, + filterIdsAppliedOnChart, + ); extraData = { - extra_form_data: getExtraFormData(dataMask, filterIdsAppliedOnChart), + extra_form_data: aggregatedFormData, }; + + const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi'; + const hasLayerScopeInActiveFilters = + passedActiveFilters && + Object.values(passedActiveFilters).some(filter => filter.layerScope); + + if (isDeckMultiChart || hasLayerScopeInActiveFilters) { + const filterDataMapping = createFilterDataMapping( + dataMask, + filterIdsAppliedOnChart, + ); + extraData.filter_data_mapping = filterDataMapping; + } + } + + let layerFilterScope: { [filterId: string]: number[] } | undefined; + + const isDeckMultiChart = chart.form_data?.viz_type === 'deck_multi'; + const hasLayerScopeInActiveFilters = + passedActiveFilters && + Object.values(passedActiveFilters).some(filter => filter.layerScope); + + if (isDeckMultiChart || hasLayerScopeInActiveFilters) { + layerFilterScope = {}; + + Object.entries(activeFilters).forEach(([filterId, activeFilter]) => { + if (activeFilter.layerScope?.[chart.id]) { + layerFilterScope![filterId] = activeFilter.layerScope[chart.id]; + } + }); + + if (Object.keys(layerFilterScope).length === 0) { + layerFilterScope = undefined; + } } const formData: CachedFormDataWithExtraControls = { @@ -154,6 +218,7 @@ export default function getFormDataWithExtraFilters({ extra_filters: getEffectiveExtraFilters(filters), ...extraData, ...extraControls, + ...(layerFilterScope && { layer_filter_scope: layerFilterScope }), }; cachedFiltersByChart[sliceId] = filters; diff --git a/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts b/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts index 5bd37f26bcb..f6b3c91ae41 100644 --- a/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts +++ b/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts @@ -20,12 +20,59 @@ import { NativeFilterScope } from '@superset-ui/core'; import { CHART_TYPE } from './componentTypes'; import { LayoutItem } from '../types'; +interface ExtendedNativeFilterScope extends NativeFilterScope { + selectedLayers?: string[]; +} + export function getChartIdsInFilterScope( - filterScope: NativeFilterScope, + filterScope: ExtendedNativeFilterScope, chartIds: number[], layoutItems: LayoutItem[], -) { - return chartIds.filter( +): number[] { + if (filterScope.selectedLayers && filterScope.selectedLayers.length > 0) { + const targetChartIds: number[] = []; + + filterScope.selectedLayers.forEach(selectionKey => { + const layerMatch = selectionKey.match(/^chart-(\d+)-layer-(\d+)$/); + if (layerMatch) { + const chartId = parseInt(layerMatch[1], 10); + if (chartIds.includes(chartId) && !targetChartIds.includes(chartId)) { + targetChartIds.push(chartId); + } + } + }); + const chartsWithLayerSelections = new Set(); + filterScope.selectedLayers.forEach(selectionKey => { + const layerMatch = selectionKey.match(/^chart-(\d+)-layer-(\d+)$/); + if (layerMatch) { + chartsWithLayerSelections.add(parseInt(layerMatch[1], 10)); + } + }); + + const regularChartIds = chartIds.filter( + chartId => + !filterScope.excluded.includes(chartId) && + !chartsWithLayerSelections.has(chartId) && + layoutItems + .find( + layoutItem => + layoutItem?.type === CHART_TYPE && + layoutItem.meta?.chartId === chartId, + ) + ?.parents?.some(elementId => + filterScope.rootPath.includes(elementId), + ), + ); + + regularChartIds.forEach(chartId => { + if (!targetChartIds.includes(chartId)) { + targetChartIds.push(chartId); + } + }); + return targetChartIds; + } + + const traditionalResult = chartIds.filter( chartId => !filterScope.excluded.includes(chartId) && layoutItems @@ -36,4 +83,6 @@ export function getChartIdsInFilterScope( ) ?.parents?.some(elementId => filterScope.rootPath.includes(elementId)), ); + + return traditionalResult; } diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 6f6df842b4f..13b67c5b997 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -769,7 +769,29 @@ function mapStateToProps(state) { const fieldsToOmit = hasQueryMode ? retainQueryModeRequirements(hiddenFormData) : Object.keys(hiddenFormData ?? {}); - const form_data = omit(getFormDataFromControls(controls), fieldsToOmit); + + const controlsBasedFormData = omit( + getFormDataFromControls(controls), + fieldsToOmit, + ); + const isDeckGLChart = explore.form_data?.viz_type === 'deck_multi'; + + const getDeckGLFormData = () => { + const formData = { ...controlsBasedFormData }; + + if (explore.form_data?.layer_filter_scope) { + formData.layer_filter_scope = explore.form_data.layer_filter_scope; + } + + if (explore.form_data?.filter_data_mapping) { + formData.filter_data_mapping = explore.form_data.filter_data_mapping; + } + + return formData; + }; + + const form_data = isDeckGLChart ? getDeckGLFormData() : controlsBasedFormData; + const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart form_data.extra_form_data = mergeExtraFormData( { ...form_data.extra_form_data }, diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js index 7c132365737..eeea251a39a 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js @@ -63,6 +63,8 @@ export default class AdhocFilter { this.isExtra = !!adhocFilter.isExtra; this.isNew = !!adhocFilter.isNew; this.datasourceWarning = !!adhocFilter.datasourceWarning; + this.deck_slices = adhocFilter?.deck_slices; + this.layerFilterScope = adhocFilter?.layerFilterScope; this.filterOptionName = adhocFilter.filterOptionName || diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx index 55a0515dd8f..112580bfbc7 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx @@ -18,9 +18,9 @@ */ import { createRef, Component } from 'react'; import PropTypes from 'prop-types'; -import { Button, Icons } from '@superset-ui/core/components'; +import { Button, Icons, Select } from '@superset-ui/core/components'; import { ErrorBoundary } from 'src/components'; -import { styled, t } from '@superset-ui/core'; +import { styled, t, SupersetClient } from '@superset-ui/core'; import Tabs from '@superset-ui/core/components/Tabs'; import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType'; @@ -32,6 +32,8 @@ import { POPOVER_INITIAL_HEIGHT, POPOVER_INITIAL_WIDTH, } from 'src/explore/constants'; +import rison from 'rison'; +import { isObject } from 'lodash'; import { ExpressionTypes } from '../types'; const propTypes = { @@ -86,6 +88,11 @@ const FilterActionsContainer = styled.div` margin-top: ${({ theme }) => theme.sizeUnit * 2}px; `; +const LayerSelectContainer = styled.div` + margin-top: ${({ theme }) => theme.sizeUnit * 2}px; + margin-bottom: ${({ theme }) => theme.sizeUnit * 12}px; +`; + export default class AdhocFilterEditPopover extends Component { constructor(props) { super(props); @@ -97,6 +104,8 @@ export default class AdhocFilterEditPopover extends Component { this.setSimpleTabIsValid = this.setSimpleTabIsValid.bind(this); this.adjustHeight = this.adjustHeight.bind(this); this.onTabChange = this.onTabChange.bind(this); + this.loadLayerOptions = this.loadLayerOptions.bind(this); + this.onLayerChange = this.onLayerChange.bind(this); this.state = { adhocFilter: this.props.adhocFilter, @@ -104,6 +113,9 @@ export default class AdhocFilterEditPopover extends Component { height: POPOVER_INITIAL_HEIGHT, activeKey: this.props?.adhocFilter?.expressionType || 'SIMPLE', isSimpleTabValid: true, + selectedLayers: [{ id: null, value: -1, label: 'All' }], + layerOptions: [], + hasLayerFilterScopeChanged: false, }; this.popoverContentRef = createRef(); @@ -111,6 +123,26 @@ export default class AdhocFilterEditPopover extends Component { componentDidMount() { document.addEventListener('mouseup', this.onMouseUp); + + // Load layer options if deck_slices exist + if ( + this.props.adhocFilter?.deck_slices && + this.props.adhocFilter.deck_slices.length > 0 + ) { + this.loadLayerOptions(0, 100).then(result => { + this.setState({ layerOptions: result.data }); + const layerFilterScope = this.props.adhocFilter?.layerFilterScope; + if (layerFilterScope) { + const selectedLayers = layerFilterScope.map(item => { + const layerOption = result.data.find( + option => option.value === item, + ); + return layerOption; + }); + this.setState({ selectedLayers }); + } + }); + } } componentWillUnmount() { @@ -127,7 +159,28 @@ export default class AdhocFilterEditPopover extends Component { } onSave() { - this.props.onChange(this.state.adhocFilter); + const hasDeckSlices = + this.state.adhocFilter.deck_slices && + this.state.adhocFilter.deck_slices.length > 0; + + if (!hasDeckSlices) { + this.props.onChange(this.state.adhocFilter); + this.props.onClose(); + return; + } + // Update layer filter scope for deck multi + const selectedLayers = this.state.selectedLayers.map(item => { + if (isObject(item)) { + return item.value; + } + return item; + }); + const correctedAdhocFilter = { + ...this.state.adhocFilter, + layerFilterScope: selectedLayers, + }; + this.setState({ hasLayerFilterScopeChanged: false }); + this.props.onChange(correctedAdhocFilter); this.props.onClose(); } @@ -167,6 +220,86 @@ export default class AdhocFilterEditPopover extends Component { this.setState(state => ({ height: state.height + heightDifference })); } + loadLayerOptions(page, pageSize) { + const query = rison.encode({ + columns: ['id', 'slice_name', 'viz_type'], + filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }], + page, + page_size: pageSize, + order_column: 'slice_name', + order_direction: 'asc', + }); + + return SupersetClient.get({ + endpoint: `/api/v1/chart/?q=${query}`, + }).then(response => { + if (!response?.json?.result) { + return { + data: [ + { + id: null, + value: -1, + label: 'All', + }, + ], + totalCount: 1, + }; + } + + const deckSlices = this.props.adhocFilter?.deck_slices || []; + + const list = [ + { + id: null, + value: -1, + label: 'All', + }, + ...response.json.result + .map(item => { + const sliceIndex = deckSlices.indexOf(item.id); + return { + id: item.id, + value: sliceIndex >= 0 ? sliceIndex : item.id, + label: item.slice_name, + sliceIndex, + }; + }) + .filter(item => item.sliceIndex !== -1) + .map(({ sliceIndex, ...item }) => item), + ]; + + return { + data: list, + totalCount: list.length, + }; + }); + } + + onLayerChange(selectedValue) { + let updatedSelectedLayers = selectedValue; + + if (!selectedValue || selectedValue.length === 0) { + updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }]; + } else if ( + selectedValue.length > 1 && + selectedValue.some(item => item.value === -1 || item === -1) + ) { + if ( + selectedValue[selectedValue.length - 1].value === -1 || + selectedValue[selectedValue.length - 1] === -1 + ) { + updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }]; + } else { + updatedSelectedLayers = selectedValue + .filter(item => item.value !== -1) + .filter(item => item !== -1); + } + } + + this.setState({ selectedLayers: updatedSelectedLayers }); + this.setState({ hasLayerFilterScopeChanged: true }); + } + render() { const { adhocFilter: propsAdhocFilter, @@ -182,10 +315,16 @@ export default class AdhocFilterEditPopover extends Component { ...popoverProps } = this.props; - const { adhocFilter } = this.state; + const { adhocFilter, selectedLayers, hasLayerFilterScopeChanged } = + this.state; const stateIsValid = adhocFilter.isValid(); const hasUnsavedChanges = - requireSave || !adhocFilter.equals(propsAdhocFilter); + requireSave || + !adhocFilter.equals(propsAdhocFilter) || + hasLayerFilterScopeChanged; + + const hasDeckSlices = + adhocFilter.deck_slices && adhocFilter.deck_slices.length > 0; return ( + {hasDeckSlices && ( + +