diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js
index 814e53cf58411..35783dd85d286 100644
--- a/superset-frontend/.storybook/main.js
+++ b/superset-frontend/.storybook/main.js
@@ -24,8 +24,8 @@ module.exports = {
builder: 'webpack5',
},
stories: [
- '../src/@(components|common|filters|explore|views)/**/*.stories.@(tsx|jsx)',
- '../src/@(components|common|filters|explore|views)/**/*.*.@(mdx)',
+ '../src/@(components|common|filters|explore|views|dashboard)/**/*.stories.@(tsx|jsx)',
+ '../src/@(components|common|filters|explore|views|dashboard)/**/*.*.@(mdx)',
],
addons: [
'@storybook/addon-essentials',
diff --git a/superset-frontend/spec/fixtures/mockStore.js b/superset-frontend/spec/fixtures/mockStore.js
index 9f62f52b68249..119e19a0847d5 100644
--- a/superset-frontend/spec/fixtures/mockStore.js
+++ b/superset-frontend/spec/fixtures/mockStore.js
@@ -20,6 +20,7 @@ import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from 'src/views/store';
+import { FilterBarOrientation } from 'src/dashboard/types';
import mockState from './mockState';
import {
@@ -125,6 +126,9 @@ export const stateWithNativeFilters = {
},
},
},
+ dashboardInfo: {
+ filterBarOrientation: FilterBarOrientation.VERTICAL,
+ },
};
export const getMockStoreWithNativeFilters = () =>
@@ -153,6 +157,7 @@ export const stateWithoutNativeFilters = {
},
dashboardInfo: {
dash_edit_perm: true,
+ filterBarOrientation: FilterBarOrientation.VERTICAL,
metadata: {
native_filter_configuration: [],
},
diff --git a/superset-frontend/src/components/DropdownContainer/index.tsx b/superset-frontend/src/components/DropdownContainer/index.tsx
index 6111698f05a84..9af3a96534a75 100644
--- a/superset-frontend/src/components/DropdownContainer/index.tsx
+++ b/superset-frontend/src/components/DropdownContainer/index.tsx
@@ -215,7 +215,7 @@ const DropdownContainer = forwardRef(
css={css`
display: flex;
flex-direction: column;
- gap: ${theme.gridUnit * 3}px;
+ gap: ${theme.gridUnit * 4}px;
`}
data-test="dropdown-content"
style={popoverStyle}
@@ -252,14 +252,14 @@ const DropdownContainer = forwardRef(
ref={ref}
css={css`
display: flex;
- align-items: flex-end;
+ align-items: center;
`}
>
css`
`;
const horizontalStyle = (theme: SupersetTheme) => css`
- margin: 0 ${theme.gridUnit * 2}px;
+ margin: 0 ${theme.gridUnit * 4}px;
&& > .filter-clear-all-button {
text-transform: capitalize;
font-weight: ${theme.typography.weights.normal};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
index 082111b94ab29..29cdf9d460b1f 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
@@ -20,25 +20,40 @@ import React, { useContext, useMemo, useState } from 'react';
import { styled, SupersetTheme } from '@superset-ui/core';
import { FormItem as StyledFormItem, Form } from 'src/components/Form';
import { Tooltip } from 'src/components/Tooltip';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import { truncationCSS } from 'src/hooks/useTruncation';
import { checkIsMissingRequiredValue } from '../utils';
import FilterValue from './FilterValue';
-import { FilterProps } from './types';
import { FilterCard } from '../../FilterCard';
import { FilterBarScrollContext } from '../Vertical';
+import { FilterControlProps } from './types';
+import { FilterCardPlacement } from '../../FilterCard/types';
const StyledIcon = styled.div`
position: absolute;
right: 0;
`;
-const StyledFilterControlTitle = styled.h4`
+const VerticalFilterControlTitle = styled.h4`
font-size: ${({ theme }) => theme.typography.sizes.s}px;
color: ${({ theme }) => theme.colors.grayscale.dark1};
margin: 0;
overflow-wrap: break-word;
`;
-const StyledFilterControlTitleBox = styled.div`
+const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
+ font-weight: ${({ theme }) => theme.typography.weights.normal};
+ color: ${({ theme }) => theme.colors.grayscale.base};
+ ${truncationCSS}
+`;
+
+const HorizontalOverflowFilterControlTitle = styled(
+ HorizontalFilterControlTitle,
+)`
+ max-width: none;
+`;
+
+const VerticalFilterControlTitleBox = styled.div`
display: flex;
flex-direction: row;
align-items: center;
@@ -46,7 +61,18 @@ const StyledFilterControlTitleBox = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit}px;
`;
-const StyledFilterControlContainer = styled(Form)`
+const HorizontalFilterControlTitleBox = styled(VerticalFilterControlTitleBox)`
+ margin-bottom: unset;
+ max-width: ${({ theme }) => theme.gridUnit * 15}px;
+`;
+
+const HorizontalOverflowFilterControlTitleBox = styled(
+ VerticalFilterControlTitleBox,
+)`
+ width: 100%;
+`;
+
+const VerticalFilterControlContainer = styled(Form)`
width: 100%;
&& .ant-form-item-label > label {
text-transform: none;
@@ -58,7 +84,25 @@ const StyledFilterControlContainer = styled(Form)`
}
`;
-const FormItem = styled(StyledFormItem)`
+const HorizontalFilterControlContainer = styled(Form)`
+ && .ant-form-item-label > label {
+ margin-bottom: 0;
+ text-transform: none;
+ }
+ .ant-form-item-tooltip {
+ margin-bottom: ${({ theme }) => theme.gridUnit}px;
+ }
+`;
+
+const HorizontalOverflowFilterControlContainer = styled(
+ VerticalFilterControlContainer,
+)`
+ && .ant-form-item-label > label {
+ padding-right: unset;
+ }
+`;
+
+const VerticalFormItem = styled(StyledFormItem)`
.ant-form-item-label {
label.ant-form-item-required:not(.ant-form-item-required-mark-optional) {
&::after {
@@ -68,6 +112,62 @@ const FormItem = styled(StyledFormItem)`
}
`;
+const HorizontalFormItem = styled(StyledFormItem)`
+ && {
+ margin-bottom: 0;
+ align-items: center;
+ }
+
+ .ant-form-item-label {
+ padding-bottom: 0;
+ margin-right: ${({ theme }) => theme.gridUnit * 2}px;
+ label.ant-form-item-required:not(.ant-form-item-required-mark-optional) {
+ &::after {
+ display: none;
+ }
+ }
+
+ & > label::after {
+ display: none;
+ }
+ }
+
+ .ant-form-item-control {
+ width: ${({ theme }) => theme.gridUnit * 40}px;
+ }
+`;
+
+const HorizontalOverflowFormItem = VerticalFormItem;
+
+const useFilterControlDisplay = (
+ orientation: FilterBarOrientation,
+ overflow: boolean,
+) =>
+ useMemo(() => {
+ if (orientation === FilterBarOrientation.HORIZONTAL) {
+ if (overflow) {
+ return {
+ FilterControlContainer: HorizontalOverflowFilterControlContainer,
+ FormItem: HorizontalOverflowFormItem,
+ FilterControlTitleBox: HorizontalOverflowFilterControlTitleBox,
+ FilterControlTitle: HorizontalOverflowFilterControlTitle,
+ };
+ }
+ return {
+ FilterControlContainer: HorizontalFilterControlContainer,
+ FormItem: HorizontalFormItem,
+ FilterControlTitleBox: HorizontalFilterControlTitleBox,
+ FilterControlTitle: HorizontalFilterControlTitle,
+ };
+ }
+ return {
+ FilterControlContainer: VerticalFilterControlContainer,
+ FormItem: VerticalFormItem,
+ FilterControlTitleBox: VerticalFilterControlTitleBox,
+ FilterControlTitle: VerticalFilterControlTitle,
+ };
+ }, [orientation, overflow]);
+
const ToolTipContainer = styled.div`
font-size: ${({ theme }) => theme.typography.sizes.m}px;
display: flex;
@@ -109,7 +209,7 @@ const DescriptionToolTip = ({ description }: { description: string }) => (
);
-const FilterControl: React.FC
= ({
+const FilterControl = ({
dataMaskSelected,
filter,
icon,
@@ -118,7 +218,9 @@ const FilterControl: React.FC = ({
inView,
showOverflow,
parentRef,
-}) => {
+ orientation = FilterBarOrientation.VERTICAL,
+ overflow = false,
+}: FilterControlProps) => {
const [isFilterActive, setIsFilterActive] = useState(false);
const { name = '' } = filter;
@@ -129,27 +231,60 @@ const FilterControl: React.FC = ({
);
const isRequired = !!filter.controlValues?.enableEmptyFilter;
+ const {
+ FilterControlContainer,
+ FormItem,
+ FilterControlTitleBox,
+ FilterControlTitle,
+ } = useFilterControlDisplay(orientation, overflow);
+
const label = useMemo(
() => (
-
-
+
+
{name}
-
+
{isRequired && }
{filter.description?.trim() && (
)}
{icon}
-
+
),
- [name, isRequired, filter.description, icon],
+ [
+ FilterControlTitleBox,
+ FilterControlTitle,
+ name,
+ isRequired,
+ filter.description,
+ icon,
+ ],
);
const isScrolling = useContext(FilterBarScrollContext);
+ const filterCardPlacement = useMemo(() => {
+ if (orientation === FilterBarOrientation.HORIZONTAL) {
+ if (overflow) {
+ return FilterCardPlacement.Left;
+ }
+ return FilterCardPlacement.Bottom;
+ }
+ return FilterCardPlacement.Right;
+ }, [orientation, overflow]);
return (
-
-
+
+
= ({
inView={inView}
parentRef={parentRef}
setFilterActive={setIsFilterActive}
+ orientation={orientation}
+ overflow={overflow}
/>
-
+
);
};
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 79085daee3f48..9b0347ebf0221 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
@@ -16,34 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
-import React, { FC, useCallback, useMemo } from 'react';
-import { css } from '@emotion/react';
+import React, { FC, useCallback, useMemo, useState } from 'react';
import {
DataMask,
DataMaskStateWithId,
Filter,
- isFilterDivider,
- styled,
- t,
+ Divider,
+ css,
+ SupersetTheme,
} from '@superset-ui/core';
import {
createHtmlPortalNode,
InPortal,
OutPortal,
} from 'react-reverse-portal';
-import { AntdCollapse } from 'src/components';
+import { useSelector } from 'react-redux';
import {
useDashboardHasTabs,
useSelectFiltersInScope,
} from 'src/dashboard/components/nativeFilters/state';
-import { useFilters } from '../state';
-import FilterControl from './FilterControl';
-
-const Wrapper = styled.div`
- padding: ${({ theme }) => theme.gridUnit * 4}px;
- // 108px padding to make room for buttons with position: absolute
- padding-bottom: ${({ theme }) => theme.gridUnit * 27}px;
-`;
+import { FilterBarOrientation, RootState } from 'src/dashboard/types';
+import DropdownContainer from 'src/components/DropdownContainer';
+import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
+import { useFilterControlFactory } from '../useFilterControlFactory';
+import { FiltersDropdownContent } from '../FiltersDropdownContent';
type FilterControlsProps = {
directPathToChild?: string[];
@@ -56,112 +52,115 @@ const FilterControls: FC = ({
dataMaskSelected,
onFilterSelectionChange,
}) => {
- const filters = useFilters();
- const filterValues = useMemo(() => Object.values(filters), [filters]);
+ const filterBarOrientation = useSelector(
+ state => state.dashboardInfo.filterBarOrientation,
+ );
+
+ const [overflowIndex, setOverflowIndex] = useState(0);
+
+ const { filterControlFactory, filtersWithValues } = useFilterControlFactory(
+ dataMaskSelected,
+ directPathToChild,
+ onFilterSelectionChange,
+ );
const portalNodes = useMemo(() => {
- const nodes = new Array(filterValues.length);
- for (let i = 0; i < filterValues.length; i += 1) {
+ const nodes = new Array(filtersWithValues.length);
+ for (let i = 0; i < filtersWithValues.length; i += 1) {
nodes[i] = createHtmlPortalNode();
}
return nodes;
- }, [filterValues.length]);
+ }, [filtersWithValues.length]);
- const filtersWithValues = useMemo(
- () =>
- filterValues.map(filter => ({
- ...filter,
- dataMask: dataMaskSelected[filter.id],
- })),
- [filterValues, dataMaskSelected],
- );
const filterIds = new Set(filtersWithValues.map(item => item.id));
const [filtersInScope, filtersOutOfScope] =
useSelectFiltersInScope(filtersWithValues);
+
const dashboardHasTabs = useDashboardHasTabs();
const showCollapsePanel = dashboardHasTabs && filtersWithValues.length > 0;
- const filterControlFactory = useCallback(
- index => {
- const filter = filtersWithValues[index];
- if (isFilterDivider(filter)) {
- return (
-
-
{filter.title}
-
{filter.description}
-
- );
- }
- return (
-
- );
+ const renderer = useCallback(
+ ({ id }: Filter | Divider) => {
+ const index = filtersWithValues.findIndex(f => f.id === id);
+ return ;
},
- [
- filtersWithValues,
- JSON.stringify(dataMaskSelected),
- directPathToChild,
- onFilterSelectionChange,
- ],
+ [filtersWithValues, portalNodes],
);
- return (
-
- {portalNodes
- .filter((node, index) => filterIds.has(filterValues[index].id))
- .map((node, index) => (
- {filterControlFactory(index)}
- ))}
- {filtersInScope.map(filter => {
- const index = filterValues.findIndex(f => f.id === filter.id);
- return ;
- })}
- {showCollapsePanel && (
- css`
- &.ant-collapse {
- margin-top: ${filtersInScope.length > 0
- ? theme.gridUnit * 6
- : 0}px;
- & > .ant-collapse-item {
- & > .ant-collapse-header {
- padding-left: 0;
- padding-bottom: ${theme.gridUnit * 2}px;
- & > .ant-collapse-arrow {
- right: ${theme.gridUnit}px;
- }
- }
+ const renderVerticalContent = () => (
+ <>
+ {filtersInScope.map(renderer)}
+ {showCollapsePanel && (
+ 0}
+ renderer={renderer}
+ />
+ )}
+ >
+ );
- & .ant-collapse-content-box {
- padding: ${theme.gridUnit * 4}px 0 0;
- }
- }
- }
+ const renderHorizontalContent = () => {
+ const items = filtersInScope.map(filter => ({
+ id: filter.id,
+ element: (
+
-
- {filtersOutOfScope.map(filter => {
- const index = filtersWithValues.findIndex(
- f => f.id === filter.id,
- );
- return ;
- })}
-
-
- )}
-
+ {renderer(filter)}
+
+ ),
+ }));
+ return (
+
+ css`
+ padding-left: ${theme.gridUnit * 4}px;
+ min-width: 0;
+ `
+ }
+ >
+ {
+ const overflowedItemIds = new Set(
+ overflowedItems.map(({ id }) => id),
+ );
+ return (
+
+ overflowedItemIds.has(id),
+ )}
+ filtersOutOfScope={filtersOutOfScope}
+ renderer={renderer}
+ showCollapsePanel={showCollapsePanel}
+ />
+ );
+ }}
+ onOverflowingStateChange={overflowingState =>
+ setOverflowIndex(overflowingState.notOverflowed.length)
+ }
+ />
+
+ );
+ };
+
+ return (
+ <>
+ {portalNodes
+ .filter((node, index) => filterIds.has(filtersWithValues[index].id))
+ .map((node, index) => (
+
+ {filterControlFactory(index, filterBarOrientation, overflowIndex)}
+
+ ))}
+ {filterBarOrientation === FilterBarOrientation.VERTICAL &&
+ renderVerticalContent()}
+ {filterBarOrientation === FilterBarOrientation.HORIZONTAL &&
+ renderHorizontalContent()}
+ >
);
};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.stories.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.stories.tsx
new file mode 100644
index 0000000000000..212e9033588f3
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.stories.tsx
@@ -0,0 +1,122 @@
+/**
+ * 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 { css } from '@emotion/react';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import FilterDivider from './FilterDivider';
+import 'src/dashboard/stylesheets/index.less';
+import { FilterDividerProps } from './types';
+
+export default {
+ title: 'FilterDivider',
+ component: FilterDivider,
+};
+
+export const VerticalFilterDivider = (props: FilterDividerProps) => (
+
+);
+
+export const HorizontalFilterDivider = (props: FilterDividerProps) => (
+
+);
+
+export const HorizontalOverflowFilterDivider = (props: FilterDividerProps) => (
+
+);
+
+const args = {
+ title: 'Sample title',
+ description: 'Sample description',
+};
+
+const story = { parameters: { knobs: { disable: true } } };
+
+VerticalFilterDivider.args = {
+ ...args,
+ horizontal: false,
+ overflow: false,
+};
+
+VerticalFilterDivider.story = story;
+
+HorizontalFilterDivider.args = {
+ ...args,
+ horizontal: true,
+ overflow: false,
+};
+
+HorizontalFilterDivider.story = story;
+
+HorizontalOverflowFilterDivider.args = {
+ ...args,
+ horizontal: true,
+ overflow: true,
+};
+
+HorizontalOverflowFilterDivider.story = story;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.test.tsx
new file mode 100644
index 0000000000000..6aae27fc5edd7
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * 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 FilterDivider from './FilterDivider';
+
+const SAMPLE_TITLE = 'Sample title';
+const SAMPLE_DESCRIPTION =
+ 'Sample description that is even longer, it goes on and on and on and on and on and on and on and on and on and on.';
+
+test('vertical mode, title', () => {
+ render();
+ const title = screen.getByRole('heading', { name: SAMPLE_TITLE });
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent(SAMPLE_TITLE);
+ const description = screen.queryByTestId('divider-description');
+ expect(description).not.toBeInTheDocument();
+ const descriptionIcon = screen.queryByTestId('divider-description-icon');
+ expect(descriptionIcon).not.toBeInTheDocument();
+});
+
+test('vertical mode, title and description', () => {
+ render(
+ ,
+ );
+
+ const title = screen.getByRole('heading', { name: SAMPLE_TITLE });
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent(SAMPLE_TITLE);
+ const description = screen.getByTestId('divider-description');
+ expect(description).toBeVisible();
+ expect(description).toHaveTextContent(SAMPLE_DESCRIPTION);
+ const descriptionIcon = screen.queryByTestId('divider-description-icon');
+ expect(descriptionIcon).not.toBeInTheDocument();
+});
+
+test('horizontal mode, title', () => {
+ render(
+ ,
+ );
+
+ const title = screen.getByRole('heading', { name: SAMPLE_TITLE });
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent(SAMPLE_TITLE);
+ const description = screen.queryByTestId('divider-description');
+ expect(description).not.toBeInTheDocument();
+ const descriptionIcon = screen.queryByTestId('divider-description-icon');
+ expect(descriptionIcon).not.toBeInTheDocument();
+});
+
+test('horizontal mode, title and description', async () => {
+ render(
+ ,
+ );
+
+ const title = screen.getByRole('heading', { name: SAMPLE_TITLE });
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent(SAMPLE_TITLE);
+ const description = screen.queryByTestId('divider-description');
+ expect(description).not.toBeInTheDocument();
+ const descriptionIcon = screen.getByTestId('divider-description-icon');
+ expect(descriptionIcon).toBeVisible();
+ userEvent.hover(descriptionIcon);
+ const tooltip = await screen.findByRole('tooltip', {
+ name: SAMPLE_DESCRIPTION,
+ });
+
+ expect(tooltip).toBeInTheDocument();
+ expect(tooltip).toHaveTextContent(SAMPLE_DESCRIPTION);
+});
+
+test('horizontal overflow mode, title', () => {
+ render(
+ ,
+ );
+
+ const title = screen.getByRole('heading', { name: SAMPLE_TITLE });
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent(SAMPLE_TITLE);
+ const description = screen.queryByTestId('divider-description');
+ expect(description).not.toBeInTheDocument();
+ const descriptionIcon = screen.queryByTestId('divider-description-icon');
+ expect(descriptionIcon).not.toBeInTheDocument();
+});
+
+test('horizontal overflow mode, title and description', () => {
+ render(
+ ,
+ );
+
+ const title = screen.getByRole('heading', { name: SAMPLE_TITLE });
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent(SAMPLE_TITLE);
+ const description = screen.queryByTestId('divider-description');
+ expect(description).toBeVisible();
+ expect(description).toHaveTextContent(SAMPLE_DESCRIPTION);
+ const descriptionIcon = screen.queryByTestId('divider-description-icon');
+ expect(descriptionIcon).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx
new file mode 100644
index 0000000000000..522bd977aaa6b
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.tsx
@@ -0,0 +1,166 @@
+/**
+ * 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, useTheme } from '@superset-ui/core';
+import React from 'react';
+import Icons from 'src/components/Icons';
+import { Tooltip } from 'src/components/Tooltip';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import { useCSSTextTruncation, truncationCSS } from 'src/hooks/useTruncation';
+import { FilterDividerProps } from './types';
+
+const VerticalDivider = ({ title, description }: FilterDividerProps) => (
+
+
{title}
+ {description ?
{description}
: null}
+
+);
+
+const HorizontalDivider = ({ title, description }: FilterDividerProps) => {
+ const theme = useTheme();
+ const [titleRef, titleIsTruncated] =
+ useCSSTextTruncation(title);
+
+ const tooltipOverlay = (
+ <>
+ {titleIsTruncated ? (
+
+ {title}
+
+ ) : null}
+ {description ? {description}
: null}
+ >
+ );
+
+ return (
+
+
+ {title}
+
+ {titleIsTruncated || description ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+const HorizontalOverflowDivider = ({
+ title,
+ description,
+}: FilterDividerProps) => {
+ const theme = useTheme();
+ const [titleRef, titleIsTruncated] =
+ useCSSTextTruncation(title);
+
+ const [descriptionRef, descriptionIsTruncated] =
+ useCSSTextTruncation(description);
+
+ return (
+
+
{title} : null}>
+
+ {title}
+
+
+ {description ? (
+
+
+ {description}
+
+
+ ) : null}
+
+ );
+};
+
+const FilterDivider = ({
+ title,
+ description,
+ orientation = FilterBarOrientation.VERTICAL,
+ overflow = false,
+}: FilterDividerProps) => {
+ if (orientation === FilterBarOrientation.HORIZONTAL) {
+ if (overflow) {
+ return (
+
+ );
+ }
+
+ return ;
+ }
+
+ return ;
+};
+
+export default FilterDivider;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
index 4337d59ed86fc..a08200d83b217 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
@@ -42,10 +42,10 @@ import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
import { ClientErrorObject } from 'src/utils/getClientErrorObject';
-import { RootState } from 'src/dashboard/types';
+import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import { onFiltersRefreshSuccess } from 'src/dashboard/actions/dashboardState';
import { dispatchFocusAction } from './utils';
-import { FilterProps } from './types';
+import { FilterControlProps } from './types';
import { getFormData } from '../../utils';
import { useFilterDependencies } from './state';
import { checkIsMissingRequiredValue } from '../utils';
@@ -75,7 +75,7 @@ const useShouldFilterRefresh = () => {
return !isDashboardRefreshing && isFilterRefreshing;
};
-const FilterValue: React.FC = ({
+const FilterValue: React.FC = ({
dataMaskSelected,
filter,
directPathToChild,
@@ -84,6 +84,8 @@ const FilterValue: React.FC = ({
showOverflow,
parentRef,
setFilterActive,
+ orientation = FilterBarOrientation.VERTICAL,
+ overflow = false,
}) => {
const { id, targets, filterType, adhoc_filters, time_range } = filter;
const metadata = getChartMetadataRegistry().get(filterType);
@@ -251,6 +253,11 @@ const FilterValue: React.FC = ({
[filter.dataMask?.filterState, isMissingRequiredValue],
);
+ const formDataWithDisplayParams = useMemo(
+ () => ({ ...formData, orientation, overflow }),
+ [formData, orientation, overflow],
+ );
+
if (error) {
return (
= ({
height={HEIGHT}
width="100%"
showOverflow={showOverflow}
- formData={formData}
+ formData={formDataWithDisplayParams}
parentRef={parentRef}
inputRef={inputRef}
// For charts that don't have datasource we need workaround for empty placeholder
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts
index e5b553712634e..a48ca5f0aab6f 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts
@@ -18,8 +18,19 @@
*/
import React, { RefObject } from 'react';
import { DataMask, DataMaskStateWithId, Filter } from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
-export interface FilterProps {
+export interface BaseFilterProps {
+ orientation?: FilterBarOrientation;
+ overflow?: boolean;
+}
+
+export interface FilterDividerProps extends BaseFilterProps {
+ title: string;
+ description: string;
+}
+
+export interface FilterControlProps extends BaseFilterProps {
dataMaskSelected?: DataMaskStateWithId;
filter: Filter & {
dataMask?: DataMask;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.tsx
new file mode 100644
index 0000000000000..7ed19f44c670a
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersDropdownContent/index.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 React, { ReactNode } from 'react';
+import { css, Divider, Filter, SupersetTheme } from '@superset-ui/core';
+import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
+
+export interface FiltersDropdownContentProps {
+ filtersInScope: (Filter | Divider)[];
+ filtersOutOfScope: (Filter | Divider)[];
+ renderer: (filter: Filter | Divider) => ReactNode;
+ showCollapsePanel?: boolean;
+}
+
+export const FiltersDropdownContent = ({
+ filtersInScope,
+ filtersOutOfScope,
+ renderer,
+ showCollapsePanel,
+}: FiltersDropdownContentProps) => (
+
+ css`
+ width: ${theme.gridUnit * 56}px;
+ `
+ }
+ >
+ {filtersInScope.map(renderer)}
+ {showCollapsePanel && (
+
+ )}
+
+);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx
new file mode 100644
index 0000000000000..aee46f8d61569
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FiltersOutOfScopeCollapsible/index.tsx
@@ -0,0 +1,69 @@
+/**
+ * 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, { ReactNode } from 'react';
+import { css } from '@emotion/react';
+import { Divider, Filter, t } from '@superset-ui/core';
+import { AntdCollapse } from 'src/components';
+
+export interface FiltersOutOfScopeCollapsibleProps {
+ filtersOutOfScope: (Filter | Divider)[];
+ renderer: (filter: Filter | Divider) => ReactNode;
+ hasTopMargin?: boolean;
+}
+
+export const FiltersOutOfScopeCollapsible = ({
+ filtersOutOfScope,
+ hasTopMargin,
+ renderer,
+}: FiltersOutOfScopeCollapsibleProps) => (
+ css`
+ &.ant-collapse {
+ margin-top: ${hasTopMargin
+ ? theme.gridUnit * 6
+ : theme.gridUnit * -3}px;
+ & > .ant-collapse-item {
+ & > .ant-collapse-header {
+ padding-left: 0;
+ padding-bottom: ${theme.gridUnit * 2}px;
+
+ & > .ant-collapse-arrow {
+ right: ${theme.gridUnit}px;
+ }
+ }
+
+ & .ant-collapse-content-box {
+ padding: ${theme.gridUnit * 4}px 0 0;
+ }
+ }
+ }
+ `}
+ >
+
+ {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 57bf0c0e56def..ac03b0e0ff598 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx
@@ -63,7 +63,10 @@ const FilterBarEmptyStateContainer = styled.div`
const FiltersLinkContainer = styled.div<{ hasFilters: boolean }>`
${({ theme, hasFilters }) => `
- padding: 0 ${theme.gridUnit * 2}px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ padding: 0 ${theme.gridUnit * 4}px 0 ${theme.gridUnit * 4}px;
border-right: ${
hasFilters ? `1px solid ${theme.colors.grayscale.light2}` : 0
};
@@ -76,7 +79,7 @@ const FiltersLinkContainer = styled.div<{ hasFilters: boolean }>`
color: ${theme.colors.primary.base};
> .anticon {
height: 24px;
- padding-right: ${theme.gridUnit * 2}px;
+ padding-right: ${theme.gridUnit}px;
}
> .anticon + span, > .anticon {
margin-right: 0;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx
index 258489cc2a2de..54ec436ea4cbc 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx
@@ -130,6 +130,12 @@ const FilterBarEmptyStateContainer = styled.div`
margin-top: ${({ theme }) => theme.gridUnit * 8}px;
`;
+const FilterControlsWrapper = styled.div`
+ padding: ${({ theme }) => theme.gridUnit * 4}px;
+ // 108px padding to make room for buttons with position: absolute
+ padding-bottom: ${({ theme }) => theme.gridUnit * 27}px;
+`;
+
export const FilterBarScrollContext = createContext(false);
const VerticalFilterBar: React.FC = ({
actions,
@@ -249,11 +255,13 @@ const VerticalFilterBar: React.FC = ({
/>
) : (
-
+
+
+
)}
= ({
/>
) : (
-
+
+
+
)}
)}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/useFilterControlFactory.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/useFilterControlFactory.tsx
new file mode 100644
index 0000000000000..fe855ec3e3fc9
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/useFilterControlFactory.tsx
@@ -0,0 +1,87 @@
+/**
+ * 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, useMemo } from 'react';
+import {
+ DataMask,
+ DataMaskStateWithId,
+ Divider,
+ Filter,
+ isFilterDivider,
+} from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import FilterControl from './FilterControls/FilterControl';
+import { useFilters } from './state';
+import FilterDivider from './FilterControls/FilterDivider';
+
+export const useFilterControlFactory = (
+ dataMaskSelected: DataMaskStateWithId,
+ directPathToChild: string[] | undefined,
+ onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void,
+) => {
+ const filters = useFilters();
+ const filterValues = useMemo(() => Object.values(filters), [filters]);
+ const filtersWithValues: (Filter | Divider)[] = useMemo(
+ () =>
+ filterValues.map(filter => ({
+ ...filter,
+ dataMask: dataMaskSelected[filter.id],
+ })),
+ [filterValues, dataMaskSelected],
+ );
+
+ const filterControlFactory = useCallback(
+ (
+ index: number,
+ filterBarOrientation: FilterBarOrientation,
+ overflowIndex: number,
+ ) => {
+ const filter = filtersWithValues[index];
+ if (isFilterDivider(filter)) {
+ return (
+ = overflowIndex}
+ />
+ );
+ }
+ return (
+ = overflowIndex}
+ />
+ );
+ },
+ [
+ filtersWithValues,
+ dataMaskSelected,
+ directPathToChild,
+ onFilterSelectionChange,
+ ],
+ );
+
+ return { filterControlFactory, filtersWithValues };
+};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/index.tsx
index bc1f7b2ea3712..8d4b9051eb717 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/index.tsx
@@ -27,6 +27,7 @@ export const FilterCard = ({
filter,
getPopupContainer,
isVisible: externalIsVisible = true,
+ placement,
}: FilterCardProps) => {
const [internalIsVisible, setInternalIsVisible] = useState(false);
@@ -37,7 +38,7 @@ export const FilterCard = ({
}, [externalIsVisible]);
return (
HTMLElement;
isVisible?: boolean;
+ placement: FilterCardPlacement;
}
export interface FilterCardRowProps {
diff --git a/superset-frontend/src/filters/components/types.ts b/superset-frontend/src/filters/components/types.ts
index 2a403fe61bb73..4ab75a825c775 100644
--- a/superset-frontend/src/filters/components/types.ts
+++ b/superset-frontend/src/filters/components/types.ts
@@ -1,4 +1,5 @@
import { SetDataMaskHook } from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
/**
* Licensed to the Apache Software Foundation (ASF) under one
@@ -21,6 +22,8 @@ import { SetDataMaskHook } from '@superset-ui/core';
export interface PluginFilterStylesProps {
height: number;
width: number;
+ orientation?: FilterBarOrientation;
+ overflow?: boolean;
}
export interface PluginFilterHooks {
diff --git a/superset-frontend/src/hooks/useTruncation/index.ts b/superset-frontend/src/hooks/useTruncation/index.ts
index 7f3e1bcadecee..5dc5550188a3d 100644
--- a/superset-frontend/src/hooks/useTruncation/index.ts
+++ b/superset-frontend/src/hooks/useTruncation/index.ts
@@ -16,92 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { RefObject, useLayoutEffect, useState, useRef } from 'react';
-export const useTruncation = (
- elementRef: RefObject,
- plusRef?: RefObject,
-) => {
- const [elementsTruncated, setElementsTruncated] = useState(0);
- const [hasHiddenElements, setHasHiddenElements] = useState(false);
+import useTruncation from './useChildElementTruncation';
+import useCSSTextTruncation, { truncationCSS } from './useCSSTextTruncation';
- const previousEffectInfoRef = useRef({
- scrollWidth: 0,
- parentElementWidth: 0,
- plusRefWidth: 0,
- });
-
- useLayoutEffect(() => {
- const currentElement = elementRef.current;
- const plusRefElement = plusRef?.current;
-
- if (!currentElement) {
- return;
- }
-
- const { scrollWidth, clientWidth, childNodes } = currentElement;
-
- // By using the result of this effect to truncate content
- // we're effectively changing it's size.
- // That will trigger another pass at this effect.
- // Depending on the content elements width, that second rerender could
- // yield a different truncate count, thus potentially leading to a
- // rendering loop.
- // There's only a need to recompute if the parent width or the width of
- // the child nodes changes.
- const previousEffectInfo = previousEffectInfoRef.current;
- const parentElementWidth = currentElement.parentElement?.clientWidth || 0;
- const plusRefWidth = plusRefElement?.offsetWidth || 0;
- previousEffectInfoRef.current = {
- scrollWidth,
- parentElementWidth,
- plusRefWidth,
- };
-
- if (
- previousEffectInfo.parentElementWidth === parentElementWidth &&
- previousEffectInfo.scrollWidth === scrollWidth &&
- previousEffectInfo.plusRefWidth === plusRefWidth
- ) {
- return;
- }
-
- if (scrollWidth > clientWidth) {
- // "..." is around 6px wide
- const truncationWidth = 6;
- const plusSize = plusRefElement?.offsetWidth || 0;
- const maxWidth = clientWidth - truncationWidth;
- const elementsCount = childNodes.length;
-
- let width = 0;
- let hiddenElements = 0;
- for (let i = 0; i < elementsCount; i += 1) {
- const itemWidth = (childNodes[i] as HTMLElement).offsetWidth;
- const remainingWidth = maxWidth - truncationWidth - width - plusSize;
-
- // assures it shows +{number} only when the item is not visible
- if (remainingWidth <= 0) {
- hiddenElements += 1;
- }
- width += itemWidth;
- }
-
- if (elementsCount > 1 && hiddenElements) {
- setHasHiddenElements(true);
- setElementsTruncated(hiddenElements);
- } else {
- setHasHiddenElements(false);
- setElementsTruncated(1);
- }
- } else {
- setHasHiddenElements(false);
- setElementsTruncated(0);
- }
- }, [
- elementRef.current?.offsetWidth,
- elementRef.current?.clientWidth,
- elementRef,
- ]);
-
- return [elementsTruncated, hasHiddenElements];
-};
+export { useTruncation, useCSSTextTruncation, truncationCSS };
diff --git a/superset-frontend/src/hooks/useTruncation/useCSSTextTruncation.ts b/superset-frontend/src/hooks/useTruncation/useCSSTextTruncation.ts
new file mode 100644
index 0000000000000..e9486f8705e82
--- /dev/null
+++ b/superset-frontend/src/hooks/useTruncation/useCSSTextTruncation.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 { css } from '@emotion/react';
+import React, { useEffect, useRef, useState } from 'react';
+
+/**
+ * Importable CSS that enables text truncation on fixed-width block
+ * elements.
+ */
+export const truncationCSS = css`
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+/**
+ * This hook encapsulates logic supporting truncation of text via
+ * the CSS "text-overflow: ellipsis;" feature. Given the text content
+ * to be displayed, this hook returns a ref to attach to the text
+ * element and a boolean for whether that element is currently truncated.
+ */
+const useCSSTextTruncation = (
+ text: string,
+): [React.RefObject, boolean] => {
+ const ref = useRef(null);
+ const [isTruncated, setIsTruncated] = useState(true);
+ useEffect(() => {
+ if (ref.current) {
+ setIsTruncated(ref.current.offsetWidth < ref.current.scrollWidth);
+ }
+ }, [text]);
+
+ return [ref, isTruncated];
+};
+
+export default useCSSTextTruncation;
diff --git a/superset-frontend/src/hooks/useTruncation/useChildElementTruncation.ts b/superset-frontend/src/hooks/useTruncation/useChildElementTruncation.ts
new file mode 100644
index 0000000000000..4f6b628642ab4
--- /dev/null
+++ b/superset-frontend/src/hooks/useTruncation/useChildElementTruncation.ts
@@ -0,0 +1,118 @@
+/**
+ * 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 { RefObject, useLayoutEffect, useState, useRef } from 'react';
+
+/**
+ * This hook encapsulates logic to support truncation of child HTML
+ * elements contained in a fixed-width parent HTML element. Given
+ * a ref to the parent element and optionally a ref to the "+x"
+ * component that shows the number of truncated items, this hook
+ * will return the number of elements that are not fully visible
+ * (including those completely hidden) and whether any elements
+ * are completely hidden.
+ */
+const useChildElementTruncation = (
+ elementRef: RefObject,
+ plusRef?: RefObject,
+) => {
+ const [elementsTruncated, setElementsTruncated] = useState(0);
+ const [hasHiddenElements, setHasHiddenElements] = useState(false);
+
+ const previousEffectInfoRef = useRef({
+ scrollWidth: 0,
+ parentElementWidth: 0,
+ plusRefWidth: 0,
+ });
+
+ useLayoutEffect(() => {
+ const currentElement = elementRef.current;
+ const plusRefElement = plusRef?.current;
+
+ if (!currentElement) {
+ return;
+ }
+
+ const { scrollWidth, clientWidth, childNodes } = currentElement;
+
+ // By using the result of this effect to truncate content
+ // we're effectively changing it's size.
+ // That will trigger another pass at this effect.
+ // Depending on the content elements width, that second rerender could
+ // yield a different truncate count, thus potentially leading to a
+ // rendering loop.
+ // There's only a need to recompute if the parent width or the width of
+ // the child nodes changes.
+ const previousEffectInfo = previousEffectInfoRef.current;
+ const parentElementWidth = currentElement.parentElement?.clientWidth || 0;
+ const plusRefWidth = plusRefElement?.offsetWidth || 0;
+ previousEffectInfoRef.current = {
+ scrollWidth,
+ parentElementWidth,
+ plusRefWidth,
+ };
+
+ if (
+ previousEffectInfo.parentElementWidth === parentElementWidth &&
+ previousEffectInfo.scrollWidth === scrollWidth &&
+ previousEffectInfo.plusRefWidth === plusRefWidth
+ ) {
+ return;
+ }
+
+ if (scrollWidth > clientWidth) {
+ // "..." is around 6px wide
+ const truncationWidth = 6;
+ const plusSize = plusRefElement?.offsetWidth || 0;
+ const maxWidth = clientWidth - truncationWidth;
+ const elementsCount = childNodes.length;
+
+ let width = 0;
+ let hiddenElements = 0;
+ for (let i = 0; i < elementsCount; i += 1) {
+ const itemWidth = (childNodes[i] as HTMLElement).offsetWidth;
+ const remainingWidth = maxWidth - truncationWidth - width - plusSize;
+
+ // assures it shows +{number} only when the item is not visible
+ if (remainingWidth <= 0) {
+ hiddenElements += 1;
+ }
+ width += itemWidth;
+ }
+
+ if (elementsCount > 1 && hiddenElements) {
+ setHasHiddenElements(true);
+ setElementsTruncated(hiddenElements);
+ } else {
+ setHasHiddenElements(false);
+ setElementsTruncated(1);
+ }
+ } else {
+ setHasHiddenElements(false);
+ setElementsTruncated(0);
+ }
+ }, [
+ elementRef.current?.offsetWidth,
+ elementRef.current?.clientWidth,
+ elementRef,
+ ]);
+
+ return [elementsTruncated, hasHiddenElements];
+};
+
+export default useChildElementTruncation;