diff --git a/superset-frontend/src/components/DropdownSelectableIcon/index.tsx b/superset-frontend/src/components/DropdownSelectableIcon/index.tsx index 85c4284439f19..582bc182e3c15 100644 --- a/superset-frontend/src/components/DropdownSelectableIcon/index.tsx +++ b/superset-frontend/src/components/DropdownSelectableIcon/index.tsx @@ -100,24 +100,25 @@ const StyleSubmenuItem = styled.div` export default (props: DropDownSelectableProps) => { const theme = useTheme(); const { icon, info, menuItems, selectedKeys, onSelect } = props; - const menuItem = ( - label: string | React.ReactNode, - key: string, - divider?: boolean, - ) => ( - - - {label} - {selectedKeys?.includes(key) && ( - - )} - - + const menuItem = useMemo( + () => (label: string | React.ReactNode, key: string, divider?: boolean) => + ( + + + {label} + {selectedKeys?.includes(key) && ( + + )} + + + ), + [selectedKeys, theme.colors.primary.base], ); + const overlayMenu = useMemo( () => ( @@ -141,7 +142,7 @@ export default (props: DropDownSelectableProps) => { )} ), - [info, menuItems], + [selectedKeys, onSelect, info, menuItems, menuItem], ); return ( diff --git a/superset-frontend/src/components/Modal/Modal.tsx b/superset-frontend/src/components/Modal/Modal.tsx index d1e1affcfbe98..fc9b82168397e 100644 --- a/superset-frontend/src/components/Modal/Modal.tsx +++ b/superset-frontend/src/components/Modal/Modal.tsx @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useMemo, useRef, useState } from 'react'; +import React, { + CSSProperties, + ReactNode, + useMemo, + useRef, + useState, +} from 'react'; import { isNil } from 'lodash'; import { ModalFuncProps } from 'antd/lib/modal'; import { styled, t } from '@superset-ui/core'; @@ -33,7 +39,7 @@ import Draggable, { export interface ModalProps { className?: string; - children: React.ReactNode; + children: ReactNode; disablePrimaryButton?: boolean; primaryButtonLoading?: boolean; onHide: () => void; @@ -42,13 +48,13 @@ export interface ModalProps { primaryButtonType?: 'primary' | 'danger'; show: boolean; name?: string; - title: React.ReactNode; + title: ReactNode; width?: string; maxWidth?: string; responsive?: boolean; hideFooter?: boolean; centered?: boolean; - footer?: React.ReactNode; + footer?: ReactNode; wrapProps?: object; height?: string; closable?: boolean; @@ -59,6 +65,7 @@ export interface ModalProps { destroyOnClose?: boolean; maskClosable?: boolean; zIndex?: number; + bodyStyle?: CSSProperties; } interface StyledModalProps { diff --git a/superset-frontend/src/dashboard/actions/dashboardInfo.ts b/superset-frontend/src/dashboard/actions/dashboardInfo.ts index bbc06d37b0ad4..472f945b5469e 100644 --- a/superset-frontend/src/dashboard/actions/dashboardInfo.ts +++ b/superset-frontend/src/dashboard/actions/dashboardInfo.ts @@ -17,16 +17,17 @@ * under the License. */ import { Dispatch } from 'redux'; -import { makeApi, CategoricalColorNamespace } from '@superset-ui/core'; +import { makeApi, CategoricalColorNamespace, t } from '@superset-ui/core'; import { isString } from 'lodash'; import { getErrorText } from 'src/utils/getClientErrorObject'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { + ChartConfiguration, DashboardInfo, FilterBarOrientation, + GlobalChartCrossFilterConfig, RootState, } from 'src/dashboard/types'; -import { ChartConfiguration } from 'src/dashboard/reducers/types'; import { onSave } from './dashboardState'; export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED'; @@ -66,27 +67,23 @@ export function dashboardInfoChanged(newInfo: { metadata: any }) { return { type: DASHBOARD_INFO_UPDATED, newInfo }; } -export const SET_CHART_CONFIG_BEGIN = 'SET_CHART_CONFIG_BEGIN'; -export interface SetChartConfigBegin { - type: typeof SET_CHART_CONFIG_BEGIN; - chartConfiguration: ChartConfiguration; -} -export const SET_CHART_CONFIG_COMPLETE = 'SET_CHART_CONFIG_COMPLETE'; -export interface SetChartConfigComplete { - type: typeof SET_CHART_CONFIG_COMPLETE; - chartConfiguration: ChartConfiguration; -} -export const SET_CHART_CONFIG_FAIL = 'SET_CHART_CONFIG_FAIL'; -export interface SetChartConfigFail { - type: typeof SET_CHART_CONFIG_FAIL; - chartConfiguration: ChartConfiguration; -} -export const setChartConfiguration = - (chartConfiguration: ChartConfiguration) => - async (dispatch: Dispatch, getState: () => any) => { +export const SAVE_CHART_CONFIG_BEGIN = 'SAVE_CHART_CONFIG_BEGIN'; +export const SAVE_CHART_CONFIG_COMPLETE = 'SAVE_CHART_CONFIG_COMPLETE'; +export const SAVE_CHART_CONFIG_FAIL = 'SAVE_CHART_CONFIG_FAIL'; + +export const saveChartConfiguration = + ({ + chartConfiguration, + globalChartConfiguration, + }: { + chartConfiguration?: ChartConfiguration; + globalChartConfiguration?: GlobalChartCrossFilterConfig; + }) => + async (dispatch: Dispatch, getState: () => RootState) => { dispatch({ - type: SET_CHART_CONFIG_BEGIN, + type: SAVE_CHART_CONFIG_BEGIN, chartConfiguration, + globalChartConfiguration, }); const { id, metadata } = getState().dashboardInfo; @@ -103,7 +100,10 @@ export const setChartConfiguration = const response = await updateDashboard({ json_metadata: JSON.stringify({ ...metadata, - chart_configuration: chartConfiguration, + chart_configuration: + chartConfiguration ?? metadata.chart_configuration, + global_chart_configuration: + globalChartConfiguration ?? metadata.global_chart_configuration, }), }); dispatch( @@ -112,19 +112,22 @@ export const setChartConfiguration = }), ); dispatch({ - type: SET_CHART_CONFIG_COMPLETE, + type: SAVE_CHART_CONFIG_COMPLETE, chartConfiguration, + globalChartConfiguration, }); } catch (err) { - dispatch({ type: SET_CHART_CONFIG_FAIL, chartConfiguration }); + dispatch({ + type: SAVE_CHART_CONFIG_FAIL, + chartConfiguration, + globalChartConfiguration, + }); + dispatch(addDangerToast(t('Failed to save cross-filter scoping'))); } }; export const SET_FILTER_BAR_ORIENTATION = 'SET_FILTER_BAR_ORIENTATION'; -export interface SetFilterBarOrientation { - type: typeof SET_FILTER_BAR_ORIENTATION; - filterBarOrientation: FilterBarOrientation; -} + export function setFilterBarOrientation( filterBarOrientation: FilterBarOrientation, ) { @@ -132,10 +135,7 @@ export function setFilterBarOrientation( } export const SET_CROSS_FILTERS_ENABLED = 'SET_CROSS_FILTERS_ENABLED'; -export interface SetCrossFiltersEnabled { - type: typeof SET_CROSS_FILTERS_ENABLED; - crossFiltersEnabled: boolean; -} + export function setCrossFiltersEnabled(crossFiltersEnabled: boolean) { return { type: SET_CROSS_FILTERS_ENABLED, crossFiltersEnabled }; } diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index d3216411ebf7b..54c5294fba4fe 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -56,9 +56,9 @@ import { logEvent } from 'src/logger/actions'; import { LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA } from 'src/logger/LogUtils'; import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout'; import { - setChartConfiguration, + saveChartConfiguration, dashboardInfoChanged, - SET_CHART_CONFIG_COMPLETE, + SAVE_CHART_CONFIG_COMPLETE, } from './dashboardInfo'; import { fetchDatasourceMetadata } from './datasources'; import { @@ -89,7 +89,6 @@ export function toggleFaveStar(isStarred) { return { type: TOGGLE_FAVE_STAR, isStarred }; } -export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR'; export function fetchFaveStar(id) { return function fetchFaveStarThunk(dispatch) { return SupersetClient.get({ @@ -110,7 +109,6 @@ export function fetchFaveStar(id) { }; } -export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR'; export function saveFaveStar(id, isStarred) { return function saveFaveStarThunk(dispatch) { const endpoint = `/api/v1/dashboard/${id}/favorites/`; @@ -287,13 +285,11 @@ export function saveDashboardRequest(data, id, saveType) { const { dashboardLayout, charts, - dashboardInfo: { - metadata: { chart_configuration = {} }, - }, + dashboardInfo: { metadata }, } = getState(); return getCrossFiltersConfiguration( dashboardLayout.present, - chart_configuration, + metadata, charts, ); }; @@ -304,8 +300,14 @@ export function saveDashboardRequest(data, id, saveType) { dispatch(saveDashboardRequestSuccess(lastModifiedTime)); } if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { - const chartConfiguration = handleChartConfiguration(); - dispatch(setChartConfiguration(chartConfiguration)); + const { chartConfiguration, globalChartConfiguration } = + handleChartConfiguration(); + dispatch( + saveChartConfiguration({ + chartConfiguration, + globalChartConfiguration, + }), + ); } dispatch(saveDashboardFinished()); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); @@ -325,7 +327,7 @@ export function saveDashboardRequest(data, id, saveType) { ); if (metadata.chart_configuration) { dispatch({ - type: SET_CHART_CONFIG_COMPLETE, + type: SAVE_CHART_CONFIG_COMPLETE, chartConfiguration: metadata.chart_configuration, }); } @@ -373,8 +375,10 @@ export function saveDashboardRequest(data, id, saveType) { [SAVE_TYPE_OVERWRITE, SAVE_TYPE_OVERWRITE_CONFIRMED].includes(saveType) ) { let chartConfiguration = {}; + let globalChartConfiguration = {}; if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { - chartConfiguration = handleChartConfiguration(); + ({ chartConfiguration, globalChartConfiguration } = + handleChartConfiguration()); } const updatedDashboard = saveType === SAVE_TYPE_OVERWRITE_CONFIRMED @@ -392,6 +396,7 @@ export function saveDashboardRequest(data, id, saveType) { default_filters: safeStringify(serializedFilters), filter_scopes: serializedFilterScopes, chart_configuration: chartConfiguration, + global_chart_configuration: globalChartConfiguration, }), }; @@ -601,13 +606,6 @@ export function setColorScheme(colorScheme) { return { type: SET_COLOR_SCHEME, colorScheme }; } -export function setColorSchemeAndUnsavedChanges(colorScheme) { - return dispatch => { - dispatch(setColorScheme(colorScheme)); - dispatch(setUnsavedChanges(true)); - }; -} - export const SET_DIRECT_PATH = 'SET_DIRECT_PATH'; export function setDirectPathToChild(path) { return { type: SET_DIRECT_PATH, path }; diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index 4f1b94379b7eb..9cd45eced43ea 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -309,11 +309,14 @@ export const hydrateDashboard = }); if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { - metadata.chart_configuration = getCrossFiltersConfiguration( - dashboardLayout.present, - metadata.chart_configuration, - chartQueries, - ); + const { chartConfiguration, globalChartConfiguration } = + getCrossFiltersConfiguration( + dashboardLayout.present, + metadata, + chartQueries, + ); + metadata.chart_configuration = chartConfiguration; + metadata.global_chart_configuration = globalChartConfiguration; } const { roles } = user; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index fc1570a975005..cc4e2db780e74 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -34,7 +34,6 @@ import pick from 'lodash/pick'; import Tabs from 'src/components/Tabs'; import DashboardGrid from 'src/dashboard/containers/DashboardGrid'; import { - ChartsState, DashboardInfo, DashboardLayout, LayoutItem, @@ -86,7 +85,9 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { const directPathToChild = useSelector( state => state.dashboardState.directPathToChild, ); - const charts = useSelector(state => state.charts); + const chartIds = useSelector(state => + Object.values(state.charts).map(chart => chart.id), + ); const tabIndex = useMemo(() => { const nextTabIndex = findTabIndexByComponentId({ @@ -116,7 +117,7 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { } const chartsInScope: number[] = getChartIdsInFilterScope( filterScope.scope, - charts, + chartIds, dashboardLayout, ); const tabsInScope = findTabsWithChartsInScope( @@ -207,7 +208,7 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { } } } - }, [charts]); + }, [chartIds]); useComponentDidUpdate(verifyUpdateColorScheme); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 71a158503613b..bba888710a54c 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -31,13 +31,16 @@ import { } from 'react-router-dom'; import moment from 'moment'; import { + Behavior, css, FeatureFlag, + getChartMetadataRegistry, QueryFormData, styled, t, useTheme, } from '@superset-ui/core'; +import { useSelector } from 'react-redux'; import { Menu } from 'src/components/Menu'; import { NoAnimationDropdown } from 'src/components/Dropdown'; import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; @@ -53,6 +56,8 @@ import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane'; import Modal from 'src/components/Modal'; import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail'; import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; +import { RootState } from 'src/dashboard/types'; +import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal'; const MENU_KEYS = { DOWNLOAD_AS_IMAGE: 'download_as_image', @@ -65,6 +70,7 @@ const MENU_KEYS = { VIEW_QUERY: 'view_query', VIEW_RESULTS: 'view_results', DRILL_TO_DETAIL: 'drill_to_detail', + CROSS_FILTER_SCOPING: 'cross_filter_scoping', }; // TODO: replace 3 dots with an icon @@ -152,9 +158,6 @@ export interface SliceHeaderControlsProps { } type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps & RouteComponentProps; -interface State { - showControls: boolean; -} const dropdownIconsStyles = css` &&.anticon > .anticon:first-child { @@ -240,66 +243,56 @@ const ViewResultsModalTrigger = ({ ); }; -class SliceHeaderControls extends React.PureComponent< - SliceHeaderControlsPropsWithRouter, - State -> { - constructor(props: SliceHeaderControlsPropsWithRouter) { - super(props); - this.toggleControls = this.toggleControls.bind(this); - this.refreshChart = this.refreshChart.bind(this); - this.handleMenuClick = this.handleMenuClick.bind(this); - - this.state = { - showControls: false, - }; - } +const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { + const [openScopingModal, scopingModal] = useCrossFiltersScopingModal( + props.slice.slice_id, + ); - refreshChart() { - if (this.props.updatedDttm) { - this.props.forceRefresh( - this.props.slice.slice_id, - this.props.dashboardId, - ); + const canEditCrossFilters = + useSelector( + ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, + ) && + isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) && + getChartMetadataRegistry() + .get(props.slice.viz_type) + ?.behaviors?.includes(Behavior.INTERACTIVE_CHART); + + const refreshChart = () => { + if (props.updatedDttm) { + props.forceRefresh(props.slice.slice_id, props.dashboardId); } - } - - toggleControls() { - this.setState(prevState => ({ - showControls: !prevState.showControls, - })); - } + }; - handleMenuClick({ + const handleMenuClick = ({ key, domEvent, }: { key: Key; domEvent: MouseEvent; - }) { + }) => { switch (key) { case MENU_KEYS.FORCE_REFRESH: - this.refreshChart(); - this.props.addSuccessToast(t('Data refreshed')); + refreshChart(); + props.addSuccessToast(t('Data refreshed')); break; case MENU_KEYS.TOGGLE_CHART_DESCRIPTION: // eslint-disable-next-line no-unused-expressions - this.props.toggleExpandSlice?.(this.props.slice.slice_id); + props.toggleExpandSlice?.(props.slice.slice_id); break; case MENU_KEYS.EXPLORE_CHART: // eslint-disable-next-line no-unused-expressions - this.props.logExploreChart?.(this.props.slice.slice_id); + props.logExploreChart?.(props.slice.slice_id); break; case MENU_KEYS.EXPORT_CSV: // eslint-disable-next-line no-unused-expressions - this.props.exportCSV?.(this.props.slice.slice_id); + props.exportCSV?.(props.slice.slice_id); break; case MENU_KEYS.FULLSCREEN: - this.props.handleToggleFullSize(); + props.handleToggleFullSize(); break; case MENU_KEYS.EXPORT_FULL_CSV: // eslint-disable-next-line no-unused-expressions - this.props.exportFullCSV?.(this.props.slice.slice_id); + props.exportFullCSV?.(props.slice.slice_id); break; case MENU_KEYS.DOWNLOAD_AS_IMAGE: { // menu closes with a delay, we need to hide it manually, @@ -309,235 +302,238 @@ class SliceHeaderControls extends React.PureComponent< ) as HTMLElement; menu.style.visibility = 'hidden'; downloadAsImage( - getScreenshotNodeSelector(this.props.slice.slice_id), - this.props.slice.slice_name, + getScreenshotNodeSelector(props.slice.slice_id), + props.slice.slice_name, true, // @ts-ignore )(domEvent).then(() => { menu.style.visibility = 'visible'; }); - this.props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, { - chartId: this.props.slice.slice_id, + props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, { + chartId: props.slice.slice_id, }); break; } + case MENU_KEYS.CROSS_FILTER_SCOPING: { + openScopingModal(); + break; + } default: break; } - } + }; - render() { - const { - componentId, - dashboardId, - slice, - isFullSize, - cachedDttm = [], - updatedDttm = null, - addSuccessToast = () => {}, - addDangerToast = () => {}, - supersetCanShare = false, - isCached = [], - } = this.props; - const isTable = slice.viz_type === 'table'; - const cachedWhen = (cachedDttm || []).map(itemCachedDttm => - moment.utc(itemCachedDttm).fromNow(), - ); - const updatedWhen = updatedDttm ? moment.utc(updatedDttm).fromNow() : ''; - const getCachedTitle = (itemCached: boolean) => { - if (itemCached) { - return t('Cached %s', cachedWhen); - } - if (updatedWhen) { - return t('Fetched %s', updatedWhen); - } - return ''; - }; - const refreshTooltipData = [...new Set(isCached.map(getCachedTitle) || '')]; - // If all queries have same cache time we can unit them to one - const refreshTooltip = refreshTooltipData.map((item, index) => ( -
- {refreshTooltipData.length > 1 - ? t('Query %s: %s', index + 1, item) - : item} -
- )); - const fullscreenLabel = isFullSize - ? t('Exit fullscreen') - : t('Enter fullscreen'); - - const menu = ( - {}, + addDangerToast = () => {}, + supersetCanShare = false, + isCached = [], + } = props; + const isTable = slice.viz_type === 'table'; + const cachedWhen = (cachedDttm || []).map(itemCachedDttm => + moment.utc(itemCachedDttm).fromNow(), + ); + const updatedWhen = updatedDttm ? moment.utc(updatedDttm).fromNow() : ''; + const getCachedTitle = (itemCached: boolean) => { + if (itemCached) { + return t('Cached %s', cachedWhen); + } + if (updatedWhen) { + return t('Fetched %s', updatedWhen); + } + return ''; + }; + const refreshTooltipData = [...new Set(isCached.map(getCachedTitle) || '')]; + // If all queries have same cache time we can unit them to one + const refreshTooltip = refreshTooltipData.map((item, index) => ( +
+ {refreshTooltipData.length > 1 + ? t('Query %s: %s', index + 1, item) + : item} +
+ )); + const fullscreenLabel = isFullSize + ? t('Exit fullscreen') + : t('Enter fullscreen'); + + const menu = ( + + - - {t('Force refresh')} - - {refreshTooltip} - - + {t('Force refresh')} + + {refreshTooltip} + + - {fullscreenLabel} + {fullscreenLabel} - + - {slice.description && ( - - {this.props.isDescriptionExpanded - ? t('Hide chart description') - : t('Show chart description')} - - )} + {slice.description && ( + + {props.isDescriptionExpanded + ? t('Hide chart description') + : t('Show chart description')} + + )} + + {props.supersetCanExplore && ( + + + + {t('Edit chart')} + + + + )} - {this.props.supersetCanExplore && ( - - - - {t('Edit chart')} - - + {canEditCrossFilters && ( + <> + + {t('Cross-filtering scoping')} - )} + + + )} + + {props.supersetCanExplore && ( + + {t('View query')} + } + modalTitle={t('View query')} + modalBody={} + draggable + resizable + responsive + /> + + )} + + {props.supersetCanExplore && ( + + {t('View as table')} + } + modalTitle={t('Chart Data: %s', slice.slice_name)} + modalBody={ + + } + /> + + )} - {this.props.supersetCanExplore && ( - - {t('View query')} - } - modalTitle={t('View query')} - modalBody={ - - } - draggable - resizable - responsive - /> - + {isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && + props.supersetCanExplore && ( + )} - {this.props.supersetCanExplore && ( - - - {t('View as table')} - - } - modalTitle={t('Chart Data: %s', slice.slice_name)} - modalBody={ - - } - /> + {(slice.description || props.supersetCanExplore) && } + + {supersetCanShare && ( + + + + )} + + {props.slice.viz_type !== 'filter_box' && props.supersetCanCSV && ( + + } + > + {t('Export to .CSV')} - )} - - {isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && - this.props.supersetCanExplore && ( - - )} - {(slice.description || this.props.supersetCanExplore) && ( - - )} - - {supersetCanShare && ( - - - - )} - - {this.props.slice.viz_type !== 'filter_box' && - this.props.supersetCanCSV && ( - + {props.slice.viz_type !== 'filter_box' && + isFeatureEnabled(FeatureFlag.ALLOW_FULL_CSV_EXPORT) && + props.supersetCanCSV && + isTable && ( } > - {t('Export to .CSV')} + {t('Export to full .CSV')} + )} - {this.props.slice.viz_type !== 'filter_box' && - isFeatureEnabled(FeatureFlag.ALLOW_FULL_CSV_EXPORT) && - this.props.supersetCanCSV && - isTable && ( - } - > - {t('Export to full .CSV')} - - )} + } + > + {t('Download as image')} + + + )} + + ); - } - > - {t('Download as image')} - - - )} -
- ); - - return ( - <> - {isFullSize && ( - { - this.props.handleToggleFullSize(); - }} - /> - )} - + {isFullSize && ( + { + props.handleToggleFullSize(); + }} + /> + )} + + - - - - - - ); - } -} + + + + {canEditCrossFilters && scopingModal} + + ); +}; export default withRouter(SliceHeaderControls); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.test.tsx new file mode 100644 index 0000000000000..997933d97e747 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.test.tsx @@ -0,0 +1,175 @@ +/** + * 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 from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, within } from 'spec/helpers/testing-library'; +import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; +import { + ChartsScopingListPanel, + ChartsScopingListPanelProps, +} from './ChartsScopingListPanel'; + +const DEFAULT_PROPS: ChartsScopingListPanelProps = { + addNewCustomScope: jest.fn(), + removeCustomScope: jest.fn(), + setCurrentChartId: jest.fn(), + activeChartId: undefined, + chartConfigs: { + 1: { + id: 1, + crossFilters: { + scope: 'global' as const, + chartsInScope: [2, 3, 4], + }, + }, + 2: { + id: 2, + crossFilters: { + scope: 'global' as const, + chartsInScope: [1, 3, 4], + }, + }, + 3: { + id: 3, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1, 3], + }, + chartsInScope: [2, 4], + }, + }, + 4: { + id: 4, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1, 4], + }, + chartsInScope: [2, 3], + }, + }, + }, +}; + +const INITIAL_STATE = { + dashboardLayout: { + past: [], + future: [], + present: { + CHART_1: { + id: 'CHART_1', + type: CHART_TYPE, + meta: { + chartId: 1, + sliceName: 'chart 1', + }, + }, + CHART_2: { + id: 'CHART_2', + type: CHART_TYPE, + meta: { + chartId: 2, + sliceName: 'chart 2', + }, + }, + CHART_3: { + id: 'CHART_3', + type: CHART_TYPE, + meta: { + chartId: 3, + sliceName: 'chart 3', + sliceNameOverride: 'Chart 3', + }, + }, + CHART_4: { + id: 'CHART_4', + type: CHART_TYPE, + meta: { + chartId: 4, + sliceName: 'chart 4', + }, + }, + }, + }, +}; + +const setup = (props = DEFAULT_PROPS) => + render(, { + useRedux: true, + initialState: INITIAL_STATE, + }); + +it('Renders charts scoping list panel', () => { + setup(); + expect(screen.getByText('Add custom scoping')).toBeVisible(); + expect(screen.getByText('All charts/global scoping')).toBeVisible(); + expect(screen.getByText('All charts/global scoping')).toHaveClass('active'); + expect(screen.queryByText('chart 1')).not.toBeInTheDocument(); + expect(screen.queryByText('chart 2')).not.toBeInTheDocument(); + expect(screen.getByText('Chart 3')).toBeVisible(); + expect(screen.getByText('chart 4')).toBeVisible(); + expect(screen.queryByText('[new custom scoping]')).not.toBeInTheDocument(); +}); + +it('Renders custom scoping item', () => { + setup({ + ...DEFAULT_PROPS, + activeChartId: -1, + chartConfigs: { + ...DEFAULT_PROPS.chartConfigs, + [-1]: { + id: -1, + crossFilters: { + scope: 'global', + chartsInScope: [1, 2, 3, 4], + }, + }, + }, + }); + expect(screen.getByText('All charts/global scoping')).toBeVisible(); + expect(screen.getByText('All charts/global scoping')).not.toHaveClass( + 'active', + ); + expect(screen.queryByText('chart 1')).not.toBeInTheDocument(); + expect(screen.queryByText('chart 2')).not.toBeInTheDocument(); + expect(screen.getByText('Chart 3')).toBeVisible(); + expect(screen.getByText('chart 4')).toBeVisible(); + expect(screen.getByText('[new custom scoping]')).toBeVisible(); + expect(screen.getByText('[new custom scoping]')).toHaveClass('active'); +}); + +it('Uses callbacks on click', () => { + setup(); + + userEvent.click(screen.getByText('Add custom scoping')); + expect(DEFAULT_PROPS.addNewCustomScope).toHaveBeenCalled(); + + userEvent.click(screen.getByText('All charts/global scoping')); + expect(DEFAULT_PROPS.setCurrentChartId).toHaveBeenCalledWith(undefined); + + userEvent.click(screen.getByText('Chart 3')); + expect(DEFAULT_PROPS.setCurrentChartId).toHaveBeenCalledWith(3); + + const chart4Container = screen.getByText('chart 4').closest('div'); + if (chart4Container) { + userEvent.click(within(chart4Container).getByLabelText('trash')); + } + expect(DEFAULT_PROPS.removeCustomScope).toHaveBeenCalledWith(4); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx new file mode 100644 index 0000000000000..7e2b90e7b6728 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ChartsScopingListPanel.tsx @@ -0,0 +1,177 @@ +/** + * 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, { ReactNode, useMemo } from 'react'; +import { css, styled, t, useTheme } from '@superset-ui/core'; +import { + ChartConfiguration, + DashboardLayout, + isCrossFilterScopeGlobal, + RootState, +} from 'src/dashboard/types'; +import { useSelector } from 'react-redux'; +import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; +import Icons from 'src/components/Icons'; +import Button from 'src/components/Button'; +import { FilterTitle } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer'; +import { NEW_CHART_SCOPING_ID } from './constants'; + +const AddButtonContainer = styled.div` + ${({ theme }) => css` + margin-top: ${theme.gridUnit * 2}px; + + & button > [role='img']:first-of-type { + margin-right: ${theme.gridUnit}px; + line-height: 0; + } + + span[role='img'] { + padding-bottom: 1px; + } + + .ant-btn > .anticon + span { + margin-left: 0; + } + `} +`; + +const ScopingTitle = ({ + isActive, + onClick, + id, + label, + onRemove, +}: { + isActive: boolean; + onClick: (id: number) => void; + id: number; + label: ReactNode; + onRemove: (id: number) => void; +}) => { + const theme = useTheme(); + return ( + onClick(id)} + > + {label} + { + event.stopPropagation(); + onRemove(id); + }} + css={css` + margin-left: auto; + `} + /> + + ); +}; + +export interface ChartsScopingListPanelProps { + activeChartId: number | undefined; + chartConfigs: ChartConfiguration; + setCurrentChartId: (chartId: number | undefined) => void; + removeCustomScope: (chartId: number) => void; + addNewCustomScope: () => void; +} +export const ChartsScopingListPanel = ({ + activeChartId, + chartConfigs, + setCurrentChartId, + removeCustomScope, + addNewCustomScope, +}: ChartsScopingListPanelProps) => { + const theme = useTheme(); + const layout = useSelector( + state => state.dashboardLayout.present, + ); + const customScopedCharts = useMemo(() => { + const chartLayoutItems = Object.values(layout).filter( + item => item.type === CHART_TYPE, + ); + return Object.values(chartConfigs) + .filter( + config => + !isCrossFilterScopeGlobal(config.crossFilters.scope) && + config.id !== NEW_CHART_SCOPING_ID, + ) + .map(config => { + const chartLayoutItem = chartLayoutItems.find( + item => item.meta.chartId === config.id, + ); + return { + id: config.id, + label: + chartLayoutItem?.meta.sliceNameOverride || + chartLayoutItem?.meta.sliceName || + '', + }; + }); + }, [chartConfigs, layout]); + + const newScoping = chartConfigs[NEW_CHART_SCOPING_ID]; + return ( + <> + + + + setCurrentChartId(undefined)} + className={activeChartId === undefined ? 'active' : ''} + > + {t('All charts/global scoping')} + +
+ {customScopedCharts.map(chartInfo => ( + + ))} + {newScoping && ( + + )} + + ); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.test.tsx new file mode 100644 index 0000000000000..eef5d1fa07fff --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.test.tsx @@ -0,0 +1,322 @@ +/** + * 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 from 'react'; +import fetchMock from 'fetch-mock'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; +import { + CHART_TYPE, + DASHBOARD_ROOT_TYPE, +} from 'src/dashboard/util/componentTypes'; +import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; +import { ScopingModal, ScopingModalProps } from './ScopingModal'; + +const INITIAL_STATE = { + charts: { + 1: { id: 1 }, + 2: { id: 2 }, + 3: { id: 3 }, + 4: { id: 4 }, + }, + dashboardInfo: { + id: 1, + metadata: { + chart_configuration: { + 1: { + id: 1, + crossFilters: { + scope: 'global' as const, + chartsInScope: [2, 3, 4], + }, + }, + 2: { + id: 2, + crossFilters: { + scope: 'global' as const, + chartsInScope: [1, 3, 4], + }, + }, + 3: { + id: 3, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1, 3], + }, + chartsInScope: [2, 4], + }, + }, + 4: { + id: 4, + crossFilters: { + scope: { + rootPath: ['ROOT_ID'], + excluded: [1, 4], + }, + chartsInScope: [2, 3], + }, + }, + }, + global_chart_configuration: { + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + chartsInScope: [1, 2, 3, 4], + }, + }, + }, + dashboardLayout: { + past: [], + future: [], + present: { + [DASHBOARD_ROOT_ID]: { + type: DASHBOARD_ROOT_TYPE, + id: DASHBOARD_ROOT_ID, + children: ['CHART_1', 'CHART_2', 'CHART_3', 'CHART_4'], + }, + CHART_1: { + id: 'CHART_1', + type: CHART_TYPE, + meta: { + chartId: 1, + sliceName: 'chart 1', + }, + parents: ['ROOT_ID'], + }, + CHART_2: { + id: 'CHART_2', + type: CHART_TYPE, + meta: { + chartId: 2, + sliceName: 'chart 2', + }, + parents: ['ROOT_ID'], + }, + CHART_3: { + id: 'CHART_3', + type: CHART_TYPE, + meta: { + chartId: 3, + sliceName: 'chart 3', + sliceNameOverride: 'Chart 3', + }, + parents: ['ROOT_ID'], + }, + CHART_4: { + id: 'CHART_4', + type: CHART_TYPE, + meta: { + chartId: 4, + sliceName: 'chart 4', + }, + parents: ['ROOT_ID'], + }, + }, + }, +}; + +const DEFAULT_PROPS: ScopingModalProps = { + closeModal: jest.fn(), + initialChartId: undefined, + isVisible: true, +}; + +const setup = (props = DEFAULT_PROPS) => + render(, { + useRedux: true, + initialState: INITIAL_STATE, + }); + +const DASHBOARD_UPDATE_URL = 'glob:*api/v1/dashboard/1'; +beforeEach(() => { + fetchMock.put(DASHBOARD_UPDATE_URL, 200); +}); + +afterEach(() => { + fetchMock.restore(); +}); + +it('renders modal', () => { + setup(); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(screen.getByTestId('scoping-tree-panel')).toBeInTheDocument(); + expect(screen.getByTestId('scoping-list-panel')).toBeInTheDocument(); +}); + +it('switch currently edited chart scoping', async () => { + setup(); + const withinScopingList = within(screen.getByTestId('scoping-list-panel')); + expect(withinScopingList.getByText('All charts/global scoping')).toHaveClass( + 'active', + ); + userEvent.click(withinScopingList.getByText('Chart 3')); + await waitFor(() => { + expect(withinScopingList.getByText('Chart 3')).toHaveClass('active'); + expect( + withinScopingList.getByText('All charts/global scoping'), + ).not.toHaveClass('active'); + }); +}); + +it('scoping tree global and custom checks', () => { + setup(); + + expect( + document.querySelectorAll( + '[data-test="scoping-tree-panel"] .ant-tree-checkbox-checked', + ), + ).toHaveLength(5); + + userEvent.click( + within(screen.getByTestId('scoping-list-panel')).getByText('Chart 3'), + ); + + expect( + document.querySelectorAll( + '[data-test="scoping-tree-panel"] .ant-tree-checkbox-checked', + ), + ).toHaveLength(2); +}); + +it('add new custom scoping', async () => { + setup(); + + userEvent.click(screen.getByText('Add custom scoping')); + + expect(screen.getByText('[new custom scoping]')).toBeInTheDocument(); + expect(screen.getByText('[new custom scoping]')).toHaveClass('active'); + + await waitFor(() => + userEvent.click(screen.getByRole('combobox', { name: 'Select chart' })), + ); + await waitFor(() => { + userEvent.click( + within(document.querySelector('.rc-virtual-list')!).getByText('chart 1'), + ); + }); + + expect( + within(document.querySelector('.ant-select-selection-item')!).getByText( + 'chart 1', + ), + ).toBeInTheDocument(); + + expect( + document.querySelectorAll( + '[data-test="scoping-tree-panel"] .ant-tree-checkbox-checked', + ), + ).toHaveLength(4); + + userEvent.click( + within(document.querySelector('.ant-tree')!).getByText('chart 2'), + ); + + expect( + document.querySelectorAll( + '[data-test="scoping-tree-panel"] .ant-tree-checkbox-checked', + ), + ).toHaveLength(2); +}); + +it('edit scope and save', async () => { + setup(); + + // unselect chart 2 in global scoping + userEvent.click( + within(document.querySelector('.ant-tree')!).getByText('chart 2'), + ); + + userEvent.click( + within(screen.getByTestId('scoping-list-panel')).getByText('Chart 3'), + ); + + // select chart 1 in chart 3's custom scoping + userEvent.click( + within(document.querySelector('.ant-tree')!).getByText('chart 1'), + ); + + // create custom scoping for chart 1 with unselected chart 2 (from global) and chart 4 + userEvent.click(screen.getByText('Add custom scoping')); + await waitFor(() => + userEvent.click(screen.getByRole('combobox', { name: 'Select chart' })), + ); + await waitFor(() => { + userEvent.click( + within(document.querySelector('.rc-virtual-list')!).getByText('chart 1'), + ); + }); + userEvent.click( + within(document.querySelector('.ant-tree')!).getByText('chart 4'), + ); + + // remove custom scoping for chart 4 + userEvent.click( + within( + within(screen.getByTestId('scoping-list-panel')) + .getByText('chart 4') + .closest('div')!, + ).getByLabelText('trash'), + ); + expect( + within(screen.getByTestId('scoping-list-panel')).queryByText('chart 4'), + ).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Save')); + + await waitFor(() => fetchMock.called(DASHBOARD_UPDATE_URL)); + + expect( + JSON.parse( + JSON.parse(fetchMock.lastCall()?.[1]?.body as string).json_metadata, + ), + ).toEqual({ + chart_configuration: { + '1': { + id: 1, + crossFilters: { + scope: { rootPath: ['ROOT_ID'], excluded: [1, 2, 4] }, + chartsInScope: [3], + }, + }, + '2': { + id: 2, + crossFilters: { + scope: 'global', + chartsInScope: [1, 3, 4], + }, + }, + '3': { + id: 3, + crossFilters: { + scope: { rootPath: ['ROOT_ID'], excluded: [3] }, + chartsInScope: [1, 2, 4], + }, + }, + '4': { + id: 4, + crossFilters: { + scope: 'global', + chartsInScope: [1, 3], + }, + }, + }, + global_chart_configuration: { + scope: { rootPath: ['ROOT_ID'], excluded: [2] }, + chartsInScope: [1, 3, 4], + }, + }); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.tsx new file mode 100644 index 0000000000000..5452fca71932b --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.tsx @@ -0,0 +1,324 @@ +/** + * 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, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { isDefined, NativeFilterScope, t } from '@superset-ui/core'; +import Modal from 'src/components/Modal'; +import { + ChartConfiguration, + Layout, + RootState, + isCrossFilterScopeGlobal, + GlobalChartCrossFilterConfig, + GLOBAL_SCOPE_POINTER, +} from 'src/dashboard/types'; +import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope'; +import { useChartIds } from 'src/dashboard/util/charts/useChartIds'; +import { saveChartConfiguration } from 'src/dashboard/actions/dashboardInfo'; +import { DEFAULT_CROSS_FILTER_SCOPING } from 'src/dashboard/constants'; +import { ScopingModalContent } from './ScopingModalContent'; +import { NEW_CHART_SCOPING_ID } from './constants'; + +const getUpdatedGloballyScopedChartsInScope = ( + configs: ChartConfiguration, + globalChartsInScope: number[], +) => + Object.entries(configs).reduce((acc, [id, config]) => { + if (isCrossFilterScopeGlobal(config.crossFilters.scope)) { + acc[id] = { + id: Number(config.id), + crossFilters: { + scope: GLOBAL_SCOPE_POINTER, + chartsInScope: globalChartsInScope.filter( + chartId => chartId !== Number(config.id), + ), + }, + }; + } else { + acc[id] = config; + } + return acc; + }, {}); + +const getActualScopeFromGlobalScope = ( + chartId: number, + globalScope: NativeFilterScope, +) => ({ + rootPath: globalScope.rootPath, + excluded: globalScope.excluded.filter(id => id !== chartId), +}); + +export interface ScopingModalProps { + initialChartId: number | undefined; + isVisible: boolean; + closeModal: () => void; +} + +export const ScopingModal = ({ + initialChartId, + isVisible, + closeModal, +}: ScopingModalProps) => { + const dispatch = useDispatch(); + const layout = useSelector( + state => state.dashboardLayout.present, + ); + const chartIds = useChartIds(); + const [currentChartId, setCurrentChartId] = useState(initialChartId); + const initialChartConfig = useSelector( + state => state.dashboardInfo.metadata?.chart_configuration || {}, + ); + const defaultGlobalChartConfig = useMemo( + () => ({ + scope: DEFAULT_CROSS_FILTER_SCOPING, + chartsInScope: chartIds, + }), + [chartIds], + ); + + const initialGlobalChartConfig = useSelector< + RootState, + GlobalChartCrossFilterConfig + >( + state => + state.dashboardInfo.metadata?.global_chart_configuration || + defaultGlobalChartConfig, + ); + + const getInitialChartConfig = () => { + if ( + isDefined(initialChartId) && + isCrossFilterScopeGlobal( + initialChartConfig[initialChartId]?.crossFilters.scope, + ) + ) { + return { + ...initialChartConfig, + [initialChartId]: { + id: initialChartId, + crossFilters: { + scope: getActualScopeFromGlobalScope( + initialChartId, + initialGlobalChartConfig.scope, + ), + chartsInScope: + initialChartConfig[initialChartId]?.crossFilters.chartsInScope, + }, + }, + }; + } + return initialChartConfig; + }; + + const [chartConfigs, setChartConfigs] = useState(getInitialChartConfig()); + const [globalChartConfig, setGlobalChartConfig] = useState( + initialGlobalChartConfig, + ); + + const saveScoping = useCallback(() => { + const savedChartConfigs = { ...chartConfigs }; + if (savedChartConfigs[NEW_CHART_SCOPING_ID]) { + delete savedChartConfigs[NEW_CHART_SCOPING_ID]; + } + dispatch( + saveChartConfiguration({ + chartConfiguration: savedChartConfigs, + globalChartConfiguration: globalChartConfig, + }), + ); + closeModal(); + }, [chartConfigs, closeModal, dispatch, globalChartConfig]); + + const handleScopeUpdate = useCallback( + ({ scope }: { scope: NativeFilterScope }) => { + if (isDefined(currentChartId)) { + setChartConfigs(prevConfig => ({ + ...prevConfig, + [currentChartId]: { + id: currentChartId, + crossFilters: { + scope, + chartsInScope: getChartIdsInFilterScope(scope, chartIds, layout), + }, + }, + })); + } else { + const globalChartsInScope = getChartIdsInFilterScope( + scope, + chartIds, + layout, + ); + setGlobalChartConfig({ + scope, + chartsInScope: globalChartsInScope, + }); + setChartConfigs(prevConfig => + getUpdatedGloballyScopedChartsInScope( + prevConfig, + globalChartsInScope, + ), + ); + } + }, + [currentChartId, chartIds, layout], + ); + + const removeCustomScope = useCallback( + (chartId: number) => { + setChartConfigs(prevConfigs => { + const newConfigs = { ...prevConfigs }; + if (chartId === NEW_CHART_SCOPING_ID) { + delete newConfigs[NEW_CHART_SCOPING_ID]; + } else { + newConfigs[chartId] = { + id: chartId, + crossFilters: { + scope: GLOBAL_SCOPE_POINTER, + chartsInScope: globalChartConfig.chartsInScope.filter( + id => id !== chartId, + ), + }, + }; + } + return newConfigs; + }); + if (currentChartId === chartId) { + setCurrentChartId(undefined); + } + }, + [currentChartId, globalChartConfig.chartsInScope], + ); + + const addNewCustomScope = useCallback(() => { + setCurrentChartId(NEW_CHART_SCOPING_ID); + if (!chartConfigs[NEW_CHART_SCOPING_ID]) { + setChartConfigs(prevConfigs => ({ + ...prevConfigs, + [NEW_CHART_SCOPING_ID]: { + id: NEW_CHART_SCOPING_ID, + crossFilters: { + scope: globalChartConfig.scope, + chartsInScope: globalChartConfig.chartsInScope, + }, + }, + })); + } + }, [chartConfigs, globalChartConfig.chartsInScope, globalChartConfig.scope]); + + const handleSelectChange = useCallback( + (newChartId: number) => { + if (isDefined(currentChartId)) { + const currentScope = !isCrossFilterScopeGlobal( + chartConfigs[currentChartId]?.crossFilters.scope, + ) + ? (chartConfigs[currentChartId].crossFilters + .scope as NativeFilterScope) + : globalChartConfig.scope; + const newScope = { + rootPath: currentScope.rootPath, + excluded: [ + ...currentScope.excluded.filter(id => id !== currentChartId), + newChartId, + ], + }; + const newCrossFiltersConfig = { + id: newChartId, + crossFilters: { + scope: newScope, + chartsInScope: getChartIdsInFilterScope(newScope, chartIds, layout), + }, + }; + + setChartConfigs(prevConfig => { + const newConfig = { + ...prevConfig, + [newChartId]: newCrossFiltersConfig, + }; + if (currentChartId === NEW_CHART_SCOPING_ID) { + delete newConfig[NEW_CHART_SCOPING_ID]; + } else { + newConfig[currentChartId] = { + id: currentChartId, + crossFilters: { + scope: GLOBAL_SCOPE_POINTER, + chartsInScope: globalChartConfig.chartsInScope.filter( + id => id !== currentChartId, + ), + }, + }; + } + return newConfig; + }); + + setCurrentChartId(newChartId); + } + }, + [ + chartConfigs, + chartIds, + currentChartId, + globalChartConfig.chartsInScope, + globalChartConfig.scope, + layout, + ], + ); + + const scope = useMemo(() => { + const globalScope = globalChartConfig.scope; + if (!isDefined(currentChartId)) { + return globalScope; + } + if ( + isCrossFilterScopeGlobal( + chartConfigs[currentChartId]?.crossFilters?.scope, + ) + ) { + return getActualScopeFromGlobalScope(currentChartId, globalScope); + } + return chartConfigs[currentChartId]?.crossFilters + ?.scope as NativeFilterScope; + }, [chartConfigs, currentChartId, globalChartConfig.scope]); + + return ( + + + + ); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModalContent.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModalContent.tsx new file mode 100644 index 0000000000000..eb45666250907 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModalContent.tsx @@ -0,0 +1,83 @@ +/** + * 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 from 'react'; +import { css, NativeFilterScope, styled, useTheme } from '@superset-ui/core'; +import { ChartConfiguration } from 'src/dashboard/types'; +import { ScopingTreePanel } from './ScopingTreePanel'; +import { ChartsScopingListPanel } from './ChartsScopingListPanel'; + +export interface ScopingModalContentProps { + chartId: number | undefined; + currentScope: NativeFilterScope; + onScopeUpdate: ({ scope }: { scope: NativeFilterScope }) => void; + onSelectChange: (chartId: number) => void; + chartConfigs: ChartConfiguration; + setCurrentChartId: (chartId: number | undefined) => void; + removeCustomScope: (chartId: number) => void; + addNewCustomScope: () => void; +} + +const ModalContentContainer = styled.div` + ${({ theme }) => css` + display: flex; + height: 100%; + & > div { + padding: ${theme.gridUnit * 4}px; + } + `} +`; + +export const ScopingModalContent = ({ + chartId, + currentScope, + onScopeUpdate, + onSelectChange, + chartConfigs, + setCurrentChartId, + removeCustomScope, + addNewCustomScope, +}: ScopingModalContentProps) => { + const theme = useTheme(); + return ( + +
+ +
+ +
+ ); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx new file mode 100644 index 0000000000000..3d630d34cf5bf --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingTreePanel.tsx @@ -0,0 +1,204 @@ +/** + * 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, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + css, + isDefined, + NativeFilterScope, + styled, + t, + useTheme, +} from '@superset-ui/core'; +import { Select } from 'src/components'; +import { noOp } from 'src/utils/common'; +import ScopingTree from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree'; +import { + ChartConfiguration, + DashboardLayout, + isCrossFilterScopeGlobal, + RootState, +} from 'src/dashboard/types'; +import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; +import { SelectOptionsType } from 'src/components/Select/types'; +import Icons from 'src/components/Icons'; +import { Tooltip } from 'src/components/Tooltip'; +import Alert from 'src/components/Alert'; +import { NEW_CHART_SCOPING_ID } from './constants'; + +interface ScopingTreePanelProps { + chartId: number | undefined; + currentScope: NativeFilterScope; + onScopeUpdate: ({ scope }: { scope: NativeFilterScope }) => void; + onSelectChange: (chartId: number) => void; + chartConfigs: ChartConfiguration; +} + +const InfoText = styled.div` + ${({ theme }) => css` + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.grayscale.base}; + margin-bottom: ${theme.gridUnit * 7}px; + `} +`; + +const ChartSelect = ({ + value, + onSelectChange, + chartConfigs, +}: { + value: number | undefined; + onSelectChange: (chartId: number) => void; + chartConfigs: ChartConfiguration; +}) => { + const theme = useTheme(); + const layout = useSelector( + state => state.dashboardLayout.present, + ); + const options: SelectOptionsType = useMemo(() => { + const chartLayoutItems = Object.values(layout).filter( + item => item.type === CHART_TYPE, + ); + return Object.values(chartConfigs) + .filter( + chartConfig => + isCrossFilterScopeGlobal(chartConfig.crossFilters.scope) || + (chartConfig.id === value && value !== NEW_CHART_SCOPING_ID), + ) + .map(chartConfig => { + const chartLayoutItem = chartLayoutItems.find( + item => item.meta.chartId === Number(chartConfig.id), + ); + return { + value: Number(chartConfig.id), + label: + chartLayoutItem?.meta.sliceNameOverride || + chartLayoutItem?.meta.sliceName || + '', + }; + }); + }, [chartConfigs, layout, value]); + + return ( +
+
+ {`${t('Chart')} *`} + + span { + line-height: 0; + } + `} + /> + +
+