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
This commit is contained in:
Grace Guo 2021-11-09 09:55:25 -08:00 committed by GitHub
parent 6b1de57207
commit 7d22c9ce17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1133 additions and 79 deletions

View File

@ -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}
>
<div
className={`slice_container ${isFaded ? ' faded' : ''}`}
@ -266,7 +283,7 @@ class Chart extends React.PureComponent {
</RefreshOverlayWrapper>
)}
{isLoading && <Loading />}
{isLoading && !isDeactivatedViz && <Loading />}
</Styles>
</ErrorBoundary>
);

View File

@ -153,10 +153,18 @@ export function useTransformedResource<IN, OUT>(
// 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]);
}

View File

@ -26,7 +26,8 @@ export const useDashboard = (idOrSlug: string | number) =>
useApiV1Resource<Dashboard>(`/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),
}),

View File

@ -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',

View File

@ -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);
}
const nativeFilters = getInitialNativeFilterState({
filterConfig: metadata?.native_filter_configuration || [],
});
if (!metadata) {
metadata = {};
// 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.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,
},

View File

@ -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,

View File

@ -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) {

View File

@ -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<FilterBoxMigrationModalProps> = ({
onClickReview,
onClickSnooze,
onHide,
show,
hideFooter = false,
}) => (
<StyledFilterBoxMigrationModal
show={show}
onHide={onHide}
title={t('Ready to review filters in this dashboard?')}
hideFooter={hideFooter}
footer={
<>
<Button buttonSize="small" onClick={onClickSnooze}>
{t('Remind me in 24 hours')}
</Button>
<Button buttonSize="small" onClick={onHide}>
{t('Cancel')}
</Button>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={onClickReview}
>
{t('Start Review')}
</Button>
</>
}
responsive
>
<div>
{t(
'filter_box will be deprecated ' +
'in a future version of Superset. ' +
'Please replace filter_box by dashboard filter components.',
)}
</div>
</StyledFilterBoxMigrationModal>
);
export default FilterBoxMigrationModal;

View File

@ -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 {
/>
</Menu.Item>
{editMode && (
<Menu.Item key={MENU_KEYS.SET_FILTER_MAPPING}>
<FilterScopeModal
className="m-r-5"
triggerNode={t('Set filter mapping')}
/>
</Menu.Item>
)}
{editMode &&
filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.CONVERTED && (
<Menu.Item key={MENU_KEYS.SET_FILTER_MAPPING}>
<FilterScopeModal
className="m-r-5"
triggerNode={t('Set filter mapping')}
/>
</Menu.Item>
)}
{editMode && (
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>

View File

@ -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}
/>
</div>
</StyledDashboardHeader>

View File

@ -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) {

View File

@ -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) && (
<ChartOverlay
className={cx(isDeactivatedViz && 'is-deactivated')}
style={{
width,
height: this.getChartHeight(),
}}
/>
)}
<ChartContainer
width={width}
height={this.getChartHeight()}
@ -408,6 +422,8 @@ export default class Chart extends React.Component {
timeout={timeout}
triggerQuery={chart.triggerQuery}
vizType={slice.viz_type}
isDeactivatedViz={isDeactivatedViz}
filterboxMigrationState={filterboxMigrationState}
/>
</div>
</div>

View File

@ -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);

View File

@ -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<any, FilterSetsType>(
@ -102,14 +105,30 @@ export const useFilterUpdates = (
export const useInitialization = () => {
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const filters = useFilters();
const charts = useSelector<RootState, ChartsState>(state => state.charts);
const filterboxMigrationState = useContext(MigrationContext);
let charts = useSelector<RootState, ChartsState>(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) {

View File

@ -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[];

View File

@ -97,6 +97,7 @@ function mapStateToProps(
ownState: dataMask[id]?.ownState,
filterState: dataMask[id]?.filterState,
maxRows: common.conf.SQL_MAX_ROW,
filterboxMigrationState: dashboardState.filterboxMigrationState,
};
}

View File

@ -101,6 +101,7 @@ function mapStateToProps({
slug: dashboardInfo.slug,
metadata: dashboardInfo.metadata,
reports,
filterboxMigrationState: dashboardState.filterboxMigrationState,
};
}

View File

@ -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<any, UserWithPermissionsAndRoles>(
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,
);
if (readyToRender && !isDashboardHydrated.current) {
isDashboardHydrated.current = true;
dispatch(hydrateDashboard(dashboard, charts));
if (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET)) {
dispatch(getFilterSets());
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;
}
// 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 <Loading />;
return <DashboardContainer />;
return (
<>
<FilterBoxMigrationModal
show={filterboxMigrationState === FILTER_BOX_MIGRATION_STATES.UNDECIDED}
hideFooter={!isMigrationEnabled}
onHide={() => {
// 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);
}}
/>
<MigrationContext.Provider value={filterboxMigrationState}>
<DashboardContainer />
</MigrationContext.Provider>
</>
);
};
export default DashboardPage;

View File

@ -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,
};
}

View File

@ -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 */

View File

@ -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),
);

View File

@ -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: '<bound method AuditMixinNullable.modified of Region Filter>',
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: "<bound method AuditMixinNullable.modified of World's Population>",
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'],
},
]);
});

View File

@ -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;
}

View File

@ -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

View File

@ -38,6 +38,9 @@ export interface Chart {
thumbnail_url?: string;
owners?: Owner[];
datasource_name_text?: string;
form_data: {
viz_type: string;
};
}
export type Slice = {

View File

@ -392,6 +392,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
"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

View File

@ -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()