You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2023/02/27 11:55:18 UTC
[superset] branch master updated: feat: Cross Filters in FilterBar (#23138)
This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new dcd3e00f39 feat: Cross Filters in FilterBar (#23138)
dcd3e00f39 is described below
commit dcd3e00f39ca16979c53d7bead865a54d23bc622
Author: Geido <60...@users.noreply.github.com>
AuthorDate: Mon Feb 27 12:55:09 2023 +0100
feat: Cross Filters in FilterBar (#23138)
Co-authored-by: Kamil Gabryjelski <ka...@gmail.com>
---
.../DashboardBuilder/DashboardBuilder.tsx | 6 +-
.../DetailsPanel/DetailsPanel.test.tsx | 2 +-
.../components/FiltersBadge/DetailsPanel/index.tsx | 2 +-
.../FilterIndicator/FilterIndicator.test.tsx | 2 +-
.../FiltersBadge/FilterIndicator/index.tsx | 2 +-
.../dashboard/components/FiltersBadge/index.tsx | 14 +-
.../FilterBar/CrossFilters/CrossFilter.test.tsx | 82 ++++++++++
.../FilterBar/CrossFilters/CrossFilter.tsx | 114 +++++++++++++
.../FilterBar/CrossFilters/CrossFilterTag.test.tsx | 61 +++++++
.../FilterBar/CrossFilters/CrossFilterTag.tsx | 93 +++++++++++
.../CrossFilters/CrossFilterTitle.test.tsx | 52 ++++++
.../FilterBar/CrossFilters/CrossFilterTitle.tsx | 90 +++++++++++
.../FilterBar/CrossFilters/Vertical.tsx | 46 ++++++
.../CrossFilters/VerticalCollapse.test.tsx | 107 ++++++++++++
.../FilterBar/CrossFilters/VerticalCollapse.tsx | 102 ++++++++++++
.../FilterBar/CrossFilters/selectors.ts | 53 ++++++
.../nativeFilters/FilterBar/CrossFilters/styles.ts | 28 ++++
.../FilterBar/FilterControls/FilterControls.tsx | 110 ++++++++++---
.../FilterBar/FiltersDropdownContent/index.tsx | 17 ++
.../FiltersOutOfScopeCollapsible/index.tsx | 10 +-
.../nativeFilters/FilterBar/Horizontal.tsx | 32 +++-
.../nativeFilters/FilterBar/Vertical.tsx | 180 +++++++++++----------
.../{FiltersBadge => nativeFilters}/selectors.ts | 161 +++++++++++-------
23 files changed, 1190 insertions(+), 176 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
index 9d6d3a51eb..99360801c5 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
@@ -445,6 +445,9 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const fullSizeChartId = useSelector<RootState, number | null>(
state => state.dashboardState.fullSizeChartId,
);
+ const crossFiltersEnabled = isFeatureEnabled(
+ FeatureFlag.DASHBOARD_CROSS_FILTERS,
+ );
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
({ dashboardInfo }) =>
isFeatureEnabled(FeatureFlag.HORIZONTAL_FILTER_BAR)
@@ -523,7 +526,8 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const filterSetEnabled = isFeatureEnabled(
FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET,
);
- const showFilterBar = nativeFiltersEnabled && !editMode;
+ const showFilterBar =
+ (crossFiltersEnabled || nativeFiltersEnabled) && !editMode;
const offset =
FILTER_BAR_HEADER_HEIGHT +
diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx
index 8d2b121ff6..3c3e25ee56 100644
--- a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx
+++ b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/DetailsPanel.test.tsx
@@ -19,7 +19,7 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
-import { Indicator } from 'src/dashboard/components/FiltersBadge/selectors';
+import { Indicator } from 'src/dashboard/components/nativeFilters/selectors';
import DetailsPanel from '.';
const createProps = () => ({
diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx
index 3531ab1be9..022dab7968 100644
--- a/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx
+++ b/superset-frontend/src/dashboard/components/FiltersBadge/DetailsPanel/index.tsx
@@ -29,7 +29,7 @@ import {
Reset,
Title,
} from 'src/dashboard/components/FiltersBadge/Styles';
-import { Indicator } from 'src/dashboard/components/FiltersBadge/selectors';
+import { Indicator } from 'src/dashboard/components/nativeFilters/selectors';
import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator';
import { RootState } from 'src/dashboard/types';
diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/FilterIndicator.test.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/FilterIndicator.test.tsx
index fdded804b8..de49516a64 100644
--- a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/FilterIndicator.test.tsx
+++ b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/FilterIndicator.test.tsx
@@ -19,7 +19,7 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
-import { Indicator } from 'src/dashboard/components/FiltersBadge/selectors';
+import { Indicator } from 'src/dashboard/components/nativeFilters/selectors';
import FilterIndicator from '.';
const createProps = () => ({
diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx
index e7675a3ef0..2954474e64 100644
--- a/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx
+++ b/superset-frontend/src/dashboard/components/FiltersBadge/FilterIndicator/index.tsx
@@ -28,7 +28,7 @@ import {
ItemIcon,
Title,
} from 'src/dashboard/components/FiltersBadge/Styles';
-import { Indicator } from 'src/dashboard/components/FiltersBadge/selectors';
+import { Indicator } from 'src/dashboard/components/nativeFilters/selectors';
export interface IndicatorProps {
indicator: Indicator;
diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx
index c7748849a8..fb0718bf54 100644
--- a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx
+++ b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx
@@ -24,12 +24,6 @@ import { DataMaskStateWithId, Filters } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { usePrevious } from 'src/hooks/usePrevious';
import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
-import {
- ChartsState,
- DashboardInfo,
- DashboardLayout,
- RootState,
-} from 'src/dashboard/types';
import DetailsPanelPopover from './DetailsPanel';
import { Pill } from './Styles';
import {
@@ -37,7 +31,13 @@ import {
IndicatorStatus,
selectIndicatorsForChart,
selectNativeIndicatorsForChart,
-} from './selectors';
+} from '../nativeFilters/selectors';
+import {
+ ChartsState,
+ DashboardInfo,
+ DashboardLayout,
+ RootState,
+} from '../../types';
export interface FiltersBadgeProps {
chartId: number;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilter.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilter.test.tsx
new file mode 100644
index 0000000000..85dc3bf705
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilter.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import { IndicatorStatus } from '../../selectors';
+import CrossFilter from './CrossFilter';
+
+const mockedProps = {
+ filter: {
+ name: 'test',
+ emitterId: 1,
+ column: 'country_name',
+ value: 'Italy',
+ status: IndicatorStatus.CrossFilterApplied,
+ path: ['test-path'],
+ },
+ orientation: FilterBarOrientation.HORIZONTAL,
+ last: false,
+};
+
+const setup = (props: typeof mockedProps) =>
+ render(<CrossFilter {...props} />, {
+ useRedux: true,
+ });
+
+test('CrossFilter should render', () => {
+ const { container } = setup(mockedProps);
+ expect(container).toBeInTheDocument();
+});
+
+test('Title should render', () => {
+ setup(mockedProps);
+ expect(screen.getByText('test')).toBeInTheDocument();
+});
+
+test('Search icon should be visible', () => {
+ setup(mockedProps);
+ expect(
+ screen.getByTestId('cross-filters-highlight-emitter'),
+ ).toBeInTheDocument();
+});
+
+test('Column and value should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getByText('country_name')).toBeInTheDocument();
+ expect(screen.getByText('Italy')).toBeInTheDocument();
+});
+
+test('Tag should be closable', () => {
+ setup(mockedProps);
+ expect(screen.getByRole('img', { name: 'close' })).toBeInTheDocument();
+});
+
+test('Divider should not be visible', () => {
+ setup(mockedProps);
+ expect(screen.queryByTestId('cross-filters-divider')).not.toBeInTheDocument();
+});
+
+test('Divider should be visible', () => {
+ setup({
+ ...mockedProps,
+ last: true,
+ });
+ expect(screen.getByTestId('cross-filters-divider')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilter.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilter.tsx
new file mode 100644
index 0000000000..f03e777b25
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilter.tsx
@@ -0,0 +1,114 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useCallback } from 'react';
+import { css, useTheme } from '@superset-ui/core';
+import { CrossFilterIndicator } from 'src/dashboard/components/nativeFilters/selectors';
+import { useDispatch } from 'react-redux';
+import { setFocusedNativeFilter } from 'src/dashboard/actions/nativeFilters';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import { updateDataMask } from 'src/dataMask/actions';
+import CrossFilterTag from './CrossFilterTag';
+import CrossFilterTitle from './CrossFilterTitle';
+
+const CrossFilter = (props: {
+ filter: CrossFilterIndicator;
+ orientation: FilterBarOrientation;
+ last?: boolean;
+}) => {
+ const { filter, orientation, last } = props;
+ const theme = useTheme();
+ const dispatch = useDispatch();
+
+ const handleHighlightFilterSource = useCallback(
+ (path?: string[]) => {
+ if (path) {
+ dispatch(setFocusedNativeFilter(path[0]));
+ }
+ },
+ [dispatch],
+ );
+
+ const handleRemoveCrossFilter = (chartId: number) => {
+ dispatch(
+ updateDataMask(chartId, {
+ extraFormData: {
+ filters: [],
+ },
+ filterState: {
+ value: null,
+ selectedValues: null,
+ },
+ }),
+ );
+ };
+
+ return (
+ <div
+ key={`${filter.name}${filter.emitterId}`}
+ css={css`
+ ${orientation === FilterBarOrientation.VERTICAL
+ ? `
+ display: block;
+ margin-bottom: ${theme.gridUnit * 4}px;
+ `
+ : `
+ display: flex;
+ `}
+ `}
+ >
+ <CrossFilterTitle
+ title={filter.name}
+ orientation={orientation || FilterBarOrientation.HORIZONTAL}
+ onHighlightFilterSource={() => handleHighlightFilterSource(filter.path)}
+ />
+ {(filter.column || filter.value) && (
+ <CrossFilterTag
+ filter={filter}
+ orientation={orientation}
+ removeCrossFilter={handleRemoveCrossFilter}
+ />
+ )}
+ {last && (
+ <span
+ data-test="cross-filters-divider"
+ css={css`
+ ${orientation === FilterBarOrientation.HORIZONTAL
+ ? `
+ width: 1px;
+ height: 22px;
+ margin-left: ${theme.gridUnit * 4}px;
+ margin-right: ${theme.gridUnit}px;
+ `
+ : `
+ width: 100%;
+ height: 1px;
+ display: block;
+ margin-bottom: ${theme.gridUnit * 4}px;
+ margin-top: ${theme.gridUnit * 4}px;
+ `}
+ background: ${theme.colors.grayscale.light2};
+ `}
+ />
+ )}
+ </div>
+ );
+};
+
+export default CrossFilter;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTag.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTag.test.tsx
new file mode 100644
index 0000000000..72f831dcfa
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTag.test.tsx
@@ -0,0 +1,61 @@
+/**
+ * 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 userEvent from '@testing-library/user-event';
+import React from 'react';
+import { render, screen } from 'spec/helpers/testing-library';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import { IndicatorStatus } from '../../selectors';
+import CrossFilterTag from './CrossFilterTag';
+
+const mockedProps = {
+ filter: {
+ name: 'test',
+ emitterId: 1,
+ column: 'country_name',
+ value: 'Italy',
+ status: IndicatorStatus.CrossFilterApplied,
+ path: ['test-path'],
+ },
+ orientation: FilterBarOrientation.HORIZONTAL,
+ removeCrossFilter: jest.fn(),
+};
+
+const setup = (props: typeof mockedProps) =>
+ render(<CrossFilterTag {...props} />, {
+ useRedux: true,
+ });
+
+test('CrossFilterTag should render', () => {
+ const { container } = setup(mockedProps);
+ expect(container).toBeInTheDocument();
+});
+
+test('Column and value should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getByText('country_name')).toBeInTheDocument();
+ expect(screen.getByText('Italy')).toBeInTheDocument();
+});
+
+test('Tag should be closable', () => {
+ setup(mockedProps);
+ const close = screen.getByRole('img', { name: 'close' });
+ expect(close).toBeInTheDocument();
+ userEvent.click(close);
+ expect(mockedProps.removeCrossFilter).toHaveBeenCalledWith(1);
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTag.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTag.tsx
new file mode 100644
index 0000000000..e59070df7a
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTag.tsx
@@ -0,0 +1,93 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { styled, css, useTheme } from '@superset-ui/core';
+import { CrossFilterIndicator } from 'src/dashboard/components/nativeFilters/selectors';
+import { Tag } from 'src/components';
+import { Tooltip } from 'src/components/Tooltip';
+import useCSSTextTruncation from 'src/hooks/useTruncation/useCSSTextTruncation';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import { ellipsisCss } from './styles';
+
+const StyledCrossFilterValue = styled.b`
+ ${({ theme }) => `
+ max-width: ${theme.gridUnit * 25}px;
+ `}
+ ${ellipsisCss}
+`;
+
+const StyledCrossFilterColumn = styled('span')`
+ ${({ theme }) => `
+ max-width: ${theme.gridUnit * 25}px;
+ padding-right: ${theme.gridUnit}px;
+ `}
+ ${ellipsisCss}
+`;
+
+const StyledTag = styled(Tag)`
+ ${({ theme }) => `
+ border: 1px solid ${theme.colors.grayscale.light3};
+ border-radius: 2px;
+ .anticon-close {
+ vertical-align: middle;
+ }
+ `}
+`;
+
+const CrossFilterTag = (props: {
+ filter: CrossFilterIndicator;
+ orientation: FilterBarOrientation;
+ removeCrossFilter: (filterId: number) => void;
+}) => {
+ const { filter, orientation, removeCrossFilter } = props;
+ const theme = useTheme();
+ const [columnRef, columnIsTruncated] =
+ useCSSTextTruncation<HTMLSpanElement>();
+ const [valueRef, valueIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
+
+ return (
+ <StyledTag
+ css={css`
+ ${orientation === FilterBarOrientation.VERTICAL
+ ? `
+ margin-top: ${theme.gridUnit * 2}px;
+ `
+ : `
+ margin-left: ${theme.gridUnit * 2}px;
+ `}
+ `}
+ closable
+ onClose={() => removeCrossFilter(filter.emitterId)}
+ >
+ <Tooltip title={columnIsTruncated ? filter.column : null}>
+ <StyledCrossFilterColumn ref={columnRef}>
+ {filter.column}
+ </StyledCrossFilterColumn>
+ </Tooltip>
+ <Tooltip title={valueIsTruncated ? filter.value : null}>
+ <StyledCrossFilterValue ref={valueRef}>
+ {filter.value}
+ </StyledCrossFilterValue>
+ </Tooltip>
+ </StyledTag>
+ );
+};
+
+export default CrossFilterTag;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.test.tsx
new file mode 100644
index 0000000000..16987440a2
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.test.tsx
@@ -0,0 +1,52 @@
+/**
+ * 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 userEvent from '@testing-library/user-event';
+import React from 'react';
+import { render, screen } from 'spec/helpers/testing-library';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import CrossFilterTitle from './CrossFilterTitle';
+
+const mockedProps = {
+ title: 'test-title',
+ orientation: FilterBarOrientation.HORIZONTAL,
+ onHighlightFilterSource: jest.fn(),
+};
+
+const setup = (props: typeof mockedProps) =>
+ render(<CrossFilterTitle {...props} />, {
+ useRedux: true,
+ });
+
+test('CrossFilterTitle should render', () => {
+ const { container } = setup(mockedProps);
+ expect(container).toBeInTheDocument();
+});
+
+test('Title should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getByText('test-title')).toBeInTheDocument();
+});
+
+test('Search icon should highlight emitter', () => {
+ setup(mockedProps);
+ const search = screen.getByTestId('cross-filters-highlight-emitter');
+ expect(search).toBeInTheDocument();
+ userEvent.click(search);
+ expect(mockedProps.onHighlightFilterSource).toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx
new file mode 100644
index 0000000000..70f0ad2cd9
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/CrossFilterTitle.tsx
@@ -0,0 +1,90 @@
+/**
+ * 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 { t, css, styled, useTheme } from '@superset-ui/core';
+import { Tooltip } from 'src/components/Tooltip';
+import useCSSTextTruncation from 'src/hooks/useTruncation/useCSSTextTruncation';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import Icons from 'src/components/Icons';
+import { ellipsisCss } from './styles';
+
+const StyledCrossFilterTitle = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ font-size: ${theme.typography.sizes.s}px;
+ color: ${theme.colors.grayscale.base};
+ vertical-align: middle;
+ align-items: center;
+ `}
+`;
+
+const StyledIconSearch = styled(Icons.SearchOutlined)`
+ ${({ theme }) => `
+ & > span.anticon.anticon-search {
+ color: ${theme.colors.grayscale.light1};
+ margin-left: ${theme.gridUnit}px;
+ transition: 0.3s;
+ vertical-align: middle;
+ line-height: 0;
+ &:hover {
+ color: ${theme.colors.grayscale.base};
+ }
+ }
+ `}
+`;
+
+const CrossFilterChartTitle = (props: {
+ title: string;
+ orientation: FilterBarOrientation;
+ onHighlightFilterSource: () => void;
+}) => {
+ const { title, orientation, onHighlightFilterSource } = props;
+ const [titleRef, titleIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
+ const theme = useTheme();
+ return (
+ <StyledCrossFilterTitle>
+ <Tooltip title={titleIsTruncated ? title : null}>
+ <span
+ css={css`
+ max-width: ${orientation === FilterBarOrientation.VERTICAL
+ ? `${theme.gridUnit * 45}px`
+ : `${theme.gridUnit * 15}px`};
+ line-height: 1.4;
+ ${ellipsisCss}
+ `}
+ ref={titleRef}
+ >
+ {title}
+ </span>
+ </Tooltip>
+ <Tooltip title={t('Locate the chart')}>
+ <StyledIconSearch
+ iconSize="s"
+ data-test="cross-filters-highlight-emitter"
+ role="button"
+ tabIndex={0}
+ onClick={onHighlightFilterSource}
+ />
+ </Tooltip>
+ </StyledCrossFilterTitle>
+ );
+};
+
+export default CrossFilterChartTitle;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx
new file mode 100644
index 0000000000..93fb649d68
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/Vertical.tsx
@@ -0,0 +1,46 @@
+/**
+ * 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 { DataMaskStateWithId } from '@superset-ui/core';
+import { useSelector } from 'react-redux';
+import { DashboardInfo, DashboardLayout, RootState } from 'src/dashboard/types';
+import crossFiltersSelector from './selectors';
+import VerticalCollapse from './VerticalCollapse';
+
+const CrossFiltersVertical = () => {
+ const dataMask = useSelector<RootState, DataMaskStateWithId>(
+ state => state.dataMask,
+ );
+ const dashboardInfo = useSelector<RootState, DashboardInfo>(
+ state => state.dashboardInfo,
+ );
+ const dashboardLayout = useSelector<RootState, DashboardLayout>(
+ state => state.dashboardLayout.present,
+ );
+ const selectedCrossFilters = crossFiltersSelector({
+ dataMask,
+ dashboardInfo,
+ dashboardLayout,
+ });
+
+ return <VerticalCollapse crossFilters={selectedCrossFilters} />;
+};
+
+export default CrossFiltersVertical;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/VerticalCollapse.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/VerticalCollapse.test.tsx
new file mode 100644
index 0000000000..92aaf8cfc6
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/VerticalCollapse.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import { IndicatorStatus } from '../../selectors';
+import VerticalCollapse from './VerticalCollapse';
+
+const mockedProps = {
+ crossFilters: [
+ {
+ name: 'test',
+ emitterId: 1,
+ column: 'country_name',
+ value: 'Italy',
+ status: IndicatorStatus.CrossFilterApplied,
+ path: ['test-path'],
+ },
+ {
+ name: 'test-b',
+ emitterId: 2,
+ column: 'country_code',
+ value: 'IT',
+ status: IndicatorStatus.CrossFilterApplied,
+ path: ['test-path-2'],
+ },
+ ],
+};
+
+const setup = (props: typeof mockedProps) =>
+ render(<VerticalCollapse {...props} />, {
+ useRedux: true,
+ });
+
+test('VerticalCollapse should render', () => {
+ const { container } = setup(mockedProps);
+ expect(container).toBeInTheDocument();
+});
+
+test('Collapse with title should render', () => {
+ setup(mockedProps);
+ expect(screen.getByText('Cross-filters')).toBeInTheDocument();
+});
+
+test('Collapse should not render when empty', () => {
+ setup({
+ crossFilters: [],
+ });
+ expect(screen.queryByText('Cross-filters')).not.toBeInTheDocument();
+ expect(screen.queryByText('test')).not.toBeInTheDocument();
+ expect(screen.queryByText('test-b')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('cross-filters-highlight-emitter'),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'close' })).not.toBeInTheDocument();
+ expect(screen.queryByText('country_name')).not.toBeInTheDocument();
+ expect(screen.queryByText('Italy')).not.toBeInTheDocument();
+ expect(screen.queryByText('country_code')).not.toBeInTheDocument();
+ expect(screen.queryByText('IT')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('cross-filters-divider')).not.toBeInTheDocument();
+});
+
+test('Titles should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getByText('test')).toBeInTheDocument();
+ expect(screen.getByText('test-b')).toBeInTheDocument();
+});
+
+test('Search icons should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getAllByTestId('cross-filters-highlight-emitter')).toHaveLength(
+ 2,
+ );
+});
+
+test('Tags should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getByText('country_name')).toBeInTheDocument();
+ expect(screen.getByText('Italy')).toBeInTheDocument();
+ expect(screen.getByText('country_code')).toBeInTheDocument();
+ expect(screen.getByText('IT')).toBeInTheDocument();
+});
+
+test('Tags should be closable', () => {
+ setup(mockedProps);
+ expect(screen.getAllByRole('img', { name: 'close' })).toHaveLength(2);
+});
+
+test('Divider should be visible', () => {
+ setup(mockedProps);
+ expect(screen.getByTestId('cross-filters-divider')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/VerticalCollapse.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/VerticalCollapse.tsx
new file mode 100644
index 0000000000..a748f9ed99
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/VerticalCollapse.tsx
@@ -0,0 +1,102 @@
+/**
+ * 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, { useMemo } from 'react';
+import Collapse from 'src/components/Collapse';
+import { styled, t, useTheme, css } from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import CrossFilter from './CrossFilter';
+import { CrossFilterIndicator } from '../../selectors';
+
+const StyledCollapse = styled(Collapse)`
+ ${({ theme }) => `
+ .ant-collapse-header {
+ margin-bottom: ${theme.gridUnit * 4}px;
+ }
+ .ant-collapse-item > .ant-collapse-header {
+ padding-bottom: 0;
+ }
+ .ant-collapse-item > .ant-collapse-header > .ant-collapse-arrow {
+ font-size: ${theme.typography.sizes.xs}px;
+ padding-top: ${theme.gridUnit * 3}px;
+ }
+ .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box {
+ padding-top: 0;
+ }
+ `}
+`;
+
+const StyledCrossFiltersTitle = styled.span`
+ ${({ theme }) => `
+ font-size: ${theme.typography.sizes.s}px;
+ `}
+`;
+
+const CrossFiltersVerticalCollapse = (props: {
+ crossFilters: CrossFilterIndicator[];
+}) => {
+ const { crossFilters } = props;
+ const theme = useTheme();
+ const crossFiltersIndicators = useMemo(
+ () =>
+ crossFilters.map(filter => (
+ <CrossFilter
+ key={filter.emitterId}
+ filter={filter}
+ orientation={FilterBarOrientation.VERTICAL}
+ />
+ )),
+ [crossFilters],
+ );
+
+ if (!crossFilters.length) {
+ return null;
+ }
+
+ return (
+ <StyledCollapse
+ ghost
+ defaultActiveKey="crossFilters"
+ expandIconPosition="right"
+ >
+ <Collapse.Panel
+ key="crossFilters"
+ header={
+ <StyledCrossFiltersTitle>
+ {t('Cross-filters')}
+ </StyledCrossFiltersTitle>
+ }
+ >
+ {crossFiltersIndicators}
+ <span
+ data-test="cross-filters-divider"
+ css={css`
+ width: 100%;
+ height: 1px;
+ display: block;
+ background: ${theme.colors.grayscale.light3};
+ margin: ${theme.gridUnit * 8}px auto 0 auto;
+ `}
+ />
+ </Collapse.Panel>
+ </StyledCollapse>
+ );
+};
+
+export default CrossFiltersVerticalCollapse;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts
new file mode 100644
index 0000000000..c0f45af5b3
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/selectors.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 { DataMaskStateWithId } from '@superset-ui/core';
+import { DashboardInfo, DashboardLayout } from 'src/dashboard/types';
+import { CrossFilterIndicator, selectChartCrossFilters } from '../../selectors';
+
+export const crossFiltersSelector = (props: {
+ dataMask: DataMaskStateWithId;
+ dashboardInfo: DashboardInfo;
+ dashboardLayout: DashboardLayout;
+}): CrossFilterIndicator[] => {
+ const { dataMask, dashboardInfo, dashboardLayout } = props;
+ const chartConfiguration = dashboardInfo.metadata?.chart_configuration;
+ const chartsIds = Object.keys(chartConfiguration);
+ const shouldFilterEmitters = true;
+
+ let selectedCrossFilters: CrossFilterIndicator[] = [];
+
+ for (let i = 0; i < chartsIds.length; i += 1) {
+ const chartId = Number(chartsIds[i]);
+ const crossFilters = selectChartCrossFilters(
+ dataMask,
+ chartId,
+ dashboardLayout,
+ chartConfiguration,
+ shouldFilterEmitters,
+ );
+ selectedCrossFilters = [
+ ...selectedCrossFilters,
+ ...(crossFilters as CrossFilterIndicator[]),
+ ];
+ }
+ return selectedCrossFilters;
+};
+
+export default crossFiltersSelector;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/styles.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/styles.ts
new file mode 100644
index 0000000000..0eee141c10
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CrossFilters/styles.ts
@@ -0,0 +1,28 @@
+/**
+ * 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 { css } from '@superset-ui/core';
+
+export const ellipsisCss = css`
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: middle;
+`;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
index fa0bfeac6f..d4ec83c1e2 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
@@ -46,7 +46,12 @@ import {
useDashboardHasTabs,
useSelectFiltersInScope,
} from 'src/dashboard/components/nativeFilters/state';
-import { FilterBarOrientation, RootState } from 'src/dashboard/types';
+import {
+ DashboardInfo,
+ DashboardLayout,
+ FilterBarOrientation,
+ RootState,
+} from 'src/dashboard/types';
import DropdownContainer, {
Ref as DropdownContainerRef,
} from 'src/components/DropdownContainer';
@@ -54,6 +59,8 @@ import Icons from 'src/components/Icons';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
import { useFilterControlFactory } from '../useFilterControlFactory';
import { FiltersDropdownContent } from '../FiltersDropdownContent';
+import crossFiltersSelector from '../CrossFilters/selectors';
+import CrossFilter from '../CrossFilters/CrossFilter';
import { useFilterOutlined } from '../useFilterOutlined';
type FilterControlsProps = {
@@ -77,6 +84,29 @@ const FilterControls: FC<FilterControlsProps> = ({
const [overflowedIds, setOverflowedIds] = useState<string[]>([]);
const popoverRef = useRef<DropdownContainerRef>(null);
+ const dataMask = useSelector<RootState, DataMaskStateWithId>(
+ state => state.dataMask,
+ );
+ const dashboardInfo = useSelector<RootState, DashboardInfo>(
+ state => state.dashboardInfo,
+ );
+ const dashboardLayout = useSelector<RootState, DashboardLayout>(
+ state => state.dashboardLayout.present,
+ );
+ const isCrossFiltersEnabled = isFeatureEnabled(
+ FeatureFlag.DASHBOARD_CROSS_FILTERS,
+ );
+ const selectedCrossFilters = useMemo(
+ () =>
+ isCrossFiltersEnabled
+ ? crossFiltersSelector({
+ dataMask,
+ dashboardInfo,
+ dashboardLayout,
+ })
+ : [],
+ [dashboardInfo, dashboardLayout, dataMask, isCrossFiltersEnabled],
+ );
const { filterControlFactory, filtersWithValues } = useFilterControlFactory(
dataMaskSelected,
onFilterSelectionChange,
@@ -126,37 +156,68 @@ const FilterControls: FC<FilterControlsProps> = ({
</>
);
- const items = useMemo(
- () =>
- filtersInScope.map((filter, index) => ({
- id: filter.id,
- element: (
- <div
- className="filter-item-wrapper"
- css={css`
- flex-shrink: 0;
- `}
- >
- {renderer(filter, index)}
- </div>
- ),
- })),
- [filtersInScope, renderer],
- );
-
const overflowedFiltersInScope = useMemo(
() => filtersInScope.filter(({ id }) => overflowedIds?.includes(id)),
[filtersInScope, overflowedIds],
);
- const activeOverflowedFiltersInScope = useMemo(
+ const overflowedCrossFilters = useMemo(
() =>
- overflowedFiltersInScope.filter(filter =>
- isNativeFilterWithDataMask(filter),
+ selectedCrossFilters.filter(({ emitterId, name }) =>
+ overflowedIds?.includes(`${name}${emitterId}`),
),
- [overflowedFiltersInScope],
+ [overflowedIds, selectedCrossFilters],
+ );
+
+ const activeOverflowedFiltersInScope = useMemo(() => {
+ const activeOverflowedFilters = overflowedFiltersInScope.filter(filter =>
+ isNativeFilterWithDataMask(filter),
+ );
+ return [...activeOverflowedFilters, ...overflowedCrossFilters];
+ }, [overflowedCrossFilters, overflowedFiltersInScope]);
+
+ const rendererCrossFilter = useCallback(
+ (crossFilter, orientation, last) => (
+ <CrossFilter
+ filter={crossFilter}
+ orientation={orientation}
+ last={
+ filtersInScope.length > 0 &&
+ `${last.name}${last.emitterId}` ===
+ `${crossFilter.name}${crossFilter.emitterId}`
+ }
+ />
+ ),
+ [filtersInScope.length],
);
+ const items = useMemo(() => {
+ const crossFilters = selectedCrossFilters.map(c => ({
+ // a combination of filter name and chart id to account
+ // for multiple cross filters from the same chart in the future
+ id: `${c.name}${c.emitterId}`,
+ element: rendererCrossFilter(
+ c,
+ FilterBarOrientation.HORIZONTAL,
+ selectedCrossFilters.at(-1),
+ ),
+ }));
+ const nativeFiltersInScope = filtersInScope.map((filter, index) => ({
+ id: filter.id,
+ element: (
+ <div
+ className="filter-item-wrapper"
+ css={css`
+ flex-shrink: 0;
+ `}
+ >
+ {renderer(filter, index)}
+ </div>
+ ),
+ }));
+ return [...crossFilters, ...nativeFiltersInScope];
+ }, [filtersInScope, renderer, rendererCrossFilter, selectedCrossFilters]);
+
const renderHorizontalContent = () => (
<div
css={(theme: SupersetTheme) =>
@@ -193,12 +254,15 @@ const FilterControls: FC<FilterControlsProps> = ({
}
dropdownContent={
overflowedFiltersInScope.length ||
+ overflowedCrossFilters.length ||
(filtersOutOfScope.length && showCollapsePanel)
? () => (
<FiltersDropdownContent
+ overflowedCrossFilters={overflowedCrossFilters}
filtersInScope={overflowedFiltersInScope}
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
+ rendererCrossFilter={rendererCrossFilter}
showCollapsePanel={showCollapsePanel}
/>
)
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx
index 84710c94b5..c4c720710b 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx
@@ -19,19 +19,29 @@
import React, { ReactNode } from 'react';
import { css, Divider, Filter, SupersetTheme } from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
+import { CrossFilterIndicator } from '../../selectors';
export interface FiltersDropdownContentProps {
+ overflowedCrossFilters: CrossFilterIndicator[];
filtersInScope: (Filter | Divider)[];
filtersOutOfScope: (Filter | Divider)[];
renderer: (filter: Filter | Divider, index: number) => ReactNode;
+ rendererCrossFilter: (
+ crossFilter: CrossFilterIndicator,
+ orientation: FilterBarOrientation.VERTICAL,
+ last: CrossFilterIndicator,
+ ) => ReactNode;
showCollapsePanel?: boolean;
}
export const FiltersDropdownContent = ({
+ overflowedCrossFilters,
filtersInScope,
filtersOutOfScope,
renderer,
+ rendererCrossFilter,
showCollapsePanel,
}: FiltersDropdownContentProps) => (
<div
@@ -42,6 +52,13 @@ export const FiltersDropdownContent = ({
`
}
>
+ {overflowedCrossFilters.map(crossFilter =>
+ rendererCrossFilter(
+ crossFilter,
+ FilterBarOrientation.VERTICAL,
+ overflowedCrossFilters.at(-1) as CrossFilterIndicator,
+ ),
+ )}
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<FiltersOutOfScopeCollapsible
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx
index a85f190732..898ca2768e 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx
@@ -80,7 +80,15 @@ export const FiltersOutOfScopeCollapsible = ({
}
>
<AntdCollapse.Panel
- header={t('Filters out of scope (%d)', filtersOutOfScope.length)}
+ header={
+ <span
+ css={(theme: SupersetTheme) => css`
+ font-size: ${theme.typography.sizes.s}px;
+ `}
+ >
+ {t('Filters out of scope (%d)', filtersOutOfScope.length)}
+ </span>
+ }
key="1"
>
{filtersOutOfScope.map(renderer)}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx
index 9dc2d62d44..badff64295 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx
@@ -18,14 +18,23 @@
*/
import React from 'react';
-import { styled, t } from '@superset-ui/core';
+import {
+ DataMaskStateWithId,
+ FeatureFlag,
+ isFeatureEnabled,
+ styled,
+ t,
+} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Loading from 'src/components/Loading';
+import { DashboardInfo, DashboardLayout, RootState } from 'src/dashboard/types';
+import { useSelector } from 'react-redux';
import FilterControls from './FilterControls/FilterControls';
import { getFilterBarTestId } from './utils';
import { HorizontalBarProps } from './types';
import FilterBarSettings from './FilterBarSettings';
import FilterConfigurationLink from './FilterConfigurationLink';
+import crossFiltersSelector from './CrossFilters/selectors';
const HorizontalBar = styled.div`
${({ theme }) => `
@@ -95,7 +104,26 @@ const HorizontalFilterBar: React.FC<HorizontalBarProps> = ({
isInitialized,
onSelectionChange,
}) => {
- const hasFilters = filterValues.length > 0;
+ const dataMask = useSelector<RootState, DataMaskStateWithId>(
+ state => state.dataMask,
+ );
+ const dashboardInfo = useSelector<RootState, DashboardInfo>(
+ state => state.dashboardInfo,
+ );
+ const dashboardLayout = useSelector<RootState, DashboardLayout>(
+ state => state.dashboardLayout.present,
+ );
+ const isCrossFiltersEnabled = isFeatureEnabled(
+ FeatureFlag.DASHBOARD_CROSS_FILTERS,
+ );
+ const selectedCrossFilters = isCrossFiltersEnabled
+ ? crossFiltersSelector({
+ dataMask,
+ dashboardInfo,
+ dashboardLayout,
+ })
+ : [];
+ const hasFilters = filterValues.length > 0 || selectedCrossFilters.length > 0;
return (
<HorizontalBar {...getFilterBarTestId()}>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx
index 3150ed5e90..265c5932ac 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx
@@ -41,6 +41,7 @@ import { useFilterSets } from './state';
import EditSection from './FilterSets/EditSection';
import Header from './Header';
import FilterControls from './FilterControls/FilterControls';
+import CrossFiltersVertical from './CrossFilters/Vertical';
const BarWrapper = styled.div<{ width: number }>`
width: ${({ theme }) => theme.gridUnit * 8}px;
@@ -190,6 +191,96 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
const numberOfFilters = nativeFilterValues.length;
+ const filterControls = useMemo(
+ () =>
+ filterValues.length === 0 ? (
+ <FilterBarEmptyStateContainer>
+ <EmptyStateSmall
+ title={t('No global filters are currently added')}
+ image="filter.svg"
+ description={
+ canEdit &&
+ t(
+ 'Click on "+Add/Edit Filters" button to create new dashboard filters',
+ )
+ }
+ />
+ </FilterBarEmptyStateContainer>
+ ) : (
+ <FilterControlsWrapper>
+ <FilterControls
+ dataMaskSelected={dataMaskSelected}
+ onFilterSelectionChange={onSelectionChange}
+ />
+ </FilterControlsWrapper>
+ ),
+ [canEdit, dataMaskSelected, filterValues.length, onSelectionChange],
+ );
+
+ const filterSetsTabs = useMemo(
+ () => (
+ <StyledTabs
+ centered
+ onChange={setTab as HandlerFunction}
+ defaultActiveKey={TabIds.AllFilters}
+ activeKey={editFilterSetId ? TabIds.AllFilters : undefined}
+ >
+ <AntdTabs.TabPane
+ tab={t('All filters (%(filterCount)d)', {
+ filterCount: numberOfFilters,
+ })}
+ key={TabIds.AllFilters}
+ css={tabPaneStyle}
+ >
+ {editFilterSetId && (
+ <EditSection
+ dataMaskSelected={dataMaskSelected}
+ disabled={!isDisabled}
+ onCancel={() => setEditFilterSetId(null)}
+ filterSetId={editFilterSetId}
+ />
+ )}
+ {filterControls}
+ </AntdTabs.TabPane>
+ <AntdTabs.TabPane
+ disabled={!!editFilterSetId}
+ tab={t('Filter sets (%(filterSetCount)d)', {
+ filterSetCount: filterSetFilterValues.length,
+ })}
+ key={TabIds.FilterSets}
+ css={tabPaneStyle}
+ >
+ <FilterSets
+ onEditFilterSet={setEditFilterSetId}
+ disabled={!isDisabled}
+ dataMaskSelected={dataMaskSelected}
+ tab={tab}
+ onFilterSelectionChange={onSelectionChange}
+ />
+ </AntdTabs.TabPane>
+ </StyledTabs>
+ ),
+ [
+ dataMaskSelected,
+ editFilterSetId,
+ filterControls,
+ filterSetFilterValues.length,
+ isDisabled,
+ numberOfFilters,
+ onSelectionChange,
+ tab,
+ tabPaneStyle,
+ ],
+ );
+
+ const crossFilters = useMemo(
+ () =>
+ isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) ? (
+ <CrossFiltersVertical />
+ ) : null,
+ [],
+ );
+
return (
<FilterBarScrollContext.Provider value={isScrolling}>
<BarWrapper
@@ -219,89 +310,16 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
<Loading />
</div>
) : isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET) ? (
- <StyledTabs
- centered
- onChange={setTab as HandlerFunction}
- defaultActiveKey={TabIds.AllFilters}
- activeKey={editFilterSetId ? TabIds.AllFilters : undefined}
- >
- <AntdTabs.TabPane
- tab={t('All filters (%(filterCount)d)', {
- filterCount: numberOfFilters,
- })}
- key={TabIds.AllFilters}
- css={tabPaneStyle}
- >
- {editFilterSetId && (
- <EditSection
- dataMaskSelected={dataMaskSelected}
- disabled={!isDisabled}
- onCancel={() => setEditFilterSetId(null)}
- filterSetId={editFilterSetId}
- />
- )}
- {filterValues.length === 0 ? (
- <FilterBarEmptyStateContainer>
- <EmptyStateSmall
- title={t('No filters are currently added')}
- image="filter.svg"
- description={
- canEdit &&
- t(
- 'Click the button above to add a filter to the dashboard',
- )
- }
- />
- </FilterBarEmptyStateContainer>
- ) : (
- <FilterControlsWrapper>
- <FilterControls
- dataMaskSelected={dataMaskSelected}
- onFilterSelectionChange={onSelectionChange}
- />
- </FilterControlsWrapper>
- )}
- </AntdTabs.TabPane>
- <AntdTabs.TabPane
- disabled={!!editFilterSetId}
- tab={t('Filter sets (%(filterSetCount)d)', {
- filterSetCount: filterSetFilterValues.length,
- })}
- key={TabIds.FilterSets}
- css={tabPaneStyle}
- >
- <FilterSets
- onEditFilterSet={setEditFilterSetId}
- disabled={!isDisabled}
- dataMaskSelected={dataMaskSelected}
- tab={tab}
- onFilterSelectionChange={onSelectionChange}
- />
- </AntdTabs.TabPane>
- </StyledTabs>
+ <>
+ {crossFilters}
+ {filterSetsTabs}
+ </>
) : (
<div css={tabPaneStyle} onScroll={onScroll}>
- {filterValues.length === 0 ? (
- <FilterBarEmptyStateContainer>
- <EmptyStateSmall
- title={t('No filters are currently added')}
- image="filter.svg"
- description={
- canEdit &&
- t(
- 'Click the button above to add a filter to the dashboard',
- )
- }
- />
- </FilterBarEmptyStateContainer>
- ) : (
- <FilterControlsWrapper>
- <FilterControls
- dataMaskSelected={dataMaskSelected}
- onFilterSelectionChange={onSelectionChange}
- />
- </FilterControlsWrapper>
- )}
+ <>
+ {crossFilters}
+ {filterControls}
+ </>
</div>
)}
{actions}
diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
similarity index 80%
rename from superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts
rename to superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
index c0916b9960..871a8d402e 100644
--- a/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
@@ -60,7 +60,7 @@ type Filter = {
};
const extractLabel = (filter?: FilterState): string | null => {
- if (filter?.label) {
+ if (filter?.label && !filter?.label?.includes(undefined)) {
return filter.label;
}
if (filter?.value) {
@@ -158,6 +158,8 @@ export type Indicator = {
path?: string[];
};
+export type CrossFilterIndicator = Indicator & { emitterId: number };
+
const cachedIndicatorsForChart = {};
const cachedDashboardFilterDataForChart = {};
// inspects redux state to find what the filter indicators should be shown for a given chart
@@ -214,9 +216,97 @@ export const selectIndicatorsForChart = (
return indicators;
};
+const getStatus = ({
+ label,
+ column,
+ type = DataMaskType.NativeFilters,
+ rejectedColumns,
+ appliedColumns,
+}: {
+ label: string | null;
+ column?: string;
+ type?: DataMaskType;
+ rejectedColumns?: Set<string>;
+ appliedColumns?: Set<string>;
+}): IndicatorStatus => {
+ // a filter is only considered unset if it's value is null
+ const hasValue = label !== null;
+ if (type === DataMaskType.CrossFilters && hasValue) {
+ return IndicatorStatus.CrossFilterApplied;
+ }
+ if (!column && hasValue) {
+ // Filter without datasource
+ return IndicatorStatus.Applied;
+ }
+ if (column && rejectedColumns?.has(column))
+ return IndicatorStatus.Incompatible;
+ if (column && appliedColumns?.has(column) && hasValue) {
+ return IndicatorStatus.Applied;
+ }
+ return IndicatorStatus.Unset;
+};
+
+const defaultChartConfig = {};
+export const selectChartCrossFilters = (
+ dataMask: DataMaskStateWithId,
+ chartId: number,
+ dashboardLayout: Layout,
+ chartConfiguration: ChartConfiguration = defaultChartConfig,
+ filterEmitter = false,
+): Indicator[] | CrossFilterIndicator[] => {
+ let crossFilterIndicators: any = [];
+ if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
+ const dashboardLayoutValues = Object.values(dashboardLayout);
+ crossFilterIndicators = Object.values(chartConfiguration)
+ .filter(chartConfig => {
+ const inScope =
+ chartConfig.crossFilters?.chartsInScope?.includes(chartId);
+ if (!filterEmitter && inScope) {
+ return true;
+ }
+ if (filterEmitter && !inScope) {
+ return true;
+ }
+ return false;
+ })
+ .map(chartConfig => {
+ const filterState = dataMask[chartConfig.id]?.filterState;
+ const extraFormData = dataMask[chartConfig.id]?.extraFormData;
+ const label = extractLabel(filterState);
+ const filtersState = filterState?.filters;
+ const column =
+ extraFormData?.filters?.[0]?.col ||
+ (filtersState && Object.keys(filtersState)[0]);
+
+ const dashboardLayoutItem = dashboardLayoutValues.find(
+ layoutItem => layoutItem?.meta?.chartId === chartConfig.id,
+ );
+ const filterObject: Indicator = {
+ column,
+ name: dashboardLayoutItem?.meta?.sliceName as string,
+ path: [
+ ...(dashboardLayoutItem?.parents ?? []),
+ dashboardLayoutItem?.id || '',
+ ],
+ status: getStatus({
+ label,
+ type: DataMaskType.CrossFilters,
+ }),
+ value: label,
+ };
+ if (filterEmitter) {
+ (filterObject as CrossFilterIndicator).emitterId = chartId;
+ }
+ return filterObject;
+ })
+ .filter(filter => filter.status === IndicatorStatus.CrossFilterApplied);
+ }
+
+ return crossFilterIndicators;
+};
+
const cachedNativeIndicatorsForChart = {};
const cachedNativeFilterDataForChart: any = {};
-const defaultChartConfig = {};
export const selectNativeIndicatorsForChart = (
nativeFilters: Filters,
dataMask: DataMaskStateWithId,
@@ -240,31 +330,6 @@ export const selectNativeIndicatorsForChart = (
) {
return cachedNativeIndicatorsForChart[chartId];
}
- const getStatus = ({
- label,
- column,
- type = DataMaskType.NativeFilters,
- }: {
- label: string | null;
- column?: string;
- type?: DataMaskType;
- }): IndicatorStatus => {
- // a filter is only considered unset if it's value is null
- const hasValue = label !== null;
- if (type === DataMaskType.CrossFilters && hasValue) {
- return IndicatorStatus.CrossFilterApplied;
- }
- if (!column && hasValue) {
- // Filter without datasource
- return IndicatorStatus.Applied;
- }
- if (column && rejectedColumns.has(column))
- return IndicatorStatus.Incompatible;
- if (column && appliedColumns.has(column) && hasValue) {
- return IndicatorStatus.Applied;
- }
- return IndicatorStatus.Unset;
- };
let nativeFilterIndicators: any = [];
if (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS)) {
@@ -284,7 +349,12 @@ export const selectNativeIndicatorsForChart = (
column,
name: nativeFilter.name,
path: [nativeFilter.id],
- status: getStatus({ label, column }),
+ status: getStatus({
+ label,
+ column,
+ rejectedColumns,
+ appliedColumns,
+ }),
value: label,
};
});
@@ -292,35 +362,12 @@ export const selectNativeIndicatorsForChart = (
let crossFilterIndicators: any = [];
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
- const dashboardLayoutValues = Object.values(dashboardLayout);
- crossFilterIndicators = Object.values(chartConfiguration)
- .filter(chartConfig =>
- chartConfig.crossFilters?.chartsInScope?.includes(chartId),
- )
- .map(chartConfig => {
- const filterState = dataMask[chartConfig.id]?.filterState;
- const label = extractLabel(filterState);
- const filtersState = filterState?.filters;
- const column = filtersState && Object.keys(filtersState)[0];
-
- const dashboardLayoutItem = dashboardLayoutValues.find(
- layoutItem => layoutItem?.meta?.chartId === chartConfig.id,
- );
- return {
- column,
- name: dashboardLayoutItem?.meta?.sliceName as string,
- path: [
- ...(dashboardLayoutItem?.parents ?? []),
- dashboardLayoutItem?.id,
- ],
- status: getStatus({
- label,
- type: DataMaskType.CrossFilters,
- }),
- value: label,
- };
- })
- .filter(filter => filter.status === IndicatorStatus.CrossFilterApplied);
+ crossFilterIndicators = selectChartCrossFilters(
+ dataMask,
+ chartId,
+ dashboardLayout,
+ chartConfiguration,
+ );
}
const indicators = crossFilterIndicators.concat(nativeFilterIndicators);
cachedNativeIndicatorsForChart[chartId] = indicators;