mirror of https://github.com/apache/superset.git
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: '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
|
||||
* 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>,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 { 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 }) => {
|
||||
|
|
|
@ -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)`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue