feat(native-filters): Set default scope by filters' and charts' datasets (#15302)

* feat(native-filters): Set default scope by filter's and charts datasets

* Fix undefined error

* Use JSON.stringify in dependency array

* Fix lint issue

* Lock scope after switching radio buttons

* Fix weird eslint issue

* Change prop names

* Implement useComponentDidUpdate

* Fix lint

* Refactor useComponentDidUpdate

* Remove screen.debug()
This commit is contained in:
Kamil Gabryjelski 2021-06-24 09:23:35 +02:00 committed by GitHub
parent 352656a398
commit f0b64190b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 291 additions and 53 deletions

View File

@ -48,6 +48,7 @@ describe('getFormDataWithExtraFilters', () => {
val: ['United States'], val: ['United States'],
}, },
], ],
datasource: '123',
}, },
}; };
const mockArgs: GetFormDataWithExtraFiltersArguments = { const mockArgs: GetFormDataWithExtraFiltersArguments = {

View File

@ -0,0 +1,20 @@
/**
* 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.
*/
export * from './useComponentDidUpdate';

View File

@ -0,0 +1,31 @@
/**
* 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 { renderHook } from '@testing-library/react-hooks';
import { useComponentDidUpdate } from './useComponentDidUpdate';
test('the effect should not be executed on the first render', () => {
const effect = jest.fn();
const hook = renderHook(props => useComponentDidUpdate(props.effect), {
initialProps: { effect },
});
expect(effect).toBeCalledTimes(0);
const changedEffect = jest.fn();
hook.rerender({ effect: changedEffect });
expect(changedEffect).toBeCalledTimes(1);
});

View File

@ -0,0 +1,31 @@
/**
* 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 { EffectCallback, useEffect, useRef } from 'react';
export const useComponentDidUpdate = (effect: EffectCallback) => {
const isMountedRef = useRef(false);
useEffect(() => {
if (isMountedRef.current) {
effect();
} else {
isMountedRef.current = true;
}
}, [effect]);
};

View File

@ -43,9 +43,9 @@ test('Should send correct props', () => {
expect(FilterScope).toHaveBeenCalledWith( expect(FilterScope).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
chartId: 123, chartId: 123,
scope: 'Scope', filterScope: 'Scope',
formScope: 'scope', formFilterScope: 'scope',
formScoping: 'scoping', formScopingType: 'scoping',
}), }),
{}, {},
); );

View File

@ -45,11 +45,11 @@ const CrossFilterScopingForm: FC<CrossFilterScopingFormProps> = ({
...values, ...values,
}); });
}} }}
scope={scope} filterScope={scope}
chartId={chartId} chartId={chartId}
formScope={formScope} formFilterScope={formScope}
forceUpdate={forceUpdate} forceUpdate={forceUpdate}
formScoping={formScoping} formScopingType={formScoping}
/> />
); );
}; };

View File

@ -17,23 +17,25 @@
* under the License. * under the License.
*/ */
import React, { FC } from 'react'; import React, { FC, useCallback, useState } from 'react';
import { t, styled } from '@superset-ui/core'; import { t, styled } from '@superset-ui/core';
import { Radio } from 'src/components/Radio'; import { Radio } from 'src/components/Radio';
import { Form, Typography } from 'src/common/components'; import { Form, Typography } from 'src/common/components';
import { useComponentDidUpdate } from 'src/common/hooks/useComponentDidUpdate/useComponentDidUpdate';
import { Scope } from '../../../types'; import { Scope } from '../../../types';
import { Scoping } from './types'; import { ScopingType } from './types';
import ScopingTree from './ScopingTree'; import ScopingTree from './ScopingTree';
import { getDefaultScopeValue, isScopingAll } from './utils'; import { getDefaultScopeValue, isScopingAll } from './utils';
type FilterScopeProps = { type FilterScopeProps = {
pathToFormValue?: string[]; pathToFormValue?: string[];
updateFormValues: (values: any) => void; updateFormValues: (values: any) => void;
formScope?: Scope; formFilterScope?: Scope;
forceUpdate: Function; forceUpdate: Function;
scope?: Scope; filterScope?: Scope;
formScoping?: Scoping; formScopingType?: ScopingType;
chartId?: number; chartId?: number;
initiallyExcludedCharts?: number[];
}; };
const Wrapper = styled.div` const Wrapper = styled.div`
@ -50,59 +52,98 @@ const CleanFormItem = styled(Form.Item)`
const FilterScope: FC<FilterScopeProps> = ({ const FilterScope: FC<FilterScopeProps> = ({
pathToFormValue = [], pathToFormValue = [],
formScoping, formScopingType,
formScope, formFilterScope,
forceUpdate, forceUpdate,
scope, filterScope,
updateFormValues, updateFormValues,
chartId, chartId,
initiallyExcludedCharts,
}) => { }) => {
const initialScope = scope || getDefaultScopeValue(chartId); const [initialFilterScope] = useState(
const initialScoping = isScopingAll(initialScope, chartId) filterScope || getDefaultScopeValue(chartId, initiallyExcludedCharts),
? Scoping.all );
: Scoping.specific; const [initialScopingType] = useState(
isScopingAll(initialFilterScope, chartId)
? ScopingType.all
: ScopingType.specific,
);
const [hasScopeBeenModified, setHasScopeBeenModified] = useState(
!!filterScope,
);
const onUpdateFormValues = useCallback(
(formValues: any) => {
updateFormValues(formValues);
setHasScopeBeenModified(true);
},
[updateFormValues],
);
const updateScopes = useCallback(() => {
if (filterScope || hasScopeBeenModified) {
return;
}
const newScope = getDefaultScopeValue(chartId, initiallyExcludedCharts);
updateFormValues({
scope: newScope,
scoping: isScopingAll(newScope, chartId)
? ScopingType.all
: ScopingType.specific,
});
}, [
chartId,
filterScope,
hasScopeBeenModified,
initiallyExcludedCharts,
updateFormValues,
]);
useComponentDidUpdate(updateScopes);
return ( return (
<Wrapper> <Wrapper>
<CleanFormItem <CleanFormItem
name={[...pathToFormValue, 'scoping']} name={[...pathToFormValue, 'scoping']}
initialValue={initialScoping} initialValue={initialScopingType}
> >
<Radio.Group <Radio.Group
onChange={({ target: { value } }) => { onChange={({ target: { value } }) => {
if (value === Scoping.all) { if (value === ScopingType.all) {
const scope = getDefaultScopeValue(chartId); const scope = getDefaultScopeValue(chartId);
updateFormValues({ updateFormValues({
scope, scope,
}); });
} }
setHasScopeBeenModified(true);
forceUpdate(); forceUpdate();
}} }}
> >
<Radio value={Scoping.all}>{t('Apply to all panels')}</Radio> <Radio value={ScopingType.all}>{t('Apply to all panels')}</Radio>
<Radio value={Scoping.specific}> <Radio value={ScopingType.specific}>
{t('Apply to specific panels')} {t('Apply to specific panels')}
</Radio> </Radio>
</Radio.Group> </Radio.Group>
</CleanFormItem> </CleanFormItem>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{(formScoping ?? initialScoping) === Scoping.specific {(formScopingType ?? initialScopingType) === ScopingType.specific
? t('Only selected panels will be affected by this filter') ? t('Only selected panels will be affected by this filter')
: t('All panels with this column will be affected by this filter')} : t('All panels with this column will be affected by this filter')}
</Typography.Text> </Typography.Text>
{(formScoping ?? initialScoping) === Scoping.specific && ( {(formScopingType ?? initialScopingType) === ScopingType.specific && (
<ScopingTree <ScopingTree
updateFormValues={updateFormValues} updateFormValues={onUpdateFormValues}
initialScope={initialScope} initialScope={initialFilterScope}
formScope={formScope} formScope={formFilterScope}
forceUpdate={forceUpdate} forceUpdate={forceUpdate}
chartId={chartId} chartId={chartId}
initiallyExcludedCharts={initiallyExcludedCharts}
/> />
)} )}
<CleanFormItem <CleanFormItem
name={[...pathToFormValue, 'scope']} name={[...pathToFormValue, 'scope']}
hidden hidden
initialValue={initialScope} initialValue={initialFilterScope}
/> />
</Wrapper> </Wrapper>
); );

View File

@ -20,6 +20,8 @@
import React, { FC, useMemo, useState } from 'react'; import React, { FC, useMemo, useState } from 'react';
import { Tree } from 'src/common/components'; import { Tree } from 'src/common/components';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import { useFilterScopeTree } from './state'; import { useFilterScopeTree } from './state';
import { findFilterScope, getTreeCheckedItems } from './utils'; import { findFilterScope, getTreeCheckedItems } from './utils';
import { Scope } from '../../../types'; import { Scope } from '../../../types';
@ -30,6 +32,26 @@ type ScopingTreeProps = {
formScope?: Scope; formScope?: Scope;
initialScope: Scope; initialScope: Scope;
chartId?: number; chartId?: number;
initiallyExcludedCharts?: number[];
};
const buildTreeLeafTitle = (
label: string,
hasTooltip: boolean,
tooltipTitle?: string,
) => {
let title = <span>{label}</span>;
if (hasTooltip) {
title = (
<>
{title}&nbsp;
<Tooltip title={tooltipTitle}>
<Icons.Info iconSize="m" />
</Tooltip>
</>
);
}
return title;
}; };
const ScopingTree: FC<ScopingTreeProps> = ({ const ScopingTree: FC<ScopingTreeProps> = ({
@ -38,12 +60,17 @@ const ScopingTree: FC<ScopingTreeProps> = ({
forceUpdate, forceUpdate,
updateFormValues, updateFormValues,
chartId, chartId,
initiallyExcludedCharts = [],
}) => { }) => {
const [expandedKeys, setExpandedKeys] = useState<string[]>([ const [expandedKeys, setExpandedKeys] = useState<string[]>([
DASHBOARD_ROOT_ID, DASHBOARD_ROOT_ID,
]); ]);
const { treeData, layout } = useFilterScopeTree(chartId); const { treeData, layout } = useFilterScopeTree(
chartId,
initiallyExcludedCharts,
buildTreeLeafTitle,
);
const [autoExpandParent, setAutoExpandParent] = useState<boolean>(true); const [autoExpandParent, setAutoExpandParent] = useState<boolean>(true);
const handleExpand = (expandedKeys: string[]) => { const handleExpand = (expandedKeys: string[]) => {

View File

@ -25,12 +25,14 @@ import {
CHART_TYPE, CHART_TYPE,
DASHBOARD_ROOT_TYPE, DASHBOARD_ROOT_TYPE,
} from 'src/dashboard/util/componentTypes'; } from 'src/dashboard/util/componentTypes';
import { TreeItem } from './types'; import { BuildTreeLeafTitle, TreeItem } from './types';
import { buildTree } from './utils'; import { buildTree } from './utils';
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export function useFilterScopeTree( export function useFilterScopeTree(
currentChartId?: number, currentChartId?: number,
initiallyExcludedCharts: number[] = [],
buildTreeLeafTitle: BuildTreeLeafTitle = label => label,
): { ): {
treeData: [TreeItem]; treeData: [TreeItem];
layout: Layout; layout: Layout;
@ -61,8 +63,16 @@ export function useFilterScopeTree(
); );
useMemo(() => { useMemo(() => {
buildTree(layout[DASHBOARD_ROOT_ID], tree, layout, charts, validNodes); buildTree(
}, [charts, layout, tree]); layout[DASHBOARD_ROOT_ID],
tree,
layout,
charts,
validNodes,
initiallyExcludedCharts,
buildTreeLeafTitle,
);
}, [layout, tree, charts, initiallyExcludedCharts, buildTreeLeafTitle]);
return { treeData: [tree], layout }; return { treeData: [tree], layout };
} }

View File

@ -17,7 +17,9 @@
* under the License. * under the License.
*/ */
export enum Scoping { import { ReactNode } from 'react';
export enum ScopingType {
all, all,
specific, specific,
} }
@ -26,5 +28,11 @@ export enum Scoping {
export type TreeItem = { export type TreeItem = {
children: TreeItem[]; children: TreeItem[];
key: string; key: string;
title: string; title: ReactNode;
}; };
export type BuildTreeLeafTitle = (
label: string,
hasTooltip: boolean,
tooltipTitle?: string,
) => ReactNode;

View File

@ -23,7 +23,8 @@ import {
TAB_TYPE, TAB_TYPE,
} from 'src/dashboard/util/componentTypes'; } from 'src/dashboard/util/componentTypes';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { TreeItem } from './types'; import { t } from '@superset-ui/core';
import { BuildTreeLeafTitle, TreeItem } from './types';
import { Scope } from '../../../types'; import { Scope } from '../../../types';
export const isShowTypeInTree = ({ type, meta }: LayoutItem, charts?: Charts) => export const isShowTypeInTree = ({ type, meta }: LayoutItem, charts?: Charts) =>
@ -36,6 +37,8 @@ export const buildTree = (
layout: Layout, layout: Layout,
charts: Charts, charts: Charts,
validNodes: string[], validNodes: string[],
initiallyExcludedCharts: number[],
buildTreeLeafTitle: BuildTreeLeafTitle,
) => { ) => {
let itemToPass: TreeItem = treeItem; let itemToPass: TreeItem = treeItem;
if ( if (
@ -43,20 +46,35 @@ export const buildTree = (
node.type !== DASHBOARD_ROOT_TYPE && node.type !== DASHBOARD_ROOT_TYPE &&
validNodes.includes(node.id) validNodes.includes(node.id)
) { ) {
const currentTreeItem = { const title = buildTreeLeafTitle(
key: node.id, node.meta.sliceNameOverride ||
title:
node.meta.sliceNameOverride ||
node.meta.sliceName || node.meta.sliceName ||
node.meta.text || node.meta.text ||
node.id.toString(), node.id.toString(),
initiallyExcludedCharts.includes(node.meta?.chartId),
t(
"This chart might be incompatible with the filter (datasets don't match)",
),
);
const currentTreeItem = {
key: node.id,
title,
children: [], children: [],
}; };
treeItem.children.push(currentTreeItem); treeItem.children.push(currentTreeItem);
itemToPass = currentTreeItem; itemToPass = currentTreeItem;
} }
node.children.forEach(child => node.children.forEach(child =>
buildTree(layout[child], itemToPass, layout, charts, validNodes), buildTree(
layout[child],
itemToPass,
layout,
charts,
validNodes,
initiallyExcludedCharts,
buildTreeLeafTitle,
),
); );
}; };
@ -148,9 +166,14 @@ export const findFilterScope = (
}; };
}; };
export const getDefaultScopeValue = (chartId?: number): Scope => ({ export const getDefaultScopeValue = (
chartId?: number,
initiallyExcludedCharts: number[] = [],
): Scope => ({
rootPath: [DASHBOARD_ROOT_ID], rootPath: [DASHBOARD_ROOT_ID],
excluded: chartId ? [chartId] : [], excluded: chartId
? [chartId, ...initiallyExcludedCharts]
: initiallyExcludedCharts,
}); });
export const isScopingAll = (scope: Scope, chartId?: number) => export const isScopingAll = (scope: Scope, chartId?: number) =>

View File

@ -64,6 +64,12 @@ import Tabs from 'src/components/Tabs';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert'; import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert';
import {
Chart,
ChartsState,
DatasourcesState,
RootState,
} from 'src/dashboard/types';
import { ColumnSelect } from './ColumnSelect'; import { ColumnSelect } from './ColumnSelect';
import { NativeFiltersForm } from '../types'; import { NativeFiltersForm } from '../types';
import { import {
@ -324,9 +330,10 @@ const FiltersConfigForm = (
) )
.map(([key]) => key); .map(([key]) => key);
const loadedDatasets = useSelector<any, DatasourceMeta>( const loadedDatasets = useSelector<RootState, DatasourcesState>(
({ datasources }) => datasources, ({ datasources }) => datasources,
); );
const charts = useSelector<RootState, ChartsState>(({ charts }) => charts);
const doLoadedDatasetsHaveTemporalColumns = useMemo( const doLoadedDatasetsHaveTemporalColumns = useMemo(
() => () =>
@ -349,9 +356,9 @@ const FiltersConfigForm = (
?.datasourceCount; ?.datasourceCount;
const hasColumn = const hasColumn =
hasDataset && !FILTERS_WITHOUT_COLUMN.includes(formFilter?.filterType); hasDataset && !FILTERS_WITHOUT_COLUMN.includes(formFilter?.filterType);
const nativeFilterItem = nativeFilterItems[formFilter?.filterType] ?? {};
// @ts-ignore // @ts-ignore
const enableNoResults = !!nativeFilterItems[formFilter?.filterType]?.value const enableNoResults = !!nativeFilterItem.value?.enableNoResults;
?.enableNoResults;
const datasetId = formFilter?.dataset?.value; const datasetId = formFilter?.dataset?.value;
useEffect(() => { useEffect(() => {
@ -517,6 +524,11 @@ const FiltersConfigForm = (
[], [],
); );
const updateFormValues = useCallback(
(values: any) => setNativeFilterFieldValues(form, filterId, values),
[filterId, form],
);
const parentFilterOptions = parentFilters.map(filter => ({ const parentFilterOptions = parentFilters.map(filter => ({
value: filter.id, value: filter.id,
label: filter.title, label: filter.title,
@ -598,6 +610,28 @@ const FiltersConfigForm = (
setActiveFilterPanelKey(activeFilterPanelKey); setActiveFilterPanelKey(activeFilterPanelKey);
}, [hasCheckedAdvancedControl]); }, [hasCheckedAdvancedControl]);
const initiallyExcludedCharts = useMemo(() => {
const excluded: number[] = [];
if (formFilter?.dataset?.value === undefined) {
return [];
}
Object.values(charts).forEach((chart: Chart) => {
const chartDatasetUid = chart.formData?.datasource;
if (chartDatasetUid === undefined) {
return;
}
if (loadedDatasets[chartDatasetUid]?.id !== formFilter?.dataset?.value) {
excluded.push(chart.id);
}
});
return excluded;
}, [
JSON.stringify(charts),
formFilter?.dataset?.value,
JSON.stringify(loadedDatasets),
]);
if (removed) { if (removed) {
return <RemovedFilter onClick={() => restoreFilter(filterId)} />; return <RemovedFilter onClick={() => restoreFilter(filterId)} />;
} }
@ -1053,14 +1087,13 @@ const FiltersConfigForm = (
forceRender forceRender
> >
<FilterScope <FilterScope
updateFormValues={(values: any) => updateFormValues={updateFormValues}
setNativeFilterFieldValues(form, filterId, values)
}
pathToFormValue={['filters', filterId]} pathToFormValue={['filters', filterId]}
forceUpdate={forceUpdate} forceUpdate={forceUpdate}
scope={filterToEdit?.scope} filterScope={filterToEdit?.scope}
formScope={formFilter?.scope} formFilterScope={formFilter?.scope}
formScoping={formFilter?.scoping} formScopingType={formFilter?.scoping}
initiallyExcludedCharts={initiallyExcludedCharts}
/> />
</TabPane> </TabPane>
</StyledTabs> </StyledTabs>

View File

@ -16,7 +16,13 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ChartProps, ExtraFormData, JsonObject } from '@superset-ui/core'; import {
ChartProps,
ExtraFormData,
GenericDataType,
JsonObject,
} from '@superset-ui/core';
import { DatasourceMeta } from '@superset-ui/chart-controls';
import { chart } from 'src/chart/chartReducer'; import { chart } from 'src/chart/chartReducer';
import componentTypes from 'src/dashboard/util/componentTypes'; import componentTypes from 'src/dashboard/util/componentTypes';
import { DataMaskStateWithId } from '../dataMask/types'; import { DataMaskStateWithId } from '../dataMask/types';
@ -38,6 +44,7 @@ export interface ChartQueryPayload extends Partial<ChartReducerInitialState> {
export type Chart = ChartState & { export type Chart = ChartState & {
formData: { formData: {
viz_type: string; viz_type: string;
datasource: string;
}; };
}; };
@ -60,10 +67,16 @@ export type DashboardInfo = {
}; };
export type ChartsState = { [key: string]: Chart }; export type ChartsState = { [key: string]: Chart };
export type DatasourcesState = {
[key: string]: DatasourceMeta & {
column_types: GenericDataType[];
table_name: string;
};
};
/** Root state of redux */ /** Root state of redux */
export type RootState = { export type RootState = {
datasources: JsonObject; datasources: DatasourcesState;
sliceEntities: JsonObject; sliceEntities: JsonObject;
charts: ChartsState; charts: ChartsState;
dashboardLayout: DashboardLayoutState; dashboardLayout: DashboardLayoutState;