perf: Optimize native filters and cross filters (#31243)

This commit is contained in:
Kamil Gabryjelski 2024-12-02 15:42:34 +01:00 committed by GitHub
parent 5006f97f70
commit ce0e06a935
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 376 additions and 432 deletions

View File

@ -60,6 +60,7 @@ import {
ensureSyncedSharedLabelsColors,
ensureSyncedLabelsColorMap,
} from 'src/dashboard/actions/dashboardState';
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { getColorNamespace, resetColors } from 'src/utils/colorScheme';
import { NATIVE_FILTER_DIVIDER_PREFIX } from '../nativeFilters/FiltersConfigModal/utils';
import { findTabsWithChartsInScope } from '../nativeFilters/utils';
@ -160,14 +161,18 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
};
}
const chartLayoutItems = Object.values(dashboardLayout).filter(
item => item?.type === CHART_TYPE,
);
const chartsInScope: number[] = getChartIdsInFilterScope(
filterScope.scope,
chartIds,
dashboardLayout,
chartLayoutItems,
);
const tabsInScope = findTabsWithChartsInScope(
dashboardLayout,
chartLayoutItems,
chartsInScope,
);
return {

View File

@ -39,6 +39,7 @@ import {
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import Badge from 'src/components/Badge';
import DetailsPanelPopover from './DetailsPanel';
import {
@ -47,7 +48,7 @@ import {
selectIndicatorsForChart,
selectNativeIndicatorsForChart,
} from '../nativeFilters/selectors';
import { Chart, DashboardLayout, RootState } from '../../types';
import { Chart, RootState } from '../../types';
export interface FiltersBadgeProps {
chartId: number;
@ -126,9 +127,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
state => state.dashboardInfo.metadata?.chart_configuration,
);
const chart = useSelector<RootState, Chart>(state => state.charts[chartId]);
const present = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const chartLayoutItems = useChartLayoutItems();
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
@ -207,7 +206,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
]);
const prevNativeFilters = usePrevious(nativeFilters);
const prevDashboardLayout = usePrevious(present);
const prevChartLayoutItems = usePrevious(chartLayoutItems);
const prevDataMask = usePrevious(dataMask);
const prevChartConfig = usePrevious(chartConfiguration);
@ -221,7 +220,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
chart?.queriesResponse?.[0]?.applied_filters !==
prevChart?.queriesResponse?.[0]?.applied_filters ||
nativeFilters !== prevNativeFilters ||
present !== prevDashboardLayout ||
chartLayoutItems !== prevChartLayoutItems ||
dataMask !== prevDataMask ||
prevChartConfig !== chartConfiguration
) {
@ -231,7 +230,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
dataMask,
chartId,
chart,
present,
chartLayoutItems,
chartConfiguration,
),
);
@ -244,14 +243,14 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
dataMask,
nativeFilters,
nativeIndicators.length,
present,
prevChart?.queriesResponse,
prevChartConfig,
prevChartStatus,
prevDashboardLayout,
prevDataMask,
prevNativeFilters,
showIndicators,
chartLayoutItems,
prevChartLayoutItems,
]);
const indicators = useMemo(

View File

@ -39,6 +39,9 @@ const INITIAL_STATE = {
3: { id: 3 },
4: { id: 4 },
},
dashboardState: {
sliceIds: [1, 2, 3, 4],
},
dashboardInfo: {
id: 1,
metadata: {

View File

@ -22,7 +22,6 @@ import { isDefined, NativeFilterScope, t } from '@superset-ui/core';
import Modal from 'src/components/Modal';
import {
ChartConfiguration,
Layout,
RootState,
isCrossFilterScopeGlobal,
GlobalChartCrossFilterConfig,
@ -32,6 +31,7 @@ import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilter
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { saveChartConfiguration } from 'src/dashboard/actions/dashboardInfo';
import { DEFAULT_CROSS_FILTER_SCOPING } from 'src/dashboard/constants';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { ScopingModalContent } from './ScopingModalContent';
import { NEW_CHART_SCOPING_ID } from './constants';
@ -76,9 +76,7 @@ export const ScopingModal = ({
closeModal,
}: ScopingModalProps) => {
const dispatch = useDispatch();
const layout = useSelector<RootState, Layout>(
state => state.dashboardLayout.present,
);
const chartLayoutItems = useChartLayoutItems();
const chartIds = useChartIds();
const [currentChartId, setCurrentChartId] = useState(initialChartId);
const initialChartConfig = useSelector<RootState, ChartConfiguration>(
@ -154,7 +152,11 @@ export const ScopingModal = ({
id: currentChartId,
crossFilters: {
scope,
chartsInScope: getChartIdsInFilterScope(scope, chartIds, layout),
chartsInScope: getChartIdsInFilterScope(
scope,
chartIds,
chartLayoutItems,
),
},
},
}));
@ -162,7 +164,7 @@ export const ScopingModal = ({
const globalChartsInScope = getChartIdsInFilterScope(
scope,
chartIds,
layout,
chartLayoutItems,
);
setGlobalChartConfig({
scope,
@ -176,7 +178,7 @@ export const ScopingModal = ({
);
}
},
[currentChartId, chartIds, layout],
[currentChartId, chartIds, chartLayoutItems],
);
const removeCustomScope = useCallback(
@ -241,7 +243,11 @@ export const ScopingModal = ({
id: newChartId,
crossFilters: {
scope: newScope,
chartsInScope: getChartIdsInFilterScope(newScope, chartIds, layout),
chartsInScope: getChartIdsInFilterScope(
newScope,
chartIds,
chartLayoutItems,
),
},
};
@ -275,7 +281,7 @@ export const ScopingModal = ({
currentChartId,
globalChartConfig.chartsInScope,
globalChartConfig.scope,
layout,
chartLayoutItems,
],
);

View File

@ -17,9 +17,11 @@
* under the License.
*/
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
import { DataMaskStateWithId } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { DashboardLayout, RootState } from 'src/dashboard/types';
import { RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import crossFiltersSelector from './selectors';
import VerticalCollapse from './VerticalCollapse';
import { useChartsVerboseMaps } from '../utils';
@ -28,17 +30,13 @@ const CrossFiltersVertical = () => {
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const chartConfiguration = useSelector<RootState, JsonObject>(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const verboseMaps = useChartsVerboseMaps();
const selectedCrossFilters = crossFiltersSelector({
dataMask,
chartConfiguration,
dashboardLayout,
chartIds,
chartLayoutItems,
verboseMaps,
});

View File

@ -21,36 +21,37 @@ import {
DataMaskStateWithId,
getColumnLabel,
isDefined,
JsonObject,
} from '@superset-ui/core';
import { DashboardLayout } from 'src/dashboard/types';
import { LayoutItem } from 'src/dashboard/types';
import { CrossFilterIndicator, getCrossFilterIndicator } from '../../selectors';
export const crossFiltersSelector = (props: {
dataMask: DataMaskStateWithId;
chartConfiguration: JsonObject;
dashboardLayout: DashboardLayout;
chartIds: number[];
chartLayoutItems: LayoutItem[];
verboseMaps: { [key: string]: Record<string, string> };
}): CrossFilterIndicator[] => {
const { dataMask, chartConfiguration, dashboardLayout, verboseMaps } = props;
const chartsIds = Object.keys(chartConfiguration || {});
const { dataMask, chartIds, chartLayoutItems, verboseMaps } = props;
return chartsIds
return chartIds
.map(chartId => {
const id = Number(chartId);
const filterIndicator = getCrossFilterIndicator(
id,
dataMask[id],
dashboardLayout,
chartId,
dataMask[chartId],
chartLayoutItems,
);
if (
isDefined(filterIndicator.column) &&
isDefined(filterIndicator.value)
) {
const verboseColName =
verboseMaps[id]?.[getColumnLabel(filterIndicator.column)] ||
verboseMaps[chartId]?.[getColumnLabel(filterIndicator.column)] ||
filterIndicator.column;
return { ...filterIndicator, column: verboseColName, emitterId: id };
return {
...filterIndicator,
column: verboseColName,
emitterId: chartId,
};
}
return null;
})

View File

@ -37,7 +37,6 @@ import {
isFeatureEnabled,
FeatureFlag,
isNativeFilterWithDataMask,
JsonObject,
} from '@superset-ui/core';
import {
createHtmlPortalNode,
@ -49,15 +48,13 @@ import {
useDashboardHasTabs,
useSelectFiltersInScope,
} from 'src/dashboard/components/nativeFilters/state';
import {
DashboardLayout,
FilterBarOrientation,
RootState,
} from 'src/dashboard/types';
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import DropdownContainer, {
Ref as DropdownContainerRef,
} from 'src/components/DropdownContainer';
import Icons from 'src/components/Icons';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
import { useFilterControlFactory } from '../useFilterControlFactory';
import { FiltersDropdownContent } from '../FiltersDropdownContent';
@ -65,12 +62,15 @@ import crossFiltersSelector from '../CrossFilters/selectors';
import CrossFilter from '../CrossFilters/CrossFilter';
import { useFilterOutlined } from '../useFilterOutlined';
import { useChartsVerboseMaps } from '../utils';
import { CrossFilterIndicator } from '../../selectors';
type FilterControlsProps = {
dataMaskSelected: DataMaskStateWithId;
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
};
const EMPTY_ARRAY: CrossFilterIndicator[] = [];
const FilterControls: FC<FilterControlsProps> = ({
dataMaskSelected,
onFilterSelectionChange,
@ -90,12 +90,8 @@ const FilterControls: FC<FilterControlsProps> = ({
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const chartConfiguration = useSelector<RootState, JsonObject>(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const verboseMaps = useChartsVerboseMaps();
const isCrossFiltersEnabled = isFeatureEnabled(
@ -106,12 +102,12 @@ const FilterControls: FC<FilterControlsProps> = ({
isCrossFiltersEnabled
? crossFiltersSelector({
dataMask,
chartConfiguration,
dashboardLayout,
chartIds,
chartLayoutItems,
verboseMaps,
})
: [],
[chartConfiguration, dashboardLayout, dataMask, isCrossFiltersEnabled],
: EMPTY_ARRAY,
[chartIds, chartLayoutItems, dataMask, isCrossFiltersEnabled, verboseMaps],
);
const { filterControlFactory, filtersWithValues } = useFilterControlFactory(
dataMaskSelected,
@ -154,18 +150,27 @@ const FilterControls: FC<FilterControlsProps> = ({
[filtersWithValues, portalNodes],
);
const renderVerticalContent = () => (
<>
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope}
forceRender={hasRequiredFirst}
hasTopMargin={filtersInScope.length > 0}
renderer={renderer}
/>
)}
</>
const renderVerticalContent = useCallback(
() => (
<>
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope}
forceRender={hasRequiredFirst}
hasTopMargin={filtersInScope.length > 0}
renderer={renderer}
/>
)}
</>
),
[
filtersInScope,
renderer,
showCollapsePanel,
filtersOutOfScope,
hasRequiredFirst,
],
);
const overflowedFiltersInScope = useMemo(
@ -230,70 +235,84 @@ const FilterControls: FC<FilterControlsProps> = ({
return [...crossFilters, ...nativeFiltersInScope];
}, [filtersInScope, renderer, rendererCrossFilter, selectedCrossFilters]);
const renderHorizontalContent = () => (
<div
css={(theme: SupersetTheme) => css`
padding: 0 ${theme.gridUnit * 4}px;
min-width: 0;
flex: 1;
`}
>
<DropdownContainer
items={items}
dropdownTriggerIcon={
<Icons.FilterSmall
css={css`
&& {
margin-right: -4px;
display: flex;
}
`}
/>
}
dropdownTriggerText={t('More filters')}
dropdownTriggerCount={activeOverflowedFiltersInScope.length}
dropdownTriggerTooltip={
activeOverflowedFiltersInScope.length === 0
? t('No applied filters')
: t(
'Applied filters: %s',
activeOverflowedFiltersInScope
.map(filter => filter.name)
.join(', '),
)
}
dropdownContent={
overflowedFiltersInScope.length ||
overflowedCrossFilters.length ||
(filtersOutOfScope.length && showCollapsePanel)
? () => (
<FiltersDropdownContent
overflowedCrossFilters={overflowedCrossFilters}
filtersInScope={overflowedFiltersInScope}
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
rendererCrossFilter={rendererCrossFilter}
showCollapsePanel={showCollapsePanel}
forceRenderOutOfScope={hasRequiredFirst}
/>
)
: undefined
}
forceRender={hasRequiredFirst}
ref={popoverRef}
onOverflowingStateChange={({ overflowed: nextOverflowedIds }) => {
if (
nextOverflowedIds.length !== overflowedIds.length ||
overflowedIds.reduce(
(a, b, i) => a || b !== nextOverflowedIds[i],
false,
)
) {
setOverflowedIds(nextOverflowedIds);
const renderHorizontalContent = useCallback(
() => (
<div
css={(theme: SupersetTheme) => css`
padding: 0 ${theme.gridUnit * 4}px;
min-width: 0;
flex: 1;
`}
>
<DropdownContainer
items={items}
dropdownTriggerIcon={
<Icons.FilterSmall
css={css`
&& {
margin-right: -4px;
display: flex;
}
`}
/>
}
}}
/>
</div>
dropdownTriggerText={t('More filters')}
dropdownTriggerCount={activeOverflowedFiltersInScope.length}
dropdownTriggerTooltip={
activeOverflowedFiltersInScope.length === 0
? t('No applied filters')
: t(
'Applied filters: %s',
activeOverflowedFiltersInScope
.map(filter => filter.name)
.join(', '),
)
}
dropdownContent={
overflowedFiltersInScope.length ||
overflowedCrossFilters.length ||
(filtersOutOfScope.length && showCollapsePanel)
? () => (
<FiltersDropdownContent
overflowedCrossFilters={overflowedCrossFilters}
filtersInScope={overflowedFiltersInScope}
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
rendererCrossFilter={rendererCrossFilter}
showCollapsePanel={showCollapsePanel}
forceRenderOutOfScope={hasRequiredFirst}
/>
)
: undefined
}
forceRender={hasRequiredFirst}
ref={popoverRef}
onOverflowingStateChange={({ overflowed: nextOverflowedIds }) => {
if (
nextOverflowedIds.length !== overflowedIds.length ||
overflowedIds.reduce(
(a, b, i) => a || b !== nextOverflowedIds[i],
false,
)
) {
setOverflowedIds(nextOverflowedIds);
}
}}
/>
</div>
),
[
items,
activeOverflowedFiltersInScope,
overflowedFiltersInScope,
overflowedCrossFilters,
filtersOutOfScope,
showCollapsePanel,
renderer,
rendererCrossFilter,
hasRequiredFirst,
overflowedIds,
],
);
const overflowedByIndex = useMemo(() => {

View File

@ -221,7 +221,7 @@ const FilterValue: FC<FilterControlProps> = ({
datasetId,
groupby,
handleFilterLoadFinish,
JSON.stringify(filter),
filter,
hasDataSource,
isRefreshing,
shouldRefresh,

View File

@ -17,18 +17,19 @@
* under the License.
*/
import { FC, memo } from 'react';
import { FC, memo, useMemo } from 'react';
import {
DataMaskStateWithId,
FeatureFlag,
isFeatureEnabled,
JsonObject,
styled,
t,
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Loading from 'src/components/Loading';
import { DashboardLayout, RootState } from 'src/dashboard/types';
import { RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useSelector } from 'react-redux';
import FilterControls from './FilterControls/FilterControls';
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
@ -36,6 +37,7 @@ import { HorizontalBarProps } from './types';
import FilterBarSettings from './FilterBarSettings';
import FilterConfigurationLink from './FilterConfigurationLink';
import crossFiltersSelector from './CrossFilters/selectors';
import { CrossFilterIndicator } from '../selectors';
const HorizontalBar = styled.div`
${({ theme }) => `
@ -96,6 +98,7 @@ const FiltersLinkContainer = styled.div<{ hasFilters: boolean }>`
`}
`;
const EMPTY_ARRAY: CrossFilterIndicator[] = [];
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
actions,
canEdit,
@ -108,25 +111,26 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const chartConfiguration = useSelector<RootState, JsonObject>(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const isCrossFiltersEnabled = isFeatureEnabled(
FeatureFlag.DashboardCrossFilters,
);
const verboseMaps = useChartsVerboseMaps();
const selectedCrossFilters = isCrossFiltersEnabled
? crossFiltersSelector({
dataMask,
chartConfiguration,
dashboardLayout,
verboseMaps,
})
: [];
const selectedCrossFilters = useMemo(
() =>
isCrossFiltersEnabled
? crossFiltersSelector({
dataMask,
chartIds,
chartLayoutItems,
verboseMaps,
})
: EMPTY_ARRAY,
[chartIds, chartLayoutItems, dataMask, isCrossFiltersEnabled, verboseMaps],
);
const hasFilters = filterValues.length > 0 || selectedCrossFilters.length > 0;
return (

View File

@ -24,8 +24,8 @@ import {
useEffect,
useState,
useCallback,
createContext,
useRef,
useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
@ -129,7 +129,6 @@ const publishDataMask = debounce(
SLOW_DEBOUNCE,
);
export const FilterBarScrollContext = createContext(false);
const FilterBar: FC<FiltersBarProps> = ({
orientation = FilterBarOrientation.Vertical,
verticalConfig,
@ -144,8 +143,11 @@ const FilterBar: FC<FiltersBarProps> = ({
const tabId = useTabId();
const filters = useFilters();
const previousFilters = usePrevious(filters);
const filterValues = Object.values(filters);
const nativeFilterValues = filterValues.filter(isNativeFilter);
const filterValues = useMemo(() => Object.values(filters), [filters]);
const nativeFilterValues = useMemo(
() => filterValues.filter(isNativeFilter),
[filterValues],
);
const dashboardId = useSelector<any, number>(
({ dashboardInfo }) => dashboardInfo?.id,
);
@ -212,14 +214,9 @@ const FilterBar: FC<FiltersBarProps> = ({
if (!isEmpty(updates)) {
setDataMaskSelected(draft => ({ ...draft, ...updates }));
Object.keys(updates).forEach(key => dispatch(clearDataMask(key)));
}
}
}, [
JSON.stringify(filters),
JSON.stringify(previousFilters),
previousDashboardId,
]);
}, [dashboardId, filters, previousDashboardId, setDataMaskSelected]);
const dataMaskAppliedText = JSON.stringify(dataMaskApplied);
@ -276,16 +273,27 @@ const FilterBar: FC<FiltersBarProps> = ({
);
const isInitialized = useInitialization();
const actions = (
<ActionButtons
filterBarOrientation={orientation}
width={verticalConfig?.width}
onApply={handleApply}
onClearAll={handleClearAll}
dataMaskSelected={dataMaskSelected}
dataMaskApplied={dataMaskApplied}
isApplyDisabled={isApplyDisabled}
/>
const actions = useMemo(
() => (
<ActionButtons
filterBarOrientation={orientation}
width={verticalConfig?.width}
onApply={handleApply}
onClearAll={handleClearAll}
dataMaskSelected={dataMaskSelected}
dataMaskApplied={dataMaskApplied}
isApplyDisabled={isApplyDisabled}
/>
),
[
orientation,
verticalConfig?.width,
handleApply,
handleClearAll,
dataMaskSelected,
dataMaskAppliedText,
isApplyDisabled,
],
);
const filterBarComponent =

View File

@ -18,17 +18,26 @@
*/
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'src/dashboard/types';
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
const filterOutlinedSelector = createSelector(
[
(state: RootState) => state.dashboardState.directPathToChild,
(state: RootState) => state.dashboardState.directPathLastUpdated,
],
(directPathToChild, directPathLastUpdated) => ({
outlinedFilterId: (
getChartAndLabelComponentIdFromPath(directPathToChild || []) as Record<
string,
string
>
)?.native_filter,
lastUpdated: directPathLastUpdated,
}),
);
export const useFilterOutlined = () =>
useSelector<RootState, { outlinedFilterId: string; lastUpdated: number }>(
state => ({
outlinedFilterId: (
getChartAndLabelComponentIdFromPath(
state.dashboardState.directPathToChild || [],
) as Record<string, string>
)?.native_filter,
lastUpdated: state.dashboardState.directPathLastUpdated,
}),
filterOutlinedSelector,
);

View File

@ -22,6 +22,7 @@ import { DataMaskStateWithId, Filter, FilterState } from '@superset-ui/core';
import { testWithId } from 'src/utils/testUtils';
import { RootState } from 'src/dashboard/types';
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
export const getOnlyExtraFormData = (data: DataMaskStateWithId) =>
Object.values(data).reduce(
@ -64,20 +65,26 @@ export const checkIsApplyDisabled = (
);
};
const chartsVerboseMapSelector = createSelector(
[
(state: RootState) => state.sliceEntities.slices,
(state: RootState) => state.datasources,
],
(slices, datasources) =>
Object.keys(slices).reduce((chartsVerboseMaps, chartId) => {
const chartDatasource = slices[chartId]?.datasource
? datasources[slices[chartId].datasource]
: undefined;
return {
...chartsVerboseMaps,
[chartId]: chartDatasource ? chartDatasource.verbose_map : {},
};
}, {}),
);
export const useChartsVerboseMaps = () =>
useSelector<RootState, { [chartId: string]: Record<string, string> }>(
state => {
const { charts, datasources } = state;
return Object.keys(state.charts).reduce((chartsVerboseMaps, chartId) => {
const chartDatasource =
datasources[charts[chartId]?.form_data?.datasource];
return {
...chartsVerboseMaps,
[chartId]: chartDatasource ? chartDatasource.verbose_map : {},
};
}, {});
},
chartsVerboseMapSelector,
);
export const FILTER_BAR_TEST_ID = 'filter-bar';

View File

@ -86,6 +86,9 @@ const baseInitialState = {
id: 3,
},
},
dashboardState: {
sliceIds: [1, 2, 3],
},
dashboardLayout: {
past: [],
future: [],

View File

@ -32,11 +32,7 @@ import {
} from '@superset-ui/core';
import { TIME_FILTER_MAP } from 'src/explore/constants';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import {
ChartConfiguration,
DashboardLayout,
Layout,
} from 'src/dashboard/types';
import { ChartConfiguration, LayoutItem } from 'src/dashboard/types';
import { areObjectsEqual } from 'src/reduxUtils';
export enum IndicatorStatus {
@ -170,7 +166,7 @@ export type CrossFilterIndicator = Indicator & { emitterId: number };
export const getCrossFilterIndicator = (
chartId: number,
dataMask: DataMask,
dashboardLayout: DashboardLayout,
chartLayoutItems: LayoutItem[],
) => {
const filterState = dataMask?.filterState;
const filters = dataMask?.extraFormData?.filters;
@ -179,19 +175,17 @@ export const getCrossFilterIndicator = (
const column =
filters?.[0]?.col || (filtersState && Object.keys(filtersState)[0]);
const dashboardLayoutItem = Object.values(dashboardLayout).find(
const chartLayoutItem = chartLayoutItems.find(
layoutItem => layoutItem?.meta?.chartId === chartId,
);
const filterObject: Indicator = {
column,
name:
dashboardLayoutItem?.meta?.sliceNameOverride ||
dashboardLayoutItem?.meta?.sliceName ||
chartLayoutItem?.meta?.sliceNameOverride ||
chartLayoutItem?.meta?.sliceName ||
'',
path: [
...(dashboardLayoutItem?.parents ?? []),
dashboardLayoutItem?.id || '',
],
path: [...(chartLayoutItem?.parents ?? []), chartLayoutItem?.id || ''],
value: label,
};
return filterObject;
@ -288,7 +282,7 @@ const defaultChartConfig = {};
export const selectChartCrossFilters = (
dataMask: DataMaskStateWithId,
chartId: number,
dashboardLayout: Layout,
chartLayoutItems: LayoutItem[],
chartConfiguration: ChartConfiguration = defaultChartConfig,
appliedColumns: Set<string>,
rejectedColumns: Set<string>,
@ -312,7 +306,7 @@ export const selectChartCrossFilters = (
const filterIndicator = getCrossFilterIndicator(
Number(chartConfig.id),
dataMask[chartConfig.id],
dashboardLayout,
chartLayoutItems,
);
const filterStatus = getStatus({
label: filterIndicator.value,
@ -339,7 +333,7 @@ export const selectNativeIndicatorsForChart = (
dataMask: DataMaskStateWithId,
chartId: number,
chart: any,
dashboardLayout: Layout,
chartLayoutItems: LayoutItem[],
chartConfiguration: ChartConfiguration = defaultChartConfig,
): Indicator[] => {
const appliedColumns = getAppliedColumns(chart);
@ -351,7 +345,7 @@ export const selectNativeIndicatorsForChart = (
areObjectsEqual(cachedFilterData?.appliedColumns, appliedColumns) &&
areObjectsEqual(cachedFilterData?.rejectedColumns, rejectedColumns) &&
cachedFilterData?.nativeFilters === nativeFilters &&
cachedFilterData?.dashboardLayout === dashboardLayout &&
cachedFilterData?.chartLayoutItems === chartLayoutItems &&
cachedFilterData?.chartConfiguration === chartConfiguration &&
cachedFilterData?.dataMask === dataMask
) {
@ -389,7 +383,7 @@ export const selectNativeIndicatorsForChart = (
crossFilterIndicators = selectChartCrossFilters(
dataMask,
chartId,
dashboardLayout,
chartLayoutItems,
chartConfiguration,
appliedColumns,
rejectedColumns,
@ -399,7 +393,7 @@ export const selectNativeIndicatorsForChart = (
cachedNativeIndicatorsForChart[chartId] = indicators;
cachedNativeFilterDataForChart[chartId] = {
nativeFilters,
dashboardLayout,
chartLayoutItems,
chartConfiguration,
dataMask,
appliedColumns,

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import {
Filter,
FilterConfiguration,
@ -25,7 +25,7 @@ import {
isFilterDivider,
} from '@superset-ui/core';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { TAB_TYPE } from '../../util/componentTypes';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
const defaultFilterConfiguration: Filter[] = [];
@ -79,34 +79,45 @@ function useActiveDashboardTabs() {
function useSelectChartTabParents() {
const dashboardLayout = useDashboardLayout();
return (chartId: number) => {
const chartLayoutItem = Object.values(dashboardLayout).find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
);
};
const layoutChartItems = useMemo(
() =>
Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
[dashboardLayout],
);
return useCallback(
(chartId: number) => {
const chartLayoutItem = layoutChartItems.find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
);
},
[dashboardLayout, layoutChartItems],
);
}
export function useIsFilterInScope() {
const activeTabs = useActiveDashboardTabs();
const selectChartTabParents = useSelectChartTabParents();
// Filter is in scope if any of it's charts is visible.
// Filter is in scope if any of its charts is visible.
// Chart is visible if it's placed in an active tab tree or if it's not attached to any tab.
// Chart is in an active tab tree if all of it's ancestors of type TAB are active
// Chart is in an active tab tree if all of its ancestors of type TAB are active
// Dividers are always in scope
return (filter: Filter | Divider) =>
isFilterDivider(filter) ||
('chartsInScope' in filter &&
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
}));
return useCallback(
(filter: Filter | Divider) =>
isFilterDivider(filter) ||
('chartsInScope' in filter &&
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
})),
[selectChartTabParents, activeTabs],
);
}
export function useSelectFiltersInScope(filters: (Filter | Divider)[]) {

View File

@ -19,6 +19,7 @@
import { Behavior, FeatureFlag } from '@superset-ui/core';
import * as uiCore from '@superset-ui/core';
import { DashboardLayout } from 'src/dashboard/types';
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { nativeFilterGate, findTabsWithChartsInScope } from './utils';
let isFeatureEnabledMock: jest.MockInstance<boolean, [feature: FeatureFlag]>;
@ -119,7 +120,10 @@ test('findTabsWithChartsInScope should handle a recursive layout structure', ()
},
} as any as DashboardLayout;
expect(Array.from(findTabsWithChartsInScope(dashboardLayout, []))).toEqual(
const chartLayoutItems = Object.values(dashboardLayout).filter(
item => item.type === CHART_TYPE,
);
expect(Array.from(findTabsWithChartsInScope(chartLayoutItems, []))).toEqual(
[],
);
});

View File

@ -30,10 +30,9 @@ import {
QueryFormData,
t,
} from '@superset-ui/core';
import { DashboardLayout } from 'src/dashboard/types';
import { LayoutItem } from 'src/dashboard/types';
import extractUrlParams from 'src/dashboard/util/extractUrlParams';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
import { TAB_TYPE } from '../../util/componentTypes';
import getBootstrapData from '../../../utils/getBootstrapData';
const getDefaultRowLimit = (): number => {
@ -156,84 +155,20 @@ export function nativeFilterGate(behaviors: Behavior[]): boolean {
);
}
const isComponentATab = (
dashboardLayout: DashboardLayout,
componentId: string,
) => dashboardLayout?.[componentId]?.type === TAB_TYPE;
const findTabsWithChartsInScopeHelper = (
dashboardLayout: DashboardLayout,
chartsInScope: number[],
componentId: string,
tabIds: string[],
tabsToHighlight: Set<string>,
visited: Set<string>,
) => {
if (visited.has(componentId)) {
return;
}
visited.add(componentId);
if (
dashboardLayout?.[componentId]?.type === CHART_TYPE &&
chartsInScope.includes(dashboardLayout[componentId]?.meta?.chartId)
) {
tabIds.forEach(tabsToHighlight.add, tabsToHighlight);
}
if (
dashboardLayout?.[componentId]?.children?.length === 0 ||
(isComponentATab(dashboardLayout, componentId) &&
tabsToHighlight.has(componentId))
) {
return;
}
dashboardLayout[componentId]?.children.forEach(childId =>
findTabsWithChartsInScopeHelper(
dashboardLayout,
chartsInScope,
childId,
isComponentATab(dashboardLayout, childId) ? [...tabIds, childId] : tabIds,
tabsToHighlight,
visited,
),
);
};
export const findTabsWithChartsInScope = (
dashboardLayout: DashboardLayout,
chartLayoutItems: LayoutItem[],
chartsInScope: number[],
) => {
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
const hasTopLevelTabs = rootChildId !== DASHBOARD_GRID_ID;
const tabsInScope = new Set<string>();
const visited = new Set<string>();
if (hasTopLevelTabs) {
dashboardLayout[rootChildId]?.children?.forEach(tabId =>
findTabsWithChartsInScopeHelper(
dashboardLayout,
chartsInScope,
tabId,
[tabId],
tabsInScope,
visited,
),
);
} else {
Object.values(dashboardLayout)
.filter(element => element?.type === TAB_TYPE)
.forEach(element =>
findTabsWithChartsInScopeHelper(
dashboardLayout,
chartsInScope,
element.id,
[element.id],
tabsInScope,
visited,
),
);
}
return tabsInScope;
};
) =>
new Set<string>(
chartsInScope
.map(chartId =>
chartLayoutItems
.find(item => item?.meta?.chartId === chartId)
?.parents?.filter(parent => parent.startsWith(`${TAB_TYPE}-`)),
)
.filter(id => id !== undefined)
.flat() as string[],
);
export const getFilterValueForDisplay = (
value?: string[] | null | string | number | object,

View File

@ -17,20 +17,7 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { isEqual } from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'src/dashboard/types';
import { useMemoCompare } from 'src/hooks/useMemoCompare';
const chartIdsSelector = createSelector(
(state: RootState) => state.charts,
charts => Object.values(charts).map(chart => chart.id),
);
export const useChartIds = () => {
const chartIds = useSelector<RootState, number[]>(chartIdsSelector);
return useMemoCompare(
chartIds,
(prev, next) => prev === next || isEqual(prev, next),
);
};
export const useChartIds = () =>
useSelector<RootState, number[]>(state => state.dashboardState.sliceIds);

View File

@ -33,6 +33,7 @@ import {
isCrossFilterScopeGlobal,
} from '../types';
import { DEFAULT_CROSS_FILTER_SCOPING } from '../constants';
import { CHART_TYPE } from './componentTypes';
export const isCrossFiltersEnabled = (
metadataCrossFiltersEnabled: boolean | undefined,
@ -52,13 +53,17 @@ export const getCrossFiltersConfiguration = (
return undefined;
}
const chartLayoutItems = Object.values(dashboardLayout).filter(
item => item?.type === CHART_TYPE,
);
const globalChartConfiguration = metadata.global_chart_configuration?.scope
? {
scope: metadata.global_chart_configuration.scope,
chartsInScope: getChartIdsInFilterScope(
metadata.global_chart_configuration.scope,
Object.values(charts).map(chart => chart.id),
dashboardLayout,
chartLayoutItems,
),
}
: {
@ -69,7 +74,7 @@ export const getCrossFiltersConfiguration = (
// If user just added cross filter to dashboard it's not saving its scope on server,
// so we tweak it until user will update scope and will save it in server
const chartConfiguration = {};
Object.values(dashboardLayout).forEach(layoutItem => {
chartLayoutItems.forEach(layoutItem => {
const chartId = layoutItem.meta?.chartId;
if (!isDefined(chartId)) {
@ -105,7 +110,7 @@ export const getCrossFiltersConfiguration = (
: getChartIdsInFilterScope(
chartConfiguration[chartId].crossFilters.scope,
Object.values(charts).map(chart => chart.id),
dashboardLayout,
chartLayoutItems,
);
}
});

View File

@ -18,14 +18,13 @@
*/
import { NativeFilterScope } from '@superset-ui/core';
import { CHART_TYPE } from './componentTypes';
import { Layout } from '../types';
import { LayoutItem } from '../types';
export function getChartIdsInFilterScope(
filterScope: NativeFilterScope,
chartIds: number[],
layout: Layout,
layoutItems: LayoutItem[],
) {
const layoutItems = Object.values(layout);
return chartIds.filter(
chartId =>
!filterScope.excluded.includes(chartId) &&

View File

@ -0,0 +1,29 @@
/**
* 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 { createSelector } from '@reduxjs/toolkit';
import { useSelector } from 'react-redux';
import { RootState } from '../types';
import { CHART_TYPE } from './componentTypes';
const chartLayoutItemsSelector = createSelector(
(state: RootState) => state.dashboardLayout.present,
layout => Object.values(layout).filter(item => item?.type === CHART_TYPE),
);
export const useChartLayoutItems = () => useSelector(chartLayoutItemsSelector);

View File

@ -141,54 +141,4 @@ describe('useFilterFocusHighlightStyles', () => {
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(1);
});
it('should return unfocused styles if focusedFilterField is targeting a different chart', async () => {
const chartId = 18;
mockGetRelatedCharts.mockReturnValue([]);
const store = createMockStore({
dashboardState: {
focusedFilterField: {
chartId: 10,
column: 'test',
},
},
dashboardFilters: {
10: {
scopes: {},
},
},
});
renderWrapper(chartId, store);
const container = screen.getByTestId('test-component');
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(0.3);
});
it('should return focused styles if focusedFilterField chart equals our own', async () => {
const chartId = 18;
mockGetRelatedCharts.mockReturnValue([chartId]);
const store = createMockStore({
dashboardState: {
focusedFilterField: {
chartId,
column: 'test',
},
},
dashboardFilters: {
[chartId]: {
scopes: {
otherColumn: {},
},
},
},
});
renderWrapper(chartId, store);
const container = screen.getByTestId('test-component');
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(1);
});
});

View File

@ -16,40 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { Filter, useTheme } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import { DashboardState, RootState } from 'src/dashboard/types';
import { RootState } from 'src/dashboard/types';
import { getRelatedCharts } from './getRelatedCharts';
const selectFocusedFilterScope = (
dashboardState: DashboardState,
dashboardFilters: any,
) => {
if (!dashboardState.focusedFilterField) return null;
const { chartId, column } = dashboardState.focusedFilterField;
return {
chartId,
scope: dashboardFilters[chartId].scopes[column],
};
};
const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' };
const EMPTY = {};
const useFilterFocusHighlightStyles = (chartId: number) => {
const theme = useTheme();
const nativeFilters = useSelector((state: RootState) => state.nativeFilters);
const dashboardState = useSelector(
(state: RootState) => state.dashboardState,
const focusedChartStyles = useMemo(
() => ({
borderColor: theme.colors.primary.light2,
opacity: 1,
boxShadow: `0px 0px ${theme.gridUnit * 2}px ${theme.colors.primary.base}`,
pointerEvents: 'auto',
}),
[theme],
);
const dashboardFilters = useSelector(
(state: RootState) => state.dashboardFilters,
);
const focusedFilterScope = selectFocusedFilterScope(
dashboardState,
dashboardFilters,
);
const nativeFilters = useSelector((state: RootState) => state.nativeFilters);
const slices =
useSelector((state: RootState) => state.sliceEntities.slices) || {};
@ -57,8 +47,8 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
if (!(focusedFilterScope || highlightedFilterId)) {
return {};
if (!highlightedFilterId) {
return EMPTY;
}
const relatedCharts = getRelatedCharts(
@ -67,29 +57,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
slices,
);
// we use local styles here instead of a conditionally-applied class,
// because adding any conditional class to this container
// causes performance issues in Chrome.
// default to the "de-emphasized" state
const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' };
const focusedChartStyles = {
borderColor: theme.colors.primary.light2,
opacity: 1,
boxShadow: `0px 0px ${theme.gridUnit * 2}px ${theme.colors.primary.base}`,
pointerEvents: 'auto',
};
if (highlightedFilterId) {
if (relatedCharts.includes(chartId)) {
return focusedChartStyles;
}
} else if (
chartId === focusedFilterScope?.chartId ||
getChartIdsInFilterScope({
filterScope: focusedFilterScope?.scope,
}).includes(chartId)
) {
if (highlightedFilterId && relatedCharts.includes(chartId)) {
return focusedChartStyles;
}