feat: Make filters and dividers display horizontally in horizontal native filters filter bar (#22169)

Co-authored-by: Michael S. Molina <michael.s.molina@gmail.com>
This commit is contained in:
Cody Leff 2022-11-25 03:59:10 -07:00 committed by GitHub
parent b2fcdc56c1
commit 64939f2872
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1130 additions and 228 deletions

View File

@ -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',

View File

@ -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: [],
},

View File

@ -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;
`}
>
<div
css={css`
display: flex;
align-items: center;
gap: ${theme.gridUnit * 3}px;
gap: ${theme.gridUnit * 4}px;
margin-right: ${theme.gridUnit * 3}px;
min-width: 100px;
`}

View File

@ -87,7 +87,7 @@ const verticalStyle = (theme: SupersetTheme, width: number) => 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};

View File

@ -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 }) => (
</ToolTipContainer>
);
const FilterControl: React.FC<FilterProps> = ({
const FilterControl = ({
dataMaskSelected,
filter,
icon,
@ -118,7 +218,9 @@ const FilterControl: React.FC<FilterProps> = ({
inView,
showOverflow,
parentRef,
}) => {
orientation = FilterBarOrientation.VERTICAL,
overflow = false,
}: FilterControlProps) => {
const [isFilterActive, setIsFilterActive] = useState(false);
const { name = '<undefined>' } = filter;
@ -129,27 +231,60 @@ const FilterControl: React.FC<FilterProps> = ({
);
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const {
FilterControlContainer,
FormItem,
FilterControlTitleBox,
FilterControlTitle,
} = useFilterControlDisplay(orientation, overflow);
const label = useMemo(
() => (
<StyledFilterControlTitleBox>
<StyledFilterControlTitle data-test="filter-control-name">
<FilterControlTitleBox>
<FilterControlTitle data-test="filter-control-name">
{name}
</StyledFilterControlTitle>
</FilterControlTitle>
{isRequired && <RequiredFieldIndicator />}
{filter.description?.trim() && (
<DescriptionToolTip description={filter.description} />
)}
<StyledIcon data-test="filter-icon">{icon}</StyledIcon>
</StyledFilterControlTitleBox>
</FilterControlTitleBox>
),
[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 (
<StyledFilterControlContainer layout="vertical">
<FilterCard filter={filter} isVisible={!isFilterActive && !isScrolling}>
<FilterControlContainer
layout={
orientation === FilterBarOrientation.HORIZONTAL && !overflow
? 'horizontal'
: 'vertical'
}
>
<FilterCard
filter={filter}
isVisible={!isFilterActive && !isScrolling}
placement={filterCardPlacement}
>
<div>
<FormItem
label={label}
@ -165,11 +300,13 @@ const FilterControl: React.FC<FilterProps> = ({
inView={inView}
parentRef={parentRef}
setFilterActive={setIsFilterActive}
orientation={orientation}
overflow={overflow}
/>
</FormItem>
</div>
</FilterCard>
</StyledFilterControlContainer>
</FilterControlContainer>
);
};

View File

@ -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<FilterControlsProps> = ({
dataMaskSelected,
onFilterSelectionChange,
}) => {
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
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 (
<div>
<h3>{filter.title}</h3>
<p>{filter.description}</p>
</div>
);
}
return (
<FilterControl
dataMaskSelected={dataMaskSelected}
filter={filter}
directPathToChild={directPathToChild}
onFilterSelectionChange={onFilterSelectionChange}
inView={false}
/>
);
const renderer = useCallback(
({ id }: Filter | Divider) => {
const index = filtersWithValues.findIndex(f => f.id === id);
return <OutPortal node={portalNodes[index]} inView />;
},
[
filtersWithValues,
JSON.stringify(dataMaskSelected),
directPathToChild,
onFilterSelectionChange,
],
[filtersWithValues, portalNodes],
);
return (
<Wrapper>
{portalNodes
.filter((node, index) => filterIds.has(filterValues[index].id))
.map((node, index) => (
<InPortal node={node}>{filterControlFactory(index)}</InPortal>
))}
{filtersInScope.map(filter => {
const index = filterValues.findIndex(f => f.id === filter.id);
return <OutPortal node={portalNodes[index]} inView />;
})}
const renderVerticalContent = () => (
<>
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<AntdCollapse
ghost
bordered
expandIconPosition="right"
collapsible={filtersOutOfScope.length === 0 ? 'disabled' : undefined}
css={theme => 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;
<FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope}
hasTopMargin={filtersInScope.length > 0}
renderer={renderer}
/>
)}
</>
);
& > .ant-collapse-arrow {
right: ${theme.gridUnit}px;
}
}
& .ant-collapse-content-box {
padding: ${theme.gridUnit * 4}px 0 0;
}
}
}
const renderHorizontalContent = () => {
const items = filtersInScope.map(filter => ({
id: filter.id,
element: (
<div
css={css`
flex-shrink: 0;
`}
>
<AntdCollapse.Panel
header={t('Filters out of scope (%d)', filtersOutOfScope.length)}
key="1"
>
{filtersOutOfScope.map(filter => {
const index = filtersWithValues.findIndex(
f => f.id === filter.id,
);
return <OutPortal node={portalNodes[index]} inView />;
})}
</AntdCollapse.Panel>
</AntdCollapse>
)}
</Wrapper>
{renderer(filter)}
</div>
),
}));
return (
<div
css={(theme: SupersetTheme) =>
css`
padding-left: ${theme.gridUnit * 4}px;
min-width: 0;
`
}
>
<DropdownContainer
items={items}
dropdownContent={overflowedItems => {
const overflowedItemIds = new Set(
overflowedItems.map(({ id }) => id),
);
return (
<FiltersDropdownContent
filtersInScope={filtersInScope.filter(({ id }) =>
overflowedItemIds.has(id),
)}
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
showCollapsePanel={showCollapsePanel}
/>
);
}}
onOverflowingStateChange={overflowingState =>
setOverflowIndex(overflowingState.notOverflowed.length)
}
/>
</div>
);
};
return (
<>
{portalNodes
.filter((node, index) => filterIds.has(filtersWithValues[index].id))
.map((node, index) => (
<InPortal node={node}>
{filterControlFactory(index, filterBarOrientation, overflowIndex)}
</InPortal>
))}
{filterBarOrientation === FilterBarOrientation.VERTICAL &&
renderVerticalContent()}
{filterBarOrientation === FilterBarOrientation.HORIZONTAL &&
renderHorizontalContent()}
</>
);
};

View File

@ -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) => (
<div
css={css`
background-color: #ddd;
padding: 50px;
`}
>
<div
css={css`
display: flex;
flex-direction: column;
width: 259px;
padding: 16px;
background-color: white;
`}
>
<FilterDivider {...props} />
</div>
</div>
);
export const HorizontalFilterDivider = (props: FilterDividerProps) => (
<div
css={css`
background-color: #ddd;
padding: 50px;
`}
>
<div
css={css`
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
background-color: white;
`}
>
<FilterDivider orientation={FilterBarOrientation.HORIZONTAL} {...props} />
</div>
</div>
);
export const HorizontalOverflowFilterDivider = (props: FilterDividerProps) => (
<div
css={css`
background-color: #ddd;
padding: 50px;
`}
>
<div
css={css`
width: 224px;
padding: 16px;
background-color: white;
`}
>
<FilterDivider {...props} />
</div>
</div>
);
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;

View File

@ -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(<FilterDivider title={SAMPLE_TITLE} description="" />);
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(
<FilterDivider title={SAMPLE_TITLE} description={SAMPLE_DESCRIPTION} />,
);
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(
<FilterDivider
orientation={FilterBarOrientation.HORIZONTAL}
title={SAMPLE_TITLE}
description=""
/>,
);
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(
<FilterDivider
orientation={FilterBarOrientation.HORIZONTAL}
title={SAMPLE_TITLE}
description={SAMPLE_DESCRIPTION}
/>,
);
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(
<FilterDivider
orientation={FilterBarOrientation.HORIZONTAL}
overflow
title={SAMPLE_TITLE}
description=""
/>,
);
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(
<FilterDivider
orientation={FilterBarOrientation.HORIZONTAL}
overflow
title={SAMPLE_TITLE}
description={SAMPLE_DESCRIPTION}
/>,
);
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();
});

View File

@ -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) => (
<div>
<h3>{title}</h3>
{description ? <p data-test="divider-description">{description}</p> : null}
</div>
);
const HorizontalDivider = ({ title, description }: FilterDividerProps) => {
const theme = useTheme();
const [titleRef, titleIsTruncated] =
useCSSTextTruncation<HTMLHeadingElement>(title);
const tooltipOverlay = (
<>
{titleIsTruncated ? (
<div>
<strong>{title}</strong>
</div>
) : null}
{description ? <div>{description}</div> : null}
</>
);
return (
<div
css={css`
display: flex;
align-items: center;
height: ${8 * theme.gridUnit}px;
border-left: 1px solid ${theme.colors.grayscale.light2};
padding-left: ${4 * theme.gridUnit}px;
`}
>
<h3
ref={titleRef}
css={css`
${truncationCSS}
max-width: ${theme.gridUnit * 32.5}px;
font-size: ${theme.typography.sizes.m}px;
font-weight: ${theme.typography.weights.normal};
margin: 0;
color: ${theme.colors.grayscale.dark1};
`}
>
{title}
</h3>
{titleIsTruncated || description ? (
<Tooltip overlay={tooltipOverlay}>
<Icons.BookOutlined
data-test="divider-description-icon"
iconSize="l"
iconColor={theme.colors.grayscale.base}
css={css`
margin: 0 ${theme.gridUnit * 1.5}px;
vertical-align: unset;
line-height: unset;
`}
/>
</Tooltip>
) : null}
</div>
);
};
const HorizontalOverflowDivider = ({
title,
description,
}: FilterDividerProps) => {
const theme = useTheme();
const [titleRef, titleIsTruncated] =
useCSSTextTruncation<HTMLHeadingElement>(title);
const [descriptionRef, descriptionIsTruncated] =
useCSSTextTruncation<HTMLHeadingElement>(description);
return (
<div
css={css`
border-top: 1px solid ${theme.colors.grayscale.light2};
padding-top: ${theme.gridUnit * 4}px;
margin-bottom: ${theme.gridUnit * 4}px;
`}
>
<Tooltip overlay={titleIsTruncated ? <strong>{title}</strong> : null}>
<h3
ref={titleRef}
css={css`
${truncationCSS}
display: block;
color: ${theme.colors.grayscale.dark1};
font-weight: ${theme.typography.weights.normal};
font-size: ${theme.typography.sizes.m}px;
margin: 0 0 ${theme.gridUnit}px 0;
`}
>
{title}
</h3>
</Tooltip>
{description ? (
<Tooltip overlay={descriptionIsTruncated ? description : null}>
<p
ref={descriptionRef}
data-test="divider-description"
css={css`
${truncationCSS}
display: block;
font-size: ${theme.typography.sizes.s}px;
color: ${theme.colors.grayscale.base};
margin: 0;
`}
>
{description}
</p>
</Tooltip>
) : null}
</div>
);
};
const FilterDivider = ({
title,
description,
orientation = FilterBarOrientation.VERTICAL,
overflow = false,
}: FilterDividerProps) => {
if (orientation === FilterBarOrientation.HORIZONTAL) {
if (overflow) {
return (
<HorizontalOverflowDivider title={title} description={description} />
);
}
return <HorizontalDivider title={title} description={description} />;
}
return <VerticalDivider title={title} description={description} />;
};
export default FilterDivider;

View File

@ -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<FilterProps> = ({
const FilterValue: React.FC<FilterControlProps> = ({
dataMaskSelected,
filter,
directPathToChild,
@ -84,6 +84,8 @@ const FilterValue: React.FC<FilterProps> = ({
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<FilterProps> = ({
[filter.dataMask?.filterState, isMissingRequiredValue],
);
const formDataWithDisplayParams = useMemo(
() => ({ ...formData, orientation, overflow }),
[formData, orientation, overflow],
);
if (error) {
return (
<BasicErrorAlert
@ -270,7 +277,7 @@ const FilterValue: React.FC<FilterProps> = ({
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

View File

@ -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;

View File

@ -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) => (
<div
css={(theme: SupersetTheme) =>
css`
width: ${theme.gridUnit * 56}px;
`
}
>
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
/>
)}
</div>
);

View File

@ -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) => (
<AntdCollapse
ghost
bordered
expandIconPosition="right"
collapsible={filtersOutOfScope.length === 0 ? 'disabled' : undefined}
css={theme => 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;
}
}
}
`}
>
<AntdCollapse.Panel
header={t('Filters out of scope (%d)', filtersOutOfScope.length)}
key="1"
>
{filtersOutOfScope.map(renderer)}
</AntdCollapse.Panel>
</AntdCollapse>
);

View File

@ -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;

View File

@ -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<VerticalBarProps> = ({
actions,
@ -249,11 +255,13 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
/>
</FilterBarEmptyStateContainer>
) : (
<FilterControls
dataMaskSelected={dataMaskSelected}
directPathToChild={directPathToChild}
onFilterSelectionChange={onSelectionChange}
/>
<FilterControlsWrapper>
<FilterControls
dataMaskSelected={dataMaskSelected}
directPathToChild={directPathToChild}
onFilterSelectionChange={onSelectionChange}
/>
</FilterControlsWrapper>
)}
</AntdTabs.TabPane>
<AntdTabs.TabPane
@ -289,11 +297,13 @@ const VerticalFilterBar: React.FC<VerticalBarProps> = ({
/>
</FilterBarEmptyStateContainer>
) : (
<FilterControls
dataMaskSelected={dataMaskSelected}
directPathToChild={directPathToChild}
onFilterSelectionChange={onSelectionChange}
/>
<FilterControlsWrapper>
<FilterControls
dataMaskSelected={dataMaskSelected}
directPathToChild={directPathToChild}
onFilterSelectionChange={onSelectionChange}
/>
</FilterControlsWrapper>
)}
</div>
)}

View File

@ -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 (
<FilterDivider
title={filter.title}
description={filter.description}
orientation={filterBarOrientation}
overflow={index >= overflowIndex}
/>
);
}
return (
<FilterControl
dataMaskSelected={dataMaskSelected}
filter={filter}
directPathToChild={directPathToChild}
onFilterSelectionChange={onFilterSelectionChange}
inView={false}
orientation={filterBarOrientation}
overflow={index >= overflowIndex}
/>
);
},
[
filtersWithValues,
dataMaskSelected,
directPathToChild,
onFilterSelectionChange,
],
);
return { filterControlFactory, filtersWithValues };
};

View File

@ -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 (
<Popover
placement="right"
placement={placement}
overlayClassName="filter-card-popover"
mouseEnterDelay={0.2}
mouseLeaveDelay={0.2}

View File

@ -19,11 +19,18 @@
import { ReactNode } from 'react';
import { Filter } from '@superset-ui/core';
export enum FilterCardPlacement {
Right = 'right',
Bottom = 'bottom',
Left = 'left',
}
export interface FilterCardProps {
children: ReactNode;
filter: Filter;
getPopupContainer?: (node: HTMLElement) => HTMLElement;
isVisible?: boolean;
placement: FilterCardPlacement;
}
export interface FilterCardRowProps {

View File

@ -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 {

View File

@ -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<HTMLElement>,
plusRef?: RefObject<HTMLElement>,
) => {
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 };

View File

@ -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 = <T extends HTMLElement>(
text: string,
): [React.RefObject<T>, boolean] => {
const ref = useRef<T>(null);
const [isTruncated, setIsTruncated] = useState(true);
useEffect(() => {
if (ref.current) {
setIsTruncated(ref.current.offsetWidth < ref.current.scrollWidth);
}
}, [text]);
return [ref, isTruncated];
};
export default useCSSTextTruncation;

View File

@ -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<HTMLElement>,
plusRef?: RefObject<HTMLElement>,
) => {
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;