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;