diff --git a/superset-frontend/spec/fixtures/mockNativeFilters.ts b/superset-frontend/spec/fixtures/mockNativeFilters.ts index 6da3a3561d..d912545d14 100644 --- a/superset-frontend/spec/fixtures/mockNativeFilters.ts +++ b/superset-frontend/spec/fixtures/mockNativeFilters.ts @@ -448,3 +448,39 @@ export const mockQueryDataForCountries = [ { country_name: 'Zambia', 'SUM(SP_POP_TOTL)': 438847085 }, { country_name: 'Zimbabwe', 'SUM(SP_POP_TOTL)': 509866860 }, ]; + +export const buildNativeFilter = ( + id: string, + name: string, + parents: string[], +) => ({ + id, + controlValues: { + multiSelect: true, + enableEmptyFilter: false, + defaultToFirstItem: false, + inverseSelection: false, + searchAllOptions: false, + }, + name, + filterType: 'filter_select', + targets: [ + { + datasetId: 1, + column: { + name, + }, + }, + ], + defaultDataMask: { + extraFormData: {}, + filterState: {}, + ownState: {}, + }, + cascadeParentIds: parents, + scope: { + rootPath: ['ROOT_ID'], + excluded: [], + }, + type: 'NATIVE_FILTER', +}); diff --git a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx index 7fc0d7e2c7..b153d32cd5 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx +++ b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx @@ -16,14 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; -import { styledMount as mount } from 'spec/helpers/theming'; -import { act } from 'react-dom/test-utils'; import { ReactWrapper } from 'enzyme'; +import React from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { act } from 'react-dom/test-utils'; import { Provider } from 'react-redux'; -import Alert from 'src/components/Alert'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { mockStore } from 'spec/fixtures/mockStore'; +import { styledMount as mount } from 'spec/helpers/theming'; +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import Alert from 'src/components/Alert'; import { FiltersConfigModal } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal'; Object.defineProperty(window, 'matchMedia', { @@ -66,7 +68,9 @@ describe('FiltersConfigModal', () => { function setup(overridesProps?: any) { return mount( - + + + , ); } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx index d76f09f851..90ff4ef86f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx @@ -220,8 +220,9 @@ describe('FilterBar', () => { const renderWrapper = (props = closedBarProps, state?: object) => render(, { - useRedux: true, initialState: state, + useDnd: true, + useRedux: true, useRouter: true, }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx index 49dea83394..106388d5e5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx @@ -27,6 +27,7 @@ import { getFilterBarTestId } from '..'; export interface FCBProps { createNewOnOpen?: boolean; + dashboardId?: number; } const HeaderButton = styled(Button)` @@ -35,6 +36,7 @@ const HeaderButton = styled(Button)` export const FilterConfigurationLink: React.FC = ({ createNewOnOpen, + dashboardId, children, }) => { const dispatch = useDispatch(); @@ -65,6 +67,7 @@ export const FilterConfigurationLink: React.FC = ({ onSave={submit} onCancel={close} createNewOnOpen={createNewOnOpen} + key={`filters-for-${dashboardId}`} /> > ); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx index 299c9fb005..613bc4ab89 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx @@ -17,16 +17,23 @@ * under the License. */ import React, { FC, useMemo, useState } from 'react'; -import { DataMask, styled, t } from '@superset-ui/core'; import { css } from '@emotion/react'; -import * as portals from 'react-reverse-portal'; -import { DataMaskStateWithId } from 'src/dataMask/types'; +import { DataMask, styled, t } from '@superset-ui/core'; +import { + createHtmlPortalNode, + InPortal, + OutPortal, +} from 'react-reverse-portal'; import { Collapse } from 'src/common/components'; +import { DataMaskStateWithId } from 'src/dataMask/types'; +import { + useDashboardHasTabs, + useSelectFiltersInScope, +} from 'src/dashboard/components/nativeFilters/state'; +import { Filter } from 'src/dashboard/components/nativeFilters/types'; import CascadePopover from '../CascadeFilters/CascadePopover'; -import { buildCascadeFiltersTree } from './utils'; import { useFilters } from '../state'; -import { Filter } from '../../types'; -import { useDashboardHasTabs, useSelectFiltersInScope } from '../../state'; +import { buildCascadeFiltersTree } from './utils'; const Wrapper = styled.div` padding: ${({ theme }) => theme.gridUnit * 4}px; @@ -52,7 +59,7 @@ const FilterControls: FC = ({ const portalNodes = React.useMemo(() => { const nodes = new Array(filterValues.length); for (let i = 0; i < filterValues.length; i += 1) { - nodes[i] = portals.createHtmlPortalNode(); + nodes[i] = createHtmlPortalNode(); } return nodes; }, [filterValues.length]); @@ -78,7 +85,7 @@ const FilterControls: FC = ({ {portalNodes .filter((node, index) => cascadeFilterIds.has(filterValues[index].id)) .map((node, index) => ( - + = ({ directPathToChild={directPathToChild} inView={false} /> - + ))} {filtersInScope.map(filter => { const index = filterValues.findIndex(f => f.id === filter.id); - return ; + return ; })} {showCollapsePanel && ( = ({ > {filtersOutOfScope.map(filter => { const index = cascadeFilters.findIndex(f => f.id === filter.id); - return ; + return ; })} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx index 16714ac8a6..396ff69683 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx @@ -87,6 +87,9 @@ const Header: FC = ({ const canEdit = useSelector( ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ); + const dashboardId = useSelector( + ({ dashboardInfo }) => dashboardInfo.id, + ); const isClearAllDisabled = Object.values(dataMaskApplied).every( filter => @@ -99,7 +102,10 @@ const Header: FC = ({ {t('Filters')} {canEdit && ( - + ` + ${({ isDragging, theme }) => ` + opacity: ${isDragging ? 0.3 : 1}; + cursor: ${isDragging ? 'grabbing' : 'pointer'}; + width: 100%; + display: flex; + padding: ${theme.gridUnit}px + `} +`; + +const DragIcon = styled(Icons.Drag, { + shouldForwardProp: propName => propName !== 'isDragging', +})` + ${({ isDragging, theme }) => ` + font-size: ${theme.typography.sizes.m}px; + margin-top: 15px; + cursor: ${isDragging ? 'grabbing' : 'grab'}; + padding-left: ${theme.gridUnit}px; + `} +`; + +interface FilterTabTitleProps { + index: number; + filterIds: string[]; + onRearrage: (dragItemIndex: number, targetIndex: number) => void; +} + +interface DragItem { + index: number; + filterIds: string[]; + type: string; +} + +export const DraggableFilter: React.FC = ({ + index, + onRearrage, + filterIds, + children, +}) => { + const ref = useRef(null); + const [{ isDragging }, drag] = useDrag({ + item: { filterIds, type: FILTER_TYPE, index }, + collect: (monitor: DragSourceMonitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + const [, drop] = useDrop({ + accept: FILTER_TYPE, + hover: (item: DragItem, monitor: DropTargetMonitor) => { + if (!ref.current) { + return; + } + + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) { + return; + } + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = + (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + onRearrage(dragIndex, hoverIndex); + // Note: we're mutating the monitor item here. + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + // eslint-disable-next-line no-param-reassign + item.index = hoverIndex; + }, + }); + drag(drop(ref)); + return ( + + + {children} + + ); +}; + +export default DraggableFilter; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx new file mode 100644 index 0000000000..8785e7bd33 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx @@ -0,0 +1,114 @@ +/** + * 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 { dashboardLayout } from 'spec/fixtures/mockDashboardLayout'; +import { buildNativeFilter } from 'spec/fixtures/mockNativeFilters'; +import { act, fireEvent, render, screen } from 'spec/helpers/testing-library'; +import FilterConfigPane from './FilterConfigurePane'; + +const defaultProps = { + children: jest.fn(), + getFilterTitle: (id: string) => id, + onChange: jest.fn(), + onEdit: jest.fn(), + onRearrange: jest.fn(), + restoreFilter: jest.fn(), + currentFilterId: 'NATIVE_FILTER-1', + filterGroups: [['NATIVE_FILTER-2', 'NATIVE_FILTER-1'], ['NATIVE_FILTER-3']], + removedFilters: {}, + erroredFilters: [], +}; +const defaultState = { + dashboardInfo: { + metadata: { + native_filter_configuration: [ + buildNativeFilter('NATIVE_FILTER-1', 'state', ['NATIVE_FILTER-2']), + buildNativeFilter('NATIVE_FILTER-2', 'country', []), + buildNativeFilter('NATIVE_FILTER-3', 'product', []), + ], + }, + }, + dashboardLayout, +}; + +function defaultRender(initialState: any = defaultState, props = defaultProps) { + return render(, { + initialState, + useDnd: true, + useRedux: true, + }); +} + +test('renders form', async () => { + await act(async () => { + defaultRender(); + }); + expect(defaultProps.children).toHaveBeenCalledTimes(3); +}); + +test('drag and drop', async () => { + defaultRender(); + // Drag the state and contry filter above the product filter + const [countryStateFilter, productFilter] = document.querySelectorAll( + 'div[draggable=true]', + ); + // const productFilter = await screen.findByText('NATIVE_FILTER-3'); + await act(async () => { + fireEvent.dragStart(productFilter); + fireEvent.dragEnter(countryStateFilter); + fireEvent.dragOver(countryStateFilter); + fireEvent.drop(countryStateFilter); + fireEvent.dragLeave(countryStateFilter); + fireEvent.dragEnd(productFilter); + }); + expect(defaultProps.onRearrange).toHaveBeenCalledTimes(1); +}); + +test('remove filter', async () => { + defaultRender(); + // First trash icon + const removeFilterIcon = document.querySelector("[alt='RemoveFilter']")!; + await act(async () => { + fireEvent( + removeFilterIcon, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ); + }); + expect(defaultProps.onEdit).toHaveBeenCalledWith('NATIVE_FILTER-2', 'remove'); +}); + +test('add filter', async () => { + defaultRender(); + // First trash icon + const removeFilterIcon = screen.getByText('Add filter')!; + await act(async () => { + fireEvent( + removeFilterIcon, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(defaultProps.onEdit).toHaveBeenCalledWith('', 'add'); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx new file mode 100644 index 0000000000..4d5174094a --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx @@ -0,0 +1,99 @@ +/** + * 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 { styled } from '@superset-ui/core'; +import React from 'react'; +import FilterTitlePane from './FilterTitlePane'; +import { FilterRemoval } from './types'; + +interface Props { + children: (filterId: string) => React.ReactNode; + getFilterTitle: (filterId: string) => string; + onChange: (activeKey: string) => void; + onEdit: (filterId: string, action: 'add' | 'remove') => void; + onRearrange: (dragIndex: number, targetIndex: number) => void; + erroredFilters: string[]; + restoreFilter: (id: string) => void; + currentFilterId: string; + filterGroups: string[][]; + removedFilters: Record; +} + +const Container = styled.div` + display: flex; + height: 100%; +`; + +const ContentHolder = styled.div` + flex-grow: 3; + margin-left: ${({ theme }) => theme.gridUnit * -1 - 1}; +`; + +const TitlesContainer = styled.div` + width: 270px; + border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; +`; + +const FiltureConfigurePane: React.FC = ({ + getFilterTitle, + onChange, + onEdit, + onRearrange, + restoreFilter, + erroredFilters, + children, + currentFilterId, + filterGroups, + removedFilters, +}) => { + const active = filterGroups.flat().filter(id => id === currentFilterId)[0]; + return ( + + + onEdit(id, 'remove')} + restoreFilter={restoreFilter} + /> + + + {filterGroups.flat().map(id => ( + + {children(id)} + + ))} + + + ); +}; + +export default FiltureConfigurePane; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTabs.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTabs.tsx deleted file mode 100644 index 925e1dfa57..0000000000 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTabs.tsx +++ /dev/null @@ -1,278 +0,0 @@ -/** - * 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 { PlusOutlined } from '@ant-design/icons'; -import { styled, t } from '@superset-ui/core'; -import React, { FC } from 'react'; -import { LineEditableTabs } from 'src/components/Tabs'; -import Icons from 'src/components/Icons'; -import { FilterRemoval } from './types'; -import { REMOVAL_DELAY_SECS } from './utils'; - -export const FILTER_WIDTH = 180; - -export const StyledSpan = styled.span` - cursor: pointer; - color: ${({ theme }) => theme.colors.primary.dark1}; - &:hover { - color: ${({ theme }) => theme.colors.primary.dark2}; - } -`; - -export const StyledFilterTitle = styled.span` - width: 100%; - white-space: normal; - color: ${({ theme }) => theme.colors.grayscale.dark1}; -`; - -export const StyledAddFilterBox = styled.div` - color: ${({ theme }) => theme.colors.primary.dark1}; - padding: ${({ theme }) => theme.gridUnit * 2}px; - border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - cursor: pointer; - - &:hover { - color: ${({ theme }) => theme.colors.primary.base}; - } -`; - -export const StyledTrashIcon = styled(Icons.Trash)` - color: ${({ theme }) => theme.colors.grayscale.light3}; -`; - -export const FilterTabTitle = styled.span` - transition: color ${({ theme }) => theme.transitionTiming}s; - width: 100%; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - @keyframes tabTitleRemovalAnimation { - 0%, - 90% { - opacity: 1; - } - 95%, - 100% { - opacity: 0; - } - } - - &.removed { - color: ${({ theme }) => theme.colors.warning.dark1}; - transform-origin: top; - animation-name: tabTitleRemovalAnimation; - animation-duration: ${REMOVAL_DELAY_SECS}s; - } - - &.errored > span { - color: ${({ theme }) => theme.colors.error.base}; - } -`; - -const StyledWarning = styled(Icons.Warning)` - color: ${({ theme }) => theme.colors.error.base}; - &.anticon { - margin-right: 0; - } -`; - -const FilterTabsContainer = styled(LineEditableTabs)` - ${({ theme }) => ` - height: 100%; - - & > .ant-tabs-content-holder { - border-left: 1px solid ${theme.colors.grayscale.light2}; - padding-right: ${theme.gridUnit * 4}px; - overflow-x: hidden; - overflow-y: auto; - } - - & > .ant-tabs-content-holder ~ .ant-tabs-content-holder { - border: none; - } - - &.ant-tabs-card > .ant-tabs-nav .ant-tabs-ink-bar { - visibility: hidden; - } - - &.ant-tabs-left - > .ant-tabs-content-holder - > .ant-tabs-content - > .ant-tabs-tabpane { - padding-left: ${theme.gridUnit * 4}px; - margin-top: ${theme.gridUnit * 4}px; - } - - .ant-tabs-nav-list { - overflow-x: hidden; - overflow-y: auto; - padding-top: ${theme.gridUnit * 2}px; - padding-right: ${theme.gridUnit}px; - padding-bottom: ${theme.gridUnit * 3}px; - padding-left: ${theme.gridUnit * 3}px; - width: 270px; - } - - // extra selector specificity: - &.ant-tabs-card > .ant-tabs-nav .ant-tabs-tab { - min-width: ${FILTER_WIDTH}px; - margin: 0 ${theme.gridUnit * 2}px 0 0; - padding: ${theme.gridUnit}px - ${theme.gridUnit * 2}px; - &:hover, - &-active { - color: ${theme.colors.grayscale.dark1}; - border-radius: ${theme.borderRadius}px; - background-color: ${theme.colors.secondary.light4}; - - .ant-tabs-tab-remove > span { - color: ${theme.colors.grayscale.base}; - transition: all 0.3s; - } - } - } - - .ant-tabs-tab-btn { - text-align: left; - justify-content: space-between; - text-transform: unset; - } - - .ant-tabs-nav-more { - display: none; - } - - .ant-tabs-extra-content { - width: 100%; - } - `} -`; - -const StyledHeader = styled.div` - ${({ theme }) => ` - color: ${theme.colors.grayscale.dark1}; - font-size: ${theme.typography.sizes.l}px; - padding-top: ${theme.gridUnit * 4}px; - padding-right: ${theme.gridUnit * 4}px; - padding-left: ${theme.gridUnit * 4}px; - `} -`; - -type FilterTabsProps = { - onChange: (activeKey: string) => void; - getFilterTitle: (id: string) => string; - currentFilterId: string; - onEdit: (filterId: string, action: 'add' | 'remove') => void; - filterIds: string[]; - erroredFilters: string[]; - removedFilters: Record; - restoreFilter: Function; - children: Function; -}; - -const FilterTabs: FC = ({ - onEdit, - getFilterTitle, - onChange, - currentFilterId, - filterIds = [], - erroredFilters = [], - removedFilters = [], - restoreFilter, - children, -}) => ( - {t('Filters')}, - right: ( - { - onEdit('', 'add'); - setTimeout(() => { - const element = document.getElementById('native-filters-tabs'); - if (element) { - const navList = element.getElementsByClassName( - 'ant-tabs-nav-list', - )[0]; - navList.scrollTop = navList.scrollHeight; - } - }, 0); - }} - > - {' '} - - {t('Add filter')} - - - ), - }} - > - {filterIds.map(id => { - const showErroredFilter = erroredFilters.includes(id); - const filterName = getFilterTitle(id); - return ( - - - {removedFilters[id] ? t('(Removed)') : filterName} - - {!removedFilters[id] && showErroredFilter && } - {removedFilters[id] && ( - restoreFilter(id)} - > - {t('Undo?')} - - )} - - } - key={id} - closeIcon={removedFilters[id] ? <>> : } - > - { - // @ts-ignore - children(id) - } - - ); - })} - -); - -export default FilterTabs; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx new file mode 100644 index 0000000000..daaee69dc1 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx @@ -0,0 +1,194 @@ +/** + * 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 { styled, t } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import { FilterRemoval } from './types'; +import DraggableFilter from './DraggableFilter'; + +const FilterTitle = styled.div` + ${({ theme }) => ` + display: flex; + padding: ${theme.gridUnit * 2}px; + width: 100%; + border-radius: ${theme.borderRadius}px; + &.active { + color: ${theme.colors.grayscale.dark1}; + border-radius: ${theme.borderRadius}px; + background-color: ${theme.colors.secondary.light4}; + span, .anticon { + color: ${theme.colors.grayscale.dark1}; + } + } + &:hover { + color: ${theme.colors.primary.light1}; + span, .anticon { + color: ${theme.colors.primary.light1}; + } + } + &.errored div, &.errored .warning { + color: ${theme.colors.error.base}; + } + `} +`; + +const StyledTrashIcon = styled(Icons.Trash)` + color: ${({ theme }) => theme.colors.grayscale.light3}; +`; + +const StyledWarning = styled(Icons.Warning)` + color: ${({ theme }) => theme.colors.error.base}; + &.anticon { + margin-left: auto; + } +`; + +const Container = styled.div` + height: 100%; + overflow-y: auto; +`; + +interface Props { + getFilterTitle: (filterId: string) => string; + onChange: (filterId: string) => void; + currentFilterId: string; + removedFilters: Record; + onRemove: (id: string) => void; + restoreFilter: (id: string) => void; + onRearrage: (dragIndex: number, targetIndex: number) => void; + filterGroups: string[][]; + erroredFilters: string[]; +} + +const FilterTitleContainer: React.FC = ({ + getFilterTitle, + onChange, + onRemove, + restoreFilter, + onRearrage, + currentFilterId, + removedFilters, + filterGroups, + erroredFilters = [], +}) => { + const renderComponent = (id: string) => { + const isRemoved = !!removedFilters[id]; + const isErrored = erroredFilters.includes(id); + const isActive = currentFilterId === id; + const classNames = []; + if (isErrored) { + classNames.push('errored'); + } + if (isActive) { + classNames.push('active'); + } + return ( + onChange(id)} + className={classNames.join(' ')} + > + + + {isRemoved ? t('(Removed)') : getFilterTitle(id)} + + {!removedFilters[id] && isErrored && ( + + )} + {isRemoved && ( + { + e.preventDefault(); + restoreFilter(id); + }} + > + {t('Undo?')} + + )} + + + {isRemoved ? null : ( + { + event.stopPropagation(); + onRemove(id); + }} + alt="RemoveFilter" + /> + )} + + + ); + }; + const recursivelyRender = ( + elementId: string, + nodeList: Array<{ id: string; parentId: string | null }>, + rendered: Array, + ): React.ReactNode => { + const didAlreadyRender = rendered.indexOf(elementId) >= 0; + if (didAlreadyRender) { + return null; + } + let parent = null; + const element = nodeList.filter(el => el.id === elementId)[0]; + if (!element) { + return null; + } + + rendered.push(elementId); + if (element.parentId) { + parent = recursivelyRender(element.parentId, nodeList, rendered); + } + const children = nodeList + .filter(item => item.parentId === elementId) + .map(item => recursivelyRender(item.id, nodeList, rendered)); + return ( + <> + {parent} + {renderComponent(elementId)} + {children} + > + ); + }; + + const renderFilterGroups = () => { + const items: React.ReactNode[] = []; + filterGroups.forEach((item, index) => { + items.push( + + {item.map(filter => renderComponent(filter))} + , + ); + }); + return items; + }; + return {renderFilterGroups()}; +}; + +export default FilterTitleContainer; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx new file mode 100644 index 0000000000..5da0b1031a --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitlePane.tsx @@ -0,0 +1,128 @@ +/** + * 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 { PlusOutlined } from '@ant-design/icons'; +import { styled, t, useTheme } from '@superset-ui/core'; +import React from 'react'; +import FilterTitleContainer from './FilterTitleContainer'; +import { FilterRemoval } from './types'; + +interface Props { + restoreFilter: (id: string) => void; + getFilterTitle: (id: string) => string; + onRearrage: (dragIndex: number, targetIndex: number) => void; + onRemove: (id: string) => void; + onChange: (id: string) => void; + onEdit: (filterId: string, action: 'add' | 'remove') => void; + removedFilters: Record; + currentFilterId: string; + filterGroups: string[][]; + erroredFilters: string[]; +} + +const StyledHeader = styled.div` + ${({ theme }) => ` + color: ${theme.colors.grayscale.dark1}; + font-size: ${theme.typography.sizes.l}px; + padding-top: ${theme.gridUnit * 4}px; + padding-right: ${theme.gridUnit * 4}px; + padding-left: ${theme.gridUnit * 4}px; + padding-bottom: ${theme.gridUnit * 2}px; + `} +`; + +const TabsContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; +`; + +const StyledAddFilterBox = styled.div` + color: ${({ theme }) => theme.colors.primary.dark1}; + padding: ${({ theme }) => theme.gridUnit * 2}px; + border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + cursor: pointer; + margin-top: auto; + &:hover { + color: ${({ theme }) => theme.colors.primary.base}; + } +`; + +const FilterTitlePane: React.FC = ({ + getFilterTitle, + onChange, + onEdit, + onRemove, + onRearrage, + restoreFilter, + currentFilterId, + filterGroups, + removedFilters, + erroredFilters, +}) => { + const theme = useTheme(); + return ( + + Filters + + + + { + onEdit('', 'add'); + setTimeout(() => { + const element = document.getElementById('native-filters-tabs'); + if (element) { + const navList = element.getElementsByClassName( + 'ant-tabs-nav-list', + )[0]; + navList.scrollTop = navList.scrollHeight; + } + }, 0); + }} + > + {' '} + + {t('Add filter')} + + + + ); +}; + +export default FilterTitlePane; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.test.tsx index 7f14561109..4600bb292f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.test.tsx @@ -27,18 +27,24 @@ import { import { mockStoreWithChartsInTabsAndRoot } from 'spec/fixtures/mockStore'; import { Form, FormInstance } from 'src/common/components'; import { NativeFiltersForm } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types'; -import FiltersConfigForm from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm'; +import FiltersConfigForm, { + FilterPanels, +} from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm'; describe('FilterScope', () => { const save = jest.fn(); let form: FormInstance; const mockedProps = { filterId: 'DefaultFilterId', - restoreFilter: jest.fn(), - setErroredFilters: jest.fn(), parentFilters: [], + setErroredFilters: jest.fn(), + onFilterHierarchyChange: jest.fn(), + restoreFilter: jest.fn(), save, removedFilters: {}, + handleActiveFilterPanelChange: jest.fn(), + activeFilterPanelKeys: `DefaultFilterId-${FilterPanels.basic.key}`, + isActive: true, }; const MockModal = ({ scope }: { scope?: object }) => { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx index 5bec228941..33aee84a5f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx @@ -44,6 +44,7 @@ const Wrapper = styled.div` & > * { margin-bottom: ${({ theme }) => theme.gridUnit}px; } + padding: 0px ${({ theme }) => theme.gridUnit * 4}px; `; const CleanFormItem = styled(Form.Item)` diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 18f10c8cd9..5f496b3fb0 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -92,12 +92,17 @@ import { } from '../FiltersConfigModal'; import DatasetSelect from './DatasetSelect'; -const { TabPane } = Tabs; +const TabPane = styled(Tabs.TabPane)` + padding: ${({ theme }) => theme.gridUnit * 4}px 0px; +`; const StyledContainer = styled.div` - display: flex; - flex-direction: row-reverse; - justify-content: space-between; + ${({ theme }) => ` + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + padding: 0px ${theme.gridUnit * 4}px; + `} `; const StyledRowContainer = styled.div` @@ -105,6 +110,7 @@ const StyledRowContainer = styled.div` flex-direction: row; justify-content: space-between; width: 100%; + padding: 0px ${({ theme }) => theme.gridUnit * 4}px; `; export const StyledFormItem = styled(FormItem)` @@ -184,9 +190,7 @@ const RefreshIcon = styled(Icons.Refresh)` `; const StyledCollapse = styled(Collapse)` - margin-left: ${({ theme }) => theme.gridUnit * -4 - 1}px; - margin-right: ${({ theme }) => theme.gridUnit * -4}px; - border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + border-left: 0; border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; border-radius: 0; @@ -214,8 +218,6 @@ const StyledCollapse = styled(Collapse)` const StyledTabs = styled(Tabs)` .ant-tabs-nav { position: sticky; - margin-left: ${({ theme }) => theme.gridUnit * -4}px; - margin-right: ${({ theme }) => theme.gridUnit * -4}px; top: 0; background: white; z-index: 1; @@ -250,7 +252,7 @@ const FilterTabs = { }, }; -const FilterPanels = { +export const FilterPanels = { basic: { key: 'basic', name: t('Basic'), @@ -268,6 +270,13 @@ export interface FiltersConfigFormProps { restoreFilter: (filterId: string) => void; form: FormInstance; parentFilters: { id: string; title: string }[]; + onFilterHierarchyChange: ( + filterId: string, + parentFilter: { label: string; value: string }, + ) => void; + handleActiveFilterPanelChange: (activeFilterPanel: string | string[]) => void; + activeFilterPanelKeys: string | string[]; + isActive: boolean; setErroredFilters: (f: (filters: string[]) => string[]) => void; } @@ -302,9 +311,13 @@ const FiltersConfigForm = ( filterId, filterToEdit, removedFilters, - restoreFilter, form, parentFilters, + activeFilterPanelKeys, + isActive, + restoreFilter, + onFilterHierarchyChange, + handleActiveFilterPanelChange, setErroredFilters, }: FiltersConfigFormProps, ref: React.RefObject, @@ -315,9 +328,7 @@ const FiltersConfigForm = ( const [activeTabKey, setActiveTabKey] = useState( FilterTabs.configuration.key, ); - const [activeFilterPanelKey, setActiveFilterPanelKey] = useState< - string | string[] | undefined - >(); + const [undoFormValues, setUndoFormValues] = useState { // Run only once when the control items are available - if (!activeFilterPanelKey && !isEmpty(controlItems)) { + if (isActive && !isEmpty(controlItems)) { const hasCheckedAdvancedControl = hasParentFilter || hasPreFilter || @@ -668,19 +679,16 @@ const FiltersConfigForm = ( Object.keys(controlItems) .filter(key => !BASIC_CONTROL_ITEMS.includes(key)) .some(key => controlItems[key].checked); - setActiveFilterPanelKey( + handleActiveFilterPanelChange( hasCheckedAdvancedControl - ? [FilterPanels.basic.key, FilterPanels.advanced.key] - : FilterPanels.basic.key, + ? [ + `${filterId}-${FilterPanels.basic.key}`, + `${filterId}-${FilterPanels.advanced.key}`, + ] + : `${filterId}-${FilterPanels.basic.key}`, ); } - }, [ - activeFilterPanelKey, - hasParentFilter, - hasPreFilter, - hasSorting, - controlItems, - ]); + }, [isActive]); const initiallyExcludedCharts = useMemo(() => { const excluded: number[] = []; @@ -840,14 +848,17 @@ const FiltersConfigForm = ( )} setActiveFilterPanelKey(key)} + activeKey={activeFilterPanelKeys} + onChange={key => { + handleActiveFilterPanelChange(key); + }} expandIconPosition="right" + key={`native-filter-config-${filterId}`} > {isCascadingFilter && ( { formChanged(); - if (checked) { - // execute after render - setTimeout( - () => - form.validateFields([ - ['filters', filterId, 'parentFilter'], - ]), - 0, + // execute after render + setTimeout(() => { + if (checked) { + form.validateFields([ + ['filters', filterId, 'parentFilter'], + ]); + } else { + setNativeFilterFieldValues(form, filterId, { + parentFilter: undefined, + }); + } + onFilterHierarchyChange( + filterId, + checked + ? form.getFieldValue('filters')[filterId] + .parentFilter + : undefined, ); - } + }, 0); }} > { new MainPreset().register(); }); -function defaultRender(initialState = defaultState()) { - return render(, { - useRedux: true, +function defaultRender(initialState: any = defaultState(), modalProps = props) { + return render(, { initialState, + useDnd: true, + useRedux: true, }); } @@ -337,6 +340,25 @@ test.skip("doesn't render time range pre-filter if there are no temporal columns ).not.toBeInTheDocument(), ); }); + +test('filter title groups are draggable', async () => { + const nativeFilterState = [ + buildNativeFilter('NATIVE_FILTER-1', 'state', ['NATIVE_FILTER-2']), + buildNativeFilter('NATIVE_FILTER-2', 'country', []), + buildNativeFilter('NATIVE_FILTER-3', 'product', []), + ]; + const state = { + ...defaultState(), + dashboardInfo: { + metadata: { native_filter_configuration: nativeFilterState }, + }, + dashboardLayout, + }; + defaultRender(state, { ...props, createNewOnOpen: false }); + const draggables = document.querySelectorAll('div[draggable=true]'); + expect(draggables.length).toBe(2); +}); + /* TODO adds a new value filter type with all fields filled diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx index 0709d8d9f3..803d07e193 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx @@ -17,27 +17,29 @@ * under the License. */ import React, { useCallback, useMemo, useState, useRef } from 'react'; -import { uniq, debounce, isEqual, sortBy } from 'lodash'; -import { t, styled } from '@superset-ui/core'; -import { SLOW_DEBOUNCE } from 'src/constants'; +import { uniq, isEqual, sortBy, debounce } from 'lodash'; +import { t, styled, SLOW_DEBOUNCE } from '@superset-ui/core'; import { Form } from 'src/common/components'; -import { StyledModal } from 'src/components/Modal'; import ErrorBoundary from 'src/components/ErrorBoundary'; +import { StyledModal } from 'src/components/Modal'; import { testWithId } from 'src/utils/testUtils'; import { useFilterConfigMap, useFilterConfiguration } from '../state'; -import { FilterRemoval, NativeFiltersForm } from './types'; import { FilterConfiguration } from '../types'; +import FiltureConfigurePane from './FilterConfigurePane'; +import FiltersConfigForm, { + FilterPanels, +} from './FiltersConfigForm/FiltersConfigForm'; +import Footer from './Footer/Footer'; +import { useOpenModal, useRemoveCurrentFilter } from './state'; +import { FilterRemoval, NativeFiltersForm, FilterHierarchy } from './types'; import { - validateForm, createHandleSave, createHandleTabEdit, generateFilterId, getFilterIds, + buildFilterGroup, + validateForm, } from './utils'; -import Footer from './Footer/Footer'; -import FilterTabs from './FilterTabs'; -import FiltersConfigForm from './FiltersConfigForm/FiltersConfigForm'; -import { useOpenModal, useRemoveCurrentFilter } from './state'; const StyledModalWrapper = styled(StyledModal)` min-width: 700px; @@ -142,6 +144,29 @@ export function FiltersConfigModal({ if (removal?.isPending) clearTimeout(removal.timerId); setRemovedFilters(current => ({ ...current, [id]: null })); }; + const getInitialFilterHierarchy = () => + filterConfig.map(filter => ({ + id: filter.id, + parentId: filter.cascadeParentIds[0] || null, + })); + + const [filterHierarchy, setFilterHierarchy] = useState(() => + getInitialFilterHierarchy(), + ); + + // State for tracking the re-ordering of filters + const [orderedFilters, setOrderedFilters] = useState(() => + buildFilterGroup(filterHierarchy), + ); + + const [activeFilterPanelKey, setActiveFilterPanelKey] = useState< + string | string[] + >(`${initialCurrentFilterId}-${FilterPanels.basic.key}`); + + const onTabChange = (filterId: string) => { + setCurrentFilterId(filterId); + setActiveFilterPanelKey(`${filterId}-${FilterPanels.basic.key}`); + }; // generates a new filter id and appends it to the newFilterIds const addFilter = useCallback(() => { @@ -149,33 +174,54 @@ export function FiltersConfigModal({ setNewFilterIds([...newFilterIds, newFilterId]); setCurrentFilterId(newFilterId); setSaveAlertVisible(false); - }, [newFilterIds, setCurrentFilterId]); + setFilterHierarchy(previousState => [ + ...previousState, + { id: newFilterId, parentId: null }, + ]); + setOrderedFilters([...orderedFilters, [newFilterId]]); + setActiveFilterPanelKey(`${newFilterId}-${FilterPanels.basic.key}`); + }, [ + newFilterIds, + orderedFilters, + setCurrentFilterId, + setFilterHierarchy, + setOrderedFilters, + ]); useOpenModal(isOpen, addFilter, createNewOnOpen); useRemoveCurrentFilter( removedFilters, currentFilterId, - filterIds, + orderedFilters, setCurrentFilterId, ); const handleTabEdit = createHandleTabEdit( setRemovedFilters, setSaveAlertVisible, + setOrderedFilters, + setFilterHierarchy, addFilter, + filterHierarchy, ); // After this, it should be as if the modal was just opened fresh. // Called when the modal is closed. - const resetForm = () => { + const resetForm = (isSaving = false) => { setNewFilterIds([]); setCurrentFilterId(initialCurrentFilterId); setRemovedFilters({}); setSaveAlertVisible(false); setFormValues({ filters: {} }); - form.setFieldsValue({ changed: false }); setErroredFilters([]); + if (!isSaving) { + const initialFilterHierarchy = getInitialFilterHierarchy(); + setFilterHierarchy(initialFilterHierarchy); + setOrderedFilters(buildFilterGroup(initialFilterHierarchy)); + form.resetFields(); + } + form.setFieldsValue({ changed: false }); }; const getFilterTitle = (id: string) => @@ -261,12 +307,12 @@ export function FiltersConfigModal({ cleanDeletedParents(values); createHandleSave( filterConfigMap, - filterIds, + orderedFilters.flat(), removedFilters, onSave, values, )(); - resetForm(); + resetForm(true); } else { configFormRef.current.changeTab('configuration'); } @@ -279,30 +325,70 @@ export function FiltersConfigModal({ const handleCancel = () => { const changed = form.getFieldValue('changed'); - if (unsavedFiltersIds.length > 0 || form.isFieldsTouched() || changed) { + const initialOrder = buildFilterGroup(getInitialFilterHierarchy()).flat(); + const didChangeOrder = + orderedFilters.flat().length !== initialOrder.length || + orderedFilters.flat().some((val, index) => val !== initialOrder[index]); + if ( + unsavedFiltersIds.length > 0 || + form.isFieldsTouched() || + changed || + didChangeOrder + ) { setSaveAlertVisible(true); } else { handleConfirmCancel(); } }; + const onRearrage = (dragIndex: number, targetIndex: number) => { + const newOrderedFilter = orderedFilters.map(group => [...group]); + const removed = newOrderedFilter.splice(dragIndex, 1)[0]; + newOrderedFilter.splice(targetIndex, 0, removed); + setOrderedFilters(newOrderedFilter); + }; + const handleFilterHierarchyChange = useCallback( + (filterId: string, parentFilter?: { value: string; label: string }) => { + const index = filterHierarchy.findIndex(item => item.id === filterId); + const newState = [...filterHierarchy]; + newState.splice(index, 1, { + id: filterId, + parentId: parentFilter ? parentFilter.value : null, + }); + setFilterHierarchy(newState); + setOrderedFilters(buildFilterGroup(newState)); + }, + [setFilterHierarchy, setOrderedFilters, filterHierarchy], + ); const onValuesChange = useMemo( () => debounce((changes: any, values: NativeFiltersForm) => { - if (changes.filters) { - if ( - Object.values(changes.filters).some( - (filter: any) => filter.name != null, - ) - ) { - // we only need to set this if a name changed - setFormValues(values); - } - handleErroredFilters(); + if ( + changes.filters && + Object.values(changes.filters).some( + (filter: any) => filter.name != null, + ) + ) { + // we only need to set this if a name changed + setFormValues(values); + } + const changedFilterHierarchies = Object.keys(changes.filters) + .filter(key => changes.filters[key].parentFilter) + .map(key => ({ + id: key, + parentFilter: changes.filters[key].parentFilter, + })); + if (changedFilterHierarchies.length > 0) { + const changedFilterId = changedFilterHierarchies[0]; + handleFilterHierarchyChange( + changedFilterId.id, + changedFilterId.parentFilter, + ); } setSaveAlertVisible(false); + handleErroredFilters(); }, SLOW_DEBOUNCE), - [handleErroredFilters], + [handleFilterHierarchyChange, handleErroredFilters], ); return ( @@ -329,20 +415,20 @@ export function FiltersConfigModal({ - {(id: string) => ( + setActiveFilterPanelKey(key) + } + isActive={currentFilterId === id} setErroredFilters={setErroredFilters} /> )} - + diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts index d8fe5c1804..afe1642a1a 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts @@ -1,5 +1,4 @@ import { useEffect } from 'react'; -import { findLastIndex } from 'lodash'; import { FilterRemoval } from './types'; import { usePrevious } from '../../../../common/hooks/usePrevious'; @@ -25,21 +24,22 @@ import { usePrevious } from '../../../../common/hooks/usePrevious'; export const useRemoveCurrentFilter = ( removedFilters: Record, currentFilterId: string, - filterIds: string[], + orderedFilters: string[][], setCurrentFilterId: Function, ) => { useEffect(() => { // if the currently viewed filter is fully removed, change to another tab const currentFilterRemoved = removedFilters[currentFilterId]; if (currentFilterRemoved && !currentFilterRemoved.isPending) { - const nextFilterIndex = findLastIndex( - filterIds, - id => !removedFilters[id] && id !== currentFilterId, - ); - if (nextFilterIndex !== -1) - setCurrentFilterId(filterIds[nextFilterIndex]); + const nextFilterId = orderedFilters + .flat() + .find( + filterId => !removedFilters[filterId] && filterId !== currentFilterId, + ); + + if (nextFilterId) setCurrentFilterId(nextFilterId); } - }, [currentFilterId, removedFilters, filterIds]); + }, [currentFilterId, removedFilters, orderedFilters, setCurrentFilterId]); }; export const useOpenModal = ( diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts index 246f413bcc..e0d31af462 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts @@ -45,6 +45,7 @@ export interface NativeFiltersFormItem { time_range?: string; granularity_sqla?: string; type: NativeFilterType; + hierarchicalFilter?: boolean; } export interface NativeFiltersForm { @@ -59,3 +60,6 @@ export type FilterRemoval = timerId: number; // id of the timer that finally removes the filter } | { isPending: false }; + +export type FilterHierarchyNode = { id: string; parentId: string | null }; +export type FilterHierarchy = FilterHierarchyNode[]; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts index 0e52e19f1e..11d269f3bd 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts @@ -19,8 +19,14 @@ import { FormInstance } from 'antd/lib/form'; import shortid from 'shortid'; import { getInitialDataMask } from 'src/dataMask/reducer'; + import { t } from '@superset-ui/core'; -import { FilterRemoval, NativeFiltersForm } from './types'; +import { + FilterRemoval, + NativeFiltersForm, + FilterHierarchy, + FilterHierarchyNode, +} from './types'; import { Filter, FilterConfiguration, Target } from '../types'; export const REMOVAL_DELAY_SECS = 5; @@ -158,7 +164,49 @@ export const createHandleSave = ( await saveForm(newFilterConfig); }; +export function buildFilterGroup(nodes: FilterHierarchyNode[]) { + const buildGroup = ( + elementId: string, + nodeList: FilterHierarchyNode[], + found: string[], + ): string[] | null => { + const element = nodeList.find(el => el.id === elementId); + const didFind = found.includes(elementId); + let parent: string[] = []; + let children: string[] = []; + if (!element || didFind) { + return null; + } + found.push(elementId); + const { parentId } = element; + + if (parentId) { + const parentArray = buildGroup(parentId, nodeList, found); + parent = parentArray ? parentArray.flat() : []; + } + const childrenArray = nodeList + .filter(el => el.parentId === elementId) + .map(el => buildGroup(el.id, nodeList, found)); + + if (childrenArray) { + children = childrenArray + ? (childrenArray.flat().filter(id => id) as string[]) + : []; + } + return [...parent, elementId, ...children]; + }; + const rendered: string[] = []; + const group: string[][] = []; + for (let index = 0; index < nodes.length; index += 1) { + const element = nodes[index]; + const subGroup = buildGroup(element.id, nodes, rendered); + if (subGroup) { + group.push(subGroup); + } + } + return group; +} export const createHandleTabEdit = ( setRemovedFilters: ( value: @@ -168,9 +216,25 @@ export const createHandleTabEdit = ( | Record, ) => void, setSaveAlertVisible: Function, + setOrderedFilters: ( + val: string[][] | ((prevState: string[][]) => string[][]), + ) => void, + setFilterHierarchy: ( + state: FilterHierarchy | ((prevState: FilterHierarchy) => FilterHierarchy), + ) => void, addFilter: Function, + filterHierarchy: FilterHierarchy, ) => (filterId: string, action: 'add' | 'remove') => { const completeFilterRemoval = (filterId: string) => { + const buildNewFilterHierarchy = (hierarchy: FilterHierarchy) => + hierarchy + .filter(nativeFilter => nativeFilter.id !== filterId) + .map(nativeFilter => { + const didRemoveParent = nativeFilter.parentId === filterId; + return didRemoveParent + ? { ...nativeFilter, parentId: null } + : nativeFilter; + }); // the filter state will actually stick around in the form, // and the filterConfig/newFilterIds, but we use removedFilters // to mark it as removed. @@ -178,14 +242,39 @@ export const createHandleTabEdit = ( ...removedFilters, [filterId]: { isPending: false }, })); + // Remove the filter from the side tab and de-associate children + // in case we removed a parent. + setFilterHierarchy(prevFilterHierarchy => + buildNewFilterHierarchy(prevFilterHierarchy), + ); + setOrderedFilters((orderedFilters: string[][]) => { + const newOrder = []; + for (let index = 0; index < orderedFilters.length; index += 1) { + const doesGroupContainDeletedFilter = + orderedFilters[index].findIndex(id => id === filterId) >= 0; + // Rebuild just the group that contains deleted filter ID. + if (doesGroupContainDeletedFilter) { + const newGroups = buildFilterGroup( + buildNewFilterHierarchy( + filterHierarchy.filter(filter => + orderedFilters[index].includes(filter.id), + ), + ), + ); + newGroups.forEach(group => newOrder.push(group)); + } else { + newOrder.push(orderedFilters[index]); + } + } + return newOrder; + }); }; if (action === 'remove') { // first set up the timer to completely remove it - const timerId = window.setTimeout( - () => completeFilterRemoval(filterId), - REMOVAL_DELAY_SECS * 1000, - ); + const timerId = window.setTimeout(() => { + completeFilterRemoval(filterId); + }, REMOVAL_DELAY_SECS * 1000); // mark the filter state as "removal in progress" setRemovedFilters(removedFilters => ({ ...removedFilters,