From 7d22c9ce170d0bd6ad31c680d007881b61dd378f Mon Sep 17 00:00:00 2001 From: Grace Guo Date: Tue, 9 Nov 2021 09:55:25 -0800 Subject: [PATCH] feat(native_filter_migration): add transition mode (#16992) * feat: [Migrate filter_box to filter component] add transition mode * rebase and fix comments * rebase and fix commnent -- patch 2 --- superset-frontend/src/chart/Chart.jsx | 23 +- .../common/hooks/apiResources/apiResources.ts | 16 +- .../common/hooks/apiResources/dashboards.ts | 3 +- superset-frontend/src/constants.ts | 4 + .../src/dashboard/actions/hydrate.js | 72 ++- .../src/dashboard/actions/sliceEntities.js | 9 +- .../components/DashboardBuilder/state.ts | 12 +- .../components/FilterBoxMigrationModal.tsx | 100 ++++ .../Header/HeaderActionsDropdown/index.jsx | 21 +- .../src/dashboard/components/Header/index.jsx | 11 +- .../src/dashboard/components/SliceAdder.jsx | 12 +- .../components/gridComponents/Chart.jsx | 20 +- .../components/gridComponents/Tabs.jsx | 9 +- .../nativeFilters/FilterBar/state.ts | 31 +- .../components/nativeFilters/types.ts | 3 + .../src/dashboard/containers/Chart.jsx | 1 + .../dashboard/containers/DashboardHeader.jsx | 1 + .../dashboard/containers/DashboardPage.tsx | 144 ++++- .../src/dashboard/containers/SliceAdder.jsx | 7 +- superset-frontend/src/dashboard/types.ts | 3 +- .../dashboard/util/activeDashboardFilters.js | 7 +- .../util/filterboxMigrationHelper.test.ts | 144 +++++ .../util/filterboxMigrationHelper.ts | 525 ++++++++++++++++++ superset-frontend/src/explore/constants.ts | 16 +- superset-frontend/src/types/Chart.ts | 3 + superset/config.py | 1 + superset/views/dashboard/views.py | 12 +- 27 files changed, 1132 insertions(+), 78 deletions(-) create mode 100644 superset-frontend/src/dashboard/components/FilterBoxMigrationModal.tsx create mode 100644 superset-frontend/src/dashboard/util/filterboxMigrationHelper.test.ts create mode 100644 superset-frontend/src/dashboard/util/filterboxMigrationHelper.ts 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, +}) => ( + + + + + + } + 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()