fix(Filters): Apply native & cross filters on common columns (#30438)
This commit is contained in:
parent
4cac7feb67
commit
362948324c
|
|
@ -3053,6 +3053,12 @@
|
|||
},
|
||||
"type": "array"
|
||||
},
|
||||
"column_names": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"currency_formats": {
|
||||
"type": "object"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export interface Dataset {
|
|||
owners?: Owner[];
|
||||
filter_select?: boolean;
|
||||
filter_select_enabled?: boolean;
|
||||
column_names?: string[];
|
||||
}
|
||||
|
||||
export interface ControlPanelState {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,24 @@ export type Filter = {
|
|||
description: string;
|
||||
};
|
||||
|
||||
export type AppliedFilter = {
|
||||
values: {
|
||||
filters: Record<string, any>[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AppliedCrossFilterType = {
|
||||
filterType: undefined;
|
||||
targets: number[];
|
||||
scope: number[];
|
||||
} & AppliedFilter;
|
||||
|
||||
export type AppliedNativeFilterType = {
|
||||
filterType: 'filter_select';
|
||||
scope: number[];
|
||||
targets: Partial<NativeFilterTarget>[];
|
||||
} & AppliedFilter;
|
||||
|
||||
export type FilterWithDataMask = Filter & { dataMask: DataMaskWithId };
|
||||
|
||||
export type Divider = Partial<Omit<Filter, 'id' | 'type'>> & {
|
||||
|
|
@ -89,6 +107,24 @@ export type Divider = Partial<Omit<Filter, 'id' | 'type'>> & {
|
|||
type: typeof NativeFilterType.Divider;
|
||||
};
|
||||
|
||||
export function isAppliedCrossFilterType(
|
||||
filterElement: AppliedCrossFilterType | AppliedNativeFilterType | Filter,
|
||||
): filterElement is AppliedCrossFilterType {
|
||||
return (
|
||||
filterElement.filterType === undefined &&
|
||||
filterElement.hasOwnProperty('values')
|
||||
);
|
||||
}
|
||||
|
||||
export function isAppliedNativeFilterType(
|
||||
filterElement: AppliedCrossFilterType | AppliedNativeFilterType | Filter,
|
||||
): filterElement is AppliedNativeFilterType {
|
||||
return (
|
||||
filterElement.filterType === 'filter_select' &&
|
||||
filterElement.hasOwnProperty('values')
|
||||
);
|
||||
}
|
||||
|
||||
export function isNativeFilter(
|
||||
filterElement: Filter | Divider,
|
||||
): filterElement is Filter {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ import {
|
|||
FilterWithDataMask,
|
||||
Divider,
|
||||
isNativeFilterWithDataMask,
|
||||
isAppliedCrossFilterType,
|
||||
isAppliedNativeFilterType,
|
||||
AppliedCrossFilterType,
|
||||
AppliedNativeFilterType,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
const filter: Filter = {
|
||||
|
|
@ -51,6 +55,20 @@ const filterDivider: Divider = {
|
|||
description: 'Divider description.',
|
||||
};
|
||||
|
||||
const appliedCrossFilter: AppliedCrossFilterType = {
|
||||
filterType: undefined,
|
||||
targets: [1, 2],
|
||||
scope: [1, 2],
|
||||
values: null,
|
||||
};
|
||||
|
||||
const appliedNativeFilter: AppliedNativeFilterType = {
|
||||
filterType: 'filter_select',
|
||||
scope: [1, 2],
|
||||
targets: [{}],
|
||||
values: null,
|
||||
};
|
||||
|
||||
test('filter type guard', () => {
|
||||
expect(isNativeFilter(filter)).toBeTruthy();
|
||||
expect(isNativeFilter(filterWithDataMask)).toBeTruthy();
|
||||
|
|
@ -68,3 +86,13 @@ test('filter divider type guard', () => {
|
|||
expect(isFilterDivider(filterWithDataMask)).toBeFalsy();
|
||||
expect(isFilterDivider(filterDivider)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('applied cross filter type guard', () => {
|
||||
expect(isAppliedCrossFilterType(appliedCrossFilter)).toBeTruthy();
|
||||
expect(isAppliedCrossFilterType(appliedNativeFilter)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('applied native filter type guard', () => {
|
||||
expect(isAppliedNativeFilterType(appliedNativeFilter)).toBeTruthy();
|
||||
expect(isAppliedNativeFilterType(appliedCrossFilter)).toBeFalsy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default {
|
|||
{
|
||||
id: 'DefaultsID',
|
||||
filterType: 'filter_select',
|
||||
chartsInScope: [],
|
||||
targets: [{}],
|
||||
cascadeParentIds: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export const singleNativeFiltersState = {
|
|||
id: [NATIVE_FILTER_ID],
|
||||
name: 'eth',
|
||||
type: 'text',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 13, column: { name: 'ethnic_minority' } }],
|
||||
defaultDataMask: {
|
||||
filterState: {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ import {
|
|||
dashboardInfoChanged,
|
||||
SAVE_CHART_CONFIG_COMPLETE,
|
||||
} from './dashboardInfo';
|
||||
import { fetchDatasourceMetadata } from './datasources';
|
||||
import { fetchDatasourceMetadata, setDatasources } from './datasources';
|
||||
import { updateDirectPathToFilter } from './dashboardFilters';
|
||||
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
|
||||
import getOverwriteItems from '../util/getOverwriteItems';
|
||||
|
|
@ -341,6 +341,17 @@ export function saveDashboardRequest(data, id, saveType) {
|
|||
filterConfig: metadata.native_filter_configuration,
|
||||
});
|
||||
}
|
||||
|
||||
// fetch datasets to make sure they are up to date
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${id}/datasets`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(({ json }) => {
|
||||
const datasources = json?.result ?? [];
|
||||
if (datasources.length) {
|
||||
dispatch(setDatasources(datasources));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (lastModifiedTime) {
|
||||
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { areObjectsEqual } from '../../reduxUtils';
|
|||
import getLocationHash from '../util/getLocationHash';
|
||||
import isDashboardEmpty from '../util/isDashboardEmpty';
|
||||
import { getAffectedOwnDataCharts } from '../util/charts/getOwnDataCharts';
|
||||
import { getRelatedCharts } from '../util/getRelatedCharts';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
|
|
@ -211,9 +212,10 @@ class Dashboard extends PureComponent {
|
|||
|
||||
applyFilters() {
|
||||
const { appliedFilters } = this;
|
||||
const { activeFilters, ownDataCharts } = this.props;
|
||||
const { activeFilters, ownDataCharts, datasources, slices } = this.props;
|
||||
|
||||
// refresh charts if a filter was removed, added, or changed
|
||||
|
||||
const currFilterKeys = Object.keys(activeFilters);
|
||||
const appliedFilterKeys = Object.keys(appliedFilters);
|
||||
|
||||
|
|
@ -228,10 +230,24 @@ class Dashboard extends PureComponent {
|
|||
appliedFilterKeys.includes(filterKey)
|
||||
) {
|
||||
// filterKey is removed?
|
||||
affectedChartIds.push(...appliedFilters[filterKey].scope);
|
||||
affectedChartIds.push(
|
||||
...getRelatedCharts(
|
||||
appliedFilters,
|
||||
activeFilters,
|
||||
slices,
|
||||
datasources,
|
||||
)[filterKey],
|
||||
);
|
||||
} else if (!appliedFilterKeys.includes(filterKey)) {
|
||||
// filterKey is newly added?
|
||||
affectedChartIds.push(...activeFilters[filterKey].scope);
|
||||
affectedChartIds.push(
|
||||
...getRelatedCharts(
|
||||
activeFilters,
|
||||
appliedFilters,
|
||||
slices,
|
||||
datasources,
|
||||
)[filterKey],
|
||||
);
|
||||
} else {
|
||||
// if filterKey changes value,
|
||||
// update charts in its scope
|
||||
|
|
@ -244,7 +260,14 @@ class Dashboard extends PureComponent {
|
|||
},
|
||||
)
|
||||
) {
|
||||
affectedChartIds.push(...activeFilters[filterKey].scope);
|
||||
affectedChartIds.push(
|
||||
...getRelatedCharts(
|
||||
activeFilters,
|
||||
appliedFilters,
|
||||
slices,
|
||||
datasources,
|
||||
)[filterKey],
|
||||
);
|
||||
}
|
||||
|
||||
// if filterKey changes scope,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout';
|
|||
import dashboardState from 'spec/fixtures/mockDashboardState';
|
||||
import { sliceEntitiesForChart as sliceEntities } from 'spec/fixtures/mockSliceEntities';
|
||||
import { getAllActiveFilters } from 'src/dashboard/util/activeAllDashboardFilters';
|
||||
import { getRelatedCharts } from 'src/dashboard/util/getRelatedCharts';
|
||||
|
||||
jest.mock('src/dashboard/util/getRelatedCharts');
|
||||
|
||||
describe('Dashboard', () => {
|
||||
const props = {
|
||||
|
|
@ -130,6 +133,7 @@ describe('Dashboard', () => {
|
|||
|
||||
afterEach(() => {
|
||||
refreshSpy.restore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not call refresh when is editMode', () => {
|
||||
|
|
@ -153,6 +157,9 @@ describe('Dashboard', () => {
|
|||
});
|
||||
|
||||
it('should call refresh when native filters changed', () => {
|
||||
getRelatedCharts.mockReturnValue({
|
||||
[NATIVE_FILTER_ID]: [230],
|
||||
});
|
||||
wrapper.setProps({
|
||||
activeFilters: {
|
||||
...OVERRIDE_FILTERS,
|
||||
|
|
@ -170,11 +177,27 @@ describe('Dashboard', () => {
|
|||
[NATIVE_FILTER_ID]: {
|
||||
scope: [230],
|
||||
values: extraFormData,
|
||||
filterType: 'filter_select',
|
||||
targets: [
|
||||
{
|
||||
datasetId: 13,
|
||||
column: {
|
||||
name: 'ethnic_minority',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call refresh if a filter is added', () => {
|
||||
getRelatedCharts.mockReturnValue({
|
||||
'1_region': [1],
|
||||
'2_country_name': [1, 2],
|
||||
'3_region': [1],
|
||||
'3_country_name': [],
|
||||
gender: [1],
|
||||
});
|
||||
const newFilter = {
|
||||
gender: { values: ['boy', 'girl'], scope: [1] },
|
||||
};
|
||||
|
|
@ -186,6 +209,12 @@ describe('Dashboard', () => {
|
|||
});
|
||||
|
||||
it('should call refresh if a filter is removed', () => {
|
||||
getRelatedCharts.mockReturnValue({
|
||||
'1_region': [1],
|
||||
'2_country_name': [1, 2],
|
||||
'3_region': [1],
|
||||
'3_country_name': [],
|
||||
});
|
||||
wrapper.setProps({
|
||||
activeFilters: {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@ test('renders diff viewer when it contains overwriteConfirmMetadata', async () =
|
|||
|
||||
test('requests update dashboard api when save button is clicked', async () => {
|
||||
const updateDashboardEndpoint = `glob:*/api/v1/dashboard/${overwriteConfirmMetadata.dashboardId}`;
|
||||
const fetchDatasetsEndpoint = `glob:*/api/v1/dashboard/${overwriteConfirmMetadata.dashboardId}/datasets`;
|
||||
|
||||
// mock fetch datasets
|
||||
fetchMock.get(fetchDatasetsEndpoint, []);
|
||||
|
||||
fetchMock.put(updateDashboardEndpoint, {
|
||||
id: overwriteConfirmMetadata.dashboardId,
|
||||
last_modified_time: +new Date(),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export const nativeFiltersInfo: NativeFiltersState = {
|
|||
id: 'DefaultsID',
|
||||
name: 'test',
|
||||
filterType: 'filter_select',
|
||||
chartsInScope: [],
|
||||
targets: [
|
||||
{
|
||||
datasetId: 0,
|
||||
|
|
|
|||
|
|
@ -54,9 +54,13 @@ export const getAllActiveFilters = ({
|
|||
chartConfiguration?.[filterId]?.crossFilters?.chartsInScope ??
|
||||
allSliceIds ??
|
||||
[];
|
||||
const filterType = nativeFilters?.[filterId]?.filterType;
|
||||
const targets = nativeFilters?.[filterId]?.targets ?? scope;
|
||||
// Iterate over all roots to find all affected charts
|
||||
activeFilters[filterId] = {
|
||||
scope,
|
||||
filterType,
|
||||
targets,
|
||||
values: extraFormData,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -294,14 +294,14 @@ test('Recalculate charts in global filter scope when charts change', () => {
|
|||
id: 2,
|
||||
crossFilters: {
|
||||
scope: 'global',
|
||||
chartsInScope: [1],
|
||||
chartsInScope: [1, 3],
|
||||
},
|
||||
},
|
||||
'3': {
|
||||
id: 3,
|
||||
crossFilters: {
|
||||
scope: 'global',
|
||||
chartsInScope: [],
|
||||
chartsInScope: [1, 2],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -52,20 +52,6 @@ export const getCrossFiltersConfiguration = (
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const chartsByDataSource: Record<string, Set<number>> = Object.values(
|
||||
charts,
|
||||
).reduce((acc: Record<string, Set<number>>, chart) => {
|
||||
if (!chart.form_data) {
|
||||
return acc;
|
||||
}
|
||||
const { datasource } = chart.form_data;
|
||||
if (!acc[datasource]) {
|
||||
acc[datasource] = new Set();
|
||||
}
|
||||
acc[datasource].add(chart.id);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const globalChartConfiguration = metadata.global_chart_configuration?.scope
|
||||
? {
|
||||
scope: metadata.global_chart_configuration.scope,
|
||||
|
|
@ -111,13 +97,10 @@ export const getCrossFiltersConfiguration = (
|
|||
},
|
||||
};
|
||||
}
|
||||
const chartDataSource = charts[chartId].form_data.datasource;
|
||||
chartConfiguration[chartId].crossFilters.chartsInScope =
|
||||
isCrossFilterScopeGlobal(chartConfiguration[chartId].crossFilters.scope)
|
||||
? globalChartConfiguration.chartsInScope.filter(
|
||||
id =>
|
||||
id !== Number(chartId) &&
|
||||
chartsByDataSource[chartDataSource]?.has(id),
|
||||
id => id !== Number(chartId),
|
||||
)
|
||||
: getChartIdsInFilterScope(
|
||||
chartConfiguration[chartId].crossFilters.scope,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* 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 {
|
||||
AppliedCrossFilterType,
|
||||
DatasourceType,
|
||||
Filter,
|
||||
NativeFilterType,
|
||||
} from '@superset-ui/core';
|
||||
import { DatasourcesState } from '../types';
|
||||
import { getRelatedCharts } from './getRelatedCharts';
|
||||
|
||||
const slices = {
|
||||
'1': { datasource: 'ds1', slice_id: 1 },
|
||||
'2': { datasource: 'ds2', slice_id: 2 },
|
||||
'3': { datasource: 'ds1', slice_id: 3 },
|
||||
} as any;
|
||||
|
||||
const datasources: DatasourcesState = {
|
||||
ds1: {
|
||||
uid: 'ds1',
|
||||
datasource_name: 'ds1',
|
||||
table_name: 'table1',
|
||||
description: '',
|
||||
id: 100,
|
||||
columns: [{ column_name: 'column1' }, { column_name: 'column2' }],
|
||||
column_names: ['column1', 'column2'],
|
||||
column_types: [],
|
||||
type: DatasourceType.Table,
|
||||
metrics: [],
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
verbose_map: {},
|
||||
main_dttm_col: '',
|
||||
filter_select_enabled: true,
|
||||
},
|
||||
ds2: {
|
||||
uid: 'ds2',
|
||||
datasource_name: 'ds2',
|
||||
table_name: 'table2',
|
||||
description: '',
|
||||
id: 200,
|
||||
columns: [{ column_name: 'column3' }, { column_name: 'column4' }],
|
||||
column_names: ['column3', 'column4'],
|
||||
column_types: [],
|
||||
type: DatasourceType.Table,
|
||||
metrics: [],
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
verbose_map: {},
|
||||
main_dttm_col: '',
|
||||
filter_select_enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
test('Return chart ids matching the dataset id with native filter', () => {
|
||||
const filters = {
|
||||
filterKey1: {
|
||||
filterType: 'filter_select',
|
||||
chartsInScope: [1, 2],
|
||||
scope: {
|
||||
excluded: [],
|
||||
rootPath: [],
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
column: { name: 'column1' },
|
||||
datasetId: 100,
|
||||
},
|
||||
],
|
||||
type: NativeFilterType.NativeFilter,
|
||||
} as unknown as Filter,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
filterKey1: [1],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return chart ids matching the dataset id with cross filter', () => {
|
||||
const filters = {
|
||||
'3': {
|
||||
filterType: undefined,
|
||||
scope: [1, 2],
|
||||
targets: [],
|
||||
values: null,
|
||||
} as AppliedCrossFilterType,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
'3': [1],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return chart ids matching the column name with native filter', () => {
|
||||
const filters = {
|
||||
filterKey1: {
|
||||
filterType: 'filter_select',
|
||||
chartsInScope: [1, 2],
|
||||
scope: {
|
||||
excluded: [],
|
||||
rootPath: [],
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
column: { name: 'column3' },
|
||||
datasetId: 999,
|
||||
},
|
||||
],
|
||||
type: NativeFilterType.NativeFilter,
|
||||
} as unknown as Filter,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
filterKey1: [2],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return chart ids matching the column name with cross filter', () => {
|
||||
const filters = {
|
||||
'1': {
|
||||
filterType: undefined,
|
||||
scope: [1, 2],
|
||||
targets: [],
|
||||
values: {
|
||||
filters: [{ col: 'column3' }],
|
||||
},
|
||||
} as AppliedCrossFilterType,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
'1': [2],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return chart ids when column display name matches with native filter', () => {
|
||||
const filters = {
|
||||
filterKey1: {
|
||||
filterType: 'filter_select',
|
||||
chartsInScope: [1, 2],
|
||||
scope: {
|
||||
excluded: [],
|
||||
rootPath: [],
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
column: { name: 'column4', displayName: 'column4' },
|
||||
datasetId: 999,
|
||||
},
|
||||
],
|
||||
type: NativeFilterType.NativeFilter,
|
||||
} as unknown as Filter,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
filterKey1: [2],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return chart ids when column display name matches with cross filter', () => {
|
||||
const filters = {
|
||||
'1': {
|
||||
filterType: undefined,
|
||||
scope: [1, 2],
|
||||
targets: [],
|
||||
values: {
|
||||
filters: [{ col: 'column4' }],
|
||||
},
|
||||
} as AppliedCrossFilterType,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
'1': [2],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return scope when filterType is not filter_select', () => {
|
||||
const filters = {
|
||||
filterKey1: {
|
||||
filterType: 'filter_time',
|
||||
chartsInScope: [3, 4],
|
||||
} as Filter,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
filterKey1: [3, 4],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return an empty array if no matching charts found with native filter', () => {
|
||||
const filters = {
|
||||
filterKey1: {
|
||||
filterType: 'filter_select',
|
||||
chartsInScope: [1, 2],
|
||||
scope: {
|
||||
excluded: [],
|
||||
rootPath: [],
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
column: { name: 'nonexistent_column' },
|
||||
datasetId: 300,
|
||||
},
|
||||
],
|
||||
type: NativeFilterType.NativeFilter,
|
||||
} as unknown as Filter,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
filterKey1: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('Return an empty array if no matching charts found with cross filter', () => {
|
||||
const filters = {
|
||||
'1': {
|
||||
filterType: undefined,
|
||||
scope: [1, 2],
|
||||
targets: [],
|
||||
values: {
|
||||
filters: [{ col: 'nonexistent_column' }],
|
||||
},
|
||||
} as AppliedCrossFilterType,
|
||||
};
|
||||
|
||||
const result = getRelatedCharts(filters, null, slices, datasources);
|
||||
expect(result).toEqual({
|
||||
'1': [],
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* 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 {
|
||||
AppliedCrossFilterType,
|
||||
AppliedNativeFilterType,
|
||||
ensureIsArray,
|
||||
Filter,
|
||||
isAppliedCrossFilterType,
|
||||
isAppliedNativeFilterType,
|
||||
isNativeFilter,
|
||||
} from '@superset-ui/core';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
import { DatasourcesState } from '../types';
|
||||
|
||||
function checkForExpression(formData?: Record<string, any>) {
|
||||
const groupby = ensureIsArray(formData?.groupby) ?? [];
|
||||
const allColumns = ensureIsArray(formData?.all_columns) ?? [];
|
||||
const checkColumns = groupby.concat(allColumns);
|
||||
return checkColumns.some(
|
||||
(col: string | Record<string, any>) =>
|
||||
typeof col !== 'string' && col.expressionType !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function getRelatedChartsForSelectFilter(
|
||||
nativeFilter: AppliedNativeFilterType | Filter,
|
||||
slices: Record<string, Slice>,
|
||||
chartsInScope: number[],
|
||||
datasources: DatasourcesState,
|
||||
) {
|
||||
return Object.values(slices)
|
||||
.filter(slice => {
|
||||
const { datasource, slice_id } = slice;
|
||||
// not in scope, ignore
|
||||
if (!chartsInScope.includes(slice_id)) return false;
|
||||
|
||||
const chartDatasource = datasource
|
||||
? datasources[datasource]
|
||||
: Object.values(datasources).find(ds => ds.id === slice.datasource_id);
|
||||
|
||||
const { column, datasetId } = nativeFilter.targets?.[0] ?? {};
|
||||
const datasourceColumnNames = chartDatasource?.column_names ?? [];
|
||||
|
||||
// same datasource, always apply
|
||||
if (chartDatasource?.id === datasetId) return true;
|
||||
|
||||
// let backend deal with adhoc columns and jinja
|
||||
const hasSqlExpression = checkForExpression(slice.form_data);
|
||||
if (hasSqlExpression) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return datasourceColumnNames.some(
|
||||
col => col === column?.name || col === column?.displayName,
|
||||
);
|
||||
})
|
||||
.map(slice => slice.slice_id);
|
||||
}
|
||||
function getRelatedChartsForCrossFilter(
|
||||
filterKey: string,
|
||||
crossFilter: AppliedCrossFilterType,
|
||||
slices: Record<string, Slice>,
|
||||
scope: number[],
|
||||
datasources: DatasourcesState,
|
||||
): number[] {
|
||||
const sourceSlice = slices[filterKey];
|
||||
const filters = crossFilter?.values?.filters ?? [];
|
||||
|
||||
if (!sourceSlice) return [];
|
||||
|
||||
const sourceDatasource = sourceSlice.datasource
|
||||
? datasources[sourceSlice.datasource]
|
||||
: Object.values(datasources).find(
|
||||
ds => ds.id === sourceSlice.datasource_id,
|
||||
);
|
||||
|
||||
return Object.values(slices)
|
||||
.filter(slice => {
|
||||
// cross-filter emitter
|
||||
if (slice.slice_id === Number(filterKey)) return false;
|
||||
// not in scope, ignore
|
||||
if (!scope.includes(slice.slice_id)) return false;
|
||||
|
||||
const targetDatasource = slice.datasource
|
||||
? datasources[slice.datasource]
|
||||
: Object.values(datasources).find(ds => ds.id === slice.datasource_id);
|
||||
|
||||
// same datasource, always apply
|
||||
if (targetDatasource === sourceDatasource) return true;
|
||||
|
||||
const targetColumnNames = targetDatasource?.column_names ?? [];
|
||||
|
||||
// let backend deal with adhoc columns and jinja
|
||||
const hasSqlExpression = checkForExpression(slice.form_data);
|
||||
if (hasSqlExpression) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const filter of filters) {
|
||||
// let backend deal with adhoc columns
|
||||
if (
|
||||
typeof filter.col !== 'string' &&
|
||||
filter.col.expressionType !== undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// shared column names, different datasources, apply filter
|
||||
if (targetColumnNames.includes(filter.col)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map(slice => slice.slice_id);
|
||||
}
|
||||
|
||||
export function getRelatedCharts(
|
||||
filters: Record<
|
||||
string,
|
||||
AppliedNativeFilterType | AppliedCrossFilterType | Filter
|
||||
>,
|
||||
checkFilters: Record<
|
||||
string,
|
||||
AppliedNativeFilterType | AppliedCrossFilterType | Filter
|
||||
> | null,
|
||||
slices: Record<string, Slice>,
|
||||
datasources: DatasourcesState,
|
||||
) {
|
||||
const related = Object.entries(filters).reduce((acc, [filterKey, filter]) => {
|
||||
const isCrossFilter =
|
||||
Object.keys(slices).includes(filterKey) &&
|
||||
isAppliedCrossFilterType(filter);
|
||||
|
||||
const chartsInScope = Array.isArray(filter.scope)
|
||||
? filter.scope
|
||||
: ((filter as Filter).chartsInScope ?? []);
|
||||
|
||||
if (isCrossFilter) {
|
||||
const checkFilter = checkFilters?.[filterKey] as AppliedCrossFilterType;
|
||||
const crossFilter = filter as AppliedCrossFilterType;
|
||||
const wasRemoved = !!(
|
||||
((filter.values && filter.values.filters === undefined) ||
|
||||
filter.values?.filters?.length === 0) &&
|
||||
checkFilter?.values?.filters?.length
|
||||
);
|
||||
const actualCrossFilter = wasRemoved ? checkFilter : crossFilter;
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[filterKey]: getRelatedChartsForCrossFilter(
|
||||
filterKey,
|
||||
actualCrossFilter,
|
||||
slices,
|
||||
chartsInScope,
|
||||
datasources,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const nativeFilter = filter as AppliedNativeFilterType | Filter;
|
||||
// on highlight, a standard native filter is passed
|
||||
// on apply, an applied native filter is passed
|
||||
if (
|
||||
isAppliedNativeFilterType(nativeFilter) ||
|
||||
isNativeFilter(nativeFilter)
|
||||
) {
|
||||
return {
|
||||
...acc,
|
||||
[filterKey]: getRelatedChartsForSelectFilter(
|
||||
nativeFilter,
|
||||
slices,
|
||||
chartsInScope,
|
||||
datasources,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[filterKey]: chartsInScope,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return related;
|
||||
}
|
||||
|
|
@ -24,6 +24,9 @@ import reducerIndex from 'spec/helpers/reducerIndex';
|
|||
import { screen, render } from 'spec/helpers/testing-library';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
import useFilterFocusHighlightStyles from './useFilterFocusHighlightStyles';
|
||||
import { getRelatedCharts } from './getRelatedCharts';
|
||||
|
||||
jest.mock('./getRelatedCharts');
|
||||
|
||||
const TestComponent = ({ chartId }: { chartId: number }) => {
|
||||
const styles = useFilterFocusHighlightStyles(chartId);
|
||||
|
|
@ -38,6 +41,7 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
{ ...mockState, ...(initialState as any), ...customState },
|
||||
compose(applyMiddleware(thunk)),
|
||||
);
|
||||
const mockGetRelatedCharts = getRelatedCharts as jest.Mock;
|
||||
|
||||
const renderWrapper = (chartId: number, store = createMockStore()) =>
|
||||
render(<TestComponent chartId={chartId} />, {
|
||||
|
|
@ -57,6 +61,9 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
});
|
||||
|
||||
it('should return unfocused styles if chart is not in scope of focused native filter', async () => {
|
||||
mockGetRelatedCharts.mockReturnValue({
|
||||
'test-filter': [],
|
||||
});
|
||||
const store = createMockStore({
|
||||
nativeFilters: {
|
||||
focusedFilterId: 'test-filter',
|
||||
|
|
@ -76,6 +83,9 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
});
|
||||
|
||||
it('should return unfocused styles if chart is not in scope of hovered native filter', async () => {
|
||||
mockGetRelatedCharts.mockReturnValue({
|
||||
'test-filter': [],
|
||||
});
|
||||
const store = createMockStore({
|
||||
nativeFilters: {
|
||||
hoveredFilterId: 'test-filter',
|
||||
|
|
@ -96,6 +106,9 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
|
||||
it('should return focused styles if chart is in scope of focused native filter', async () => {
|
||||
const chartId = 18;
|
||||
mockGetRelatedCharts.mockReturnValue({
|
||||
testFilter: [chartId],
|
||||
});
|
||||
const store = createMockStore({
|
||||
nativeFilters: {
|
||||
focusedFilterId: 'testFilter',
|
||||
|
|
@ -116,6 +129,9 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
|
||||
it('should return focused styles if chart is in scope of hovered native filter', async () => {
|
||||
const chartId = 18;
|
||||
mockGetRelatedCharts.mockReturnValue({
|
||||
testFilter: [chartId],
|
||||
});
|
||||
const store = createMockStore({
|
||||
nativeFilters: {
|
||||
hoveredFilterId: 'testFilter',
|
||||
|
|
@ -136,6 +152,9 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
|
||||
it('should return unfocused styles if focusedFilterField is targeting a different chart', async () => {
|
||||
const chartId = 18;
|
||||
mockGetRelatedCharts.mockReturnValue({
|
||||
testFilter: [],
|
||||
});
|
||||
const store = createMockStore({
|
||||
dashboardState: {
|
||||
focusedFilterField: {
|
||||
|
|
@ -159,6 +178,9 @@ describe('useFilterFocusHighlightStyles', () => {
|
|||
|
||||
it('should return focused styles if focusedFilterField chart equals our own', async () => {
|
||||
const chartId = 18;
|
||||
mockGetRelatedCharts.mockReturnValue({
|
||||
testFilter: [chartId],
|
||||
});
|
||||
const store = createMockStore({
|
||||
dashboardState: {
|
||||
focusedFilterField: {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useTheme } from '@superset-ui/core';
|
||||
import { Filter, useTheme } from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { DashboardState, RootState } from 'src/dashboard/types';
|
||||
import { getRelatedCharts } from './getRelatedCharts';
|
||||
|
||||
const selectFocusedFilterScope = (
|
||||
dashboardState: DashboardState,
|
||||
|
|
@ -41,6 +42,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
|
|||
const dashboardState = useSelector(
|
||||
(state: RootState) => state.dashboardState,
|
||||
);
|
||||
|
||||
const dashboardFilters = useSelector(
|
||||
(state: RootState) => state.dashboardFilters,
|
||||
);
|
||||
|
|
@ -49,6 +51,18 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
|
|||
dashboardFilters,
|
||||
);
|
||||
|
||||
const datasources =
|
||||
useSelector((state: RootState) => state.datasources) || {};
|
||||
const slices =
|
||||
useSelector((state: RootState) => state.sliceEntities.slices) || {};
|
||||
|
||||
const relatedCharts = getRelatedCharts(
|
||||
nativeFilters.filters as Record<string, Filter>,
|
||||
null,
|
||||
slices,
|
||||
datasources,
|
||||
);
|
||||
|
||||
const highlightedFilterId =
|
||||
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
|
||||
if (!(focusedFilterScope || highlightedFilterId)) {
|
||||
|
|
@ -69,11 +83,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
|
|||
};
|
||||
|
||||
if (highlightedFilterId) {
|
||||
if (
|
||||
nativeFilters.filters[highlightedFilterId]?.chartsInScope?.includes(
|
||||
chartId,
|
||||
)
|
||||
) {
|
||||
if (relatedCharts[highlightedFilterId]?.includes(chartId)) {
|
||||
return focusedChartStyles;
|
||||
}
|
||||
} else if (
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ export type Slice = {
|
|||
query_context?: object;
|
||||
is_managed_externally: boolean;
|
||||
owners?: number[];
|
||||
datasource?: string;
|
||||
datasource_id?: number;
|
||||
};
|
||||
|
||||
export default Chart;
|
||||
|
|
|
|||
|
|
@ -489,6 +489,11 @@ class BaseDatasource(AuditMixinNullable, ImportExportMixin): # pylint: disable=
|
|||
del data["description"]
|
||||
data.update({"metrics": filtered_metrics})
|
||||
data.update({"columns": filtered_columns})
|
||||
|
||||
all_columns = {
|
||||
column_["column_name"]: column_["verbose_name"] or column_["column_name"]
|
||||
for column_ in filtered_columns
|
||||
}
|
||||
verbose_map = {"__timestamp": "Time"}
|
||||
verbose_map.update(
|
||||
{
|
||||
|
|
@ -496,14 +501,9 @@ class BaseDatasource(AuditMixinNullable, ImportExportMixin): # pylint: disable=
|
|||
for metric in filtered_metrics
|
||||
}
|
||||
)
|
||||
verbose_map.update(
|
||||
{
|
||||
column_["column_name"]: column_["verbose_name"]
|
||||
or column_["column_name"]
|
||||
for column_ in filtered_columns
|
||||
}
|
||||
)
|
||||
verbose_map.update(all_columns)
|
||||
data["verbose_map"] = verbose_map
|
||||
data["column_names"] = set(all_columns.values()) | set(self.column_names)
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ class DashboardDatasetSchema(Schema):
|
|||
owners = fields.List(fields.Dict())
|
||||
columns = fields.List(fields.Dict())
|
||||
column_types = fields.List(fields.Int())
|
||||
column_names = fields.List(fields.Str())
|
||||
metrics = fields.List(fields.Dict())
|
||||
order_by_choices = fields.List(fields.List(fields.Str()))
|
||||
verbose_map = fields.Dict(fields.Str(), fields.Str())
|
||||
|
|
|
|||
Loading…
Reference in New Issue