+ {!isCrossFiltersEnabled && (
+
+ {t('Cross-filtering is not enabled in this dashboard')}
+
+ }
+ type="info"
+ closable={false}
+ css={css`
+ margin-bottom: ${theme.gridUnit * 6}px;
+ `}
+ />
+ )}
+ {isDefined(chartId) && (
+
+ )}
+
+ {isDefined(chartId)
+ ? t(
+ `Select the charts to which you want to apply cross-filters when interacting with this chart. You can select "All charts" to apply filters to all charts that use the same dataset or contain the same column name in the dashboard.`,
+ )
+ : t(
+ `Select the charts to which you want to apply cross-filters in this dashboard. Deselecting a chart will exclude it from being filtered when applying cross-filters from any chart on the dashboard. You can select "All charts" to apply cross-filters to all charts that use the same dataset or contain the same column name in the dashboard.`,
+ )}
+
+
+
+ );
+};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/constants.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/constants.ts
new file mode 100644
index 0000000000000..abacb5cd02417
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/constants.ts
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ */
+
+export const NEW_CHART_SCOPING_ID = -1;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal.test.ts
new file mode 100644
index 0000000000000..00594700e6aae
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal.test.ts
@@ -0,0 +1,40 @@
+/**
+ * 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 { ReactElement } from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { createWrapper, render } from 'spec/helpers/testing-library';
+import { useCrossFiltersScopingModal } from './useCrossFiltersScopingModal';
+
+test('Renders modal after calling method open', async () => {
+ const { result } = renderHook(() => useCrossFiltersScopingModal(), {
+ wrapper: createWrapper(),
+ });
+
+ const [openModal, Modal] = result.current;
+ expect(Modal).toBeNull();
+
+ openModal();
+
+ const { getByText } = render(result.current[1] as ReactElement, {
+ useRedux: true,
+ });
+
+ expect(getByText('Cross-filtering scoping')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal.tsx
new file mode 100644
index 0000000000000..62dae0c37185b
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal.tsx
@@ -0,0 +1,40 @@
+/**
+ * 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 React, { ReactElement, useCallback, useState } from 'react';
+import { ScopingModal } from './ScopingModal';
+
+export const useCrossFiltersScopingModal = (
+ initialChartId?: number,
+): [() => void, ReactElement | null] => {
+ const [isVisible, setIsVisible] = useState(false);
+
+ const openModal = useCallback(() => setIsVisible(true), []);
+ const closeModal = useCallback(() => setIsVisible(false), []);
+
+ return [
+ openModal,
+ isVisible ? (
+
+ ) : null,
+ ];
+};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx
index 10fd66d7078a0..8c236c2714841 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx
@@ -34,6 +34,10 @@ const initialState: { dashboardInfo: DashboardInfo } = {
metadata: {
native_filter_configuration: {},
chart_configuration: {},
+ global_chart_configuration: {
+ scope: { rootPath: ['ROOT_ID'], excluded: [] },
+ chartsInScope: [],
+ },
color_scheme: '',
color_namespace: '',
color_scheme_domain: [],
@@ -216,7 +220,7 @@ test('On selection change, send request and update checked value', async () => {
within(screen.getAllByRole('menuitem')[2]).queryByLabelText('check'),
).not.toBeInTheDocument();
- userEvent.click(await screen.findByText('Horizontal (Top)'));
+ userEvent.click(screen.getByText('Horizontal (Top)'));
// 1st check - checkmark appears immediately after click
expect(
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx
index 75572d74e51a5..8aa51f06339bc 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx
@@ -38,6 +38,7 @@ import DropdownSelectableIcon, {
} from 'src/components/DropdownSelectableIcon';
import Checkbox from 'src/components/Checkbox';
import { clearDataMaskState } from 'src/dataMask/actions';
+import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
type SelectedKey = FilterBarOrientation | string | number;
@@ -62,6 +63,12 @@ const StyledCheckbox = styled(Checkbox)`
`}
`;
+const CROSS_FILTERS_MENU_KEY = 'cross-filters-menu-key';
+const CROSS_FILTERS_SCOPING_MENU_KEY = 'cross-filters-scoping-menu-key';
+
+const isOrientation = (o: SelectedKey): o is FilterBarOrientation =>
+ o === FilterBarOrientation.VERTICAL || o === FilterBarOrientation.HORIZONTAL;
+
const FilterBarSettings = () => {
const dispatch = useDispatch();
const theme = useTheme();
@@ -77,7 +84,7 @@ const FilterBarSettings = () => {
FeatureFlag.DASHBOARD_CROSS_FILTERS,
);
const shouldEnableCrossFilters =
- !!isCrossFiltersEnabled && isCrossFiltersFeatureEnabled;
+ isCrossFiltersEnabled && isCrossFiltersFeatureEnabled;
const [crossFiltersEnabled, setCrossFiltersEnabled] = useState(
shouldEnableCrossFilters,
);
@@ -86,10 +93,9 @@ const FilterBarSettings = () => {
);
const canSetHorizontalFilterBar =
canEdit && isFeatureEnabled(FeatureFlag.HORIZONTAL_FILTER_BAR);
- const crossFiltersMenuKey = 'cross-filters-menu-key';
- const isOrientation = (o: SelectedKey): o is FilterBarOrientation =>
- o === FilterBarOrientation.VERTICAL ||
- o === FilterBarOrientation.HORIZONTAL;
+
+ const [openScopingModal, scopingModal] = useCrossFiltersScopingModal();
+
const updateCrossFiltersSetting = useCallback(
async isEnabled => {
if (!isEnabled) {
@@ -97,36 +103,50 @@ const FilterBarSettings = () => {
}
await dispatch(saveCrossFiltersSetting(isEnabled));
},
- [dispatch, crossFiltersEnabled],
+ [dispatch],
+ );
+
+ const toggleCrossFiltering = useCallback(() => {
+ setCrossFiltersEnabled(!crossFiltersEnabled);
+ updateCrossFiltersSetting(!crossFiltersEnabled);
+ }, [crossFiltersEnabled, updateCrossFiltersSetting]);
+
+ const toggleFilterBarOrientation = useCallback(
+ async (orientation: FilterBarOrientation) => {
+ if (orientation === filterBarOrientation) {
+ return;
+ }
+ // set displayed selection in local state for immediate visual response after clicking
+ setSelectedFilterBarOrientation(orientation);
+ try {
+ // save selection in Redux and backend
+ await dispatch(saveFilterBarOrientation(orientation));
+ } catch {
+ // revert local state in case of error when saving
+ setSelectedFilterBarOrientation(filterBarOrientation);
+ }
+ },
+ [dispatch, filterBarOrientation],
);
- const changeFilterBarSettings = useCallback(
- async (
+
+ const handleSelect = useCallback(
+ (
selection: Parameters<
Required>['onSelect']
>[0],
) => {
const selectedKey: SelectedKey = selection.key;
- if (selectedKey === crossFiltersMenuKey) {
- setCrossFiltersEnabled(!crossFiltersEnabled);
- updateCrossFiltersSetting(!crossFiltersEnabled);
- return;
- }
- if (isOrientation(selectedKey) && selectedKey !== filterBarOrientation) {
- // set displayed selection in local state for immediate visual response after clicking
- setSelectedFilterBarOrientation(selectedKey as FilterBarOrientation);
- try {
- // save selection in Redux and backend
- await dispatch(
- saveFilterBarOrientation(selection.key as FilterBarOrientation),
- );
- } catch {
- // revert local state in case of error when saving
- setSelectedFilterBarOrientation(filterBarOrientation);
- }
+ if (selectedKey === CROSS_FILTERS_MENU_KEY) {
+ toggleCrossFiltering();
+ } else if (isOrientation(selectedKey)) {
+ toggleFilterBarOrientation(selectedKey);
+ } else if (selectedKey === CROSS_FILTERS_SCOPING_MENU_KEY) {
+ openScopingModal();
}
},
- [dispatch, crossFiltersEnabled, filterBarOrientation],
+ [openScopingModal, toggleCrossFiltering, toggleFilterBarOrientation],
);
+
const crossFiltersMenuItem = useMemo(
() => (
@@ -142,50 +162,66 @@ const FilterBarSettings = () => {
),
[crossFiltersEnabled],
);
- const menuItems: DropDownSelectableProps['menuItems'] = [];
-
- if (isCrossFiltersFeatureEnabled && canEdit) {
- menuItems.unshift({
- key: crossFiltersMenuKey,
- label: crossFiltersMenuItem,
- divider: canSetHorizontalFilterBar,
- });
- }
- if (canSetHorizontalFilterBar) {
- menuItems.push({
- key: 'placement',
- label: t('Orientation of filter bar'),
- children: [
- {
- key: FilterBarOrientation.VERTICAL,
- label: t('Vertical (Left)'),
- },
- {
- key: FilterBarOrientation.HORIZONTAL,
- label: t('Horizontal (Top)'),
- },
- ],
- });
- }
+ const menuItems = useMemo(() => {
+ const items: DropDownSelectableProps['menuItems'] = [];
+
+ if (isCrossFiltersFeatureEnabled && canEdit) {
+ items.push({
+ key: CROSS_FILTERS_MENU_KEY,
+ label: crossFiltersMenuItem,
+ });
+ items.push({
+ key: CROSS_FILTERS_SCOPING_MENU_KEY,
+ label: t('Cross-filtering scoping'),
+ divider: canSetHorizontalFilterBar,
+ });
+ }
+
+ if (canSetHorizontalFilterBar) {
+ items.push({
+ key: 'placement',
+ label: t('Orientation of filter bar'),
+ children: [
+ {
+ key: FilterBarOrientation.VERTICAL,
+ label: t('Vertical (Left)'),
+ },
+ {
+ key: FilterBarOrientation.HORIZONTAL,
+ label: t('Horizontal (Top)'),
+ },
+ ],
+ });
+ }
+ return items;
+ }, [
+ canEdit,
+ canSetHorizontalFilterBar,
+ crossFiltersMenuItem,
+ isCrossFiltersFeatureEnabled,
+ ]);
if (!menuItems.length) {
return null;
}
return (
-
- }
- menuItems={menuItems}
- selectedKeys={[selectedFilterBarOrientation]}
- />
+ <>
+
+ }
+ menuItems={menuItems}
+ selectedKeys={[selectedFilterBarOrientation]}
+ />
+ {scopingModal}
+ >
);
};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useFilterScope.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useFilterScope.ts
index 35c84a8591d14..c93ce2426bd18 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useFilterScope.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useFilterScope.ts
@@ -16,33 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Filter, t } from '@superset-ui/core';
+import { useMemo } from 'react';
import { useSelector } from 'react-redux';
-import {
- ChartsState,
- Layout,
- LayoutItem,
- RootState,
-} from 'src/dashboard/types';
+import { Filter, t } from '@superset-ui/core';
+import { Layout, LayoutItem, RootState } from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
-import { useMemo } from 'react';
+import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
const extractTabLabel = (tab?: LayoutItem) =>
tab?.meta?.text || tab?.meta?.defaultText || '';
const extractChartLabel = (chart?: LayoutItem) =>
chart?.meta?.sliceNameOverride || chart?.meta?.sliceName || chart?.id || '';
-const useCharts = () => {
- const charts = useSelector(state => state.charts);
- return useMemo(() => Object.values(charts), [charts]);
-};
-
export const useFilterScope = (filter: Filter) => {
const layout = useSelector(
state => state.dashboardLayout.present,
);
- const charts = useCharts();
+ const chartIds = useChartIds();
return useMemo(() => {
let topLevelTabs: string[] | undefined;
@@ -87,11 +78,11 @@ export const useFilterScope = (filter: Filter) => {
// returns "CHART1, CHART2"
if (filter.scope.rootPath[0] === DASHBOARD_ROOT_ID) {
return {
- charts: charts
- .filter(chart => !filter.scope.excluded.includes(chart.id))
- .map(chart => {
+ charts: chartIds
+ .filter(chartId => !filter.scope.excluded.includes(chartId))
+ .map(chartId => {
const layoutElement = layoutCharts.find(
- layoutChart => layoutChart.meta.chartId === chart.id,
+ layoutChart => layoutChart.meta.chartId === chartId,
);
return extractChartLabel(layoutElement);
})
@@ -121,13 +112,13 @@ export const useFilterScope = (filter: Filter) => {
}
});
// Handle charts that are in scope but belong to excluded tabs.
- const chartsInExcludedTabs = charts
- .filter(chart => !filter.scope.excluded.includes(chart.id))
- .reduce((acc: LayoutItem[], chart) => {
+ const chartsInExcludedTabs = chartIds
+ .filter(chartId => !filter.scope.excluded.includes(chartId))
+ .reduce((acc: LayoutItem[], chartId) => {
const layoutChartElementInExcludedTab =
layoutChartElementsInTabsInScope.find(
element =>
- element.meta.chartId === chart.id &&
+ element.meta.chartId === chartId &&
element.parents.every(
parent => !topLevelTabsInFullScope.includes(parent),
),
@@ -147,5 +138,5 @@ export const useFilterScope = (filter: Filter) => {
}
return undefined;
- }, [charts, filter.scope.excluded, filter.scope.rootPath, layout]);
+ }, [chartIds, filter.scope.excluded, filter.scope.rootPath, layout]);
};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
index fe56777c4b08e..bfae2ce016887 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
@@ -22,13 +22,14 @@ import Icons from 'src/components/Icons';
import { FilterRemoval } from './types';
import DraggableFilter from './DraggableFilter';
-const FilterTitle = styled.div`
+export const FilterTitle = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
padding: ${theme.gridUnit * 2}px;
width: 100%;
border-radius: ${theme.borderRadius}px;
+ cursor: pointer;
&.active {
color: ${theme.colors.grayscale.dark1};
border-radius: ${theme.borderRadius}px;
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 32bbd9b6a2f91..0c49b7e54bf14 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
@@ -17,7 +17,7 @@
* under the License.
*/
-import React, { FC, useMemo, useState } from 'react';
+import React, { FC, useMemo, useState, memo } from 'react';
import { NativeFilterScope } from '@superset-ui/core';
import { Tree } from 'src/components';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
@@ -33,6 +33,7 @@ type ScopingTreeProps = {
initialScope: NativeFilterScope;
chartId?: number;
initiallyExcludedCharts?: number[];
+ title?: string;
};
const buildTreeLeafTitle = (
@@ -61,6 +62,7 @@ const ScopingTree: FC = ({
updateFormValues,
chartId,
initiallyExcludedCharts = [],
+ title,
}) => {
const [expandedKeys, setExpandedKeys] = useState([
DASHBOARD_ROOT_ID,
@@ -70,6 +72,7 @@ const ScopingTree: FC = ({
chartId,
initiallyExcludedCharts,
buildTreeLeafTitle,
+ title,
);
const [autoExpandParent, setAutoExpandParent] = useState(true);
@@ -108,4 +111,4 @@ const ScopingTree: FC = ({
);
};
-export default ScopingTree;
+export default memo(ScopingTree);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts
index f9b94a39856e7..42320f7d0a066 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts
@@ -30,9 +30,10 @@ import { buildTree } from './utils';
// eslint-disable-next-line import/prefer-default-export
export function useFilterScopeTree(
- currentChartId?: number,
+ currentChartId: number | undefined,
initiallyExcludedCharts: number[] = [],
buildTreeLeafTitle: BuildTreeLeafTitle = label => label,
+ title = t('All panels'),
): {
treeData: [TreeItem];
layout: Layout;
@@ -46,7 +47,7 @@ export function useFilterScopeTree(
children: [],
key: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
- title: t('All panels'),
+ title,
};
// We need to get only nodes that have charts as children or grandchildren
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
index 59f00f1f7a428..9247538a5d44a 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
@@ -32,8 +32,11 @@ import {
} from '@superset-ui/core';
import { TIME_FILTER_MAP } from 'src/explore/constants';
import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters';
-import { ChartConfiguration } from 'src/dashboard/reducers/types';
-import { DashboardLayout, Layout } from 'src/dashboard/types';
+import {
+ ChartConfiguration,
+ DashboardLayout,
+ Layout,
+} from 'src/dashboard/types';
import { areObjectsEqual } from 'src/reduxUtils';
export enum IndicatorStatus {
@@ -307,7 +310,7 @@ export const selectChartCrossFilters = (
})
.map(chartConfig => {
const filterIndicator = getCrossFilterIndicator(
- chartConfig.id,
+ Number(chartConfig.id),
dataMask[chartConfig.id],
dashboardLayout,
);
diff --git a/superset-frontend/src/dashboard/constants.ts b/superset-frontend/src/dashboard/constants.ts
index aa9f4d8bd3619..588e5b6cd5f71 100644
--- a/superset-frontend/src/dashboard/constants.ts
+++ b/superset-frontend/src/dashboard/constants.ts
@@ -1,6 +1,6 @@
-/* eslint-disable import/prefer-default-export */
import { DatasourceType } from '@superset-ui/core';
import { Datasource } from 'src/dashboard/types';
+import { DASHBOARD_ROOT_ID } from './util/constants';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -42,3 +42,8 @@ export const FILTER_BAR_HEADER_HEIGHT = 80;
export const FILTER_BAR_TABS_HEIGHT = 46;
export const BUILDER_SIDEPANEL_WIDTH = 374;
export const OVERWRITE_INSPECT_FIELDS = ['css', 'json_metadata.filter_scopes'];
+
+export const DEFAULT_CROSS_FILTER_SCOPING = {
+ rootPath: [DASHBOARD_ROOT_ID],
+ excluded: [],
+};
diff --git a/superset-frontend/src/dashboard/reducers/types.ts b/superset-frontend/src/dashboard/reducers/types.ts
index 9e9b4e8ca5fa0..56322670c1208 100644
--- a/superset-frontend/src/dashboard/reducers/types.ts
+++ b/superset-frontend/src/dashboard/reducers/types.ts
@@ -18,23 +18,13 @@
*/
import componentTypes from 'src/dashboard/util/componentTypes';
-import { NativeFilterScope, JsonObject } from '@superset-ui/core';
+import { JsonObject } from '@superset-ui/core';
export enum Scoping {
All = 'All',
Specific = 'Specific',
}
-export type ChartConfiguration = {
- [chartId: number]: {
- id: number;
- crossFilters: {
- scope: NativeFilterScope;
- chartsInScope: number[];
- };
- };
-};
-
export type User = {
email: string;
firstName: string;
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 7345f0b1aff19..aa261c0768393 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -23,6 +23,7 @@ import {
ExtraFormData,
GenericDataType,
JsonObject,
+ NativeFilterScope,
NativeFiltersState,
} from '@superset-ui/core';
import { Dataset } from '@superset-ui/chart-controls';
@@ -58,6 +59,28 @@ export enum FilterBarOrientation {
HORIZONTAL = 'HORIZONTAL',
}
+// chart's cross filter scoping can have its custom value or point to the global configuration
+export const GLOBAL_SCOPE_POINTER = 'global';
+export type GlobalScopePointer = typeof GLOBAL_SCOPE_POINTER;
+export type ChartCrossFiltersConfig = {
+ scope: NativeFilterScope | GlobalScopePointer;
+ chartsInScope: number[];
+};
+export type GlobalChartCrossFilterConfig = {
+ scope: NativeFilterScope;
+ chartsInScope: number[];
+};
+export const isCrossFilterScopeGlobal = (
+ scope: NativeFilterScope | GlobalScopePointer,
+): scope is GlobalScopePointer => scope === GLOBAL_SCOPE_POINTER;
+
+export type ChartConfiguration = {
+ [chartId: number]: {
+ id: number;
+ crossFilters: ChartCrossFiltersConfig;
+ };
+};
+
export type ActiveTabs = string[];
export type DashboardLayout = { [key: string]: LayoutItem };
export type DashboardLayoutState = { present: DashboardLayout };
@@ -102,7 +125,8 @@ export type DashboardInfo = {
json_metadata: string;
metadata: {
native_filter_configuration: JsonObject;
- chart_configuration: JsonObject;
+ chart_configuration: ChartConfiguration;
+ global_chart_configuration: GlobalChartCrossFilterConfig;
color_scheme: string;
color_namespace: string;
color_scheme_domain: string[];
diff --git a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts
index 14ae2d4ea083a..f9e98e85365cb 100644
--- a/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts
+++ b/superset-frontend/src/dashboard/util/activeAllDashboardFilters.ts
@@ -21,8 +21,7 @@ import {
PartialFilters,
JsonObject,
} from '@superset-ui/core';
-import { ActiveFilters } from '../types';
-import { ChartConfiguration } from '../reducers/types';
+import { ActiveFilters, ChartConfiguration } from '../types';
export const getRelevantDataMask = (
dataMask: DataMaskStateWithId,
diff --git a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts
index cbed55755a89e..0b7c6a9d7d8c0 100644
--- a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts
+++ b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts
@@ -22,11 +22,10 @@ import {
JsonObject,
PartialFilters,
} from '@superset-ui/core';
-import { ChartQueryPayload } from 'src/dashboard/types';
+import { ChartConfiguration, ChartQueryPayload } from 'src/dashboard/types';
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
import { areObjectsEqual } from 'src/reduxUtils';
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
-import { ChartConfiguration } from '../../reducers/types';
import { getAllActiveFilters } from '../activeAllDashboardFilters';
// We cache formData objects so that our connected container components don't always trigger
diff --git a/superset-frontend/src/dashboard/util/charts/useChartIds.ts b/superset-frontend/src/dashboard/util/charts/useChartIds.ts
new file mode 100644
index 0000000000000..575c94d370ff2
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/charts/useChartIds.ts
@@ -0,0 +1,36 @@
+/**
+ * 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 { useSelector } from 'react-redux';
+import isEqual from 'lodash/isEqual';
+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(chartIdsSelector);
+ return useMemoCompare(
+ chartIds,
+ (prev, next) => prev === next || isEqual(prev, next),
+ );
+};
diff --git a/superset-frontend/src/dashboard/util/crossFilters.test.ts b/superset-frontend/src/dashboard/util/crossFilters.test.ts
index 9dbdac4d241f5..45bcaaf0b6798 100644
--- a/superset-frontend/src/dashboard/util/crossFilters.test.ts
+++ b/superset-frontend/src/dashboard/util/crossFilters.test.ts
@@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-import sinon from 'sinon';
+import sinon, { SinonStub } from 'sinon';
import { Behavior, FeatureFlag } from '@superset-ui/core';
import * as core from '@superset-ui/core';
import { getCrossFiltersConfiguration } from './crossFilters';
+import { DEFAULT_CROSS_FILTER_SCOPING } from '../constants';
const DASHBOARD_LAYOUT = {
'CHART-1': {
@@ -101,19 +102,34 @@ const INITIAL_CHART_CONFIG = {
crossFilters: {
scope: {
rootPath: ['ROOT_ID'],
- excluded: [1],
+ excluded: [1, 2],
},
- chartsInScope: [2],
+ chartsInScope: [],
+ },
+ },
+ '2': {
+ id: 2,
+ crossFilters: {
+ scope: 'global' as const,
+ chartsInScope: [1],
},
},
};
-test('Generate correct cross filters configuration without initial configuration', () => {
- // @ts-ignore
- global.featureFlags = {
- [FeatureFlag.DASHBOARD_CROSS_FILTERS]: true,
- };
- const metadataRegistryStub = sinon
+const GLOBAL_CHART_CONFIG = {
+ scope: DEFAULT_CROSS_FILTER_SCOPING,
+ chartsInScope: [1, 2],
+};
+
+const CHART_CONFIG_METADATA = {
+ chart_configuration: INITIAL_CHART_CONFIG,
+ global_chart_configuration: GLOBAL_CHART_CONFIG,
+};
+
+let metadataRegistryStub: SinonStub;
+
+beforeEach(() => {
+ metadataRegistryStub = sinon
.stub(core, 'getChartMetadataRegistry')
.callsFake(() => ({
// @ts-ignore
@@ -121,29 +137,43 @@ test('Generate correct cross filters configuration without initial configuration
behaviors: [Behavior.INTERACTIVE_CHART],
}),
}));
- expect(
- getCrossFiltersConfiguration(DASHBOARD_LAYOUT, undefined, CHARTS),
- ).toEqual({
- '1': {
- id: 1,
- crossFilters: {
- scope: {
- rootPath: ['ROOT_ID'],
- excluded: [1],
+});
+
+afterEach(() => {
+ metadataRegistryStub.restore();
+});
+
+test('Generate correct cross filters configuration without initial configuration', () => {
+ // @ts-ignore
+ global.featureFlags = {
+ [FeatureFlag.DASHBOARD_CROSS_FILTERS]: true,
+ };
+
+ // @ts-ignore
+ expect(getCrossFiltersConfiguration(DASHBOARD_LAYOUT, {}, CHARTS)).toEqual({
+ chartConfiguration: {
+ '1': {
+ id: 1,
+ crossFilters: {
+ scope: 'global',
+ chartsInScope: [2],
},
- chartsInScope: [2],
},
- },
- '2': {
- id: 2,
- crossFilters: {
- scope: {
- rootPath: ['ROOT_ID'],
- excluded: [2],
+ '2': {
+ id: 2,
+ crossFilters: {
+ scope: 'global',
+ chartsInScope: [1],
},
- chartsInScope: [1],
},
},
+ globalChartConfiguration: {
+ scope: {
+ excluded: [],
+ rootPath: ['ROOT_ID'],
+ },
+ chartsInScope: [1, 2],
+ },
});
metadataRegistryStub.restore();
});
@@ -153,41 +183,40 @@ test('Generate correct cross filters configuration with initial configuration',
global.featureFlags = {
[FeatureFlag.DASHBOARD_CROSS_FILTERS]: true,
};
- const metadataRegistryStub = sinon
- .stub(core, 'getChartMetadataRegistry')
- .callsFake(() => ({
- // @ts-ignore
- get: () => ({
- behaviors: [Behavior.INTERACTIVE_CHART],
- }),
- }));
+
expect(
getCrossFiltersConfiguration(
DASHBOARD_LAYOUT,
- INITIAL_CHART_CONFIG,
+ CHART_CONFIG_METADATA,
CHARTS,
),
).toEqual({
- '1': {
- id: 1,
- crossFilters: {
- scope: {
- rootPath: ['ROOT_ID'],
- excluded: [1],
+ chartConfiguration: {
+ '1': {
+ id: 1,
+ crossFilters: {
+ scope: {
+ rootPath: ['ROOT_ID'],
+ excluded: [1, 2],
+ },
+ chartsInScope: [],
},
- chartsInScope: [2],
},
- },
- '2': {
- id: 2,
- crossFilters: {
- scope: {
- rootPath: ['ROOT_ID'],
- excluded: [2],
+ '2': {
+ id: 2,
+ crossFilters: {
+ scope: 'global',
+ chartsInScope: [1],
},
- chartsInScope: [1],
},
},
+ globalChartConfiguration: {
+ scope: {
+ excluded: [],
+ rootPath: ['ROOT_ID'],
+ },
+ chartsInScope: [1, 2],
+ },
});
metadataRegistryStub.restore();
});
@@ -200,7 +229,7 @@ test('Return undefined if DASHBOARD_CROSS_FILTERS feature flag is disabled', ()
expect(
getCrossFiltersConfiguration(
DASHBOARD_LAYOUT,
- INITIAL_CHART_CONFIG,
+ CHART_CONFIG_METADATA,
CHARTS,
),
).toEqual(undefined);
diff --git a/superset-frontend/src/dashboard/util/crossFilters.ts b/superset-frontend/src/dashboard/util/crossFilters.ts
index c166c10fbf4c0..77b2b36f35a5a 100644
--- a/superset-frontend/src/dashboard/util/crossFilters.ts
+++ b/superset-frontend/src/dashboard/util/crossFilters.ts
@@ -24,9 +24,15 @@ import {
isDefined,
isFeatureEnabled,
} from '@superset-ui/core';
-import { DASHBOARD_ROOT_ID } from './constants';
import { getChartIdsInFilterScope } from './getChartIdsInFilterScope';
-import { ChartsState, DashboardInfo, DashboardLayout } from '../types';
+import {
+ ChartsState,
+ DashboardInfo,
+ DashboardLayout,
+ GLOBAL_SCOPE_POINTER,
+ isCrossFilterScopeGlobal,
+} from '../types';
+import { DEFAULT_CROSS_FILTER_SCOPING } from '../constants';
export const isCrossFiltersEnabled = (
metadataCrossFiltersEnabled: boolean | undefined,
@@ -36,13 +42,24 @@ export const isCrossFiltersEnabled = (
export const getCrossFiltersConfiguration = (
dashboardLayout: DashboardLayout,
- initialConfig: DashboardInfo['metadata']['chart_configuration'] = {},
+ metadata: Pick<
+ DashboardInfo['metadata'],
+ 'chart_configuration' | 'global_chart_configuration'
+ >,
charts: ChartsState,
) => {
if (!isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
return undefined;
}
- // If user just added cross filter to dashboard it's not saving it scope on server,
+
+ const globalChartConfiguration = metadata.global_chart_configuration
+ ? cloneDeep(metadata.global_chart_configuration)
+ : {
+ scope: DEFAULT_CROSS_FILTER_SCOPING,
+ chartsInScope: Object.values(charts).map(chart => chart.id),
+ };
+
+ // 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 => {
@@ -59,28 +76,32 @@ export const getCrossFiltersConfiguration = (
)?.behaviors ?? [];
if (behaviors.includes(Behavior.INTERACTIVE_CHART)) {
- if (initialConfig[chartId]) {
+ if (metadata.chart_configuration?.[chartId]) {
// We need to clone to avoid mutating Redux state
- chartConfiguration[chartId] = cloneDeep(initialConfig[chartId]);
+ chartConfiguration[chartId] = cloneDeep(
+ metadata.chart_configuration[chartId],
+ );
}
if (!chartConfiguration[chartId]) {
chartConfiguration[chartId] = {
id: chartId,
crossFilters: {
- scope: {
- rootPath: [DASHBOARD_ROOT_ID],
- excluded: [chartId], // By default it doesn't affects itself
- },
+ scope: GLOBAL_SCOPE_POINTER,
},
};
}
chartConfiguration[chartId].crossFilters.chartsInScope =
- getChartIdsInFilterScope(
- chartConfiguration[chartId].crossFilters.scope,
- charts,
- dashboardLayout,
- );
+ isCrossFilterScopeGlobal(chartConfiguration[chartId].crossFilters.scope)
+ ? globalChartConfiguration.chartsInScope.filter(
+ id => id !== Number(chartId),
+ )
+ : getChartIdsInFilterScope(
+ chartConfiguration[chartId].crossFilters.scope,
+ Object.values(charts).map(chart => chart.id),
+ dashboardLayout,
+ );
}
});
- return chartConfiguration;
+
+ return { chartConfiguration, globalChartConfiguration };
};
diff --git a/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts b/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts
index 516bbf5045c6c..7e4b7b1273121 100644
--- a/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts
+++ b/superset-frontend/src/dashboard/util/getChartIdsInFilterScope.ts
@@ -18,27 +18,23 @@
*/
import { NativeFilterScope } from '@superset-ui/core';
import { CHART_TYPE } from './componentTypes';
-import { ChartsState, Layout } from '../types';
+import { Layout } from '../types';
export function getChartIdsInFilterScope(
filterScope: NativeFilterScope,
- charts: ChartsState,
+ chartIds: number[],
layout: Layout,
) {
const layoutItems = Object.values(layout);
- return Object.values(charts)
- .filter(
- chart =>
- !filterScope.excluded.includes(chart.id) &&
- layoutItems
- .find(
- layoutItem =>
- layoutItem?.type === CHART_TYPE &&
- layoutItem.meta?.chartId === chart.id,
- )
- ?.parents?.some(elementId =>
- filterScope.rootPath.includes(elementId),
- ),
- )
- .map(chart => chart.id);
+ return chartIds.filter(
+ chartId =>
+ !filterScope.excluded.includes(chartId) &&
+ layoutItems
+ .find(
+ layoutItem =>
+ layoutItem?.type === CHART_TYPE &&
+ layoutItem.meta?.chartId === chartId,
+ )
+ ?.parents?.some(elementId => filterScope.rootPath.includes(elementId)),
+ );
}
diff --git a/superset-frontend/src/hooks/useMemoCompare.ts b/superset-frontend/src/hooks/useMemoCompare.ts
new file mode 100644
index 0000000000000..2092c882e5c0a
--- /dev/null
+++ b/superset-frontend/src/hooks/useMemoCompare.ts
@@ -0,0 +1,39 @@
+/**
+ * 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 { useEffect, useRef } from 'react';
+import { isDefined } from '@superset-ui/core';
+
+export const useMemoCompare = (
+ next: T,
+ compare: (prev: T | undefined, next: T) => boolean,
+) => {
+ const previousRef = useRef();
+ const previous = previousRef.current;
+ const isEqual = compare(previous, next);
+ useEffect(() => {
+ if (!isEqual) {
+ previousRef.current = next;
+ }
+ });
+ if (!isDefined(previous)) {
+ return next;
+ }
+ return isEqual ? previous : next;
+};
diff --git a/superset-frontend/src/types/DashboardContextForExplore.ts b/superset-frontend/src/types/DashboardContextForExplore.ts
index b69ec3ca7f0ea..e5ce58056cffa 100644
--- a/superset-frontend/src/types/DashboardContextForExplore.ts
+++ b/superset-frontend/src/types/DashboardContextForExplore.ts
@@ -21,7 +21,7 @@ import {
DataRecordValue,
PartialFilters,
} from '@superset-ui/core';
-import { ChartConfiguration } from 'src/dashboard/reducers/types';
+import { ChartConfiguration } from 'src/dashboard/types';
export interface DashboardContextForExplore {
labelColors: Record;
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 156a3ac1f9111..169d80613d330 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -112,6 +112,9 @@ class DashboardJSONMetadataSchema(Schema):
native_filter_configuration = fields.List(fields.Dict(), allow_none=True)
# chart_configuration for now keeps data about cross-filter scoping for charts
chart_configuration = fields.Dict()
+ # global_chart_configuration keeps data about global cross-filter scoping
+ # for charts - can be overriden by chart_configuration for each chart
+ global_chart_configuration = fields.Dict()
# filter_sets_configuration is for dashboard-native filters
filter_sets_configuration = fields.List(fields.Dict(), allow_none=True)
timed_refresh_immune_slices = fields.List(fields.Integer())
diff --git a/superset/migrations/versions/2023-05-11_12-41_4ea966691069_cross_filter_global_scoping.py b/superset/migrations/versions/2023-05-11_12-41_4ea966691069_cross_filter_global_scoping.py
new file mode 100644
index 0000000000000..4a9e72402585b
--- /dev/null
+++ b/superset/migrations/versions/2023-05-11_12-41_4ea966691069_cross_filter_global_scoping.py
@@ -0,0 +1,110 @@
+# 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.
+"""cross-filter-global-scoping
+
+Revision ID: 4ea966691069
+Revises: 9c2a5681ddfd
+Create Date: 2023-05-11 12:41:38.095717
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "4ea966691069"
+down_revision = "9c2a5681ddfd"
+
+import copy
+import json
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.ext.declarative import declarative_base
+
+from superset import db
+from superset.migrations.shared.utils import paginated_update
+
+Base = declarative_base()
+
+
+class Dashboard(Base):
+ __tablename__ = "dashboards"
+
+ id = sa.Column(sa.Integer, primary_key=True)
+ json_metadata = sa.Column(sa.Text)
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = db.Session(bind=bind)
+ for dashboard in paginated_update(session.query(Dashboard)):
+ try:
+ json_metadata = json.loads(dashboard.json_metadata)
+ new_chart_configuration = {}
+ for config in json_metadata.get("chart_configuration", {}).values():
+ chart_id = int(config.get("id", 0))
+ scope = config.get("crossFilters", {}).get("scope", {})
+ excluded = [
+ int(excluded_id) for excluded_id in scope.get("excluded", [])
+ ]
+ new_chart_configuration[chart_id] = copy.deepcopy(config)
+ new_chart_configuration[chart_id]["id"] = chart_id
+ new_chart_configuration[chart_id]["crossFilters"]["scope"][
+ "excluded"
+ ] = excluded
+ if scope.get("rootPath") == ["ROOT_ID"] and excluded == [chart_id]:
+ new_chart_configuration[chart_id]["crossFilters"][
+ "scope"
+ ] = "global"
+
+ json_metadata["chart_configuration"] = new_chart_configuration
+ dashboard.json_metadata = json.dumps(json_metadata)
+
+ except Exception:
+ pass
+
+ session.commit()
+ session.close()
+
+
+def downgrade():
+ bind = op.get_bind()
+ session = db.Session(bind=bind)
+
+ for dashboard in paginated_update(session.query(Dashboard)):
+ try:
+ json_metadata = json.loads(dashboard.json_metadata)
+ new_chart_configuration = {}
+ for config in json_metadata.get("chart_configuration", {}).values():
+ chart_id = config.get("id")
+ if chart_id is None:
+ continue
+ scope = config.get("crossFilters", {}).get("scope", {})
+ new_chart_configuration[chart_id] = copy.deepcopy(config)
+ if scope == "global":
+ new_chart_configuration[chart_id]["crossFilters"]["scope"] = {
+ "rootPath": ["ROOT_ID"],
+ "excluded": [chart_id],
+ }
+
+ json_metadata["chart_configuration"] = new_chart_configuration
+ del json_metadata["global_chart_configuration"]
+ dashboard.json_metadata = json.dumps(json_metadata)
+
+ except Exception:
+ pass
+
+ session.commit()
+ session.close()