diff --git a/superset-frontend/src/chart/Chart.jsx b/superset-frontend/src/chart/Chart.jsx
index 106cfcdcfaae6..3cf0638f60ccb 100644
--- a/superset-frontend/src/chart/Chart.jsx
+++ b/superset-frontend/src/chart/Chart.jsx
@@ -52,6 +52,7 @@ const propTypes = {
vizType: PropTypes.string.isRequired,
triggerRender: PropTypes.bool,
isFiltersInitialized: PropTypes.bool,
+ isDeactivatedViz: PropTypes.bool,
// state
chartAlert: PropTypes.string,
chartStatus: PropTypes.string,
@@ -82,6 +83,7 @@ const defaultProps = {
triggerRender: false,
dashboardId: null,
chartStackTrace: null,
+ isDeactivatedViz: false,
};
const Styles = styled.div`
@@ -114,13 +116,25 @@ class Chart extends React.PureComponent {
}
componentDidMount() {
- if (this.props.triggerQuery) {
+ // during migration, hold chart queries before user choose review or cancel
+ if (
+ this.props.triggerQuery &&
+ this.props.filterboxMigrationState !== 'UNDECIDED'
+ ) {
this.runQuery();
}
}
componentDidUpdate() {
- if (this.props.triggerQuery) {
+ // during migration, hold chart queries before user choose review or cancel
+ if (
+ this.props.triggerQuery &&
+ this.props.filterboxMigrationState !== 'UNDECIDED'
+ ) {
+ // if the chart is deactivated (filter_box), only load once
+ if (this.props.isDeactivatedViz && this.props.queriesResponse) {
+ return;
+ }
this.runQuery();
}
}
@@ -221,6 +235,8 @@ class Chart extends React.PureComponent {
onQuery,
refreshOverlayVisible,
queriesResponse = [],
+ isDeactivatedViz = false,
+ width,
} = this.props;
const isLoading = chartStatus === 'loading';
@@ -250,6 +266,7 @@ class Chart extends React.PureComponent {
className="chart-container"
data-test="chart-container"
height={height}
+ width={width}
>
)}
- {isLoading &&
}
+ {isLoading && !isDeactivatedViz &&
}
);
diff --git a/superset-frontend/src/common/hooks/apiResources/apiResources.ts b/superset-frontend/src/common/hooks/apiResources/apiResources.ts
index 99504c7f31544..3b6a3922b11bb 100644
--- a/superset-frontend/src/common/hooks/apiResources/apiResources.ts
+++ b/superset-frontend/src/common/hooks/apiResources/apiResources.ts
@@ -153,10 +153,18 @@ export function useTransformedResource
(
// While incomplete, there is no result - no need to transform.
return resource;
}
- return {
- ...resource,
- result: transformFn(resource.result),
- };
+ try {
+ return {
+ ...resource,
+ result: transformFn(resource.result),
+ };
+ } catch (e) {
+ return {
+ status: ResourceStatus.ERROR,
+ result: null,
+ error: e,
+ };
+ }
}, [resource, transformFn]);
}
diff --git a/superset-frontend/src/common/hooks/apiResources/dashboards.ts b/superset-frontend/src/common/hooks/apiResources/dashboards.ts
index 99707c19f6f8a..b5b59d4ef4ef0 100644
--- a/superset-frontend/src/common/hooks/apiResources/dashboards.ts
+++ b/superset-frontend/src/common/hooks/apiResources/dashboards.ts
@@ -26,7 +26,8 @@ export const useDashboard = (idOrSlug: string | number) =>
useApiV1Resource(`/api/v1/dashboard/${idOrSlug}`),
dashboard => ({
...dashboard,
- metadata: dashboard.json_metadata && JSON.parse(dashboard.json_metadata),
+ metadata:
+ (dashboard.json_metadata && JSON.parse(dashboard.json_metadata)) || {},
position_data:
dashboard.position_json && JSON.parse(dashboard.position_json),
}),
diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts
index a525106189eca..ab0fb9da84f7e 100644
--- a/superset-frontend/src/constants.ts
+++ b/superset-frontend/src/constants.ts
@@ -23,6 +23,10 @@ export const BOOL_TRUE_DISPLAY = 'True';
export const BOOL_FALSE_DISPLAY = 'False';
export const URL_PARAMS = {
+ migrationState: {
+ name: 'migration_state',
+ type: 'string',
+ },
standalone: {
name: 'standalone',
type: 'number',
diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js
index 6dfe64bcb9f17..7c049228cf75c 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.js
+++ b/superset-frontend/src/dashboard/actions/hydrate.js
@@ -55,17 +55,21 @@ import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import { TIME_RANGE } from 'src/visualizations/FilterBox/FilterBox';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
import { FeatureFlag, isFeatureEnabled } from '../../featureFlags';
import extractUrlParams from '../util/extractUrlParams';
+import getNativeFilterConfig from '../util/filterboxMigrationHelper';
export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
-export const hydrateDashboard = (dashboardData, chartData) => (
- dispatch,
- getState,
-) => {
+export const hydrateDashboard = (
+ dashboardData,
+ chartData,
+ filterboxMigrationState = FILTER_BOX_MIGRATION_STATES.NOOP,
+) => (dispatch, getState) => {
const { user, common } = getState();
- let { metadata } = dashboardData;
+
+ const { metadata } = dashboardData;
const regularUrlParams = extractUrlParams('regular');
const reservedUrlParams = extractUrlParams('reserved');
const editMode = reservedUrlParams.edit === 'true';
@@ -227,19 +231,25 @@ export const hydrateDashboard = (dashboardData, chartData) => (
const componentId = chartIdToLayoutId[key];
const directPathToFilter = (layout[componentId].parents || []).slice();
directPathToFilter.push(componentId);
- dashboardFilters[key] = {
- ...dashboardFilter,
- chartId: key,
- componentId,
- datasourceId: slice.form_data.datasource,
- filterName: slice.slice_name,
- directPathToFilter,
- columns,
- labels,
- scopes: scopesByChartId,
- isInstantFilter: !!slice.form_data.instant_filtering,
- isDateFilter: Object.keys(columns).includes(TIME_RANGE),
- };
+ if (
+ [
+ FILTER_BOX_MIGRATION_STATES.NOOP,
+ FILTER_BOX_MIGRATION_STATES.SNOOZED,
+ ].includes(filterboxMigrationState)
+ ) {
+ dashboardFilters[key] = {
+ ...dashboardFilter,
+ chartId: key,
+ componentId,
+ datasourceId: slice.form_data.datasource,
+ filterName: slice.slice_name,
+ directPathToFilter,
+ columns,
+ labels,
+ scopes: scopesByChartId,
+ isDateFilter: Object.keys(columns).includes(TIME_RANGE),
+ };
+ }
}
// sync layout names with current slice names in case a slice was edited
@@ -278,17 +288,28 @@ export const hydrateDashboard = (dashboardData, chartData) => (
directPathToChild.push(directLinkComponentId);
}
+ // should convert filter_box to filter component?
+ let filterConfig = metadata?.native_filter_configuration || [];
+ if (filterboxMigrationState === FILTER_BOX_MIGRATION_STATES.REVIEWING) {
+ filterConfig = getNativeFilterConfig(
+ chartData,
+ filterScopes,
+ preselectFilters,
+ );
+ metadata.native_filter_configuration = filterConfig;
+ metadata.show_native_filters = true;
+ }
const nativeFilters = getInitialNativeFilterState({
- filterConfig: metadata?.native_filter_configuration || [],
+ filterConfig,
});
-
- if (!metadata) {
- metadata = {};
- }
-
metadata.show_native_filters =
dashboardData?.metadata?.show_native_filters ??
- isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS);
+ (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
+ [
+ FILTER_BOX_MIGRATION_STATES.CONVERTED,
+ FILTER_BOX_MIGRATION_STATES.REVIEWING,
+ FILTER_BOX_MIGRATION_STATES.NOOP,
+ ].includes(filterboxMigrationState));
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
// If user just added cross filter to dashboard it's not saving it scope on server,
@@ -379,6 +400,7 @@ export const hydrateDashboard = (dashboardData, chartData) => (
lastModifiedTime: dashboardData.changed_on,
isRefreshing: false,
activeTabs: [],
+ filterboxMigrationState,
},
dashboardLayout,
},
diff --git a/superset-frontend/src/dashboard/actions/sliceEntities.js b/superset-frontend/src/dashboard/actions/sliceEntities.js
index 388fddedda484..8fc85c9161ffd 100644
--- a/superset-frontend/src/dashboard/actions/sliceEntities.js
+++ b/superset-frontend/src/dashboard/actions/sliceEntities.js
@@ -40,7 +40,7 @@ export function fetchAllSlicesFailed(error) {
}
const FETCH_SLICES_PAGE_SIZE = 200;
-export function fetchAllSlices(userId) {
+export function fetchAllSlices(userId, excludeFilterBox = false) {
return (dispatch, getState) => {
const { sliceEntities } = getState();
if (sliceEntities.lastUpdated === 0) {
@@ -71,7 +71,12 @@ export function fetchAllSlices(userId) {
})
.then(({ json }) => {
const slices = {};
- json.result.forEach(slice => {
+ let { result } = json;
+ // disable add filter_box viz to dashboard
+ if (excludeFilterBox) {
+ result = result.filter(slice => slice.viz_type !== 'filter_box');
+ }
+ result.forEach(slice => {
let form_data = JSON.parse(slice.params);
form_data = {
...form_data,
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts
index c3e26eb154bad..31cf5ae0feb1d 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts
@@ -18,10 +18,11 @@
*/
import { useSelector } from 'react-redux';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState, useContext } from 'react';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { RootState } from 'src/dashboard/types';
+import { MigrationContext } from 'src/dashboard/containers/DashboardPage';
import {
useFilters,
useNativeFiltersDataMask,
@@ -30,6 +31,7 @@ import { Filter } from '../nativeFilters/types';
// eslint-disable-next-line import/prefer-default-export
export const useNativeFilters = () => {
+ const filterboxMigrationState = useContext(MigrationContext);
const [isInitialized, setIsInitialized] = useState(false);
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
getUrlParam(URL_PARAMS.showFilters) ?? true,
@@ -74,12 +76,14 @@ export const useNativeFilters = () => {
useEffect(() => {
if (
filterValues.length === 0 &&
- dashboardFiltersOpen &&
- nativeFiltersEnabled
+ nativeFiltersEnabled &&
+ ['CONVERTED', 'REVIEWING', 'NOOP'].includes(filterboxMigrationState)
) {
toggleDashboardFiltersOpen(false);
+ } else {
+ toggleDashboardFiltersOpen(true);
}
- }, [filterValues.length]);
+ }, [filterValues.length, filterboxMigrationState]);
useEffect(() => {
if (showDashboard) {
diff --git a/superset-frontend/src/dashboard/components/FilterBoxMigrationModal.tsx b/superset-frontend/src/dashboard/components/FilterBoxMigrationModal.tsx
new file mode 100644
index 0000000000000..dc73d34006c15
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/FilterBoxMigrationModal.tsx
@@ -0,0 +1,100 @@
+/**
+ * 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, { FunctionComponent } from 'react';
+import { styled, t } from '@superset-ui/core';
+
+import Modal from 'src/components/Modal';
+import Button from 'src/components/Button';
+
+const StyledFilterBoxMigrationModal = styled(Modal)`
+ .modal-content {
+ height: 900px;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .modal-header {
+ flex: 0 1 auto;
+ }
+
+ .modal-body {
+ flex: 1 1 auto;
+ overflow: auto;
+ }
+
+ .modal-footer {
+ flex: 0 1 auto;
+ }
+
+ .ant-modal-body {
+ overflow: auto;
+ }
+`;
+
+interface FilterBoxMigrationModalProps {
+ onHide: () => void;
+ onClickReview: () => void;
+ onClickSnooze: () => void;
+ show: boolean;
+ hideFooter: boolean;
+}
+
+const FilterBoxMigrationModal: FunctionComponent = ({
+ onClickReview,
+ onClickSnooze,
+ onHide,
+ show,
+ hideFooter = false,
+}) => (
+
+
+ {t('Remind me in 24 hours')}
+
+
+ {t('Cancel')}
+
+
+ {t('Start Review')}
+
+ >
+ }
+ responsive
+ >
+
+ {t(
+ 'filter_box will be deprecated ' +
+ 'in a future version of Superset. ' +
+ 'Please replace filter_box by dashboard filter components.',
+ )}
+
+
+);
+
+export default FilterBoxMigrationModal;
diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx
index 0e276291a0d4e..9ca63842d8352 100644
--- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx
@@ -35,6 +35,7 @@ import downloadAsImage from 'src/utils/downloadAsImage';
import getDashboardUrl from 'src/dashboard/util/getDashboardUrl';
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { getUrlParam } from 'src/utils/urlUtils';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
@@ -65,6 +66,7 @@ const propTypes = {
refreshLimit: PropTypes.number,
refreshWarning: PropTypes.string,
lastModifiedTime: PropTypes.number.isRequired,
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES,
};
const defaultProps = {
@@ -72,6 +74,7 @@ const defaultProps = {
colorScheme: undefined,
refreshLimit: 0,
refreshWarning: null,
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES.NOOP,
};
const MENU_KEYS = {
@@ -209,6 +212,7 @@ class HeaderActionsDropdown extends React.PureComponent {
lastModifiedTime,
addSuccessToast,
addDangerToast,
+ filterboxMigrationState,
} = this.props;
const emailTitle = t('Superset dashboard');
@@ -283,14 +287,15 @@ class HeaderActionsDropdown extends React.PureComponent {
/>
- {editMode && (
-
-
-
- )}
+ {editMode &&
+ filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.CONVERTED && (
+
+
+
+ )}
{editMode && (
diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx
index d7930566793ee..f8ca74b8e9713 100644
--- a/superset-frontend/src/dashboard/components/Header/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/index.jsx
@@ -52,6 +52,7 @@ import setPeriodicRunner, {
stopPeriodicRender,
} from 'src/dashboard/util/setPeriodicRunner';
import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
@@ -474,10 +475,15 @@ class Header extends React.PureComponent {
shouldPersistRefreshFrequency,
setRefreshFrequency,
lastModifiedTime,
+ filterboxMigrationState,
} = this.props;
- const userCanEdit = dashboardInfo.dash_edit_perm;
+ const userCanEdit =
+ dashboardInfo.dash_edit_perm &&
+ filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.REVIEWING;
const userCanShare = dashboardInfo.dash_share_perm;
- const userCanSaveAs = dashboardInfo.dash_save_perm;
+ const userCanSaveAs =
+ dashboardInfo.dash_save_perm &&
+ filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.REVIEWING;
const shouldShowReport = !editMode && this.canAddReports();
const refreshLimit =
dashboardInfo.common.conf.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
@@ -669,6 +675,7 @@ class Header extends React.PureComponent {
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
lastModifiedTime={lastModifiedTime}
+ filterboxMigrationState={filterboxMigrationState}
/>
diff --git a/superset-frontend/src/dashboard/components/SliceAdder.jsx b/superset-frontend/src/dashboard/components/SliceAdder.jsx
index d09c13c69fb12..c6d6ae6aa358c 100644
--- a/superset-frontend/src/dashboard/components/SliceAdder.jsx
+++ b/superset-frontend/src/dashboard/components/SliceAdder.jsx
@@ -21,7 +21,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { List } from 'react-virtualized';
import { createFilter } from 'react-search-input';
-import { t, styled } from '@superset-ui/core';
+import { t, styled, isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { Input } from 'src/common/components';
import { Select } from 'src/components';
import Loading from 'src/components/Loading';
@@ -34,6 +34,7 @@ import {
NEW_COMPONENTS_SOURCE_ID,
} from 'src/dashboard/util/constants';
import { slicePropShape } from 'src/dashboard/util/propShapes';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
import AddSliceCard from './AddSliceCard';
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
import DragDroppable from './dnd/DragDroppable';
@@ -48,6 +49,7 @@ const propTypes = {
selectedSliceIds: PropTypes.arrayOf(PropTypes.number),
editMode: PropTypes.bool,
height: PropTypes.number,
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES,
};
const defaultProps = {
@@ -55,6 +57,7 @@ const defaultProps = {
editMode: false,
errorMessage: '',
height: window.innerHeight,
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES.NOOP,
};
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
@@ -114,7 +117,12 @@ class SliceAdder extends React.Component {
}
componentDidMount() {
- this.slicesRequest = this.props.fetchAllSlices(this.props.userId);
+ const { userId, filterboxMigrationState } = this.props;
+ this.slicesRequest = this.props.fetchAllSlices(
+ userId,
+ isFeatureEnabled(FeatureFlag.ENABLE_FILTER_BOX_MIGRATION) &&
+ filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.SNOOZED,
+ );
}
UNSAFE_componentWillReceiveProps(nextProps) {
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
index adef4a640c041..b77df1b699f5d 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
@@ -34,6 +34,7 @@ import {
LOG_ACTIONS_FORCE_REFRESH_CHART,
} from 'src/logger/LogUtils';
import { areObjectsEqual } from 'src/reduxUtils';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
import SliceHeader from '../SliceHeader';
import MissingChart from '../MissingChart';
@@ -61,6 +62,7 @@ const propTypes = {
sliceName: PropTypes.string.isRequired,
timeout: PropTypes.number.isRequired,
maxRows: PropTypes.number.isRequired,
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES,
// all active filter fields in dashboard
filters: PropTypes.object.isRequired,
refreshChart: PropTypes.func.isRequired,
@@ -102,6 +104,11 @@ const ChartOverlay = styled.div`
top: 0;
left: 0;
z-index: 5;
+
+ &.is-deactivated {
+ opacity: 0.5;
+ background-color: ${({ theme }) => theme.colors.grayscale.light1};
+ }
`;
export default class Chart extends React.Component {
@@ -293,6 +300,7 @@ export default class Chart extends React.Component {
filterState,
handleToggleFullSize,
isFullSize,
+ filterboxMigrationState,
} = this.props;
const { width } = this.state;
@@ -304,6 +312,12 @@ export default class Chart extends React.Component {
const { queriesResponse, chartUpdateEndTime, chartStatus } = chart;
const isLoading = chartStatus === 'loading';
+ const isDeactivatedViz =
+ slice.viz_type === 'filter_box' &&
+ [
+ FILTER_BOX_MIGRATION_STATES.REVIEWING,
+ FILTER_BOX_MIGRATION_STATES.CONVERTED,
+ ].includes(filterboxMigrationState);
// eslint-disable-next-line camelcase
const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || [];
const cachedDttm =
@@ -378,15 +392,15 @@ export default class Chart extends React.Component {
isOverflowable && 'dashboard-chart--overflowable',
)}
>
- {isLoading && (
+ {(isLoading || isDeactivatedViz) && (
)}
-
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx
index 289ee9a2b1c29..d64f6ab7cb634 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx
@@ -23,6 +23,7 @@ import { connect } from 'react-redux';
import { LineEditableTabs } from 'src/components/Tabs';
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils';
import { Modal } from 'src/common/components';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
import DragDroppable from '../dnd/DragDroppable';
import DragHandle from '../dnd/DragHandle';
import DashboardComponent from '../../containers/DashboardComponent';
@@ -47,6 +48,7 @@ const propTypes = {
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES,
// actions (from DashboardComponent.jsx)
logEvent: PropTypes.func.isRequired,
@@ -73,6 +75,7 @@ const defaultProps = {
availableColumnCount: 0,
columnWidth: 0,
directPathToChild: [],
+ filterboxMigrationState: FILTER_BOX_MIGRATION_STATES.NOOP,
setActiveTabs() {},
onResizeStart() {},
onResize() {},
@@ -135,7 +138,10 @@ export class Tabs extends React.PureComponent {
}
componentDidUpdate(prevProps, prevState) {
- if (prevState.activeKey !== this.state.activeKey) {
+ if (
+ prevState.activeKey !== this.state.activeKey ||
+ prevProps.filterboxMigrationState !== this.props.filterboxMigrationState
+ ) {
this.props.setActiveTabs(this.state.activeKey, prevState.activeKey);
}
}
@@ -405,6 +411,7 @@ function mapStateToProps(state) {
return {
nativeFilters: state.nativeFilters,
directPathToChild: state.dashboardState.directPathToChild,
+ filterboxMigrationState: state.dashboardState.filterboxMigrationState,
};
}
export default connect(mapStateToProps)(Tabs);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
index 4fc6ae15d7a35..03f1232378b73 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
@@ -18,6 +18,7 @@
*/
/* eslint-disable no-param-reassign */
import { useSelector } from 'react-redux';
+import { filter, keyBy } from 'lodash';
import {
Filters,
FilterSets as FilterSetsType,
@@ -27,10 +28,12 @@ import {
DataMaskStateWithId,
DataMaskWithId,
} from 'src/dataMask/types';
-import { useEffect, useMemo, useState } from 'react';
+import { useContext, useEffect, useMemo, useState } from 'react';
import { ChartsState, RootState } from 'src/dashboard/types';
+import { MigrationContext } from 'src/dashboard/containers/DashboardPage';
+import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
+import { Filter } from 'src/dashboard/components/nativeFilters/types';
import { NATIVE_FILTER_PREFIX } from '../FiltersConfigModal/utils';
-import { Filter } from '../types';
export const useFilterSets = () =>
useSelector(
@@ -102,14 +105,30 @@ export const useFilterUpdates = (
export const useInitialization = () => {
const [isInitialized, setIsInitialized] = useState(false);
const filters = useFilters();
- const charts = useSelector(state => state.charts);
+ const filterboxMigrationState = useContext(MigrationContext);
+ let charts = useSelector(state => state.charts);
// We need to know how much charts now shown on dashboard to know how many of all charts should be loaded
let numberOfLoadingCharts = 0;
if (!isInitialized) {
- numberOfLoadingCharts = document.querySelectorAll(
- '[data-ui-anchor="chart"]',
- ).length;
+ // do not load filter_box in reviewing
+ if (filterboxMigrationState === FILTER_BOX_MIGRATION_STATES.REVIEWING) {
+ charts = keyBy(
+ filter(charts, chart => chart.formData?.viz_type !== 'filter_box'),
+ 'id',
+ );
+ const numberOfFilterbox = document.querySelectorAll(
+ '[data-test-viz-type="filter_box"]',
+ ).length;
+
+ numberOfLoadingCharts =
+ document.querySelectorAll('[data-ui-anchor="chart"]').length -
+ numberOfFilterbox;
+ } else {
+ numberOfLoadingCharts = document.querySelectorAll(
+ '[data-ui-anchor="chart"]',
+ ).length;
+ }
}
useEffect(() => {
if (isInitialized) {
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/types.ts
index 7b16d08b9030d..ccecc35dc5726 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/types.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/types.ts
@@ -55,6 +55,9 @@ export interface Filter {
sortMetric?: string | null;
adhoc_filters?: AdhocFilter[];
granularity_sqla?: string;
+ granularity?: string;
+ druid_time_origin?: string;
+ time_grain_sqla?: string;
time_range?: string;
requiredFirst?: boolean;
tabsInScope?: string[];
diff --git a/superset-frontend/src/dashboard/containers/Chart.jsx b/superset-frontend/src/dashboard/containers/Chart.jsx
index 22b14b81ea4a4..8c46d56b252aa 100644
--- a/superset-frontend/src/dashboard/containers/Chart.jsx
+++ b/superset-frontend/src/dashboard/containers/Chart.jsx
@@ -97,6 +97,7 @@ function mapStateToProps(
ownState: dataMask[id]?.ownState,
filterState: dataMask[id]?.filterState,
maxRows: common.conf.SQL_MAX_ROW,
+ filterboxMigrationState: dashboardState.filterboxMigrationState,
};
}
diff --git a/superset-frontend/src/dashboard/containers/DashboardHeader.jsx b/superset-frontend/src/dashboard/containers/DashboardHeader.jsx
index e8d68bad0f2a5..2605f75f754f9 100644
--- a/superset-frontend/src/dashboard/containers/DashboardHeader.jsx
+++ b/superset-frontend/src/dashboard/containers/DashboardHeader.jsx
@@ -101,6 +101,7 @@ function mapStateToProps({
slug: dashboardInfo.slug,
metadata: dashboardInfo.metadata,
reports,
+ filterboxMigrationState: dashboardState.filterboxMigrationState,
};
}
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
index 6130aad7874da..b154b6cf1b326 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
@@ -16,12 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import React, { FC, useRef, useEffect } from 'react';
+import React, { FC, useRef, useEffect, useState } from 'react';
import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import Loading from 'src/components/Loading';
+import FilterBoxMigrationModal from 'src/dashboard/components/FilterBoxMigrationModal';
import {
useDashboard,
useDashboardCharts,
@@ -31,8 +32,27 @@ import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import { setDatasources } from 'src/dashboard/actions/datasources';
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
import setupPlugins from 'src/setup/setupPlugins';
+import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { addWarningToast } from 'src/components/MessageToasts/actions';
+
+import {
+ getFromLocalStorage,
+ setInLocalStorage,
+} from 'src/utils/localStorageHelpers';
+import {
+ FILTER_BOX_MIGRATION_STATES,
+ FILTER_BOX_TRANSITION_SNOOZE_DURATION,
+ FILTER_BOX_TRANSITION_SNOOZED_AT,
+} from 'src/explore/constants';
+import { URL_PARAMS } from 'src/constants';
+import { getUrlParam } from 'src/utils/urlUtils';
+import { canUserEditDashboard } from 'src/dashboard/util/findPermission';
import { getFilterSets } from '../actions/nativeFilters';
+export const MigrationContext = React.createContext(
+ FILTER_BOX_MIGRATION_STATES.NOOP,
+);
+
setupPlugins();
const DashboardContainer = React.lazy(
() =>
@@ -47,6 +67,9 @@ const originalDocumentTitle = document.title;
const DashboardPage: FC = () => {
const dispatch = useDispatch();
+ const user = useSelector(
+ state => state.user,
+ );
const { addDangerToast } = useToasts();
const { idOrSlug } = useParams<{ idOrSlug: string }>();
const { result: dashboard, error: dashboardApiError } = useDashboard(
@@ -62,15 +85,89 @@ const DashboardPage: FC = () => {
const error = dashboardApiError || chartsApiError;
const readyToRender = Boolean(dashboard && charts);
- const { dashboard_title, css } = dashboard || {};
+ const migrationStateParam = getUrlParam(
+ URL_PARAMS.migrationState,
+ ) as FILTER_BOX_MIGRATION_STATES;
+ const isMigrationEnabled = isFeatureEnabled(
+ FeatureFlag.ENABLE_FILTER_BOX_MIGRATION,
+ );
+ const { dashboard_title, css, metadata, id = 0 } = dashboard || {};
+ const [filterboxMigrationState, setFilterboxMigrationState] = useState(
+ migrationStateParam || FILTER_BOX_MIGRATION_STATES.NOOP,
+ );
+
+ useEffect(() => {
+ // should convert filter_box to filter component?
+ const hasFilterBox =
+ charts &&
+ charts.some(chart => chart.form_data?.viz_type === 'filter_box');
+ const canEdit = dashboard && canUserEditDashboard(dashboard, user);
+
+ if (canEdit) {
+ // can user edit dashboard?
+ if (metadata?.native_filter_configuration) {
+ setFilterboxMigrationState(
+ isMigrationEnabled
+ ? FILTER_BOX_MIGRATION_STATES.CONVERTED
+ : FILTER_BOX_MIGRATION_STATES.NOOP,
+ );
+ return;
+ }
+
+ // set filterbox migration state if has filter_box in the dash:
+ if (hasFilterBox) {
+ if (isMigrationEnabled) {
+ // has url param?
+ if (
+ migrationStateParam &&
+ Object.values(FILTER_BOX_MIGRATION_STATES).includes(
+ migrationStateParam,
+ )
+ ) {
+ setFilterboxMigrationState(migrationStateParam);
+ return;
+ }
- if (readyToRender && !isDashboardHydrated.current) {
- isDashboardHydrated.current = true;
- dispatch(hydrateDashboard(dashboard, charts));
- if (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET)) {
- dispatch(getFilterSets());
+ // has cookie?
+ const snoozeDash =
+ getFromLocalStorage(FILTER_BOX_TRANSITION_SNOOZED_AT, 0) || {};
+ if (
+ Date.now() - (snoozeDash[id] || 0) <
+ FILTER_BOX_TRANSITION_SNOOZE_DURATION
+ ) {
+ setFilterboxMigrationState(FILTER_BOX_MIGRATION_STATES.SNOOZED);
+ return;
+ }
+
+ setFilterboxMigrationState(FILTER_BOX_MIGRATION_STATES.UNDECIDED);
+ } else {
+ dispatch(
+ addWarningToast(
+ t(
+ 'filter_box will be deprecated ' +
+ 'in a future version of Superset. ' +
+ 'Please replace filter_box by dashboard filter components.',
+ ),
+ ),
+ );
+ }
+ }
}
- }
+ }, [readyToRender]);
+
+ useEffect(() => {
+ if (readyToRender) {
+ if (!isDashboardHydrated.current) {
+ isDashboardHydrated.current = true;
+ if (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET)) {
+ // only initialize filterset once
+ dispatch(getFilterSets());
+ }
+ }
+ dispatch(hydrateDashboard(dashboard, charts, filterboxMigrationState));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [readyToRender, filterboxMigrationState]);
useEffect(() => {
if (dashboard_title) {
@@ -103,7 +200,34 @@ const DashboardPage: FC = () => {
if (error) throw error; // caught in error boundary
if (!readyToRender) return ;
- return ;
+ return (
+ <>
+ {
+ // cancel button: only snooze this visit
+ setFilterboxMigrationState(FILTER_BOX_MIGRATION_STATES.SNOOZED);
+ }}
+ onClickReview={() => {
+ setFilterboxMigrationState(FILTER_BOX_MIGRATION_STATES.REVIEWING);
+ }}
+ onClickSnooze={() => {
+ const snoozedDash =
+ getFromLocalStorage(FILTER_BOX_TRANSITION_SNOOZED_AT, 0) || {};
+ setInLocalStorage(FILTER_BOX_TRANSITION_SNOOZED_AT, {
+ ...snoozedDash,
+ [id]: Date.now(),
+ });
+ setFilterboxMigrationState(FILTER_BOX_MIGRATION_STATES.SNOOZED);
+ }}
+ />
+
+
+
+
+ >
+ );
};
export default DashboardPage;
diff --git a/superset-frontend/src/dashboard/containers/SliceAdder.jsx b/superset-frontend/src/dashboard/containers/SliceAdder.jsx
index b7933f69a90c9..8c02a4a360e7f 100644
--- a/superset-frontend/src/dashboard/containers/SliceAdder.jsx
+++ b/superset-frontend/src/dashboard/containers/SliceAdder.jsx
@@ -22,8 +22,12 @@ import { connect } from 'react-redux';
import { fetchAllSlices } from '../actions/sliceEntities';
import SliceAdder from '../components/SliceAdder';
-function mapStateToProps({ sliceEntities, dashboardInfo, dashboardState }) {
+function mapStateToProps(
+ { sliceEntities, dashboardInfo, dashboardState },
+ ownProps,
+) {
return {
+ height: ownProps.height,
userId: dashboardInfo.userId,
selectedSliceIds: dashboardState.sliceIds,
slices: sliceEntities.slices,
@@ -31,6 +35,7 @@ function mapStateToProps({ sliceEntities, dashboardInfo, dashboardState }) {
errorMessage: sliceEntities.errorMessage,
lastUpdated: sliceEntities.lastUpdated,
editMode: dashboardState.editMode,
+ filterboxMigrationState: dashboardState.filterboxMigrationState,
};
}
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 6f3467693c02c..1496b988943fe 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -26,6 +26,7 @@ import { DatasourceMeta } from '@superset-ui/chart-controls';
import { chart } from 'src/chart/chartReducer';
import componentTypes from 'src/dashboard/util/componentTypes';
+import { User } from 'src/types/bootstrapTypes';
import { DataMaskStateWithId } from '../dataMask/types';
import { NativeFiltersState } from './reducers/types';
import { ChartState } from '../explore/types';
@@ -64,7 +65,6 @@ export type DashboardState = {
isRefreshing: boolean;
hasUnsavedChanges: boolean;
};
-
export type DashboardInfo = {
id: number;
common: {
@@ -104,6 +104,7 @@ export type RootState = {
dataMask: DataMaskStateWithId;
impressionId: string;
nativeFilters: NativeFiltersState;
+ user: User;
};
/** State of dashboardLayout in redux */
diff --git a/superset-frontend/src/dashboard/util/activeDashboardFilters.js b/superset-frontend/src/dashboard/util/activeDashboardFilters.js
index 30bdc2540aa4b..e0a00db9ce04c 100644
--- a/superset-frontend/src/dashboard/util/activeDashboardFilters.js
+++ b/superset-frontend/src/dashboard/util/activeDashboardFilters.js
@@ -62,9 +62,7 @@ export function getAppliedFilterValues(chartId) {
return appliedFilterValuesByChart[chartId];
}
-export function getChartIdsInFilterScope({
- filterScope = DASHBOARD_FILTER_SCOPE_GLOBAL,
-}) {
+export function getChartIdsInFilterScope({ filterScope }) {
function traverse(chartIds = [], component = {}, immuneChartIds = []) {
if (!component) {
return;
@@ -85,7 +83,8 @@ export function getChartIdsInFilterScope({
}
const chartIds = [];
- const { scope: scopeComponentIds, immune: immuneChartIds } = filterScope;
+ const { scope: scopeComponentIds, immune: immuneChartIds } =
+ filterScope || DASHBOARD_FILTER_SCOPE_GLOBAL;
scopeComponentIds.forEach(componentId =>
traverse(chartIds, allComponents[componentId], immuneChartIds),
);
diff --git a/superset-frontend/src/dashboard/util/filterboxMigrationHelper.test.ts b/superset-frontend/src/dashboard/util/filterboxMigrationHelper.test.ts
new file mode 100644
index 0000000000000..bae1d8f1f2ef5
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/filterboxMigrationHelper.test.ts
@@ -0,0 +1,144 @@
+/**
+ * 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 getNativeFilterConfig from './filterboxMigrationHelper';
+
+const regionFilter = {
+ cache_timeout: null,
+ changed_on: '2021-10-07 11:57:48.355047',
+ description: null,
+ description_markeddown: '',
+ form_data: {
+ compare_lag: '10',
+ compare_suffix: 'o10Y',
+ country_fieldtype: 'cca3',
+ datasource: '1__table',
+ date_filter: false,
+ entity: 'country_code',
+ filter_configs: [
+ {
+ asc: false,
+ clearable: true,
+ column: 'region',
+ key: '2s98dfu',
+ metric: 'sum__SP_POP_TOTL',
+ multiple: false,
+ },
+ {
+ asc: false,
+ clearable: true,
+ column: 'country_name',
+ key: 'li3j2lk',
+ metric: 'sum__SP_POP_TOTL',
+ multiple: true,
+ },
+ ],
+ granularity_sqla: 'year',
+ groupby: [],
+ limit: '25',
+ markup_type: 'markdown',
+ row_limit: 50000,
+ show_bubbles: true,
+ slice_id: 32,
+ time_range: '2014-01-01 : 2014-01-02',
+ time_range_endpoints: ['inclusive', 'exclusive'],
+ viz_type: 'filter_box',
+ },
+ modified: '',
+ slice_name: 'Region Filter',
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%2032%7D',
+ slice_id: 32,
+};
+const chart1 = {
+ cache_timeout: null,
+ changed_on: '2021-09-07 18:05:18.896212',
+ description: null,
+ description_markeddown: '',
+ form_data: {
+ compare_lag: '10',
+ compare_suffix: 'over 10Y',
+ country_fieldtype: 'cca3',
+ datasource: '1__table',
+ entity: 'country_code',
+ granularity_sqla: 'year',
+ groupby: [],
+ limit: '25',
+ markup_type: 'markdown',
+ metric: 'sum__SP_POP_TOTL',
+ row_limit: 50000,
+ show_bubbles: true,
+ slice_id: 33,
+ time_range: '2000 : 2014-01-02',
+ time_range_endpoints: ['inclusive', 'exclusive'],
+ viz_type: 'big_number',
+ },
+ modified: "",
+ slice_name: "World's Population",
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%2033%7D',
+ slice_id: 33,
+};
+const chartData = [regionFilter, chart1];
+const preselectedFilters = {
+ '32': {
+ region: ['East Asia & Pacific'],
+ },
+};
+
+test('should convert filter_box config to dashboard native filter config', () => {
+ const filterConfig = getNativeFilterConfig(chartData, {}, {});
+ // convert to 2 components
+ expect(filterConfig.length).toEqual(2);
+
+ expect(filterConfig[0].id).toBeDefined();
+ expect(filterConfig[0].filterType).toBe('filter_select');
+ expect(filterConfig[0].name).toBe('region');
+ expect(filterConfig[0].targets).toEqual([
+ { column: { name: 'region' }, datasetId: 1 },
+ ]);
+ expect(filterConfig[0].scope).toEqual({
+ excluded: [],
+ rootPath: ['ROOT_ID'],
+ });
+
+ expect(filterConfig[1].id).toBeDefined();
+ expect(filterConfig[1].filterType).toBe('filter_select');
+ expect(filterConfig[1].name).toBe('country_name');
+ expect(filterConfig[1].targets).toEqual([
+ { column: { name: 'country_name' }, datasetId: 1 },
+ ]);
+ expect(filterConfig[1].scope).toEqual({
+ excluded: [],
+ rootPath: ['ROOT_ID'],
+ });
+});
+
+test('should convert preselected filters', () => {
+ const filterConfig = getNativeFilterConfig(chartData, {}, preselectedFilters);
+ const { defaultDataMask } = filterConfig[0];
+ expect(defaultDataMask.filterState).toEqual({
+ value: ['East Asia & Pacific'],
+ });
+ expect(defaultDataMask.extraFormData?.filters).toEqual([
+ {
+ col: 'region',
+ op: 'IN',
+ val: ['East Asia & Pacific'],
+ },
+ ]);
+});
diff --git a/superset-frontend/src/dashboard/util/filterboxMigrationHelper.ts b/superset-frontend/src/dashboard/util/filterboxMigrationHelper.ts
new file mode 100644
index 0000000000000..78c03944e067b
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/filterboxMigrationHelper.ts
@@ -0,0 +1,525 @@
+/**
+ * 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 shortid from 'shortid';
+import { find, isEmpty } from 'lodash';
+
+import {
+ Filter,
+ NativeFilterType,
+} from 'src/dashboard/components/nativeFilters/types';
+import {
+ FILTER_CONFIG_ATTRIBUTES,
+ TIME_FILTER_LABELS,
+ TIME_FILTER_MAP,
+} from 'src/explore/constants';
+import { DASHBOARD_FILTER_SCOPE_GLOBAL } from 'src/dashboard/reducers/dashboardFilters';
+import { TimeGranularity } from '@superset-ui/core';
+import { getChartIdsInFilterScope } from './activeDashboardFilters';
+import getFilterConfigsFromFormdata from './getFilterConfigsFromFormdata';
+
+interface FilterConfig {
+ asc: boolean;
+ clearable: boolean;
+ column: string;
+ defaultValue?: any;
+ key: string;
+ label?: string;
+ metric: string;
+ multiple: boolean;
+}
+
+interface SliceData {
+ slice_id: number;
+ form_data: {
+ adhoc_filters?: [];
+ datasource: string;
+ date_filter?: boolean;
+ filter_configs?: FilterConfig[];
+ granularity?: string;
+ granularity_sqla?: string;
+ time_grain_sqla?: string;
+ time_range?: string;
+ druid_time_origin?: string;
+ show_druid_time_granularity?: boolean;
+ show_druid_time_origin?: boolean;
+ show_sqla_time_column?: boolean;
+ show_sqla_time_granularity?: boolean;
+ viz_type: string;
+ };
+}
+
+interface FilterScopeType {
+ scope: string[];
+ immune: number[];
+}
+
+interface FilterScopesMetadata {
+ [key: string]: {
+ [key: string]: FilterScopeType;
+ };
+}
+
+interface PreselectedFilterColumn {
+ [key: string]: boolean | string | number | string[] | number[];
+}
+
+interface PreselectedFiltersMeatadata {
+ [key: string]: PreselectedFilterColumn;
+}
+
+interface FilterBoxToFilterComponentMap {
+ [key: string]: {
+ [key: string]: string;
+ };
+}
+
+interface FilterBoxDependencyMap {
+ [key: string]: {
+ [key: string]: number[];
+ };
+}
+
+enum FILTER_COMPONENT_FILTER_TYPES {
+ FILTER_TIME = 'filter_time',
+ FILTER_TIMEGRAIN = 'filter_timegrain',
+ FILTER_TIMECOLUMN = 'filter_timecolumn',
+ FILTER_SELECT = 'filter_select',
+ FILTER_RANGE = 'filter_range',
+}
+
+const getPreselectedValuesFromDashboard = (
+ preselectedFilters: PreselectedFiltersMeatadata,
+) => (filterKey: string, column: string) => {
+ if (preselectedFilters[filterKey] && preselectedFilters[filterKey][column]) {
+ // overwrite default values by dashboard default_filters
+ return preselectedFilters[filterKey][column];
+ }
+ return null;
+};
+
+const getFilterBoxDefaultValues = (config: FilterConfig) => {
+ let defaultValues = config[FILTER_CONFIG_ATTRIBUTES.DEFAULT_VALUE];
+
+ // treat empty string as null (no default value)
+ if (defaultValues === '') {
+ defaultValues = null;
+ }
+
+ // defaultValue could be ; separated values,
+ // could be null or ''
+ if (defaultValues && config[FILTER_CONFIG_ATTRIBUTES.MULTIPLE]) {
+ defaultValues = config.defaultValue.split(';');
+ }
+
+ return defaultValues;
+};
+
+const setValuesInArray = (value1: any, value2: any) => {
+ if (!isEmpty(value1)) {
+ return [value1];
+ }
+ if (!isEmpty(value2)) {
+ return [value2];
+ }
+ return [];
+};
+
+const getFilterboxDependencies = (filterScopes: FilterScopesMetadata) => {
+ const filterFieldsDependencies: FilterBoxDependencyMap = {};
+ const filterChartIds: number[] = Object.keys(filterScopes).map(key =>
+ parseInt(key, 10),
+ );
+ Object.entries(filterScopes).forEach(([key, filterFields]) => {
+ filterFieldsDependencies[key] = {};
+ Object.entries(filterFields).forEach(([filterField, filterScope]) => {
+ filterFieldsDependencies[key][filterField] = getChartIdsInFilterScope({
+ filterScope,
+ }).filter(
+ chartId => filterChartIds.includes(chartId) && String(chartId) !== key,
+ );
+ });
+ });
+ return filterFieldsDependencies;
+};
+
+export default function getNativeFilterConfig(
+ chartData: SliceData[] = [],
+ filterScopes: FilterScopesMetadata = {},
+ preselectFilters: PreselectedFiltersMeatadata = {},
+): Filter[] {
+ const filterConfig: Filter[] = [];
+ const filterBoxToFilterComponentMap: FilterBoxToFilterComponentMap = {};
+
+ chartData.forEach(slice => {
+ const key = String(slice.slice_id);
+
+ if (slice.form_data.viz_type === 'filter_box') {
+ filterBoxToFilterComponentMap[key] = {};
+ const configs = getFilterConfigsFromFormdata(slice.form_data);
+ let { columns } = configs;
+ if (preselectFilters[key]) {
+ Object.keys(columns).forEach(col => {
+ if (preselectFilters[key][col]) {
+ columns = {
+ ...columns,
+ [col]: preselectFilters[key][col],
+ };
+ }
+ });
+ }
+
+ const scopesByChartId = Object.keys(columns).reduce((map, column) => {
+ const scopeSettings = {
+ ...filterScopes[key],
+ };
+ const { scope, immune }: FilterScopeType = {
+ ...DASHBOARD_FILTER_SCOPE_GLOBAL,
+ ...scopeSettings[column],
+ };
+
+ return {
+ ...map,
+ [column]: {
+ scope,
+ immune,
+ },
+ };
+ }, {});
+
+ const {
+ adhoc_filters = [],
+ datasource = '',
+ date_filter = false,
+ druid_time_origin,
+ filter_configs = [],
+ granularity,
+ granularity_sqla,
+ show_druid_time_granularity = false,
+ show_druid_time_origin = false,
+ show_sqla_time_column = false,
+ show_sqla_time_granularity = false,
+ time_grain_sqla,
+ time_range,
+ } = slice.form_data;
+
+ const getDashboardDefaultValues = getPreselectedValuesFromDashboard(
+ preselectFilters,
+ );
+
+ if (date_filter) {
+ const { scope, immune }: FilterScopeType =
+ scopesByChartId[TIME_FILTER_MAP.time_range] ||
+ DASHBOARD_FILTER_SCOPE_GLOBAL;
+ const timeRangeFilter: Filter = {
+ id: `NATIVE_FILTER-${shortid.generate()}`,
+ description: 'time range filter',
+ controlValues: {},
+ name: TIME_FILTER_LABELS.time_range,
+ filterType: FILTER_COMPONENT_FILTER_TYPES.FILTER_TIME,
+ targets: [{}],
+ cascadeParentIds: [],
+ defaultDataMask: {},
+ type: NativeFilterType.NATIVE_FILTER,
+ scope: {
+ rootPath: scope,
+ excluded: immune,
+ },
+ };
+ filterBoxToFilterComponentMap[key][TIME_FILTER_MAP.time_range] =
+ timeRangeFilter.id;
+ const dashboardDefaultValues =
+ getDashboardDefaultValues(key, TIME_FILTER_MAP.time_range) ||
+ time_range;
+ if (!isEmpty(dashboardDefaultValues)) {
+ timeRangeFilter.defaultDataMask = {
+ extraFormData: { time_range: dashboardDefaultValues as string },
+ filterState: { value: dashboardDefaultValues },
+ };
+ }
+ filterConfig.push(timeRangeFilter);
+
+ if (show_sqla_time_granularity) {
+ const { scope, immune }: FilterScopeType =
+ scopesByChartId[TIME_FILTER_MAP.time_grain_sqla] ||
+ DASHBOARD_FILTER_SCOPE_GLOBAL;
+ const timeGrainFilter: Filter = {
+ id: `NATIVE_FILTER-${shortid.generate()}`,
+ controlValues: {},
+ description: 'time grain filter',
+ name: TIME_FILTER_LABELS.time_grain_sqla,
+ filterType: FILTER_COMPONENT_FILTER_TYPES.FILTER_TIMEGRAIN,
+ targets: [
+ {
+ datasetId: parseInt(datasource.split('__')[0], 10),
+ },
+ ],
+ cascadeParentIds: [],
+ defaultDataMask: {},
+ type: NativeFilterType.NATIVE_FILTER,
+ scope: {
+ rootPath: scope,
+ excluded: immune,
+ },
+ };
+ filterBoxToFilterComponentMap[key][TIME_FILTER_MAP.time_grain_sqla] =
+ timeGrainFilter.id;
+ const dashboardDefaultValues = getDashboardDefaultValues(
+ key,
+ TIME_FILTER_MAP.time_grain_sqla,
+ );
+ if (!isEmpty(dashboardDefaultValues)) {
+ timeGrainFilter.defaultDataMask = {
+ extraFormData: {
+ time_grain_sqla: (dashboardDefaultValues ||
+ time_grain_sqla) as TimeGranularity,
+ },
+ filterState: {
+ value: setValuesInArray(
+ dashboardDefaultValues,
+ time_grain_sqla,
+ ),
+ },
+ };
+ }
+ filterConfig.push(timeGrainFilter);
+ }
+
+ if (show_sqla_time_column) {
+ const { scope, immune }: FilterScopeType =
+ scopesByChartId[TIME_FILTER_MAP.granularity_sqla] ||
+ DASHBOARD_FILTER_SCOPE_GLOBAL;
+ const timeColumnFilter: Filter = {
+ id: `NATIVE_FILTER-${shortid.generate()}`,
+ description: 'time column filter',
+ controlValues: {},
+ name: TIME_FILTER_LABELS.granularity_sqla,
+ filterType: FILTER_COMPONENT_FILTER_TYPES.FILTER_TIMECOLUMN,
+ targets: [
+ {
+ datasetId: parseInt(datasource.split('__')[0], 10),
+ },
+ ],
+ cascadeParentIds: [],
+ defaultDataMask: {},
+ type: NativeFilterType.NATIVE_FILTER,
+ scope: {
+ rootPath: scope,
+ excluded: immune,
+ },
+ };
+ filterBoxToFilterComponentMap[key][TIME_FILTER_MAP.granularity_sqla] =
+ timeColumnFilter.id;
+ const dashboardDefaultValues = getDashboardDefaultValues(
+ key,
+ TIME_FILTER_MAP.granularity_sqla,
+ );
+ if (!isEmpty(dashboardDefaultValues)) {
+ timeColumnFilter.defaultDataMask = {
+ extraFormData: {
+ granularity_sqla: (dashboardDefaultValues ||
+ granularity_sqla) as string,
+ },
+ filterState: {
+ value: setValuesInArray(
+ dashboardDefaultValues,
+ granularity_sqla,
+ ),
+ },
+ };
+ }
+ filterConfig.push(timeColumnFilter);
+ }
+
+ if (show_druid_time_granularity) {
+ const { scope, immune }: FilterScopeType =
+ scopesByChartId[TIME_FILTER_MAP.granularity] ||
+ DASHBOARD_FILTER_SCOPE_GLOBAL;
+ const druidGranularityFilter: Filter = {
+ id: `NATIVE_FILTER-${shortid.generate()}`,
+ description: 'time grain filter',
+ controlValues: {},
+ name: TIME_FILTER_LABELS.granularity,
+ filterType: FILTER_COMPONENT_FILTER_TYPES.FILTER_TIMEGRAIN,
+ targets: [
+ {
+ datasetId: parseInt(datasource.split('__')[0], 10),
+ },
+ ],
+ cascadeParentIds: [],
+ defaultDataMask: {},
+ type: NativeFilterType.NATIVE_FILTER,
+ scope: {
+ rootPath: scope,
+ excluded: immune,
+ },
+ };
+ filterBoxToFilterComponentMap[key][TIME_FILTER_MAP.granularity] =
+ druidGranularityFilter.id;
+ const dashboardDefaultValues = getDashboardDefaultValues(
+ key,
+ TIME_FILTER_MAP.granularity,
+ );
+ if (!isEmpty(dashboardDefaultValues)) {
+ druidGranularityFilter.defaultDataMask = {
+ extraFormData: {
+ granularity_sqla: (dashboardDefaultValues ||
+ granularity) as string,
+ },
+ filterState: {
+ value: setValuesInArray(dashboardDefaultValues, granularity),
+ },
+ };
+ }
+ filterConfig.push(druidGranularityFilter);
+ }
+
+ if (show_druid_time_origin) {
+ const { scope, immune }: FilterScopeType =
+ scopesByChartId[TIME_FILTER_MAP.druid_time_origin] ||
+ DASHBOARD_FILTER_SCOPE_GLOBAL;
+ const druidOriginFilter: Filter = {
+ id: `NATIVE_FILTER-${shortid.generate()}`,
+ description: 'time column filter',
+ controlValues: {},
+ name: TIME_FILTER_LABELS.druid_time_origin,
+ filterType: FILTER_COMPONENT_FILTER_TYPES.FILTER_TIMECOLUMN,
+ targets: [
+ {
+ datasetId: parseInt(datasource.split('__')[0], 10),
+ },
+ ],
+ cascadeParentIds: [],
+ defaultDataMask: {},
+ type: NativeFilterType.NATIVE_FILTER,
+ scope: {
+ rootPath: scope,
+ excluded: immune,
+ },
+ };
+ filterBoxToFilterComponentMap[key][
+ TIME_FILTER_MAP.druid_time_origin
+ ] = druidOriginFilter.id;
+ const dashboardDefaultValues = getDashboardDefaultValues(
+ key,
+ TIME_FILTER_MAP.druid_time_origin,
+ );
+ if (!isEmpty(dashboardDefaultValues)) {
+ druidOriginFilter.defaultDataMask = {
+ extraFormData: {
+ granularity_sqla: (dashboardDefaultValues ||
+ druid_time_origin) as string,
+ },
+ filterState: {
+ value: setValuesInArray(
+ dashboardDefaultValues,
+ druid_time_origin,
+ ),
+ },
+ };
+ }
+ filterConfig.push(druidOriginFilter);
+ }
+ }
+
+ filter_configs.forEach(config => {
+ const { scope, immune }: FilterScopeType =
+ scopesByChartId[config.column] || DASHBOARD_FILTER_SCOPE_GLOBAL;
+ const entry: Filter = {
+ id: `NATIVE_FILTER-${shortid.generate()}`,
+ description: '',
+ controlValues: {
+ enableEmptyFilter: !config[FILTER_CONFIG_ATTRIBUTES.CLEARABLE],
+ defaultToFirstItem: false,
+ inverseSelection: false,
+ multiSelect: config[FILTER_CONFIG_ATTRIBUTES.MULTIPLE],
+ sortAscending: config[FILTER_CONFIG_ATTRIBUTES.SORT_ASCENDING],
+ },
+ name: config.label || config.column,
+ filterType: FILTER_COMPONENT_FILTER_TYPES.FILTER_SELECT,
+ targets: [
+ {
+ datasetId: parseInt(datasource.split('__')[0], 10),
+ column: {
+ name: config.column,
+ },
+ },
+ ],
+ cascadeParentIds: [],
+ defaultDataMask: {},
+ type: NativeFilterType.NATIVE_FILTER,
+ scope: {
+ rootPath: scope,
+ excluded: immune,
+ },
+ adhoc_filters,
+ sortMetric: config[FILTER_CONFIG_ATTRIBUTES.SORT_METRIC],
+ time_range,
+ };
+ filterBoxToFilterComponentMap[key][config.column] = entry.id;
+ const defaultValues =
+ getDashboardDefaultValues(key, config.column) ||
+ getFilterBoxDefaultValues(config);
+ if (!isEmpty(defaultValues)) {
+ entry.defaultDataMask = {
+ extraFormData: {
+ filters: [{ col: config.column, op: 'IN', val: defaultValues }],
+ },
+ filterState: { value: defaultValues },
+ };
+ }
+ filterConfig.push(entry);
+ });
+ }
+ });
+
+ const dependencies: FilterBoxDependencyMap = getFilterboxDependencies(
+ filterScopes,
+ );
+ Object.entries(dependencies).forEach(([key, filterFields]) => {
+ Object.entries(filterFields).forEach(([field, childrenChartIds]) => {
+ const parentComponentId = filterBoxToFilterComponentMap[key][field];
+ childrenChartIds.forEach(childrenChartId => {
+ const childComponentIds = Object.values(
+ filterBoxToFilterComponentMap[childrenChartId],
+ );
+ childComponentIds.forEach(childComponentId => {
+ const childComponent = find(
+ filterConfig,
+ ({ id }) => id === childComponentId,
+ );
+ if (
+ childComponent &&
+ // time related filter components don't have parent
+ [
+ FILTER_COMPONENT_FILTER_TYPES.FILTER_SELECT,
+ FILTER_COMPONENT_FILTER_TYPES.FILTER_RANGE,
+ ].includes(
+ childComponent.filterType as FILTER_COMPONENT_FILTER_TYPES,
+ )
+ ) {
+ childComponent.cascadeParentIds ||= [];
+ childComponent.cascadeParentIds.push(parentComponentId);
+ }
+ });
+ });
+ });
+ });
+
+ return filterConfig;
+}
diff --git a/superset-frontend/src/explore/constants.ts b/superset-frontend/src/explore/constants.ts
index c3b31f1de568a..0f28b233879ad 100644
--- a/superset-frontend/src/explore/constants.ts
+++ b/superset-frontend/src/explore/constants.ts
@@ -115,10 +115,12 @@ export const TIME_FILTER_LABELS = {
};
export const FILTER_CONFIG_ATTRIBUTES = {
+ CLEARABLE: 'clearable',
DEFAULT_VALUE: 'defaultValue',
MULTIPLE: 'multiple',
SEARCH_ALL_OPTIONS: 'searchAllOptions',
- CLEARABLE: 'clearable',
+ SORT_ASCENDING: 'asc',
+ SORT_METRIC: 'metric',
};
export const FILTER_OPTIONS_LIMIT = 1000;
@@ -137,3 +139,15 @@ export const TIME_FILTER_MAP = {
// TODO: make this configurable per Superset installation
export const DEFAULT_TIME_RANGE = 'No filter';
export const NO_TIME_RANGE = 'No filter';
+
+export enum FILTER_BOX_MIGRATION_STATES {
+ CONVERTED = 'CONVERTED',
+ NOOP = 'NOOP',
+ REVIEWING = 'REVIEWING',
+ SNOOZED = 'SNOOZED',
+ UNDECIDED = 'UNDECIDED',
+}
+
+export const FILTER_BOX_TRANSITION_SNOOZED_AT =
+ 'filter_box_transition_snoozed_at';
+export const FILTER_BOX_TRANSITION_SNOOZE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
diff --git a/superset-frontend/src/types/Chart.ts b/superset-frontend/src/types/Chart.ts
index 4c670482b43b2..4ef386dcb78db 100644
--- a/superset-frontend/src/types/Chart.ts
+++ b/superset-frontend/src/types/Chart.ts
@@ -38,6 +38,9 @@ export interface Chart {
thumbnail_url?: string;
owners?: Owner[];
datasource_name_text?: string;
+ form_data: {
+ viz_type: string;
+ };
}
export type Slice = {
diff --git a/superset/config.py b/superset/config.py
index 779a54211e72b..db34343782222 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -392,6 +392,7 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]:
"OMNIBAR": False,
"DASHBOARD_RBAC": False,
"ENABLE_EXPLORE_DRAG_AND_DROP": False,
+ "ENABLE_FILTER_BOX_MIGRATION": False,
"ENABLE_DND_WITH_CLICK_UX": False,
# Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message
# with screenshot and link
diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py
index 493d7f33d0726..5e03d24d13fd4 100644
--- a/superset/views/dashboard/views.py
+++ b/superset/views/dashboard/views.py
@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+import json
import re
from typing import List, Union
@@ -116,8 +117,17 @@ class Dashboard(BaseSupersetView):
@expose("/new/")
def new(self) -> FlaskResponse: # pylint: disable=no-self-use
"""Creates a new, blank dashboard and redirects to it in edit mode"""
+ metadata = {}
+ if is_feature_enabled("ENABLE_FILTER_BOX_MIGRATION"):
+ metadata = {
+ "native_filter_configuration": [],
+ "show_native_filters": True,
+ }
+
new_dashboard = DashboardModel(
- dashboard_title="[ untitled dashboard ]", owners=[g.user]
+ dashboard_title="[ untitled dashboard ]",
+ owners=[g.user],
+ json_metadata=json.dumps(metadata, sort_keys=True),
)
db.session.add(new_dashboard)
db.session.commit()