mirror of
https://github.com/apache/superset.git
synced 2024-09-17 11:09:47 -04:00
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:
parent
4a9107d7f1
commit
9e6d5fc775
@ -448,3 +448,39 @@ export const mockQueryDataForCountries = [
|
|||||||
{ country_name: 'Zambia', 'SUM(SP_POP_TOTL)': 438847085 },
|
{ country_name: 'Zambia', 'SUM(SP_POP_TOTL)': 438847085 },
|
||||||
{ country_name: 'Zimbabwe', 'SUM(SP_POP_TOTL)': 509866860 },
|
{ 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',
|
||||||
|
});
|
||||||
|
@ -16,14 +16,16 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* 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 { 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 { Provider } from 'react-redux';
|
||||||
import Alert from 'src/components/Alert';
|
|
||||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
|
||||||
import { mockStore } from 'spec/fixtures/mockStore';
|
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';
|
import { FiltersConfigModal } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
@ -66,7 +68,9 @@ describe('FiltersConfigModal', () => {
|
|||||||
function setup(overridesProps?: any) {
|
function setup(overridesProps?: any) {
|
||||||
return mount(
|
return mount(
|
||||||
<Provider store={mockStore}>
|
<Provider store={mockStore}>
|
||||||
<FiltersConfigModal {...mockedProps} {...overridesProps} />
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<FiltersConfigModal {...mockedProps} {...overridesProps} />
|
||||||
|
</DndProvider>
|
||||||
</Provider>,
|
</Provider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -220,8 +220,9 @@ describe('FilterBar', () => {
|
|||||||
|
|
||||||
const renderWrapper = (props = closedBarProps, state?: object) =>
|
const renderWrapper = (props = closedBarProps, state?: object) =>
|
||||||
render(<FilterBar {...props} width={280} height={400} offset={0} />, {
|
render(<FilterBar {...props} width={280} height={400} offset={0} />, {
|
||||||
useRedux: true,
|
|
||||||
initialState: state,
|
initialState: state,
|
||||||
|
useDnd: true,
|
||||||
|
useRedux: true,
|
||||||
useRouter: true,
|
useRouter: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ import { getFilterBarTestId } from '..';
|
|||||||
|
|
||||||
export interface FCBProps {
|
export interface FCBProps {
|
||||||
createNewOnOpen?: boolean;
|
createNewOnOpen?: boolean;
|
||||||
|
dashboardId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeaderButton = styled(Button)`
|
const HeaderButton = styled(Button)`
|
||||||
@ -35,6 +36,7 @@ const HeaderButton = styled(Button)`
|
|||||||
|
|
||||||
export const FilterConfigurationLink: React.FC<FCBProps> = ({
|
export const FilterConfigurationLink: React.FC<FCBProps> = ({
|
||||||
createNewOnOpen,
|
createNewOnOpen,
|
||||||
|
dashboardId,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -65,6 +67,7 @@ export const FilterConfigurationLink: React.FC<FCBProps> = ({
|
|||||||
onSave={submit}
|
onSave={submit}
|
||||||
onCancel={close}
|
onCancel={close}
|
||||||
createNewOnOpen={createNewOnOpen}
|
createNewOnOpen={createNewOnOpen}
|
||||||
|
key={`filters-for-${dashboardId}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -17,16 +17,23 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { FC, useMemo, useState } from 'react';
|
import React, { FC, useMemo, useState } from 'react';
|
||||||
import { DataMask, styled, t } from '@superset-ui/core';
|
|
||||||
import { css } from '@emotion/react';
|
import { css } from '@emotion/react';
|
||||||
import * as portals from 'react-reverse-portal';
|
import { DataMask, styled, t } from '@superset-ui/core';
|
||||||
import { DataMaskStateWithId } from 'src/dataMask/types';
|
import {
|
||||||
|
createHtmlPortalNode,
|
||||||
|
InPortal,
|
||||||
|
OutPortal,
|
||||||
|
} from 'react-reverse-portal';
|
||||||
import { Collapse } from 'src/common/components';
|
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 CascadePopover from '../CascadeFilters/CascadePopover';
|
||||||
import { buildCascadeFiltersTree } from './utils';
|
|
||||||
import { useFilters } from '../state';
|
import { useFilters } from '../state';
|
||||||
import { Filter } from '../../types';
|
import { buildCascadeFiltersTree } from './utils';
|
||||||
import { useDashboardHasTabs, useSelectFiltersInScope } from '../../state';
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
padding: ${({ theme }) => theme.gridUnit * 4}px;
|
padding: ${({ theme }) => theme.gridUnit * 4}px;
|
||||||
@ -52,7 +59,7 @@ const FilterControls: FC<FilterControlsProps> = ({
|
|||||||
const portalNodes = React.useMemo(() => {
|
const portalNodes = React.useMemo(() => {
|
||||||
const nodes = new Array(filterValues.length);
|
const nodes = new Array(filterValues.length);
|
||||||
for (let i = 0; i < filterValues.length; i += 1) {
|
for (let i = 0; i < filterValues.length; i += 1) {
|
||||||
nodes[i] = portals.createHtmlPortalNode();
|
nodes[i] = createHtmlPortalNode();
|
||||||
}
|
}
|
||||||
return nodes;
|
return nodes;
|
||||||
}, [filterValues.length]);
|
}, [filterValues.length]);
|
||||||
@ -78,7 +85,7 @@ const FilterControls: FC<FilterControlsProps> = ({
|
|||||||
{portalNodes
|
{portalNodes
|
||||||
.filter((node, index) => cascadeFilterIds.has(filterValues[index].id))
|
.filter((node, index) => cascadeFilterIds.has(filterValues[index].id))
|
||||||
.map((node, index) => (
|
.map((node, index) => (
|
||||||
<portals.InPortal node={node}>
|
<InPortal node={node}>
|
||||||
<CascadePopover
|
<CascadePopover
|
||||||
data-test="cascade-filters-control"
|
data-test="cascade-filters-control"
|
||||||
key={cascadeFilters[index].id}
|
key={cascadeFilters[index].id}
|
||||||
@ -92,11 +99,11 @@ const FilterControls: FC<FilterControlsProps> = ({
|
|||||||
directPathToChild={directPathToChild}
|
directPathToChild={directPathToChild}
|
||||||
inView={false}
|
inView={false}
|
||||||
/>
|
/>
|
||||||
</portals.InPortal>
|
</InPortal>
|
||||||
))}
|
))}
|
||||||
{filtersInScope.map(filter => {
|
{filtersInScope.map(filter => {
|
||||||
const index = filterValues.findIndex(f => f.id === filter.id);
|
const index = filterValues.findIndex(f => f.id === filter.id);
|
||||||
return <portals.OutPortal node={portalNodes[index]} inView />;
|
return <OutPortal node={portalNodes[index]} inView />;
|
||||||
})}
|
})}
|
||||||
{showCollapsePanel && (
|
{showCollapsePanel && (
|
||||||
<Collapse
|
<Collapse
|
||||||
@ -134,7 +141,7 @@ const FilterControls: FC<FilterControlsProps> = ({
|
|||||||
>
|
>
|
||||||
{filtersOutOfScope.map(filter => {
|
{filtersOutOfScope.map(filter => {
|
||||||
const index = cascadeFilters.findIndex(f => f.id === filter.id);
|
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.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
@ -87,6 +87,9 @@ const Header: FC<HeaderProps> = ({
|
|||||||
const canEdit = useSelector<RootState, boolean>(
|
const canEdit = useSelector<RootState, boolean>(
|
||||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||||
);
|
);
|
||||||
|
const dashboardId = useSelector<RootState, number>(
|
||||||
|
({ dashboardInfo }) => dashboardInfo.id,
|
||||||
|
);
|
||||||
|
|
||||||
const isClearAllDisabled = Object.values(dataMaskApplied).every(
|
const isClearAllDisabled = Object.values(dataMaskApplied).every(
|
||||||
filter =>
|
filter =>
|
||||||
@ -99,7 +102,10 @@ const Header: FC<HeaderProps> = ({
|
|||||||
<TitleArea>
|
<TitleArea>
|
||||||
<span>{t('Filters')}</span>
|
<span>{t('Filters')}</span>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<FilterConfigurationLink createNewOnOpen={filterValues.length === 0}>
|
<FilterConfigurationLink
|
||||||
|
dashboardId={dashboardId}
|
||||||
|
createNewOnOpen={filterValues.length === 0}
|
||||||
|
>
|
||||||
<Icons.Edit
|
<Icons.Edit
|
||||||
data-test="create-filter"
|
data-test="create-filter"
|
||||||
iconColor={theme.colors.grayscale.base}
|
iconColor={theme.colors.grayscale.base}
|
||||||
|
@ -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;
|
@ -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');
|
||||||
|
});
|
@ -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;
|
@ -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;
|
|
@ -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;
|
@ -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;
|
@ -27,18 +27,24 @@ import {
|
|||||||
import { mockStoreWithChartsInTabsAndRoot } from 'spec/fixtures/mockStore';
|
import { mockStoreWithChartsInTabsAndRoot } from 'spec/fixtures/mockStore';
|
||||||
import { Form, FormInstance } from 'src/common/components';
|
import { Form, FormInstance } from 'src/common/components';
|
||||||
import { NativeFiltersForm } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
|
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', () => {
|
describe('FilterScope', () => {
|
||||||
const save = jest.fn();
|
const save = jest.fn();
|
||||||
let form: FormInstance<NativeFiltersForm>;
|
let form: FormInstance<NativeFiltersForm>;
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
filterId: 'DefaultFilterId',
|
filterId: 'DefaultFilterId',
|
||||||
restoreFilter: jest.fn(),
|
|
||||||
setErroredFilters: jest.fn(),
|
|
||||||
parentFilters: [],
|
parentFilters: [],
|
||||||
|
setErroredFilters: jest.fn(),
|
||||||
|
onFilterHierarchyChange: jest.fn(),
|
||||||
|
restoreFilter: jest.fn(),
|
||||||
save,
|
save,
|
||||||
removedFilters: {},
|
removedFilters: {},
|
||||||
|
handleActiveFilterPanelChange: jest.fn(),
|
||||||
|
activeFilterPanelKeys: `DefaultFilterId-${FilterPanels.basic.key}`,
|
||||||
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MockModal = ({ scope }: { scope?: object }) => {
|
const MockModal = ({ scope }: { scope?: object }) => {
|
||||||
|
@ -44,6 +44,7 @@ const Wrapper = styled.div`
|
|||||||
& > * {
|
& > * {
|
||||||
margin-bottom: ${({ theme }) => theme.gridUnit}px;
|
margin-bottom: ${({ theme }) => theme.gridUnit}px;
|
||||||
}
|
}
|
||||||
|
padding: 0px ${({ theme }) => theme.gridUnit * 4}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CleanFormItem = styled(Form.Item)`
|
const CleanFormItem = styled(Form.Item)`
|
||||||
|
@ -92,12 +92,17 @@ import {
|
|||||||
} from '../FiltersConfigModal';
|
} from '../FiltersConfigModal';
|
||||||
import DatasetSelect from './DatasetSelect';
|
import DatasetSelect from './DatasetSelect';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const TabPane = styled(Tabs.TabPane)`
|
||||||
|
padding: ${({ theme }) => theme.gridUnit * 4}px 0px;
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
${({ theme }) => `
|
||||||
flex-direction: row-reverse;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: row-reverse;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0px ${theme.gridUnit * 4}px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledRowContainer = styled.div`
|
const StyledRowContainer = styled.div`
|
||||||
@ -105,6 +110,7 @@ const StyledRowContainer = styled.div`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 0px ${({ theme }) => theme.gridUnit * 4}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const StyledFormItem = styled(FormItem)`
|
export const StyledFormItem = styled(FormItem)`
|
||||||
@ -184,9 +190,7 @@ const RefreshIcon = styled(Icons.Refresh)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledCollapse = styled(Collapse)`
|
const StyledCollapse = styled(Collapse)`
|
||||||
margin-left: ${({ theme }) => theme.gridUnit * -4 - 1}px;
|
border-left: 0;
|
||||||
margin-right: ${({ theme }) => theme.gridUnit * -4}px;
|
|
||||||
border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
|
||||||
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|
||||||
@ -214,8 +218,6 @@ const StyledCollapse = styled(Collapse)`
|
|||||||
const StyledTabs = styled(Tabs)`
|
const StyledTabs = styled(Tabs)`
|
||||||
.ant-tabs-nav {
|
.ant-tabs-nav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
margin-left: ${({ theme }) => theme.gridUnit * -4}px;
|
|
||||||
margin-right: ${({ theme }) => theme.gridUnit * -4}px;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
background: white;
|
background: white;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@ -250,7 +252,7 @@ const FilterTabs = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const FilterPanels = {
|
export const FilterPanels = {
|
||||||
basic: {
|
basic: {
|
||||||
key: 'basic',
|
key: 'basic',
|
||||||
name: t('Basic'),
|
name: t('Basic'),
|
||||||
@ -268,6 +270,13 @@ export interface FiltersConfigFormProps {
|
|||||||
restoreFilter: (filterId: string) => void;
|
restoreFilter: (filterId: string) => void;
|
||||||
form: FormInstance<NativeFiltersForm>;
|
form: FormInstance<NativeFiltersForm>;
|
||||||
parentFilters: { id: string; title: string }[];
|
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;
|
setErroredFilters: (f: (filters: string[]) => string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,9 +311,13 @@ const FiltersConfigForm = (
|
|||||||
filterId,
|
filterId,
|
||||||
filterToEdit,
|
filterToEdit,
|
||||||
removedFilters,
|
removedFilters,
|
||||||
restoreFilter,
|
|
||||||
form,
|
form,
|
||||||
parentFilters,
|
parentFilters,
|
||||||
|
activeFilterPanelKeys,
|
||||||
|
isActive,
|
||||||
|
restoreFilter,
|
||||||
|
onFilterHierarchyChange,
|
||||||
|
handleActiveFilterPanelChange,
|
||||||
setErroredFilters,
|
setErroredFilters,
|
||||||
}: FiltersConfigFormProps,
|
}: FiltersConfigFormProps,
|
||||||
ref: React.RefObject<any>,
|
ref: React.RefObject<any>,
|
||||||
@ -315,9 +328,7 @@ const FiltersConfigForm = (
|
|||||||
const [activeTabKey, setActiveTabKey] = useState<string>(
|
const [activeTabKey, setActiveTabKey] = useState<string>(
|
||||||
FilterTabs.configuration.key,
|
FilterTabs.configuration.key,
|
||||||
);
|
);
|
||||||
const [activeFilterPanelKey, setActiveFilterPanelKey] = useState<
|
|
||||||
string | string[] | undefined
|
|
||||||
>();
|
|
||||||
const [undoFormValues, setUndoFormValues] = useState<Record<
|
const [undoFormValues, setUndoFormValues] = useState<Record<
|
||||||
string,
|
string,
|
||||||
any
|
any
|
||||||
@ -660,7 +671,7 @@ const FiltersConfigForm = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Run only once when the control items are available
|
// Run only once when the control items are available
|
||||||
if (!activeFilterPanelKey && !isEmpty(controlItems)) {
|
if (isActive && !isEmpty(controlItems)) {
|
||||||
const hasCheckedAdvancedControl =
|
const hasCheckedAdvancedControl =
|
||||||
hasParentFilter ||
|
hasParentFilter ||
|
||||||
hasPreFilter ||
|
hasPreFilter ||
|
||||||
@ -668,19 +679,16 @@ const FiltersConfigForm = (
|
|||||||
Object.keys(controlItems)
|
Object.keys(controlItems)
|
||||||
.filter(key => !BASIC_CONTROL_ITEMS.includes(key))
|
.filter(key => !BASIC_CONTROL_ITEMS.includes(key))
|
||||||
.some(key => controlItems[key].checked);
|
.some(key => controlItems[key].checked);
|
||||||
setActiveFilterPanelKey(
|
handleActiveFilterPanelChange(
|
||||||
hasCheckedAdvancedControl
|
hasCheckedAdvancedControl
|
||||||
? [FilterPanels.basic.key, FilterPanels.advanced.key]
|
? [
|
||||||
: FilterPanels.basic.key,
|
`${filterId}-${FilterPanels.basic.key}`,
|
||||||
|
`${filterId}-${FilterPanels.advanced.key}`,
|
||||||
|
]
|
||||||
|
: `${filterId}-${FilterPanels.basic.key}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [isActive]);
|
||||||
activeFilterPanelKey,
|
|
||||||
hasParentFilter,
|
|
||||||
hasPreFilter,
|
|
||||||
hasSorting,
|
|
||||||
controlItems,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const initiallyExcludedCharts = useMemo(() => {
|
const initiallyExcludedCharts = useMemo(() => {
|
||||||
const excluded: number[] = [];
|
const excluded: number[] = [];
|
||||||
@ -840,14 +848,17 @@ const FiltersConfigForm = (
|
|||||||
</StyledRowContainer>
|
</StyledRowContainer>
|
||||||
)}
|
)}
|
||||||
<StyledCollapse
|
<StyledCollapse
|
||||||
activeKey={activeFilterPanelKey}
|
activeKey={activeFilterPanelKeys}
|
||||||
onChange={key => setActiveFilterPanelKey(key)}
|
onChange={key => {
|
||||||
|
handleActiveFilterPanelChange(key);
|
||||||
|
}}
|
||||||
expandIconPosition="right"
|
expandIconPosition="right"
|
||||||
|
key={`native-filter-config-${filterId}`}
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
forceRender
|
forceRender
|
||||||
header={FilterPanels.basic.name}
|
header={FilterPanels.basic.name}
|
||||||
key={FilterPanels.basic.key}
|
key={`${filterId}-${FilterPanels.basic.key}`}
|
||||||
>
|
>
|
||||||
<CleanFormItem
|
<CleanFormItem
|
||||||
name={['filters', filterId, 'defaultValueQueriesData']}
|
name={['filters', filterId, 'defaultValueQueriesData']}
|
||||||
@ -960,7 +971,7 @@ const FiltersConfigForm = (
|
|||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
forceRender
|
forceRender
|
||||||
header={FilterPanels.advanced.name}
|
header={FilterPanels.advanced.name}
|
||||||
key={FilterPanels.advanced.key}
|
key={`${filterId}-${FilterPanels.advanced.key}`}
|
||||||
>
|
>
|
||||||
{isCascadingFilter && (
|
{isCascadingFilter && (
|
||||||
<CleanFormItem
|
<CleanFormItem
|
||||||
@ -971,16 +982,25 @@ const FiltersConfigForm = (
|
|||||||
initialValue={hasParentFilter}
|
initialValue={hasParentFilter}
|
||||||
onChange={checked => {
|
onChange={checked => {
|
||||||
formChanged();
|
formChanged();
|
||||||
if (checked) {
|
// execute after render
|
||||||
// execute after render
|
setTimeout(() => {
|
||||||
setTimeout(
|
if (checked) {
|
||||||
() =>
|
form.validateFields([
|
||||||
form.validateFields([
|
['filters', filterId, 'parentFilter'],
|
||||||
['filters', filterId, 'parentFilter'],
|
]);
|
||||||
]),
|
} else {
|
||||||
0,
|
setNativeFilterFieldValues(form, filterId, {
|
||||||
|
parentFilter: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onFilterHierarchyChange(
|
||||||
|
filterId,
|
||||||
|
checked
|
||||||
|
? form.getFieldValue('filters')[filterId]
|
||||||
|
.parentFilter
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
}
|
}, 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledRowSubFormItem
|
<StyledRowSubFormItem
|
||||||
|
@ -16,20 +16,22 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
|
||||||
import { Preset } from '@superset-ui/core';
|
import { Preset } from '@superset-ui/core';
|
||||||
import fetchMock from 'fetch-mock';
|
|
||||||
import userEvent, { specialChars } from '@testing-library/user-event';
|
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 {
|
import {
|
||||||
SelectFilterPlugin,
|
|
||||||
RangeFilterPlugin,
|
RangeFilterPlugin,
|
||||||
TimeFilterPlugin,
|
SelectFilterPlugin,
|
||||||
TimeColumnFilterPlugin,
|
TimeColumnFilterPlugin,
|
||||||
|
TimeFilterPlugin,
|
||||||
TimeGrainFilterPlugin,
|
TimeGrainFilterPlugin,
|
||||||
} from 'src/filters/components';
|
} 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 {
|
import {
|
||||||
FiltersConfigModal,
|
FiltersConfigModal,
|
||||||
FiltersConfigModalProps,
|
FiltersConfigModalProps,
|
||||||
@ -148,10 +150,11 @@ beforeAll(() => {
|
|||||||
new MainPreset().register();
|
new MainPreset().register();
|
||||||
});
|
});
|
||||||
|
|
||||||
function defaultRender(initialState = defaultState()) {
|
function defaultRender(initialState: any = defaultState(), modalProps = props) {
|
||||||
return render(<FiltersConfigModal {...props} />, {
|
return render(<FiltersConfigModal {...modalProps} />, {
|
||||||
useRedux: true,
|
|
||||||
initialState,
|
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(),
|
).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
|
TODO
|
||||||
adds a new value filter type with all fields filled
|
adds a new value filter type with all fields filled
|
||||||
|
@ -17,27 +17,29 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useMemo, useState, useRef } from 'react';
|
import React, { useCallback, useMemo, useState, useRef } from 'react';
|
||||||
import { uniq, debounce, isEqual, sortBy } from 'lodash';
|
import { uniq, isEqual, sortBy, debounce } from 'lodash';
|
||||||
import { t, styled } from '@superset-ui/core';
|
import { t, styled, SLOW_DEBOUNCE } from '@superset-ui/core';
|
||||||
import { SLOW_DEBOUNCE } from 'src/constants';
|
|
||||||
import { Form } from 'src/common/components';
|
import { Form } from 'src/common/components';
|
||||||
import { StyledModal } from 'src/components/Modal';
|
|
||||||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||||
|
import { StyledModal } from 'src/components/Modal';
|
||||||
import { testWithId } from 'src/utils/testUtils';
|
import { testWithId } from 'src/utils/testUtils';
|
||||||
import { useFilterConfigMap, useFilterConfiguration } from '../state';
|
import { useFilterConfigMap, useFilterConfiguration } from '../state';
|
||||||
import { FilterRemoval, NativeFiltersForm } from './types';
|
|
||||||
import { FilterConfiguration } 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 {
|
import {
|
||||||
validateForm,
|
|
||||||
createHandleSave,
|
createHandleSave,
|
||||||
createHandleTabEdit,
|
createHandleTabEdit,
|
||||||
generateFilterId,
|
generateFilterId,
|
||||||
getFilterIds,
|
getFilterIds,
|
||||||
|
buildFilterGroup,
|
||||||
|
validateForm,
|
||||||
} from './utils';
|
} 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)`
|
const StyledModalWrapper = styled(StyledModal)`
|
||||||
min-width: 700px;
|
min-width: 700px;
|
||||||
@ -142,6 +144,29 @@ export function FiltersConfigModal({
|
|||||||
if (removal?.isPending) clearTimeout(removal.timerId);
|
if (removal?.isPending) clearTimeout(removal.timerId);
|
||||||
setRemovedFilters(current => ({ ...current, [id]: null }));
|
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
|
// generates a new filter id and appends it to the newFilterIds
|
||||||
const addFilter = useCallback(() => {
|
const addFilter = useCallback(() => {
|
||||||
@ -149,33 +174,54 @@ export function FiltersConfigModal({
|
|||||||
setNewFilterIds([...newFilterIds, newFilterId]);
|
setNewFilterIds([...newFilterIds, newFilterId]);
|
||||||
setCurrentFilterId(newFilterId);
|
setCurrentFilterId(newFilterId);
|
||||||
setSaveAlertVisible(false);
|
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);
|
useOpenModal(isOpen, addFilter, createNewOnOpen);
|
||||||
|
|
||||||
useRemoveCurrentFilter(
|
useRemoveCurrentFilter(
|
||||||
removedFilters,
|
removedFilters,
|
||||||
currentFilterId,
|
currentFilterId,
|
||||||
filterIds,
|
orderedFilters,
|
||||||
setCurrentFilterId,
|
setCurrentFilterId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTabEdit = createHandleTabEdit(
|
const handleTabEdit = createHandleTabEdit(
|
||||||
setRemovedFilters,
|
setRemovedFilters,
|
||||||
setSaveAlertVisible,
|
setSaveAlertVisible,
|
||||||
|
setOrderedFilters,
|
||||||
|
setFilterHierarchy,
|
||||||
addFilter,
|
addFilter,
|
||||||
|
filterHierarchy,
|
||||||
);
|
);
|
||||||
|
|
||||||
// After this, it should be as if the modal was just opened fresh.
|
// After this, it should be as if the modal was just opened fresh.
|
||||||
// Called when the modal is closed.
|
// Called when the modal is closed.
|
||||||
const resetForm = () => {
|
const resetForm = (isSaving = false) => {
|
||||||
setNewFilterIds([]);
|
setNewFilterIds([]);
|
||||||
setCurrentFilterId(initialCurrentFilterId);
|
setCurrentFilterId(initialCurrentFilterId);
|
||||||
setRemovedFilters({});
|
setRemovedFilters({});
|
||||||
setSaveAlertVisible(false);
|
setSaveAlertVisible(false);
|
||||||
setFormValues({ filters: {} });
|
setFormValues({ filters: {} });
|
||||||
form.setFieldsValue({ changed: false });
|
|
||||||
setErroredFilters([]);
|
setErroredFilters([]);
|
||||||
|
if (!isSaving) {
|
||||||
|
const initialFilterHierarchy = getInitialFilterHierarchy();
|
||||||
|
setFilterHierarchy(initialFilterHierarchy);
|
||||||
|
setOrderedFilters(buildFilterGroup(initialFilterHierarchy));
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
form.setFieldsValue({ changed: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilterTitle = (id: string) =>
|
const getFilterTitle = (id: string) =>
|
||||||
@ -261,12 +307,12 @@ export function FiltersConfigModal({
|
|||||||
cleanDeletedParents(values);
|
cleanDeletedParents(values);
|
||||||
createHandleSave(
|
createHandleSave(
|
||||||
filterConfigMap,
|
filterConfigMap,
|
||||||
filterIds,
|
orderedFilters.flat(),
|
||||||
removedFilters,
|
removedFilters,
|
||||||
onSave,
|
onSave,
|
||||||
values,
|
values,
|
||||||
)();
|
)();
|
||||||
resetForm();
|
resetForm(true);
|
||||||
} else {
|
} else {
|
||||||
configFormRef.current.changeTab('configuration');
|
configFormRef.current.changeTab('configuration');
|
||||||
}
|
}
|
||||||
@ -279,30 +325,70 @@ export function FiltersConfigModal({
|
|||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
const changed = form.getFieldValue('changed');
|
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);
|
setSaveAlertVisible(true);
|
||||||
} else {
|
} else {
|
||||||
handleConfirmCancel();
|
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(
|
const onValuesChange = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((changes: any, values: NativeFiltersForm) => {
|
debounce((changes: any, values: NativeFiltersForm) => {
|
||||||
if (changes.filters) {
|
if (
|
||||||
if (
|
changes.filters &&
|
||||||
Object.values(changes.filters).some(
|
Object.values(changes.filters).some(
|
||||||
(filter: any) => filter.name != null,
|
(filter: any) => filter.name != null,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// we only need to set this if a name changed
|
// we only need to set this if a name changed
|
||||||
setFormValues(values);
|
setFormValues(values);
|
||||||
}
|
}
|
||||||
handleErroredFilters();
|
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);
|
setSaveAlertVisible(false);
|
||||||
|
handleErroredFilters();
|
||||||
}, SLOW_DEBOUNCE),
|
}, SLOW_DEBOUNCE),
|
||||||
[handleErroredFilters],
|
[handleFilterHierarchyChange, handleErroredFilters],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -329,20 +415,20 @@ export function FiltersConfigModal({
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<StyledModalBody>
|
<StyledModalBody>
|
||||||
<StyledForm
|
<StyledForm
|
||||||
preserve={false}
|
|
||||||
form={form}
|
form={form}
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
>
|
>
|
||||||
<FilterTabs
|
<FiltureConfigurePane
|
||||||
erroredFilters={erroredFilters}
|
erroredFilters={erroredFilters}
|
||||||
onEdit={handleTabEdit}
|
onEdit={handleTabEdit}
|
||||||
onChange={setCurrentFilterId}
|
onChange={onTabChange}
|
||||||
getFilterTitle={getFilterTitle}
|
getFilterTitle={getFilterTitle}
|
||||||
currentFilterId={currentFilterId}
|
currentFilterId={currentFilterId}
|
||||||
filterIds={filterIds}
|
|
||||||
removedFilters={removedFilters}
|
removedFilters={removedFilters}
|
||||||
restoreFilter={restoreFilter}
|
restoreFilter={restoreFilter}
|
||||||
|
onRearrange={onRearrage}
|
||||||
|
filterGroups={orderedFilters}
|
||||||
>
|
>
|
||||||
{(id: string) => (
|
{(id: string) => (
|
||||||
<FiltersConfigForm
|
<FiltersConfigForm
|
||||||
@ -353,10 +439,17 @@ export function FiltersConfigModal({
|
|||||||
removedFilters={removedFilters}
|
removedFilters={removedFilters}
|
||||||
restoreFilter={restoreFilter}
|
restoreFilter={restoreFilter}
|
||||||
parentFilters={getParentFilters(id)}
|
parentFilters={getParentFilters(id)}
|
||||||
|
onFilterHierarchyChange={handleFilterHierarchyChange}
|
||||||
|
key={id}
|
||||||
|
activeFilterPanelKeys={activeFilterPanelKey}
|
||||||
|
handleActiveFilterPanelChange={key =>
|
||||||
|
setActiveFilterPanelKey(key)
|
||||||
|
}
|
||||||
|
isActive={currentFilterId === id}
|
||||||
setErroredFilters={setErroredFilters}
|
setErroredFilters={setErroredFilters}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FilterTabs>
|
</FiltureConfigurePane>
|
||||||
</StyledForm>
|
</StyledForm>
|
||||||
</StyledModalBody>
|
</StyledModalBody>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { findLastIndex } from 'lodash';
|
|
||||||
import { FilterRemoval } from './types';
|
import { FilterRemoval } from './types';
|
||||||
import { usePrevious } from '../../../../common/hooks/usePrevious';
|
import { usePrevious } from '../../../../common/hooks/usePrevious';
|
||||||
|
|
||||||
@ -25,21 +24,22 @@ import { usePrevious } from '../../../../common/hooks/usePrevious';
|
|||||||
export const useRemoveCurrentFilter = (
|
export const useRemoveCurrentFilter = (
|
||||||
removedFilters: Record<string, FilterRemoval>,
|
removedFilters: Record<string, FilterRemoval>,
|
||||||
currentFilterId: string,
|
currentFilterId: string,
|
||||||
filterIds: string[],
|
orderedFilters: string[][],
|
||||||
setCurrentFilterId: Function,
|
setCurrentFilterId: Function,
|
||||||
) => {
|
) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if the currently viewed filter is fully removed, change to another tab
|
// if the currently viewed filter is fully removed, change to another tab
|
||||||
const currentFilterRemoved = removedFilters[currentFilterId];
|
const currentFilterRemoved = removedFilters[currentFilterId];
|
||||||
if (currentFilterRemoved && !currentFilterRemoved.isPending) {
|
if (currentFilterRemoved && !currentFilterRemoved.isPending) {
|
||||||
const nextFilterIndex = findLastIndex(
|
const nextFilterId = orderedFilters
|
||||||
filterIds,
|
.flat()
|
||||||
id => !removedFilters[id] && id !== currentFilterId,
|
.find(
|
||||||
);
|
filterId => !removedFilters[filterId] && filterId !== currentFilterId,
|
||||||
if (nextFilterIndex !== -1)
|
);
|
||||||
setCurrentFilterId(filterIds[nextFilterIndex]);
|
|
||||||
|
if (nextFilterId) setCurrentFilterId(nextFilterId);
|
||||||
}
|
}
|
||||||
}, [currentFilterId, removedFilters, filterIds]);
|
}, [currentFilterId, removedFilters, orderedFilters, setCurrentFilterId]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useOpenModal = (
|
export const useOpenModal = (
|
||||||
|
@ -45,6 +45,7 @@ export interface NativeFiltersFormItem {
|
|||||||
time_range?: string;
|
time_range?: string;
|
||||||
granularity_sqla?: string;
|
granularity_sqla?: string;
|
||||||
type: NativeFilterType;
|
type: NativeFilterType;
|
||||||
|
hierarchicalFilter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NativeFiltersForm {
|
export interface NativeFiltersForm {
|
||||||
@ -59,3 +60,6 @@ export type FilterRemoval =
|
|||||||
timerId: number; // id of the timer that finally removes the filter
|
timerId: number; // id of the timer that finally removes the filter
|
||||||
}
|
}
|
||||||
| { isPending: false };
|
| { isPending: false };
|
||||||
|
|
||||||
|
export type FilterHierarchyNode = { id: string; parentId: string | null };
|
||||||
|
export type FilterHierarchy = FilterHierarchyNode[];
|
||||||
|
@ -19,8 +19,14 @@
|
|||||||
import { FormInstance } from 'antd/lib/form';
|
import { FormInstance } from 'antd/lib/form';
|
||||||
import shortid from 'shortid';
|
import shortid from 'shortid';
|
||||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||||
|
|
||||||
import { t } from '@superset-ui/core';
|
import { t } from '@superset-ui/core';
|
||||||
import { FilterRemoval, NativeFiltersForm } from './types';
|
import {
|
||||||
|
FilterRemoval,
|
||||||
|
NativeFiltersForm,
|
||||||
|
FilterHierarchy,
|
||||||
|
FilterHierarchyNode,
|
||||||
|
} from './types';
|
||||||
import { Filter, FilterConfiguration, Target } from '../types';
|
import { Filter, FilterConfiguration, Target } from '../types';
|
||||||
|
|
||||||
export const REMOVAL_DELAY_SECS = 5;
|
export const REMOVAL_DELAY_SECS = 5;
|
||||||
@ -158,7 +164,49 @@ export const createHandleSave = (
|
|||||||
|
|
||||||
await saveForm(newFilterConfig);
|
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 = (
|
export const createHandleTabEdit = (
|
||||||
setRemovedFilters: (
|
setRemovedFilters: (
|
||||||
value:
|
value:
|
||||||
@ -168,9 +216,25 @@ export const createHandleTabEdit = (
|
|||||||
| Record<string, FilterRemoval>,
|
| Record<string, FilterRemoval>,
|
||||||
) => void,
|
) => void,
|
||||||
setSaveAlertVisible: Function,
|
setSaveAlertVisible: Function,
|
||||||
|
setOrderedFilters: (
|
||||||
|
val: string[][] | ((prevState: string[][]) => string[][]),
|
||||||
|
) => void,
|
||||||
|
setFilterHierarchy: (
|
||||||
|
state: FilterHierarchy | ((prevState: FilterHierarchy) => FilterHierarchy),
|
||||||
|
) => void,
|
||||||
addFilter: Function,
|
addFilter: Function,
|
||||||
|
filterHierarchy: FilterHierarchy,
|
||||||
) => (filterId: string, action: 'add' | 'remove') => {
|
) => (filterId: string, action: 'add' | 'remove') => {
|
||||||
const completeFilterRemoval = (filterId: string) => {
|
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,
|
// the filter state will actually stick around in the form,
|
||||||
// and the filterConfig/newFilterIds, but we use removedFilters
|
// and the filterConfig/newFilterIds, but we use removedFilters
|
||||||
// to mark it as removed.
|
// to mark it as removed.
|
||||||
@ -178,14 +242,39 @@ export const createHandleTabEdit = (
|
|||||||
...removedFilters,
|
...removedFilters,
|
||||||
[filterId]: { isPending: false },
|
[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') {
|
if (action === 'remove') {
|
||||||
// first set up the timer to completely remove it
|
// first set up the timer to completely remove it
|
||||||
const timerId = window.setTimeout(
|
const timerId = window.setTimeout(() => {
|
||||||
() => completeFilterRemoval(filterId),
|
completeFilterRemoval(filterId);
|
||||||
REMOVAL_DELAY_SECS * 1000,
|
}, REMOVAL_DELAY_SECS * 1000);
|
||||||
);
|
|
||||||
// mark the filter state as "removal in progress"
|
// mark the filter state as "removal in progress"
|
||||||
setRemovedFilters(removedFilters => ({
|
setRemovedFilters(removedFilters => ({
|
||||||
...removedFilters,
|
...removedFilters,
|
||||||
|
Loading…
Reference in New Issue
Block a user