feat(dashboard): Let users re-arrange native filters (#16154)

* feat. flag: ON

* refactor filter tabs

* WIP

* Drag and Rearrange, styling

* refactoring dnd code

* Add apache license header

* Fix bug reported during PR review

* Minor fix on remove

* turn off filters

* Fix status

* Fix a test

* Address PR comments

* iSort fixes

* Add type key to the new filters

* Fix wrong attribute

* indent

* PR comments

* PR comments

* Fix failing tests

* Styling

* Fix remove filter

* Fix the drag issue

* Save works

* fix

* Write tests

* Style changes

* New Icon

* Grab & Grabbing Cursor

* Commented out code

* Fix tests, fix CI

* Fix failing tests

* Fix test

* Style fixes

* portal nodes dependency

* More style fixes

* PR comments

* add unique ids to collapse panels

* Filter removal bug fixed

* PR comments

* Fix test warnings

* delete filter tabs

* Fix the breaking test

* Fix warnings

* Fix the weird bug on cancel

* refactor

* Fix broken scope
This commit is contained in:
Ajay M 2021-10-15 19:56:33 -04:00 committed by GitHub
parent 4a9107d7f1
commit 9e6d5fc775
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1085 additions and 395 deletions

View File

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

View File

@ -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(
<Provider store={mockStore}>
<FiltersConfigModal {...mockedProps} {...overridesProps} />
<DndProvider backend={HTML5Backend}>
<FiltersConfigModal {...mockedProps} {...overridesProps} />
</DndProvider>
</Provider>,
);
}

View File

@ -220,8 +220,9 @@ describe('FilterBar', () => {
const renderWrapper = (props = closedBarProps, state?: object) =>
render(<FilterBar {...props} width={280} height={400} offset={0} />, {
useRedux: true,
initialState: state,
useDnd: true,
useRedux: true,
useRouter: true,
});

View File

@ -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<FCBProps> = ({
createNewOnOpen,
dashboardId,
children,
}) => {
const dispatch = useDispatch();
@ -65,6 +67,7 @@ export const FilterConfigurationLink: React.FC<FCBProps> = ({
onSave={submit}
onCancel={close}
createNewOnOpen={createNewOnOpen}
key={`filters-for-${dashboardId}`}
/>
</>
);

View File

@ -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<FilterControlsProps> = ({
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<FilterControlsProps> = ({
{portalNodes
.filter((node, index) => cascadeFilterIds.has(filterValues[index].id))
.map((node, index) => (
<portals.InPortal node={node}>
<InPortal node={node}>
<CascadePopover
data-test="cascade-filters-control"
key={cascadeFilters[index].id}
@ -92,11 +99,11 @@ const FilterControls: FC<FilterControlsProps> = ({
directPathToChild={directPathToChild}
inView={false}
/>
</portals.InPortal>
</InPortal>
))}
{filtersInScope.map(filter => {
const index = filterValues.findIndex(f => f.id === filter.id);
return <portals.OutPortal node={portalNodes[index]} inView />;
return <OutPortal node={portalNodes[index]} inView />;
})}
{showCollapsePanel && (
<Collapse
@ -134,7 +141,7 @@ const FilterControls: FC<FilterControlsProps> = ({
>
{filtersOutOfScope.map(filter => {
const index = cascadeFilters.findIndex(f => f.id === filter.id);
return <portals.OutPortal node={portalNodes[index]} inView />;
return <OutPortal node={portalNodes[index]} inView />;
})}
</Collapse.Panel>
</Collapse>

View File

@ -87,6 +87,9 @@ const Header: FC<HeaderProps> = ({
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const isClearAllDisabled = Object.values(dataMaskApplied).every(
filter =>
@ -99,7 +102,10 @@ const Header: FC<HeaderProps> = ({
<TitleArea>
<span>{t('Filters')}</span>
{canEdit && (
<FilterConfigurationLink createNewOnOpen={filterValues.length === 0}>
<FilterConfigurationLink
dashboardId={dashboardId}
createNewOnOpen={filterValues.length === 0}
>
<Icons.Edit
data-test="create-filter"
iconColor={theme.colors.grayscale.base}

View File

@ -0,0 +1,141 @@
/**
* 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, { useRef } from 'react';
import {
DragSourceMonitor,
DropTargetMonitor,
useDrag,
useDrop,
XYCoord,
} from 'react-dnd';
import Icons, { IconType } from 'src/components/Icons';
interface TitleContainerProps {
readonly isDragging: boolean;
}
const FILTER_TYPE = 'FILTER';
const Container = styled.div<TitleContainerProps>`
${({ 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',
})<IconType & { isDragging: boolean }>`
${({ 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<FilterTabTitleProps> = ({
index,
onRearrage,
filterIds,
children,
}) => {
const ref = useRef<HTMLDivElement>(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 (
<Container ref={ref} isDragging={isDragging}>
<DragIcon isDragging={isDragging} alt="Move icon" className="dragIcon" />
<div css={{ flexGrow: 4 }}>{children}</div>
</Container>
);
};
export default DraggableFilter;

View File

@ -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(<FilterConfigPane {...props} />, {
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');
});

View File

@ -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<string, FilterRemoval>;
}
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<Props> = ({
getFilterTitle,
onChange,
onEdit,
onRearrange,
restoreFilter,
erroredFilters,
children,
currentFilterId,
filterGroups,
removedFilters,
}) => {
const active = filterGroups.flat().filter(id => id === currentFilterId)[0];
return (
<Container>
<TitlesContainer>
<FilterTitlePane
currentFilterId={currentFilterId}
filterGroups={filterGroups}
removedFilters={removedFilters}
erroredFilters={erroredFilters}
getFilterTitle={getFilterTitle}
onChange={onChange}
onEdit={onEdit}
onRearrage={onRearrange}
onRemove={(id: string) => onEdit(id, 'remove')}
restoreFilter={restoreFilter}
/>
</TitlesContainer>
<ContentHolder>
{filterGroups.flat().map(id => (
<div
key={id}
style={{
height: '100%',
overflowY: 'auto',
display: id === active ? '' : 'none',
}}
>
{children(id)}
</div>
))}
</ContentHolder>
</Container>
);
};
export default FiltureConfigurePane;

View File

@ -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<string, FilterRemoval>;
restoreFilter: Function;
children: Function;
};
const FilterTabs: FC<FilterTabsProps> = ({
onEdit,
getFilterTitle,
onChange,
currentFilterId,
filterIds = [],
erroredFilters = [],
removedFilters = [],
restoreFilter,
children,
}) => (
<FilterTabsContainer
id="native-filters-tabs"
type="editable-card"
tabPosition="left"
onChange={onChange}
activeKey={currentFilterId}
onEdit={onEdit}
hideAdd
tabBarExtraContent={{
left: <StyledHeader>{t('Filters')}</StyledHeader>,
right: (
<StyledAddFilterBox
onClick={() => {
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);
}}
>
<PlusOutlined />{' '}
<span data-test="add-filter-button" aria-label="Add filter">
{t('Add filter')}
</span>
</StyledAddFilterBox>
),
}}
>
{filterIds.map(id => {
const showErroredFilter = erroredFilters.includes(id);
const filterName = getFilterTitle(id);
return (
<LineEditableTabs.TabPane
tab={
<FilterTabTitle
className={
removedFilters[id]
? 'removed'
: showErroredFilter
? 'errored'
: ''
}
>
<StyledFilterTitle>
{removedFilters[id] ? t('(Removed)') : filterName}
</StyledFilterTitle>
{!removedFilters[id] && showErroredFilter && <StyledWarning />}
{removedFilters[id] && (
<StyledSpan
role="button"
data-test="undo-button"
tabIndex={0}
onClick={() => restoreFilter(id)}
>
{t('Undo?')}
</StyledSpan>
)}
</FilterTabTitle>
}
key={id}
closeIcon={removedFilters[id] ? <></> : <StyledTrashIcon />}
>
{
// @ts-ignore
children(id)
}
</LineEditableTabs.TabPane>
);
})}
</FilterTabsContainer>
);
export default FilterTabs;

View File

@ -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<string, FilterRemoval>;
onRemove: (id: string) => void;
restoreFilter: (id: string) => void;
onRearrage: (dragIndex: number, targetIndex: number) => void;
filterGroups: string[][];
erroredFilters: string[];
}
const FilterTitleContainer: React.FC<Props> = ({
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 (
<FilterTitle
role="tab"
key={`filter-title-tab-${id}`}
onClick={() => onChange(id)}
className={classNames.join(' ')}
>
<div css={{ display: 'flex', width: '100%' }}>
<div css={{ alignItems: 'center', display: 'flex' }}>
{isRemoved ? t('(Removed)') : getFilterTitle(id)}
</div>
{!removedFilters[id] && isErrored && (
<StyledWarning className="warning" />
)}
{isRemoved && (
<span
css={{ alignSelf: 'flex-end', marginLeft: 'auto' }}
role="button"
data-test="undo-button"
tabIndex={0}
onClick={e => {
e.preventDefault();
restoreFilter(id);
}}
>
{t('Undo?')}
</span>
)}
</div>
<div css={{ alignSelf: 'flex-end', marginLeft: 'auto' }}>
{isRemoved ? null : (
<StyledTrashIcon
onClick={event => {
event.stopPropagation();
onRemove(id);
}}
alt="RemoveFilter"
/>
)}
</div>
</FilterTitle>
);
};
const recursivelyRender = (
elementId: string,
nodeList: Array<{ id: string; parentId: string | null }>,
rendered: Array<string>,
): 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(
<DraggableFilter
key={index}
onRearrage={onRearrage}
index={index}
filterIds={item}
>
{item.map(filter => renderComponent(filter))}
</DraggableFilter>,
);
});
return items;
};
return <Container>{renderFilterGroups()}</Container>;
};
export default FilterTitleContainer;

View File

@ -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<string, FilterRemoval>;
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<Props> = ({
getFilterTitle,
onChange,
onEdit,
onRemove,
onRearrage,
restoreFilter,
currentFilterId,
filterGroups,
removedFilters,
erroredFilters,
}) => {
const theme = useTheme();
return (
<TabsContainer>
<StyledHeader>Filters</StyledHeader>
<div
css={{
height: '100%',
overflowY: 'auto',
marginLeft: theme.gridUnit * 3,
}}
>
<FilterTitleContainer
filterGroups={filterGroups}
currentFilterId={currentFilterId}
removedFilters={removedFilters}
getFilterTitle={getFilterTitle}
erroredFilters={erroredFilters}
onChange={onChange}
onRemove={onRemove}
onRearrage={onRearrage}
restoreFilter={restoreFilter}
/>
</div>
<StyledAddFilterBox
onClick={() => {
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);
}}
>
<PlusOutlined />{' '}
<span
data-test="add-filter-button"
aria-label="Add filter"
role="button"
>
{t('Add filter')}
</span>
</StyledAddFilterBox>
</TabsContainer>
);
};
export default FilterTitlePane;

View File

@ -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<NativeFiltersForm>;
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 }) => {

View File

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

View File

@ -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<NativeFiltersForm>;
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<any>,
@ -315,9 +328,7 @@ const FiltersConfigForm = (
const [activeTabKey, setActiveTabKey] = useState<string>(
FilterTabs.configuration.key,
);
const [activeFilterPanelKey, setActiveFilterPanelKey] = useState<
string | string[] | undefined
>();
const [undoFormValues, setUndoFormValues] = useState<Record<
string,
any
@ -660,7 +671,7 @@ const FiltersConfigForm = (
useEffect(() => {
// 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 = (
</StyledRowContainer>
)}
<StyledCollapse
activeKey={activeFilterPanelKey}
onChange={key => setActiveFilterPanelKey(key)}
activeKey={activeFilterPanelKeys}
onChange={key => {
handleActiveFilterPanelChange(key);
}}
expandIconPosition="right"
key={`native-filter-config-${filterId}`}
>
<Collapse.Panel
forceRender
header={FilterPanels.basic.name}
key={FilterPanels.basic.key}
key={`${filterId}-${FilterPanels.basic.key}`}
>
<CleanFormItem
name={['filters', filterId, 'defaultValueQueriesData']}
@ -960,7 +971,7 @@ const FiltersConfigForm = (
<Collapse.Panel
forceRender
header={FilterPanels.advanced.name}
key={FilterPanels.advanced.key}
key={`${filterId}-${FilterPanels.advanced.key}`}
>
{isCascadingFilter && (
<CleanFormItem
@ -971,16 +982,25 @@ const FiltersConfigForm = (
initialValue={hasParentFilter}
onChange={checked => {
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);
}}
>
<StyledRowSubFormItem

View File

@ -16,20 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { Preset } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import userEvent, { specialChars } from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import chartQueries from 'spec/fixtures/mockChartQueries';
import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout';
import mockDatasource, { datasourceId, id } from 'spec/fixtures/mockDatasource';
import { buildNativeFilter } from 'spec/fixtures/mockNativeFilters';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
SelectFilterPlugin,
RangeFilterPlugin,
TimeFilterPlugin,
SelectFilterPlugin,
TimeColumnFilterPlugin,
TimeFilterPlugin,
TimeGrainFilterPlugin,
} from 'src/filters/components';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import mockDatasource, { id, datasourceId } from 'spec/fixtures/mockDatasource';
import chartQueries from 'spec/fixtures/mockChartQueries';
import {
FiltersConfigModal,
FiltersConfigModalProps,
@ -148,10 +150,11 @@ beforeAll(() => {
new MainPreset().register();
});
function defaultRender(initialState = defaultState()) {
return render(<FiltersConfigModal {...props} />, {
useRedux: true,
function defaultRender(initialState: any = defaultState(), modalProps = props) {
return render(<FiltersConfigModal {...modalProps} />, {
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

View File

@ -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<FilterHierarchy>(() =>
getInitialFilterHierarchy(),
);
// State for tracking the re-ordering of filters
const [orderedFilters, setOrderedFilters] = useState<string[][]>(() =>
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({
<ErrorBoundary>
<StyledModalBody>
<StyledForm
preserve={false}
form={form}
onValuesChange={onValuesChange}
layout="vertical"
>
<FilterTabs
<FiltureConfigurePane
erroredFilters={erroredFilters}
onEdit={handleTabEdit}
onChange={setCurrentFilterId}
onChange={onTabChange}
getFilterTitle={getFilterTitle}
currentFilterId={currentFilterId}
filterIds={filterIds}
removedFilters={removedFilters}
restoreFilter={restoreFilter}
onRearrange={onRearrage}
filterGroups={orderedFilters}
>
{(id: string) => (
<FiltersConfigForm
@ -353,10 +439,17 @@ export function FiltersConfigModal({
removedFilters={removedFilters}
restoreFilter={restoreFilter}
parentFilters={getParentFilters(id)}
onFilterHierarchyChange={handleFilterHierarchyChange}
key={id}
activeFilterPanelKeys={activeFilterPanelKey}
handleActiveFilterPanelChange={key =>
setActiveFilterPanelKey(key)
}
isActive={currentFilterId === id}
setErroredFilters={setErroredFilters}
/>
)}
</FilterTabs>
</FiltureConfigurePane>
</StyledForm>
</StyledModalBody>
</ErrorBoundary>

View File

@ -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<string, FilterRemoval>,
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 = (

View File

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

View File

@ -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<string, FilterRemoval>,
) => 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,