mirror of https://github.com/apache/superset.git
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:
parent
c216565190
commit
9576478a5d
|
@ -25,6 +25,7 @@ import { Provider } from 'react-redux';
|
||||||
import { mockStore } from 'spec/fixtures/mockStore';
|
import { mockStore } from 'spec/fixtures/mockStore';
|
||||||
import { styledMount as mount } from 'spec/helpers/theming';
|
import { styledMount as mount } from 'spec/helpers/theming';
|
||||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||||
|
import { Dropdown, Menu } from 'src/common/components';
|
||||||
import Alert from 'src/components/Alert';
|
import Alert from 'src/components/Alert';
|
||||||
import { FiltersConfigModal } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
import { FiltersConfigModal } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
||||||
|
|
||||||
|
@ -60,7 +61,7 @@ jest.mock('@superset-ui/core', () => ({
|
||||||
describe('FiltersConfigModal', () => {
|
describe('FiltersConfigModal', () => {
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
initialFilterId: 'DefaultsID',
|
initialFilterId: 'NATIVE_FILTER-1',
|
||||||
createNewOnOpen: true,
|
createNewOnOpen: true,
|
||||||
onCancel: jest.fn(),
|
onCancel: jest.fn(),
|
||||||
onSave: jest.fn(),
|
onSave: jest.fn(),
|
||||||
|
@ -112,9 +113,13 @@ describe('FiltersConfigModal', () => {
|
||||||
await waitForComponentToPaint(wrapper);
|
await waitForComponentToPaint(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFilter() {
|
async function addFilter() {
|
||||||
act(() => {
|
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 () => {
|
it('shows correct alert message for unsaved filters', async () => {
|
||||||
addFilter();
|
await addFilter();
|
||||||
await clickCancel();
|
await clickCancel();
|
||||||
expect(onCancel.mock.calls).toHaveLength(0);
|
expect(onCancel.mock.calls).toHaveLength(0);
|
||||||
expect(wrapper.find(Alert).text()).toContain(
|
expect(wrapper.find(Alert).text()).toContain(
|
||||||
|
|
|
@ -36,6 +36,7 @@ import { getChartIdsInFilterScope } from '../../util/activeDashboardFilters';
|
||||||
import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
|
import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
|
||||||
import { findTabsWithChartsInScope } from '../nativeFilters/utils';
|
import { findTabsWithChartsInScope } from '../nativeFilters/utils';
|
||||||
import { setInScopeStatusOfFilters } from '../../actions/nativeFilters';
|
import { setInScopeStatusOfFilters } from '../../actions/nativeFilters';
|
||||||
|
import { NATIVE_FILTER_DIVIDER_PREFIX } from '../nativeFilters/FiltersConfigModal/utils';
|
||||||
|
|
||||||
type DashboardContainerProps = {
|
type DashboardContainerProps = {
|
||||||
topLevelTabs?: LayoutItem;
|
topLevelTabs?: LayoutItem;
|
||||||
|
@ -71,6 +72,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||||
const filterScopes = Object.values(nativeFilters ?? {}).map(filter => ({
|
const filterScopes = Object.values(nativeFilters ?? {}).map(filter => ({
|
||||||
id: filter.id,
|
id: filter.id,
|
||||||
scope: filter.scope,
|
scope: filter.scope,
|
||||||
|
type: filter.type,
|
||||||
}));
|
}));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
@ -80,6 +82,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scopes = filterScopes.map(filterScope => {
|
const scopes = filterScopes.map(filterScope => {
|
||||||
|
if (filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX)) {
|
||||||
|
return {
|
||||||
|
filterId: filterScope.id,
|
||||||
|
tabsInScope: [],
|
||||||
|
chartsInScope: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
const { scope } = filterScope;
|
const { scope } = filterScope;
|
||||||
const chartsInScope: number[] = getChartIdsInFilterScope({
|
const chartsInScope: number[] = getChartIdsInFilterScope({
|
||||||
filterScope: {
|
filterScope: {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { DataMaskStateWithId, DataMaskType } from 'src/dataMask/types';
|
||||||
import { areObjectsEqual } from 'src/reduxUtils';
|
import { areObjectsEqual } from 'src/reduxUtils';
|
||||||
import { Layout } from '../../types';
|
import { Layout } from '../../types';
|
||||||
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
|
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
|
||||||
|
import { NativeFilterType } from '../nativeFilters/types';
|
||||||
|
|
||||||
export enum IndicatorStatus {
|
export enum IndicatorStatus {
|
||||||
Unset = 'UNSET',
|
Unset = 'UNSET',
|
||||||
|
@ -268,11 +269,13 @@ export const selectNativeIndicatorsForChart = (
|
||||||
nativeFilterIndicators =
|
nativeFilterIndicators =
|
||||||
nativeFilters &&
|
nativeFilters &&
|
||||||
Object.values(nativeFilters)
|
Object.values(nativeFilters)
|
||||||
.filter(nativeFilter =>
|
.filter(
|
||||||
getTreeCheckedItems(nativeFilter.scope, dashboardLayout).some(
|
nativeFilter =>
|
||||||
layoutItem =>
|
nativeFilter.type === NativeFilterType.NATIVE_FILTER &&
|
||||||
dashboardLayout[layoutItem]?.meta?.chartId === chartId,
|
getTreeCheckedItems(nativeFilter.scope, dashboardLayout).some(
|
||||||
),
|
layoutItem =>
|
||||||
|
dashboardLayout[layoutItem]?.meta?.chartId === chartId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.map(nativeFilter => {
|
.map(nativeFilter => {
|
||||||
const column = nativeFilter.targets[0]?.column?.name;
|
const column = nativeFilter.targets[0]?.column?.name;
|
||||||
|
|
|
@ -88,12 +88,12 @@ const addFilterFlow = async () => {
|
||||||
userEvent.click(screen.getByText('Time range'));
|
userEvent.click(screen.getByText('Time range'));
|
||||||
userEvent.type(screen.getByTestId(getModalTestId('name-input')), FILTER_NAME);
|
userEvent.type(screen.getByTestId(getModalTestId('name-input')), FILTER_NAME);
|
||||||
userEvent.click(screen.getByText('Save'));
|
userEvent.click(screen.getByText('Save'));
|
||||||
await screen.findByText('All Filters (1)');
|
await screen.findByText('All filters (1)');
|
||||||
};
|
};
|
||||||
|
|
||||||
const addFilterSetFlow = async () => {
|
const addFilterSetFlow = async () => {
|
||||||
// add filter set
|
// add filter set
|
||||||
userEvent.click(screen.getByText('Filter Sets (0)'));
|
userEvent.click(screen.getByText('Filter sets (0)'));
|
||||||
|
|
||||||
// check description
|
// check description
|
||||||
expect(screen.getByText('Filters (1)')).toBeInTheDocument();
|
expect(screen.getByText('Filters (1)')).toBeInTheDocument();
|
||||||
|
@ -301,6 +301,40 @@ describe('FilterBar', () => {
|
||||||
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
|
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 () => {
|
it('create filter and apply it flow', async () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
global.featureFlags = {
|
global.featureFlags = {
|
||||||
|
@ -332,7 +366,7 @@ describe('FilterBar', () => {
|
||||||
|
|
||||||
// change filter
|
// change filter
|
||||||
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
|
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 changeFilterValue();
|
||||||
await waitFor(() => expect(screen.getAllByText('Last day').length).toBe(2));
|
await waitFor(() => expect(screen.getAllByText('Last day').length).toBe(2));
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,10 @@ import {
|
||||||
useDashboardHasTabs,
|
useDashboardHasTabs,
|
||||||
useSelectFiltersInScope,
|
useSelectFiltersInScope,
|
||||||
} from 'src/dashboard/components/nativeFilters/state';
|
} 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 CascadePopover from '../CascadeFilters/CascadePopover';
|
||||||
import { useFilters } from '../state';
|
import { useFilters } from '../state';
|
||||||
import { buildCascadeFiltersTree } from './utils';
|
import { buildCascadeFiltersTree } from './utils';
|
||||||
|
@ -79,21 +82,32 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||||
const showCollapsePanel = dashboardHasTabs && cascadeFilters.length > 0;
|
const showCollapsePanel = dashboardHasTabs && cascadeFilters.length > 0;
|
||||||
|
|
||||||
const cascadePopoverFactory = useCallback(
|
const cascadePopoverFactory = useCallback(
|
||||||
index => (
|
index => {
|
||||||
<CascadePopover
|
const filter = cascadeFilters[index];
|
||||||
data-test="cascade-filters-control"
|
if (filter.type === NativeFilterType.DIVIDER) {
|
||||||
key={cascadeFilters[index].id}
|
return (
|
||||||
dataMaskSelected={dataMaskSelected}
|
<div>
|
||||||
visible={visiblePopoverId === cascadeFilters[index].id}
|
<h3>{filter.title}</h3>
|
||||||
onVisibleChange={visible =>
|
<p>{filter.description}</p>
|
||||||
setVisiblePopoverId(visible ? cascadeFilters[index].id : null)
|
</div>
|
||||||
}
|
);
|
||||||
filter={cascadeFilters[index]}
|
}
|
||||||
onFilterSelectionChange={onFilterSelectionChange}
|
return (
|
||||||
directPathToChild={directPathToChild}
|
<CascadePopover
|
||||||
inView={false}
|
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,
|
cascadeFilters,
|
||||||
JSON.stringify(dataMaskSelected),
|
JSON.stringify(dataMaskSelected),
|
||||||
|
|
|
@ -22,12 +22,14 @@ import {
|
||||||
setFocusedNativeFilter,
|
setFocusedNativeFilter,
|
||||||
unsetFocusedNativeFilter,
|
unsetFocusedNativeFilter,
|
||||||
} from 'src/dashboard/actions/nativeFilters';
|
} from 'src/dashboard/actions/nativeFilters';
|
||||||
import { Filter } from '../../types';
|
import { Filter, NativeFilterType, Divider } from '../../types';
|
||||||
import { CascadeFilter } from '../CascadeFilters/types';
|
import { CascadeFilter } from '../CascadeFilters/types';
|
||||||
import { mapParentFiltersToChildren } from '../utils';
|
import { mapParentFiltersToChildren } from '../utils';
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// 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 cascadeChildren = mapParentFiltersToChildren(filters);
|
||||||
|
|
||||||
const getCascadeFilter = (filter: Filter): CascadeFilter => {
|
const getCascadeFilter = (filter: Filter): CascadeFilter => {
|
||||||
|
@ -39,7 +41,11 @@ export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
|
||||||
};
|
};
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
.filter(filter => !filter.cascadeParentIds?.length)
|
.filter(
|
||||||
|
filter =>
|
||||||
|
filter.type === NativeFilterType.DIVIDER ||
|
||||||
|
!(filter as Filter).cascadeParentIds?.length,
|
||||||
|
)
|
||||||
.map(getCascadeFilter);
|
.map(getCascadeFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { FilterSet } from 'src/dashboard/reducers/types';
|
||||||
import { getFilterValueForDisplay } from './utils';
|
import { getFilterValueForDisplay } from './utils';
|
||||||
import { useFilters } from '../state';
|
import { useFilters } from '../state';
|
||||||
import { getFilterBarTestId } from '../index';
|
import { getFilterBarTestId } from '../index';
|
||||||
|
import { NativeFilterType } from '../../types';
|
||||||
|
|
||||||
const FilterHeader = styled.div`
|
const FilterHeader = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -68,7 +69,9 @@ export type FiltersHeaderProps = {
|
||||||
const FiltersHeader: FC<FiltersHeaderProps> = ({ dataMask, filterSet }) => {
|
const FiltersHeader: FC<FiltersHeaderProps> = ({ dataMask, filterSet }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
const filterValues = Object.values(filters);
|
const filterValues = Object.values(filters).filter(
|
||||||
|
nativeFilter => nativeFilter.type === NativeFilterType.NATIVE_FILTER,
|
||||||
|
);
|
||||||
|
|
||||||
let resultFilters = filterValues ?? [];
|
let resultFilters = filterValues ?? [];
|
||||||
if (filterSet?.nativeFilters) {
|
if (filterSet?.nativeFilters) {
|
||||||
|
|
|
@ -33,7 +33,10 @@ import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types';
|
||||||
import { useImmer } from 'use-immer';
|
import { useImmer } from 'use-immer';
|
||||||
import { isEmpty, isEqual } from 'lodash';
|
import { isEmpty, isEqual } from 'lodash';
|
||||||
import { testWithId } from 'src/utils/testUtils';
|
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 Loading from 'src/components/Loading';
|
||||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||||
import { URL_PARAMS } from 'src/constants';
|
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};
|
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
@ -97,14 +99,12 @@ const CollapsedBar = styled.div<{ offset: number }>`
|
||||||
padding-top: ${({ theme }) => theme.gridUnit * 2}px;
|
padding-top: ${({ theme }) => theme.gridUnit * 2}px;
|
||||||
display: none;
|
display: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: ${({ theme }) => theme.gridUnit * 2}px;
|
padding: ${({ theme }) => theme.gridUnit * 2}px;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -278,9 +278,12 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||||
filterValues,
|
filterValues,
|
||||||
);
|
);
|
||||||
const isInitialized = useInitialization();
|
const isInitialized = useInitialization();
|
||||||
|
|
||||||
const tabPaneStyle = useMemo(() => ({ overflow: 'auto', height }), [height]);
|
const tabPaneStyle = useMemo(() => ({ overflow: 'auto', height }), [height]);
|
||||||
|
|
||||||
|
const numberOfFilters = filterValues.filter(
|
||||||
|
filterValue => filterValue.type === NativeFilterType.NATIVE_FILTER,
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BarWrapper
|
<BarWrapper
|
||||||
{...getFilterBarTestId()}
|
{...getFilterBarTestId()}
|
||||||
|
@ -320,8 +323,8 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||||
activeKey={editFilterSetId ? TabIds.AllFilters : undefined}
|
activeKey={editFilterSetId ? TabIds.AllFilters : undefined}
|
||||||
>
|
>
|
||||||
<Tabs.TabPane
|
<Tabs.TabPane
|
||||||
tab={t('All Filters (%(filterCount)d)', {
|
tab={t('All filters (%(filterCount)d)', {
|
||||||
filterCount: filterValues.length,
|
filterCount: numberOfFilters,
|
||||||
})}
|
})}
|
||||||
key={TabIds.AllFilters}
|
key={TabIds.AllFilters}
|
||||||
css={tabPaneStyle}
|
css={tabPaneStyle}
|
||||||
|
@ -342,7 +345,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane
|
<Tabs.TabPane
|
||||||
disabled={!!editFilterSetId}
|
disabled={!!editFilterSetId}
|
||||||
tab={t('Filter Sets (%(filterSetCount)d)', {
|
tab={t('Filter sets (%(filterSetCount)d)', {
|
||||||
filterSetCount: filterSetFilterValues.length,
|
filterSetCount: filterSetFilterValues.length,
|
||||||
})}
|
})}
|
||||||
key={TabIds.FilterSets}
|
key={TabIds.FilterSets}
|
||||||
|
|
|
@ -20,19 +20,20 @@
|
||||||
import { DataMaskStateWithId } from 'src/dataMask/types';
|
import { DataMaskStateWithId } from 'src/dataMask/types';
|
||||||
import { areObjectsEqual } from 'src/reduxUtils';
|
import { areObjectsEqual } from 'src/reduxUtils';
|
||||||
import { FilterState } from '@superset-ui/core';
|
import { FilterState } from '@superset-ui/core';
|
||||||
import { Filter } from '../types';
|
import { Filter, Divider } from '../types';
|
||||||
|
|
||||||
export enum TabIds {
|
export enum TabIds {
|
||||||
AllFilters = 'allFilters',
|
AllFilters = 'allFilters',
|
||||||
FilterSets = 'filterSets',
|
FilterSets = 'filterSets',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapParentFiltersToChildren(filters: Filter[]): {
|
export function mapParentFiltersToChildren(filters: Array<Filter | Divider>): {
|
||||||
[id: string]: Filter[];
|
[id: string]: Filter[];
|
||||||
} {
|
} {
|
||||||
const cascadeChildren = {};
|
const cascadeChildren = {};
|
||||||
filters.forEach(filter => {
|
filters.forEach(filter => {
|
||||||
const [parentId] = filter.cascadeParentIds || [];
|
const [parentId] =
|
||||||
|
('cascadeParentIds' in filter && filter.cascadeParentIds) || [];
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
if (!cascadeChildren[parentId]) {
|
if (!cascadeChildren[parentId]) {
|
||||||
cascadeChildren[parentId] = [];
|
cascadeChildren[parentId] = [];
|
||||||
|
|
|
@ -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;
|
|
@ -26,7 +26,8 @@ const defaultProps = {
|
||||||
children: jest.fn(),
|
children: jest.fn(),
|
||||||
getFilterTitle: (id: string) => id,
|
getFilterTitle: (id: string) => id,
|
||||||
onChange: jest.fn(),
|
onChange: jest.fn(),
|
||||||
onEdit: jest.fn(),
|
onAdd: jest.fn(),
|
||||||
|
onRemove: jest.fn(),
|
||||||
onRearrange: jest.fn(),
|
onRearrange: jest.fn(),
|
||||||
restoreFilter: jest.fn(),
|
restoreFilter: jest.fn(),
|
||||||
currentFilterId: 'NATIVE_FILTER-1',
|
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 () => {
|
test('add filter', async () => {
|
||||||
defaultRender();
|
defaultRender();
|
||||||
// First trash icon
|
// 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 () => {
|
await act(async () => {
|
||||||
fireEvent(
|
fireEvent(
|
||||||
removeFilterIcon,
|
addFilterButton,
|
||||||
new MouseEvent('click', {
|
new MouseEvent('click', {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
|
||||||
expect(defaultProps.onEdit).toHaveBeenCalledWith('', 'add');
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
import { styled } from '@superset-ui/core';
|
import { styled } from '@superset-ui/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { NativeFilterType } from '../types';
|
||||||
import FilterTitlePane from './FilterTitlePane';
|
import FilterTitlePane from './FilterTitlePane';
|
||||||
import { FilterRemoval } from './types';
|
import { FilterRemoval } from './types';
|
||||||
|
|
||||||
|
@ -25,7 +26,8 @@ interface Props {
|
||||||
children: (filterId: string) => React.ReactNode;
|
children: (filterId: string) => React.ReactNode;
|
||||||
getFilterTitle: (filterId: string) => string;
|
getFilterTitle: (filterId: string) => string;
|
||||||
onChange: (activeKey: string) => void;
|
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;
|
onRearrange: (dragIndex: number, targetIndex: number) => void;
|
||||||
erroredFilters: string[];
|
erroredFilters: string[];
|
||||||
restoreFilter: (id: string) => void;
|
restoreFilter: (id: string) => void;
|
||||||
|
@ -52,9 +54,10 @@ const TitlesContainer = styled.div`
|
||||||
const FiltureConfigurePane: React.FC<Props> = ({
|
const FiltureConfigurePane: React.FC<Props> = ({
|
||||||
getFilterTitle,
|
getFilterTitle,
|
||||||
onChange,
|
onChange,
|
||||||
onEdit,
|
onRemove,
|
||||||
onRearrange,
|
onRearrange,
|
||||||
restoreFilter,
|
restoreFilter,
|
||||||
|
onAdd,
|
||||||
erroredFilters,
|
erroredFilters,
|
||||||
children,
|
children,
|
||||||
currentFilterId,
|
currentFilterId,
|
||||||
|
@ -72,9 +75,9 @@ const FiltureConfigurePane: React.FC<Props> = ({
|
||||||
erroredFilters={erroredFilters}
|
erroredFilters={erroredFilters}
|
||||||
getFilterTitle={getFilterTitle}
|
getFilterTitle={getFilterTitle}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onEdit={onEdit}
|
onAdd={(type: NativeFilterType) => onAdd(type)}
|
||||||
onRearrage={onRearrange}
|
onRearrage={onRearrange}
|
||||||
onRemove={(id: string) => onEdit(id, 'remove')}
|
onRemove={(id: string) => onRemove(id)}
|
||||||
restoreFilter={restoreFilter}
|
restoreFilter={restoreFilter}
|
||||||
/>
|
/>
|
||||||
</TitlesContainer>
|
</TitlesContainer>
|
||||||
|
|
|
@ -16,9 +16,10 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { styled, t, useTheme } from '@superset-ui/core';
|
import { styled, t, useTheme } from '@superset-ui/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Dropdown, MainNav as Menu } from 'src/common/components';
|
||||||
|
import { NativeFilterType } from '../types';
|
||||||
import FilterTitleContainer from './FilterTitleContainer';
|
import FilterTitleContainer from './FilterTitleContainer';
|
||||||
import { FilterRemoval } from './types';
|
import { FilterRemoval } from './types';
|
||||||
|
|
||||||
|
@ -28,13 +29,17 @@ interface Props {
|
||||||
onRearrage: (dragIndex: number, targetIndex: number) => void;
|
onRearrage: (dragIndex: number, targetIndex: number) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
onEdit: (filterId: string, action: 'add' | 'remove') => void;
|
onAdd: (type: NativeFilterType) => void;
|
||||||
removedFilters: Record<string, FilterRemoval>;
|
removedFilters: Record<string, FilterRemoval>;
|
||||||
currentFilterId: string;
|
currentFilterId: string;
|
||||||
filterGroups: string[][];
|
filterGroups: string[][];
|
||||||
erroredFilters: string[];
|
erroredFilters: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StyledPlusButton = styled.div`
|
||||||
|
color: ${({ theme }) => theme.colors.primary.dark1};
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledHeader = styled.div`
|
const StyledHeader = styled.div`
|
||||||
${({ theme }) => `
|
${({ theme }) => `
|
||||||
color: ${theme.colors.grayscale.dark1};
|
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`
|
const TabsContainer = styled.div`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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> = ({
|
const FilterTitlePane: React.FC<Props> = ({
|
||||||
getFilterTitle,
|
getFilterTitle,
|
||||||
onChange,
|
onChange,
|
||||||
onEdit,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
onRearrage,
|
onRearrage,
|
||||||
restoreFilter,
|
restoreFilter,
|
||||||
|
@ -76,6 +79,29 @@ const FilterTitlePane: React.FC<Props> = ({
|
||||||
erroredFilters,
|
erroredFilters,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
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 (
|
return (
|
||||||
<TabsContainer>
|
<TabsContainer>
|
||||||
<StyledHeader>Filters</StyledHeader>
|
<StyledHeader>Filters</StyledHeader>
|
||||||
|
@ -98,28 +124,15 @@ const FilterTitlePane: React.FC<Props> = ({
|
||||||
restoreFilter={restoreFilter}
|
restoreFilter={restoreFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StyledAddFilterBox
|
<Dropdown overlay={menu} arrow placement="topLeft" trigger={['hover']}>
|
||||||
onClick={() => {
|
<StyledAddBox>
|
||||||
onEdit('', 'add');
|
<StyledPlusButton
|
||||||
setTimeout(() => {
|
data-test="new-dropdown-icon"
|
||||||
const element = document.getElementById('native-filters-tabs');
|
className="fa fa-plus"
|
||||||
if (element) {
|
/>{' '}
|
||||||
const navList =
|
<span>{t('Add')}</span>
|
||||||
element.getElementsByClassName('ant-tabs-nav-list')[0];
|
</StyledAddBox>
|
||||||
navList.scrollTop = navList.scrollHeight;
|
</Dropdown>
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusOutlined />{' '}
|
|
||||||
<span
|
|
||||||
data-test="add-filter-button"
|
|
||||||
aria-label="Add filter"
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
{t('Add filter')}
|
|
||||||
</span>
|
|
||||||
</StyledAddFilterBox>
|
|
||||||
</TabsContainer>
|
</TabsContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,7 +30,12 @@ import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||||
import { StyledModal } from 'src/components/Modal';
|
import { StyledModal } from 'src/components/Modal';
|
||||||
import { testWithId } from 'src/utils/testUtils';
|
import { testWithId } from 'src/utils/testUtils';
|
||||||
import { useFilterConfigMap, useFilterConfiguration } from '../state';
|
import { useFilterConfigMap, useFilterConfiguration } from '../state';
|
||||||
import { FilterConfiguration } from '../types';
|
import {
|
||||||
|
Filter,
|
||||||
|
FilterConfiguration,
|
||||||
|
NativeFilterType,
|
||||||
|
Divider,
|
||||||
|
} from '../types';
|
||||||
import FiltureConfigurePane from './FilterConfigurePane';
|
import FiltureConfigurePane from './FilterConfigurePane';
|
||||||
import FiltersConfigForm, {
|
import FiltersConfigForm, {
|
||||||
FilterPanels,
|
FilterPanels,
|
||||||
|
@ -40,12 +45,14 @@ import { useOpenModal, useRemoveCurrentFilter } from './state';
|
||||||
import { FilterRemoval, NativeFiltersForm, FilterHierarchy } from './types';
|
import { FilterRemoval, NativeFiltersForm, FilterHierarchy } from './types';
|
||||||
import {
|
import {
|
||||||
createHandleSave,
|
createHandleSave,
|
||||||
createHandleTabEdit,
|
createHandleRemoveItem,
|
||||||
generateFilterId,
|
generateFilterId,
|
||||||
getFilterIds,
|
getFilterIds,
|
||||||
buildFilterGroup,
|
buildFilterGroup,
|
||||||
validateForm,
|
validateForm,
|
||||||
|
NATIVE_FILTER_DIVIDER_PREFIX,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import DividerConfigForm from './DividerConfigForm';
|
||||||
|
|
||||||
const StyledModalWrapper = styled(StyledModal)`
|
const StyledModalWrapper = styled(StyledModal)`
|
||||||
min-width: 700px;
|
min-width: 700px;
|
||||||
|
@ -153,7 +160,10 @@ export function FiltersConfigModal({
|
||||||
const getInitialFilterHierarchy = () =>
|
const getInitialFilterHierarchy = () =>
|
||||||
filterConfig.map(filter => ({
|
filterConfig.map(filter => ({
|
||||||
id: filter.id,
|
id: filter.id,
|
||||||
parentId: filter.cascadeParentIds[0] || null,
|
parentId:
|
||||||
|
filter.type === NativeFilterType.NATIVE_FILTER
|
||||||
|
? filter.cascadeParentIds[0] || null
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [filterHierarchy, setFilterHierarchy] = useState<FilterHierarchy>(() =>
|
const [filterHierarchy, setFilterHierarchy] = useState<FilterHierarchy>(() =>
|
||||||
|
@ -175,24 +185,28 @@ export function FiltersConfigModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
// generates a new filter id and appends it to the newFilterIds
|
// generates a new filter id and appends it to the newFilterIds
|
||||||
const addFilter = useCallback(() => {
|
const addFilter = useCallback(
|
||||||
const newFilterId = generateFilterId();
|
(type: NativeFilterType) => {
|
||||||
setNewFilterIds([...newFilterIds, newFilterId]);
|
const newFilterId = generateFilterId(type);
|
||||||
setCurrentFilterId(newFilterId);
|
setNewFilterIds([...newFilterIds, newFilterId]);
|
||||||
setSaveAlertVisible(false);
|
setCurrentFilterId(newFilterId);
|
||||||
setFilterHierarchy(previousState => [
|
setSaveAlertVisible(false);
|
||||||
...previousState,
|
setFilterHierarchy(previousState => [
|
||||||
{ id: newFilterId, parentId: null },
|
...previousState,
|
||||||
]);
|
{ id: newFilterId, parentId: null },
|
||||||
setOrderedFilters([...orderedFilters, [newFilterId]]);
|
]);
|
||||||
setActiveFilterPanelKey(`${newFilterId}-${FilterPanels.basic.key}`);
|
setOrderedFilters([...orderedFilters, [newFilterId]]);
|
||||||
}, [
|
setActiveFilterPanelKey(`${newFilterId}-${FilterPanels.basic.key}`);
|
||||||
newFilterIds,
|
},
|
||||||
orderedFilters,
|
[
|
||||||
setCurrentFilterId,
|
newFilterIds,
|
||||||
setFilterHierarchy,
|
orderedFilters,
|
||||||
setOrderedFilters,
|
setCurrentFilterId,
|
||||||
]);
|
setFilterHierarchy,
|
||||||
|
setOrderedFilters,
|
||||||
|
setNewFilterIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useOpenModal(isOpen, addFilter, createNewOnOpen);
|
useOpenModal(isOpen, addFilter, createNewOnOpen);
|
||||||
|
|
||||||
|
@ -203,12 +217,11 @@ export function FiltersConfigModal({
|
||||||
setCurrentFilterId,
|
setCurrentFilterId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTabEdit = createHandleTabEdit(
|
const handleRemoveItem = createHandleRemoveItem(
|
||||||
setRemovedFilters,
|
setRemovedFilters,
|
||||||
setSaveAlertVisible,
|
setSaveAlertVisible,
|
||||||
setOrderedFilters,
|
setOrderedFilters,
|
||||||
setFilterHierarchy,
|
setFilterHierarchy,
|
||||||
addFilter,
|
|
||||||
filterHierarchy,
|
filterHierarchy,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -230,21 +243,29 @@ export function FiltersConfigModal({
|
||||||
form.setFieldsValue({ changed: false });
|
form.setFieldsValue({ changed: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilterTitle = (id: string) =>
|
const getFilterTitle = (id: string) => {
|
||||||
formValues.filters[id]?.name ||
|
const formValue = formValues.filters[id];
|
||||||
filterConfigMap[id]?.name ||
|
const config = filterConfigMap[id];
|
||||||
t('[untitled]');
|
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) =>
|
const getParentFilters = (id: string) =>
|
||||||
filterIds
|
filterIds
|
||||||
.filter(filterId => filterId !== id && !removedFilters[filterId])
|
.filter(filterId => filterId !== id && !removedFilters[filterId])
|
||||||
.filter(filterId =>
|
.filter(filterId => {
|
||||||
CASCADING_FILTERS.includes(
|
const component =
|
||||||
formValues.filters[filterId]
|
formValues.filters[filterId] || filterConfigMap[filterId];
|
||||||
? formValues.filters[filterId].filterType
|
return (
|
||||||
: filterConfigMap[filterId]?.filterType,
|
component &&
|
||||||
),
|
'filterType' in component &&
|
||||||
)
|
CASCADING_FILTERS.includes(component.filterType)
|
||||||
|
);
|
||||||
|
})
|
||||||
.map(id => ({
|
.map(id => ({
|
||||||
id,
|
id,
|
||||||
title: getFilterTitle(id),
|
title: getFilterTitle(id),
|
||||||
|
@ -253,6 +274,9 @@ export function FiltersConfigModal({
|
||||||
const cleanDeletedParents = (values: NativeFiltersForm | null) => {
|
const cleanDeletedParents = (values: NativeFiltersForm | null) => {
|
||||||
Object.keys(filterConfigMap).forEach(key => {
|
Object.keys(filterConfigMap).forEach(key => {
|
||||||
const filter = filterConfigMap[key];
|
const filter = filterConfigMap[key];
|
||||||
|
if (!('cascadeParentIds' in filter)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parentId = filter.cascadeParentIds?.[0];
|
const parentId = filter.cascadeParentIds?.[0];
|
||||||
if (parentId && removedFilters[parentId]) {
|
if (parentId && removedFilters[parentId]) {
|
||||||
filter.cascadeParentIds = [];
|
filter.cascadeParentIds = [];
|
||||||
|
@ -263,6 +287,9 @@ export function FiltersConfigModal({
|
||||||
if (filters) {
|
if (filters) {
|
||||||
Object.keys(filters).forEach(key => {
|
Object.keys(filters).forEach(key => {
|
||||||
const filter = filters[key];
|
const filter = filters[key];
|
||||||
|
if (!('parentFilter' in filter)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parentId = filter.parentFilter?.value;
|
const parentId = filter.parentFilter?.value;
|
||||||
if (parentId && removedFilters[parentId]) {
|
if (parentId && removedFilters[parentId]) {
|
||||||
filter.parentFilter = undefined;
|
filter.parentFilter = undefined;
|
||||||
|
@ -369,13 +396,18 @@ export function FiltersConfigModal({
|
||||||
const onValuesChange = useMemo(
|
const onValuesChange = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((changes: any, values: NativeFiltersForm) => {
|
debounce((changes: any, values: NativeFiltersForm) => {
|
||||||
if (
|
const didChangeFilterName =
|
||||||
changes.filters &&
|
changes.filters &&
|
||||||
Object.values(changes.filters).some(
|
Object.values(changes.filters).some(
|
||||||
(filter: any) => filter.name != null,
|
(filter: any) => filter.name !== null,
|
||||||
)
|
);
|
||||||
) {
|
const didChangeSectionTitle =
|
||||||
// we only need to set this if a name changed
|
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);
|
setFormValues(values);
|
||||||
}
|
}
|
||||||
const changedFilterHierarchies = Object.keys(changes.filters)
|
const changedFilterHierarchies = Object.keys(changes.filters)
|
||||||
|
@ -402,6 +434,31 @@ export function FiltersConfigModal({
|
||||||
prevErroredFilters.filter(f => !removedFilters[f]),
|
prevErroredFilters.filter(f => !removedFilters[f]),
|
||||||
);
|
);
|
||||||
}, [removedFilters]);
|
}, [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 (
|
return (
|
||||||
<StyledModalWrapper
|
<StyledModalWrapper
|
||||||
|
@ -434,7 +491,8 @@ export function FiltersConfigModal({
|
||||||
>
|
>
|
||||||
<FiltureConfigurePane
|
<FiltureConfigurePane
|
||||||
erroredFilters={erroredFilters}
|
erroredFilters={erroredFilters}
|
||||||
onEdit={handleTabEdit}
|
onRemove={handleRemoveItem}
|
||||||
|
onAdd={addFilter}
|
||||||
onChange={onTabChange}
|
onChange={onTabChange}
|
||||||
getFilterTitle={getFilterTitle}
|
getFilterTitle={getFilterTitle}
|
||||||
currentFilterId={currentFilterId}
|
currentFilterId={currentFilterId}
|
||||||
|
@ -443,25 +501,7 @@ export function FiltersConfigModal({
|
||||||
onRearrange={onRearrage}
|
onRearrange={onRearrage}
|
||||||
filterGroups={orderedFilters}
|
filterGroups={orderedFilters}
|
||||||
>
|
>
|
||||||
{(id: string) => (
|
{(id: string) => getForm(id)}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FiltureConfigurePane>
|
</FiltureConfigurePane>
|
||||||
</StyledForm>
|
</StyledForm>
|
||||||
</StyledModalBody>
|
</StyledModalBody>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FilterRemoval } from './types';
|
import { FilterRemoval } from './types';
|
||||||
import { usePrevious } from '../../../../common/hooks/usePrevious';
|
import { usePrevious } from '../../../../common/hooks/usePrevious';
|
||||||
|
import { NativeFilterType } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@ -52,7 +53,7 @@ export const useOpenModal = (
|
||||||
// add a filter on modal open
|
// add a filter on modal open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (createNewOnOpen && isOpen && !wasOpen) {
|
if (createNewOnOpen && isOpen && !wasOpen) {
|
||||||
addFilter();
|
addFilter(NativeFilterType.NATIVE_FILTER);
|
||||||
}
|
}
|
||||||
}, [createNewOnOpen, isOpen, wasOpen, addFilter]);
|
}, [createNewOnOpen, isOpen, wasOpen, addFilter]);
|
||||||
};
|
};
|
||||||
|
|
|
@ -44,13 +44,19 @@ export interface NativeFiltersFormItem {
|
||||||
adhoc_filters?: AdhocFilter[];
|
adhoc_filters?: AdhocFilter[];
|
||||||
time_range?: string;
|
time_range?: string;
|
||||||
granularity_sqla?: string;
|
granularity_sqla?: string;
|
||||||
type: NativeFilterType;
|
type: typeof NativeFilterType.NATIVE_FILTER;
|
||||||
description: string;
|
description: string;
|
||||||
hierarchicalFilter?: boolean;
|
hierarchicalFilter?: boolean;
|
||||||
}
|
}
|
||||||
|
export interface NativeFilterDivider {
|
||||||
|
id: string;
|
||||||
|
type: typeof NativeFilterType.DIVIDER;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NativeFiltersForm {
|
export interface NativeFiltersForm {
|
||||||
filters: Record<string, NativeFiltersFormItem>;
|
filters: Record<string, NativeFiltersFormItem | NativeFilterDivider>;
|
||||||
changed?: boolean;
|
changed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,20 +21,27 @@ import shortid from 'shortid';
|
||||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||||
|
|
||||||
import { t } from '@superset-ui/core';
|
import { t } from '@superset-ui/core';
|
||||||
|
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||||
import {
|
import {
|
||||||
FilterRemoval,
|
FilterRemoval,
|
||||||
NativeFiltersForm,
|
NativeFiltersForm,
|
||||||
FilterHierarchy,
|
FilterHierarchy,
|
||||||
FilterHierarchyNode,
|
FilterHierarchyNode,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { Filter, FilterConfiguration, Target } from '../types';
|
import {
|
||||||
|
Filter,
|
||||||
|
FilterConfiguration,
|
||||||
|
NativeFilterType,
|
||||||
|
Divider,
|
||||||
|
Target,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
export const REMOVAL_DELAY_SECS = 5;
|
export const REMOVAL_DELAY_SECS = 5;
|
||||||
|
|
||||||
export const validateForm = async (
|
export const validateForm = async (
|
||||||
form: FormInstance<NativeFiltersForm>,
|
form: FormInstance<NativeFiltersForm>,
|
||||||
currentFilterId: string,
|
currentFilterId: string,
|
||||||
filterConfigMap: Record<string, Filter>,
|
filterConfigMap: Record<string, Filter | Divider>,
|
||||||
filterIds: string[],
|
filterIds: string[],
|
||||||
removedFilters: Record<string, FilterRemoval>,
|
removedFilters: Record<string, FilterRemoval>,
|
||||||
setCurrentFilterId: Function,
|
setCurrentFilterId: Function,
|
||||||
|
@ -77,9 +84,12 @@ export const validateForm = async (
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const parentId = formValues.filters?.[filterId]
|
const formItem = formValues.filters?.[filterId];
|
||||||
? formValues.filters[filterId]?.parentFilter?.value
|
const configItem = filterConfigMap[filterId];
|
||||||
: filterConfigMap[filterId]?.cascadeParentIds?.[0];
|
const parentId = formItem
|
||||||
|
? 'parentFilter' in formItem && formItem.parentFilter?.value
|
||||||
|
: 'cascadeParentIds' in configItem && configItem?.cascadeParentIds?.[0];
|
||||||
|
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
return validateCycles(parentId, [...trace, filterId]);
|
return validateCycles(parentId, [...trace, filterId]);
|
||||||
}
|
}
|
||||||
|
@ -120,7 +130,7 @@ export const validateForm = async (
|
||||||
|
|
||||||
export const createHandleSave =
|
export const createHandleSave =
|
||||||
(
|
(
|
||||||
filterConfigMap: Record<string, Filter>,
|
filterConfigMap: Record<string, Filter | Divider>,
|
||||||
filterIds: string[],
|
filterIds: string[],
|
||||||
removedFilters: Record<string, FilterRemoval>,
|
removedFilters: Record<string, FilterRemoval>,
|
||||||
saveForm: Function,
|
saveForm: Function,
|
||||||
|
@ -134,6 +144,18 @@ export const createHandleSave =
|
||||||
const formInputs = values.filters?.[id];
|
const formInputs = values.filters?.[id];
|
||||||
// if user didn't open a filter, return the original config
|
// if user didn't open a filter, return the original config
|
||||||
if (!formInputs) return filterConfigMap[id];
|
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> = {};
|
const target: Partial<Target> = {};
|
||||||
if (formInputs.dataset) {
|
if (formInputs.dataset) {
|
||||||
target.datasetId = formInputs.dataset.value;
|
target.datasetId = formInputs.dataset.value;
|
||||||
|
@ -210,7 +232,7 @@ export function buildFilterGroup(nodes: FilterHierarchyNode[]) {
|
||||||
}
|
}
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
export const createHandleTabEdit =
|
export const createHandleRemoveItem =
|
||||||
(
|
(
|
||||||
setRemovedFilters: (
|
setRemovedFilters: (
|
||||||
value:
|
value:
|
||||||
|
@ -228,10 +250,9 @@ export const createHandleTabEdit =
|
||||||
| FilterHierarchy
|
| FilterHierarchy
|
||||||
| ((prevState: FilterHierarchy) => FilterHierarchy),
|
| ((prevState: FilterHierarchy) => FilterHierarchy),
|
||||||
) => void,
|
) => void,
|
||||||
addFilter: Function,
|
|
||||||
filterHierarchy: FilterHierarchy,
|
filterHierarchy: FilterHierarchy,
|
||||||
) =>
|
) =>
|
||||||
(filterId: string, action: 'add' | 'remove') => {
|
(filterId: string) => {
|
||||||
const completeFilterRemoval = (filterId: string) => {
|
const completeFilterRemoval = (filterId: string) => {
|
||||||
const buildNewFilterHierarchy = (hierarchy: FilterHierarchy) =>
|
const buildNewFilterHierarchy = (hierarchy: FilterHierarchy) =>
|
||||||
hierarchy
|
hierarchy
|
||||||
|
@ -277,25 +298,27 @@ export const createHandleTabEdit =
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (action === 'remove') {
|
// first set up the timer to completely remove it
|
||||||
// first set up the timer to completely remove it
|
const timerId = window.setTimeout(() => {
|
||||||
const timerId = window.setTimeout(() => {
|
completeFilterRemoval(filterId);
|
||||||
completeFilterRemoval(filterId);
|
}, REMOVAL_DELAY_SECS * 1000);
|
||||||
}, REMOVAL_DELAY_SECS * 1000);
|
// mark the filter state as "removal in progress"
|
||||||
// mark the filter state as "removal in progress"
|
setRemovedFilters(removedFilters => ({
|
||||||
setRemovedFilters(removedFilters => ({
|
...removedFilters,
|
||||||
...removedFilters,
|
[filterId]: { isPending: true, timerId },
|
||||||
[filterId]: { isPending: true, timerId },
|
}));
|
||||||
}));
|
setSaveAlertVisible(false);
|
||||||
setSaveAlertVisible(false);
|
|
||||||
} else if (action === 'add') {
|
|
||||||
addFilter();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NATIVE_FILTER_PREFIX = 'NATIVE_FILTER-';
|
export const NATIVE_FILTER_PREFIX = 'NATIVE_FILTER-';
|
||||||
export const generateFilterId = () =>
|
export const NATIVE_FILTER_DIVIDER_PREFIX = 'NATIVE_FILTER_DIVIDER-';
|
||||||
`${NATIVE_FILTER_PREFIX}${shortid.generate()}`;
|
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) =>
|
export const getFilterIds = (config: FilterConfiguration) =>
|
||||||
config.map(filter => filter.id);
|
config.map(filter => filter.id);
|
||||||
|
|
|
@ -18,7 +18,12 @@
|
||||||
*/
|
*/
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Filter, FilterConfiguration } from './types';
|
import {
|
||||||
|
Filter,
|
||||||
|
FilterConfiguration,
|
||||||
|
NativeFilterType,
|
||||||
|
Divider,
|
||||||
|
} from './types';
|
||||||
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
|
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
|
||||||
import { TAB_TYPE } from '../../util/componentTypes';
|
import { TAB_TYPE } from '../../util/componentTypes';
|
||||||
import { CascadeFilter } from './FilterBar/CascadeFilters/types';
|
import { CascadeFilter } from './FilterBar/CascadeFilters/types';
|
||||||
|
@ -41,10 +46,13 @@ export function useFilterConfigMap() {
|
||||||
const filterConfig = useFilterConfiguration();
|
const filterConfig = useFilterConfiguration();
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
filterConfig.reduce((acc: Record<string, Filter>, filter: Filter) => {
|
filterConfig.reduce(
|
||||||
acc[filter.id] = filter;
|
(acc: Record<string, Filter | Divider>, filter: Filter) => {
|
||||||
return acc;
|
acc[filter.id] = filter;
|
||||||
}, {} as Record<string, Filter>),
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, Filter | Divider>,
|
||||||
|
),
|
||||||
[filterConfig],
|
[filterConfig],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -89,29 +97,38 @@ function useIsFilterInScope() {
|
||||||
// Filter is in scope if any of it's charts is visible.
|
// 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 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
|
// Chart is in an active tab tree if all of it's ancestors of type TAB are active
|
||||||
return (filter: CascadeFilter) =>
|
// Dividers are always in scope
|
||||||
filter.chartsInScope?.some((chartId: number) => {
|
return (filter: CascadeFilter | Divider) => {
|
||||||
const tabParents = selectChartTabParents(chartId);
|
const isDivider = filter.type === NativeFilterType.DIVIDER;
|
||||||
return (
|
return (
|
||||||
tabParents?.length === 0 ||
|
isDivider ||
|
||||||
tabParents?.every(tab => activeTabs.includes(tab))
|
('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 dashboardHasTabs = useDashboardHasTabs();
|
||||||
const isFilterInScope = useIsFilterInScope();
|
const isFilterInScope = useIsFilterInScope();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
let filtersInScope: CascadeFilter[] = [];
|
let filtersInScope: (CascadeFilter | Divider)[] = [];
|
||||||
const filtersOutOfScope: CascadeFilter[] = [];
|
const filtersOutOfScope: (CascadeFilter | Divider)[] = [];
|
||||||
|
|
||||||
// we check native filters scopes only on dashboards with tabs
|
// we check native filters scopes only on dashboards with tabs
|
||||||
if (!dashboardHasTabs) {
|
if (!dashboardHasTabs) {
|
||||||
filtersInScope = cascadeFilters;
|
filtersInScope = cascadeFilters;
|
||||||
} else {
|
} else {
|
||||||
cascadeFilters.forEach((filter: CascadeFilter) => {
|
cascadeFilters.forEach(filter => {
|
||||||
const filterInScope = isFilterInScope(filter);
|
const filterInScope = isFilterInScope(filter);
|
||||||
|
|
||||||
if (filterInScope) {
|
if (filterInScope) {
|
||||||
|
|
|
@ -39,6 +39,11 @@ export interface Target {
|
||||||
// clarityColumns?: Column[];
|
// clarityColumns?: Column[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum NativeFilterType {
|
||||||
|
NATIVE_FILTER = 'NATIVE_FILTER',
|
||||||
|
DIVIDER = 'DIVIDER',
|
||||||
|
}
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
cascadeParentIds: string[];
|
cascadeParentIds: string[];
|
||||||
defaultDataMask: DataMask;
|
defaultDataMask: DataMask;
|
||||||
|
@ -62,13 +67,14 @@ export interface Filter {
|
||||||
requiredFirst?: boolean;
|
requiredFirst?: boolean;
|
||||||
tabsInScope?: string[];
|
tabsInScope?: string[];
|
||||||
chartsInScope?: number[];
|
chartsInScope?: number[];
|
||||||
type: NativeFilterType;
|
type: typeof NativeFilterType.NATIVE_FILTER;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
export interface Divider {
|
||||||
export type FilterConfiguration = Filter[];
|
id: string;
|
||||||
|
title: string;
|
||||||
export enum NativeFilterType {
|
description: string;
|
||||||
NATIVE_FILTER = 'NATIVE_FILTER',
|
type: typeof NativeFilterType.DIVIDER;
|
||||||
SECTION = 'SECTION',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FilterConfiguration = Array<Filter | Divider>;
|
||||||
|
|
Loading…
Reference in New Issue