feat(dashboard): Add divider component in native filters (#17410)

* Tests are working, type errors are fixed

* Fix filterset

* add license header to the new file

* fix test

* PR comments

* Linting

* test fix

* small fix
This commit is contained in:
Ajay M 2021-11-23 16:13:56 -08:00 committed by GitHub
parent c216565190
commit 9576478a5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 473 additions and 201 deletions

View File

@ -25,6 +25,7 @@ import { Provider } from 'react-redux';
import { mockStore } from 'spec/fixtures/mockStore';
import { styledMount as mount } from 'spec/helpers/theming';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { Dropdown, Menu } from 'src/common/components';
import Alert from 'src/components/Alert';
import { FiltersConfigModal } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
@ -60,7 +61,7 @@ jest.mock('@superset-ui/core', () => ({
describe('FiltersConfigModal', () => {
const mockedProps = {
isOpen: true,
initialFilterId: 'DefaultsID',
initialFilterId: 'NATIVE_FILTER-1',
createNewOnOpen: true,
onCancel: jest.fn(),
onSave: jest.fn(),
@ -112,9 +113,13 @@ describe('FiltersConfigModal', () => {
await waitForComponentToPaint(wrapper);
}
function addFilter() {
async function addFilter() {
act(() => {
wrapper.find('[aria-label="Add filter"]').at(0).simulate('click');
wrapper.find(Dropdown).at(0).simulate('mouseEnter');
});
await waitForComponentToPaint(wrapper, 300);
act(() => {
wrapper.find(Menu.Item).at(0).simulate('click');
});
}
@ -124,7 +129,7 @@ describe('FiltersConfigModal', () => {
});
it('shows correct alert message for unsaved filters', async () => {
addFilter();
await addFilter();
await clickCancel();
expect(onCancel.mock.calls).toHaveLength(0);
expect(wrapper.find(Alert).text()).toContain(

View File

@ -36,6 +36,7 @@ import { getChartIdsInFilterScope } from '../../util/activeDashboardFilters';
import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
import { findTabsWithChartsInScope } from '../nativeFilters/utils';
import { setInScopeStatusOfFilters } from '../../actions/nativeFilters';
import { NATIVE_FILTER_DIVIDER_PREFIX } from '../nativeFilters/FiltersConfigModal/utils';
type DashboardContainerProps = {
topLevelTabs?: LayoutItem;
@ -71,6 +72,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const filterScopes = Object.values(nativeFilters ?? {}).map(filter => ({
id: filter.id,
scope: filter.scope,
type: filter.type,
}));
useEffect(() => {
if (
@ -80,6 +82,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
return;
}
const scopes = filterScopes.map(filterScope => {
if (filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX)) {
return {
filterId: filterScope.id,
tabsInScope: [],
chartsInScope: [],
};
}
const { scope } = filterScope;
const chartsInScope: number[] = getChartIdsInFilterScope({
filterScope: {

View File

@ -29,6 +29,7 @@ import { DataMaskStateWithId, DataMaskType } from 'src/dataMask/types';
import { areObjectsEqual } from 'src/reduxUtils';
import { Layout } from '../../types';
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
import { NativeFilterType } from '../nativeFilters/types';
export enum IndicatorStatus {
Unset = 'UNSET',
@ -268,11 +269,13 @@ export const selectNativeIndicatorsForChart = (
nativeFilterIndicators =
nativeFilters &&
Object.values(nativeFilters)
.filter(nativeFilter =>
getTreeCheckedItems(nativeFilter.scope, dashboardLayout).some(
layoutItem =>
dashboardLayout[layoutItem]?.meta?.chartId === chartId,
),
.filter(
nativeFilter =>
nativeFilter.type === NativeFilterType.NATIVE_FILTER &&
getTreeCheckedItems(nativeFilter.scope, dashboardLayout).some(
layoutItem =>
dashboardLayout[layoutItem]?.meta?.chartId === chartId,
),
)
.map(nativeFilter => {
const column = nativeFilter.targets[0]?.column?.name;

View File

@ -88,12 +88,12 @@ const addFilterFlow = async () => {
userEvent.click(screen.getByText('Time range'));
userEvent.type(screen.getByTestId(getModalTestId('name-input')), FILTER_NAME);
userEvent.click(screen.getByText('Save'));
await screen.findByText('All Filters (1)');
await screen.findByText('All filters (1)');
};
const addFilterSetFlow = async () => {
// add filter set
userEvent.click(screen.getByText('Filter Sets (0)'));
userEvent.click(screen.getByText('Filter sets (0)'));
// check description
expect(screen.getByText('Filters (1)')).toBeInTheDocument();
@ -301,6 +301,40 @@ describe('FilterBar', () => {
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
});
it('renders dividers', async () => {
const divider = {
id: 'NATIVE_FILTER_DIVIDER-1',
type: 'DIVIDER',
scope: {
rootPath: ['ROOT_ID'],
excluded: [],
},
title: 'Select time range',
description: 'Select year/month etc..',
chartsInScope: [],
tabsInScope: [],
};
const stateWithDivider = {
...stateWithoutNativeFilters,
nativeFilters: {
filters: {
'NATIVE_FILTER_DIVIDER-1': divider,
},
},
};
renderWrapper(openedBarProps, stateWithDivider);
const title = await screen.findByText('Select time range');
const description = await screen.findByText('Select year/month etc..');
expect(title.tagName).toBe('H3');
expect(description.tagName).toBe('P');
// Do not enable buttons if there are not filters
expect(screen.getByTestId(getTestId('clear-button'))).toBeDisabled();
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
});
it('create filter and apply it flow', async () => {
// @ts-ignore
global.featureFlags = {
@ -332,7 +366,7 @@ describe('FilterBar', () => {
// change filter
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
userEvent.click(await screen.findByText('All Filters (1)'));
userEvent.click(await screen.findByText('All filters (1)'));
await changeFilterValue();
await waitFor(() => expect(screen.getAllByText('Last day').length).toBe(2));

View File

@ -30,7 +30,10 @@ import {
useDashboardHasTabs,
useSelectFiltersInScope,
} from 'src/dashboard/components/nativeFilters/state';
import { Filter } from 'src/dashboard/components/nativeFilters/types';
import {
Filter,
NativeFilterType,
} from 'src/dashboard/components/nativeFilters/types';
import CascadePopover from '../CascadeFilters/CascadePopover';
import { useFilters } from '../state';
import { buildCascadeFiltersTree } from './utils';
@ -79,21 +82,32 @@ const FilterControls: FC<FilterControlsProps> = ({
const showCollapsePanel = dashboardHasTabs && cascadeFilters.length > 0;
const cascadePopoverFactory = useCallback(
index => (
<CascadePopover
data-test="cascade-filters-control"
key={cascadeFilters[index].id}
dataMaskSelected={dataMaskSelected}
visible={visiblePopoverId === cascadeFilters[index].id}
onVisibleChange={visible =>
setVisiblePopoverId(visible ? cascadeFilters[index].id : null)
}
filter={cascadeFilters[index]}
onFilterSelectionChange={onFilterSelectionChange}
directPathToChild={directPathToChild}
inView={false}
/>
),
index => {
const filter = cascadeFilters[index];
if (filter.type === NativeFilterType.DIVIDER) {
return (
<div>
<h3>{filter.title}</h3>
<p>{filter.description}</p>
</div>
);
}
return (
<CascadePopover
data-test="cascade-filters-control"
key={filter.id}
dataMaskSelected={dataMaskSelected}
visible={visiblePopoverId === filter.id}
onVisibleChange={visible =>
setVisiblePopoverId(visible ? filter.id : null)
}
filter={filter}
onFilterSelectionChange={onFilterSelectionChange}
directPathToChild={directPathToChild}
inView={false}
/>
);
},
[
cascadeFilters,
JSON.stringify(dataMaskSelected),

View File

@ -22,12 +22,14 @@ import {
setFocusedNativeFilter,
unsetFocusedNativeFilter,
} from 'src/dashboard/actions/nativeFilters';
import { Filter } from '../../types';
import { Filter, NativeFilterType, Divider } from '../../types';
import { CascadeFilter } from '../CascadeFilters/types';
import { mapParentFiltersToChildren } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
export function buildCascadeFiltersTree(
filters: Array<Divider | Filter>,
): Array<CascadeFilter | Divider> {
const cascadeChildren = mapParentFiltersToChildren(filters);
const getCascadeFilter = (filter: Filter): CascadeFilter => {
@ -39,7 +41,11 @@ export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
};
return filters
.filter(filter => !filter.cascadeParentIds?.length)
.filter(
filter =>
filter.type === NativeFilterType.DIVIDER ||
!(filter as Filter).cascadeParentIds?.length,
)
.map(getCascadeFilter);
}

View File

@ -26,6 +26,7 @@ import { FilterSet } from 'src/dashboard/reducers/types';
import { getFilterValueForDisplay } from './utils';
import { useFilters } from '../state';
import { getFilterBarTestId } from '../index';
import { NativeFilterType } from '../../types';
const FilterHeader = styled.div`
display: flex;
@ -68,7 +69,9 @@ export type FiltersHeaderProps = {
const FiltersHeader: FC<FiltersHeaderProps> = ({ dataMask, filterSet }) => {
const theme = useTheme();
const filters = useFilters();
const filterValues = Object.values(filters);
const filterValues = Object.values(filters).filter(
nativeFilter => nativeFilter.type === NativeFilterType.NATIVE_FILTER,
);
let resultFilters = filterValues ?? [];
if (filterSet?.nativeFilters) {

View File

@ -33,7 +33,10 @@ import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types';
import { useImmer } from 'use-immer';
import { isEmpty, isEqual } from 'lodash';
import { testWithId } from 'src/utils/testUtils';
import { Filter } from 'src/dashboard/components/nativeFilters/types';
import {
Filter,
NativeFilterType,
} from 'src/dashboard/components/nativeFilters/types';
import Loading from 'src/components/Loading';
import { getInitialDataMask } from 'src/dataMask/reducer';
import { URL_PARAMS } from 'src/constants';
@ -82,7 +85,6 @@ const Bar = styled.div<{ width: number }>`
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
min-height: 100%;
display: none;
&.open {
display: flex;
}
@ -97,14 +99,12 @@ const CollapsedBar = styled.div<{ offset: number }>`
padding-top: ${({ theme }) => theme.gridUnit * 2}px;
display: none;
text-align: center;
&.open {
display: flex;
flex-direction: column;
align-items: center;
padding: ${({ theme }) => theme.gridUnit * 2}px;
}
svg {
cursor: pointer;
}
@ -278,9 +278,12 @@ const FilterBar: React.FC<FiltersBarProps> = ({
filterValues,
);
const isInitialized = useInitialization();
const tabPaneStyle = useMemo(() => ({ overflow: 'auto', height }), [height]);
const numberOfFilters = filterValues.filter(
filterValue => filterValue.type === NativeFilterType.NATIVE_FILTER,
).length;
return (
<BarWrapper
{...getFilterBarTestId()}
@ -320,8 +323,8 @@ const FilterBar: React.FC<FiltersBarProps> = ({
activeKey={editFilterSetId ? TabIds.AllFilters : undefined}
>
<Tabs.TabPane
tab={t('All Filters (%(filterCount)d)', {
filterCount: filterValues.length,
tab={t('All filters (%(filterCount)d)', {
filterCount: numberOfFilters,
})}
key={TabIds.AllFilters}
css={tabPaneStyle}
@ -342,7 +345,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
</Tabs.TabPane>
<Tabs.TabPane
disabled={!!editFilterSetId}
tab={t('Filter Sets (%(filterSetCount)d)', {
tab={t('Filter sets (%(filterSetCount)d)', {
filterSetCount: filterSetFilterValues.length,
})}
key={TabIds.FilterSets}

View File

@ -20,19 +20,20 @@
import { DataMaskStateWithId } from 'src/dataMask/types';
import { areObjectsEqual } from 'src/reduxUtils';
import { FilterState } from '@superset-ui/core';
import { Filter } from '../types';
import { Filter, Divider } from '../types';
export enum TabIds {
AllFilters = 'allFilters',
FilterSets = 'filterSets',
}
export function mapParentFiltersToChildren(filters: Filter[]): {
export function mapParentFiltersToChildren(filters: Array<Filter | Divider>): {
[id: string]: Filter[];
} {
const cascadeChildren = {};
filters.forEach(filter => {
const [parentId] = filter.cascadeParentIds || [];
const [parentId] =
('cascadeParentIds' in filter && filter.cascadeParentIds) || [];
if (parentId) {
if (!cascadeChildren[parentId]) {
cascadeChildren[parentId] = [];

View File

@ -0,0 +1,65 @@
/**
* 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 { FormItem } from 'src/components/Form';
import { Input, TextArea } from 'src/common/components';
import { styled, t } from '@superset-ui/core';
import { NativeFilterType } from '../types';
interface Props {
componentId: string;
divider?: {
title: string;
description: string;
};
}
const Container = styled.div`
${({ theme }) => `
padding: ${theme.gridUnit * 4}px;
`}
`;
const DividerConfigForm: React.FC<Props> = ({ componentId, divider }) => (
<Container>
<FormItem
initialValue={divider ? divider.title : ''}
label={t('Title')}
name={['filters', componentId, 'title']}
rules={[
{ required: true, message: t('Title is required'), whitespace: true },
]}
>
<Input />
</FormItem>
<FormItem
initialValue={divider ? divider.description : ''}
label={t('Description')}
name={['filters', componentId, 'description']}
>
<TextArea rows={4} />
</FormItem>
<FormItem
hidden
name={['filters', componentId, 'type']}
initialValue={NativeFilterType.DIVIDER}
/>
</Container>
);
export default DividerConfigForm;

View File

@ -26,7 +26,8 @@ const defaultProps = {
children: jest.fn(),
getFilterTitle: (id: string) => id,
onChange: jest.fn(),
onEdit: jest.fn(),
onAdd: jest.fn(),
onRemove: jest.fn(),
onRearrange: jest.fn(),
restoreFilter: jest.fn(),
currentFilterId: 'NATIVE_FILTER-1',
@ -93,22 +94,41 @@ test('remove filter', async () => {
}),
);
});
expect(defaultProps.onEdit).toHaveBeenCalledWith('NATIVE_FILTER-2', 'remove');
expect(defaultProps.onRemove).toHaveBeenCalledWith('NATIVE_FILTER-2');
});
test('add filter', async () => {
defaultRender();
// First trash icon
const removeFilterIcon = screen.getByText('Add filter')!;
const addButton = screen.getByText('Add')!;
fireEvent.mouseOver(addButton);
const addFilterButton = await screen.findByText('Filter');
await act(async () => {
fireEvent(
removeFilterIcon,
addFilterButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
});
expect(defaultProps.onEdit).toHaveBeenCalledWith('', 'add');
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
});
test('add divider', async () => {
defaultRender();
const addButton = screen.getByText('Add')!;
fireEvent.mouseOver(addButton);
const addFilterButton = await screen.findByText('Divider');
await act(async () => {
fireEvent(
addFilterButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
});
expect(defaultProps.onAdd).toHaveBeenCalledWith('DIVIDER');
});

View File

@ -18,6 +18,7 @@
*/
import { styled } from '@superset-ui/core';
import React from 'react';
import { NativeFilterType } from '../types';
import FilterTitlePane from './FilterTitlePane';
import { FilterRemoval } from './types';
@ -25,7 +26,8 @@ interface Props {
children: (filterId: string) => React.ReactNode;
getFilterTitle: (filterId: string) => string;
onChange: (activeKey: string) => void;
onEdit: (filterId: string, action: 'add' | 'remove') => void;
onAdd: (type: NativeFilterType) => void;
onRemove: (id: string) => void;
onRearrange: (dragIndex: number, targetIndex: number) => void;
erroredFilters: string[];
restoreFilter: (id: string) => void;
@ -52,9 +54,10 @@ const TitlesContainer = styled.div`
const FiltureConfigurePane: React.FC<Props> = ({
getFilterTitle,
onChange,
onEdit,
onRemove,
onRearrange,
restoreFilter,
onAdd,
erroredFilters,
children,
currentFilterId,
@ -72,9 +75,9 @@ const FiltureConfigurePane: React.FC<Props> = ({
erroredFilters={erroredFilters}
getFilterTitle={getFilterTitle}
onChange={onChange}
onEdit={onEdit}
onAdd={(type: NativeFilterType) => onAdd(type)}
onRearrage={onRearrange}
onRemove={(id: string) => onEdit(id, 'remove')}
onRemove={(id: string) => onRemove(id)}
restoreFilter={restoreFilter}
/>
</TitlesContainer>

View File

@ -16,9 +16,10 @@
* 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 { Dropdown, MainNav as Menu } from 'src/common/components';
import { NativeFilterType } from '../types';
import FilterTitleContainer from './FilterTitleContainer';
import { FilterRemoval } from './types';
@ -28,13 +29,17 @@ interface Props {
onRearrage: (dragIndex: number, targetIndex: number) => void;
onRemove: (id: string) => void;
onChange: (id: string) => void;
onEdit: (filterId: string, action: 'add' | 'remove') => void;
onAdd: (type: NativeFilterType) => void;
removedFilters: Record<string, FilterRemoval>;
currentFilterId: string;
filterGroups: string[][];
erroredFilters: string[];
}
const StyledPlusButton = styled.div`
color: ${({ theme }) => theme.colors.primary.dark1};
`;
const StyledHeader = styled.div`
${({ theme }) => `
color: ${theme.colors.grayscale.dark1};
@ -46,27 +51,25 @@ const StyledHeader = styled.div`
`}
`;
const StyledAddBox = styled.div`
${({ theme }) => `
cursor: pointer;
margin: ${theme.gridUnit * 4}px;
&:hover {
color: ${theme.colors.primary.base};
}
`}
`;
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,
onAdd,
onRemove,
onRearrage,
restoreFilter,
@ -76,6 +79,29 @@ const FilterTitlePane: React.FC<Props> = ({
erroredFilters,
}) => {
const theme = useTheme();
const options = [
{ label: 'Filter', type: NativeFilterType.NATIVE_FILTER },
{ label: 'Divider', type: NativeFilterType.DIVIDER },
];
const handleOnAdd = (type: NativeFilterType) => {
onAdd(type);
setTimeout(() => {
const element = document.getElementById('native-filters-tabs');
if (element) {
const navList = element.getElementsByClassName('ant-tabs-nav-list')[0];
navList.scrollTop = navList.scrollHeight;
}
}, 0);
};
const menu = (
<Menu mode="horizontal">
{options.map(item => (
<Menu.Item onClick={() => handleOnAdd(item.type)}>
{item.label}
</Menu.Item>
))}
</Menu>
);
return (
<TabsContainer>
<StyledHeader>Filters</StyledHeader>
@ -98,28 +124,15 @@ const FilterTitlePane: React.FC<Props> = ({
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>
<Dropdown overlay={menu} arrow placement="topLeft" trigger={['hover']}>
<StyledAddBox>
<StyledPlusButton
data-test="new-dropdown-icon"
className="fa fa-plus"
/>{' '}
<span>{t('Add')}</span>
</StyledAddBox>
</Dropdown>
</TabsContainer>
);
};

View File

@ -30,7 +30,12 @@ import ErrorBoundary from 'src/components/ErrorBoundary';
import { StyledModal } from 'src/components/Modal';
import { testWithId } from 'src/utils/testUtils';
import { useFilterConfigMap, useFilterConfiguration } from '../state';
import { FilterConfiguration } from '../types';
import {
Filter,
FilterConfiguration,
NativeFilterType,
Divider,
} from '../types';
import FiltureConfigurePane from './FilterConfigurePane';
import FiltersConfigForm, {
FilterPanels,
@ -40,12 +45,14 @@ import { useOpenModal, useRemoveCurrentFilter } from './state';
import { FilterRemoval, NativeFiltersForm, FilterHierarchy } from './types';
import {
createHandleSave,
createHandleTabEdit,
createHandleRemoveItem,
generateFilterId,
getFilterIds,
buildFilterGroup,
validateForm,
NATIVE_FILTER_DIVIDER_PREFIX,
} from './utils';
import DividerConfigForm from './DividerConfigForm';
const StyledModalWrapper = styled(StyledModal)`
min-width: 700px;
@ -153,7 +160,10 @@ export function FiltersConfigModal({
const getInitialFilterHierarchy = () =>
filterConfig.map(filter => ({
id: filter.id,
parentId: filter.cascadeParentIds[0] || null,
parentId:
filter.type === NativeFilterType.NATIVE_FILTER
? filter.cascadeParentIds[0] || null
: null,
}));
const [filterHierarchy, setFilterHierarchy] = useState<FilterHierarchy>(() =>
@ -175,24 +185,28 @@ export function FiltersConfigModal({
};
// generates a new filter id and appends it to the newFilterIds
const addFilter = useCallback(() => {
const newFilterId = generateFilterId();
setNewFilterIds([...newFilterIds, newFilterId]);
setCurrentFilterId(newFilterId);
setSaveAlertVisible(false);
setFilterHierarchy(previousState => [
...previousState,
{ id: newFilterId, parentId: null },
]);
setOrderedFilters([...orderedFilters, [newFilterId]]);
setActiveFilterPanelKey(`${newFilterId}-${FilterPanels.basic.key}`);
}, [
newFilterIds,
orderedFilters,
setCurrentFilterId,
setFilterHierarchy,
setOrderedFilters,
]);
const addFilter = useCallback(
(type: NativeFilterType) => {
const newFilterId = generateFilterId(type);
setNewFilterIds([...newFilterIds, newFilterId]);
setCurrentFilterId(newFilterId);
setSaveAlertVisible(false);
setFilterHierarchy(previousState => [
...previousState,
{ id: newFilterId, parentId: null },
]);
setOrderedFilters([...orderedFilters, [newFilterId]]);
setActiveFilterPanelKey(`${newFilterId}-${FilterPanels.basic.key}`);
},
[
newFilterIds,
orderedFilters,
setCurrentFilterId,
setFilterHierarchy,
setOrderedFilters,
setNewFilterIds,
],
);
useOpenModal(isOpen, addFilter, createNewOnOpen);
@ -203,12 +217,11 @@ export function FiltersConfigModal({
setCurrentFilterId,
);
const handleTabEdit = createHandleTabEdit(
const handleRemoveItem = createHandleRemoveItem(
setRemovedFilters,
setSaveAlertVisible,
setOrderedFilters,
setFilterHierarchy,
addFilter,
filterHierarchy,
);
@ -230,21 +243,29 @@ export function FiltersConfigModal({
form.setFieldsValue({ changed: false });
};
const getFilterTitle = (id: string) =>
formValues.filters[id]?.name ||
filterConfigMap[id]?.name ||
t('[untitled]');
const getFilterTitle = (id: string) => {
const formValue = formValues.filters[id];
const config = filterConfigMap[id];
return (
(formValue && 'name' in formValue && formValue.name) ||
(formValue && 'title' in formValue && formValue.title) ||
(config && 'name' in config && config.name) ||
(config && 'title' in config && config.title) ||
'[untitled]'
);
};
const getParentFilters = (id: string) =>
filterIds
.filter(filterId => filterId !== id && !removedFilters[filterId])
.filter(filterId =>
CASCADING_FILTERS.includes(
formValues.filters[filterId]
? formValues.filters[filterId].filterType
: filterConfigMap[filterId]?.filterType,
),
)
.filter(filterId => {
const component =
formValues.filters[filterId] || filterConfigMap[filterId];
return (
component &&
'filterType' in component &&
CASCADING_FILTERS.includes(component.filterType)
);
})
.map(id => ({
id,
title: getFilterTitle(id),
@ -253,6 +274,9 @@ export function FiltersConfigModal({
const cleanDeletedParents = (values: NativeFiltersForm | null) => {
Object.keys(filterConfigMap).forEach(key => {
const filter = filterConfigMap[key];
if (!('cascadeParentIds' in filter)) {
return;
}
const parentId = filter.cascadeParentIds?.[0];
if (parentId && removedFilters[parentId]) {
filter.cascadeParentIds = [];
@ -263,6 +287,9 @@ export function FiltersConfigModal({
if (filters) {
Object.keys(filters).forEach(key => {
const filter = filters[key];
if (!('parentFilter' in filter)) {
return;
}
const parentId = filter.parentFilter?.value;
if (parentId && removedFilters[parentId]) {
filter.parentFilter = undefined;
@ -369,13 +396,18 @@ export function FiltersConfigModal({
const onValuesChange = useMemo(
() =>
debounce((changes: any, values: NativeFiltersForm) => {
if (
const didChangeFilterName =
changes.filters &&
Object.values(changes.filters).some(
(filter: any) => filter.name != null,
)
) {
// we only need to set this if a name changed
(filter: any) => filter.name !== null,
);
const didChangeSectionTitle =
changes.filters &&
Object.values(changes.filters).some(
(filter: any) => filter.title !== null,
);
if (didChangeFilterName || didChangeSectionTitle) {
// we only need to set this if a name/title changed
setFormValues(values);
}
const changedFilterHierarchies = Object.keys(changes.filters)
@ -402,6 +434,31 @@ export function FiltersConfigModal({
prevErroredFilters.filter(f => !removedFilters[f]),
);
}, [removedFilters]);
const getForm = (id: string) => {
const isDivider = id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX);
return isDivider ? (
<DividerConfigForm
componentId={id}
divider={filterConfigMap[id] as Divider}
/>
) : (
<FiltersConfigForm
ref={configFormRef}
form={form}
filterId={id}
filterToEdit={filterConfigMap[id] as Filter}
removedFilters={removedFilters}
restoreFilter={restoreFilter}
parentFilters={getParentFilters(id)}
onFilterHierarchyChange={handleFilterHierarchyChange}
key={id}
activeFilterPanelKeys={activeFilterPanelKey}
handleActiveFilterPanelChange={key => setActiveFilterPanelKey(key)}
isActive={currentFilterId === id}
setErroredFilters={setErroredFilters}
/>
);
};
return (
<StyledModalWrapper
@ -434,7 +491,8 @@ export function FiltersConfigModal({
>
<FiltureConfigurePane
erroredFilters={erroredFilters}
onEdit={handleTabEdit}
onRemove={handleRemoveItem}
onAdd={addFilter}
onChange={onTabChange}
getFilterTitle={getFilterTitle}
currentFilterId={currentFilterId}
@ -443,25 +501,7 @@ export function FiltersConfigModal({
onRearrange={onRearrage}
filterGroups={orderedFilters}
>
{(id: string) => (
<FiltersConfigForm
ref={configFormRef}
form={form}
filterId={id}
filterToEdit={filterConfigMap[id]}
removedFilters={removedFilters}
restoreFilter={restoreFilter}
parentFilters={getParentFilters(id)}
onFilterHierarchyChange={handleFilterHierarchyChange}
key={id}
activeFilterPanelKeys={activeFilterPanelKey}
handleActiveFilterPanelChange={key =>
setActiveFilterPanelKey(key)
}
isActive={currentFilterId === id}
setErroredFilters={setErroredFilters}
/>
)}
{(id: string) => getForm(id)}
</FiltureConfigurePane>
</StyledForm>
</StyledModalBody>

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { FilterRemoval } from './types';
import { usePrevious } from '../../../../common/hooks/usePrevious';
import { NativeFilterType } from '../types';
/**
* Licensed to the Apache Software Foundation (ASF) under one
@ -52,7 +53,7 @@ export const useOpenModal = (
// add a filter on modal open
useEffect(() => {
if (createNewOnOpen && isOpen && !wasOpen) {
addFilter();
addFilter(NativeFilterType.NATIVE_FILTER);
}
}, [createNewOnOpen, isOpen, wasOpen, addFilter]);
};

View File

@ -44,13 +44,19 @@ export interface NativeFiltersFormItem {
adhoc_filters?: AdhocFilter[];
time_range?: string;
granularity_sqla?: string;
type: NativeFilterType;
type: typeof NativeFilterType.NATIVE_FILTER;
description: string;
hierarchicalFilter?: boolean;
}
export interface NativeFilterDivider {
id: string;
type: typeof NativeFilterType.DIVIDER;
title: string;
description: string;
}
export interface NativeFiltersForm {
filters: Record<string, NativeFiltersFormItem>;
filters: Record<string, NativeFiltersFormItem | NativeFilterDivider>;
changed?: boolean;
}

View File

@ -21,20 +21,27 @@ import shortid from 'shortid';
import { getInitialDataMask } from 'src/dataMask/reducer';
import { t } from '@superset-ui/core';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import {
FilterRemoval,
NativeFiltersForm,
FilterHierarchy,
FilterHierarchyNode,
} from './types';
import { Filter, FilterConfiguration, Target } from '../types';
import {
Filter,
FilterConfiguration,
NativeFilterType,
Divider,
Target,
} from '../types';
export const REMOVAL_DELAY_SECS = 5;
export const validateForm = async (
form: FormInstance<NativeFiltersForm>,
currentFilterId: string,
filterConfigMap: Record<string, Filter>,
filterConfigMap: Record<string, Filter | Divider>,
filterIds: string[],
removedFilters: Record<string, FilterRemoval>,
setCurrentFilterId: Function,
@ -77,9 +84,12 @@ export const validateForm = async (
);
return false;
}
const parentId = formValues.filters?.[filterId]
? formValues.filters[filterId]?.parentFilter?.value
: filterConfigMap[filterId]?.cascadeParentIds?.[0];
const formItem = formValues.filters?.[filterId];
const configItem = filterConfigMap[filterId];
const parentId = formItem
? 'parentFilter' in formItem && formItem.parentFilter?.value
: 'cascadeParentIds' in configItem && configItem?.cascadeParentIds?.[0];
if (parentId) {
return validateCycles(parentId, [...trace, filterId]);
}
@ -120,7 +130,7 @@ export const validateForm = async (
export const createHandleSave =
(
filterConfigMap: Record<string, Filter>,
filterConfigMap: Record<string, Filter | Divider>,
filterIds: string[],
removedFilters: Record<string, FilterRemoval>,
saveForm: Function,
@ -134,6 +144,18 @@ export const createHandleSave =
const formInputs = values.filters?.[id];
// if user didn't open a filter, return the original config
if (!formInputs) return filterConfigMap[id];
if (formInputs.type === NativeFilterType.DIVIDER) {
return {
id,
type: NativeFilterType.DIVIDER,
scope: {
rootPath: [DASHBOARD_ROOT_ID],
excluded: [],
},
title: formInputs.title,
description: formInputs.description,
};
}
const target: Partial<Target> = {};
if (formInputs.dataset) {
target.datasetId = formInputs.dataset.value;
@ -210,7 +232,7 @@ export function buildFilterGroup(nodes: FilterHierarchyNode[]) {
}
return group;
}
export const createHandleTabEdit =
export const createHandleRemoveItem =
(
setRemovedFilters: (
value:
@ -228,10 +250,9 @@ export const createHandleTabEdit =
| FilterHierarchy
| ((prevState: FilterHierarchy) => FilterHierarchy),
) => void,
addFilter: Function,
filterHierarchy: FilterHierarchy,
) =>
(filterId: string, action: 'add' | 'remove') => {
(filterId: string) => {
const completeFilterRemoval = (filterId: string) => {
const buildNewFilterHierarchy = (hierarchy: FilterHierarchy) =>
hierarchy
@ -277,25 +298,27 @@ export const createHandleTabEdit =
});
};
if (action === 'remove') {
// first set up the timer to completely remove it
const timerId = window.setTimeout(() => {
completeFilterRemoval(filterId);
}, REMOVAL_DELAY_SECS * 1000);
// mark the filter state as "removal in progress"
setRemovedFilters(removedFilters => ({
...removedFilters,
[filterId]: { isPending: true, timerId },
}));
setSaveAlertVisible(false);
} else if (action === 'add') {
addFilter();
}
// first set up the timer to completely remove it
const timerId = window.setTimeout(() => {
completeFilterRemoval(filterId);
}, REMOVAL_DELAY_SECS * 1000);
// mark the filter state as "removal in progress"
setRemovedFilters(removedFilters => ({
...removedFilters,
[filterId]: { isPending: true, timerId },
}));
setSaveAlertVisible(false);
};
export const NATIVE_FILTER_PREFIX = 'NATIVE_FILTER-';
export const generateFilterId = () =>
`${NATIVE_FILTER_PREFIX}${shortid.generate()}`;
export const NATIVE_FILTER_DIVIDER_PREFIX = 'NATIVE_FILTER_DIVIDER-';
export const generateFilterId = (type: NativeFilterType) => {
const prefix =
type === NativeFilterType.NATIVE_FILTER
? NATIVE_FILTER_PREFIX
: NATIVE_FILTER_DIVIDER_PREFIX;
return `${prefix}${shortid.generate()}`;
};
export const getFilterIds = (config: FilterConfiguration) =>
config.map(filter => filter.id);

View File

@ -18,7 +18,12 @@
*/
import { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { Filter, FilterConfiguration } from './types';
import {
Filter,
FilterConfiguration,
NativeFilterType,
Divider,
} from './types';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { TAB_TYPE } from '../../util/componentTypes';
import { CascadeFilter } from './FilterBar/CascadeFilters/types';
@ -41,10 +46,13 @@ export function useFilterConfigMap() {
const filterConfig = useFilterConfiguration();
return useMemo(
() =>
filterConfig.reduce((acc: Record<string, Filter>, filter: Filter) => {
acc[filter.id] = filter;
return acc;
}, {} as Record<string, Filter>),
filterConfig.reduce(
(acc: Record<string, Filter | Divider>, filter: Filter) => {
acc[filter.id] = filter;
return acc;
},
{} as Record<string, Filter | Divider>,
),
[filterConfig],
);
}
@ -89,29 +97,38 @@ function useIsFilterInScope() {
// Filter is in scope if any of it's charts is visible.
// Chart is visible if it's placed in an active tab tree or if it's not attached to any tab.
// Chart is in an active tab tree if all of it's ancestors of type TAB are active
return (filter: CascadeFilter) =>
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
});
// Dividers are always in scope
return (filter: CascadeFilter | Divider) => {
const isDivider = filter.type === NativeFilterType.DIVIDER;
return (
isDivider ||
('chartsInScope' in filter &&
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
}))
);
};
}
export function useSelectFiltersInScope(cascadeFilters: CascadeFilter[]) {
export function useSelectFiltersInScope(
cascadeFilters: (CascadeFilter | Divider)[],
) {
const dashboardHasTabs = useDashboardHasTabs();
const isFilterInScope = useIsFilterInScope();
return useMemo(() => {
let filtersInScope: CascadeFilter[] = [];
const filtersOutOfScope: CascadeFilter[] = [];
let filtersInScope: (CascadeFilter | Divider)[] = [];
const filtersOutOfScope: (CascadeFilter | Divider)[] = [];
// we check native filters scopes only on dashboards with tabs
if (!dashboardHasTabs) {
filtersInScope = cascadeFilters;
} else {
cascadeFilters.forEach((filter: CascadeFilter) => {
cascadeFilters.forEach(filter => {
const filterInScope = isFilterInScope(filter);
if (filterInScope) {

View File

@ -39,6 +39,11 @@ export interface Target {
// clarityColumns?: Column[];
}
export enum NativeFilterType {
NATIVE_FILTER = 'NATIVE_FILTER',
DIVIDER = 'DIVIDER',
}
export interface Filter {
cascadeParentIds: string[];
defaultDataMask: DataMask;
@ -62,13 +67,14 @@ export interface Filter {
requiredFirst?: boolean;
tabsInScope?: string[];
chartsInScope?: number[];
type: NativeFilterType;
type: typeof NativeFilterType.NATIVE_FILTER;
description: string;
}
export type FilterConfiguration = Filter[];
export enum NativeFilterType {
NATIVE_FILTER = 'NATIVE_FILTER',
SECTION = 'SECTION',
export interface Divider {
id: string;
title: string;
description: string;
type: typeof NativeFilterType.DIVIDER;
}
export type FilterConfiguration = Array<Filter | Divider>;