diff --git a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts index 324c4dfc4..5e4f04494 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts @@ -90,6 +90,8 @@ export interface ChartPropsConfig { filterState?: FilterState; /** Set of actual behaviors that this instance of chart should use */ behaviors?: Behavior[]; + /** Chart display settings related to current view context */ + displaySettings?: JsonObject; /** Application section of the chart on the screen (in what components/screen it placed) */ appSection?: AppSection; /** is the chart refreshing its contents */ @@ -132,6 +134,8 @@ export default class ChartProps { behaviors: Behavior[]; + displaySettings?: JsonObject; + appSection?: AppSection; isRefreshing?: boolean; @@ -153,6 +157,7 @@ export default class ChartProps { initialValues = {}, queriesData = [], behaviors = [], + displaySettings = {}, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, appSection, @@ -174,6 +179,7 @@ export default class ChartProps { this.ownState = ownState; this.filterState = filterState; this.behaviors = behaviors; + this.displaySettings = displaySettings; this.appSection = appSection; this.isRefreshing = isRefreshing; this.inputRef = inputRef; @@ -196,6 +202,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector { input => input.ownState, input => input.filterState, input => input.behaviors, + input => input.displaySettings, input => input.appSection, input => input.isRefreshing, input => input.inputRef, @@ -213,6 +220,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector { ownState, filterState, behaviors, + displaySettings, appSection, isRefreshing, inputRef, @@ -231,6 +239,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector { filterState, width, behaviors, + displaySettings, appSection, isRefreshing, inputRef, diff --git a/superset-frontend/src/components/Select/CustomTag.tsx b/superset-frontend/src/components/Select/CustomTag.tsx new file mode 100644 index 000000000..57aa37c81 --- /dev/null +++ b/superset-frontend/src/components/Select/CustomTag.tsx @@ -0,0 +1,77 @@ +/** + * 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 { Tag as AntdTag } from 'antd'; +import { styled } from '@superset-ui/core'; +import { useCSSTextTruncation } from 'src/hooks/useTruncation'; +import { Tooltip } from '../Tooltip'; +import { CustomTagProps } from './types'; + +const StyledTag = styled(AntdTag)` + & .ant-tag-close-icon { + display: inline-flex; + align-items: center; + margin-left: ${({ theme }) => theme.gridUnit}px; + } + + & .tag-content { + overflow: hidden; + text-overflow: ellipsis; + } +`; + +// TODO: use antd Tag props instead of any. Currently it's causing a typescript error +const Tag = (props: any) => { + const [tagRef, tagIsTruncated] = useCSSTextTruncation(); + return ( + + + + {props.children} + + + + ); +}; + +/** + * Custom tag renderer dedicated for oneLine mode + */ +export const oneLineTagRender = (props: CustomTagProps) => { + const { label } = props; + + const onPreventMouseDown = (event: React.MouseEvent) => { + // if close icon is clicked, stop propagation to avoid opening the dropdown + const target = event.target as HTMLElement; + if ( + target.tagName === 'svg' || + target.tagName === 'path' || + (target.tagName === 'span' && + target.className.includes('ant-tag-close-icon')) + ) { + event.stopPropagation(); + } + }; + + return ( + + {label} + + ); +}; diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/Select.stories.tsx index c4802a45e..4a5d3551f 100644 --- a/superset-frontend/src/components/Select/Select.stories.tsx +++ b/superset-frontend/src/components/Select/Select.stories.tsx @@ -132,6 +132,15 @@ const ARG_TYPES = { By default label and value. `, }, + oneLine: { + defaultValue: false, + description: `Sets maxTagCount to 1. The overflow tag is always displayed in + the same line, line wrapping is disabled. + When the dropdown is open, sets maxTagCount to 0, + displays only the overflow tag. + Requires '"mode=multiple"'. + `, + }, }; const mountHeader = (type: String) => { @@ -197,6 +206,7 @@ InteractiveSelect.args = { invertSelection: false, placeholder: 'Select ...', optionFilterProps: ['value', 'label', 'custom'], + oneLine: false, }; InteractiveSelect.argTypes = { diff --git a/superset-frontend/src/components/Select/Select.test.tsx b/superset-frontend/src/components/Select/Select.test.tsx index 06f5f7b8e..cb4548633 100644 --- a/superset-frontend/src/components/Select/Select.test.tsx +++ b/superset-frontend/src/components/Select/Select.test.tsx @@ -566,6 +566,54 @@ test('finds an element with a numeric value and does not duplicate the options', expect(await querySelectOption('11')).not.toBeInTheDocument(); }); +test('Renders only 1 tag and an overflow tag in oneLine mode', () => { + render( + , + ); + await open(); + + const withinSelector = within(getElementByClassName('.ant-select-selector')); + await waitFor(() => { + expect( + withinSelector.queryByText(OPTIONS[0].label), + ).not.toBeInTheDocument(); + expect( + withinSelector.queryByText(OPTIONS[1].label), + ).not.toBeInTheDocument(); + expect( + withinSelector.queryByText(OPTIONS[2].label), + ).not.toBeInTheDocument(); + expect(withinSelector.getByText('+ 3 ...')).toBeVisible(); + }); + + await type('{esc}'); + + expect(await withinSelector.findByText(OPTIONS[0].label)).toBeVisible(); + expect(withinSelector.queryByText(OPTIONS[1].label)).not.toBeInTheDocument(); + expect(withinSelector.queryByText(OPTIONS[2].label)).not.toBeInTheDocument(); + expect(withinSelector.getByText('+ 2 ...')).toBeVisible(); +}); + /* TODO: Add tests that require scroll interaction. Needs further investigation. - Fetches more data when scrolling and more data is available diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index 0e490013c..555046349 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -54,6 +54,7 @@ import { TOKEN_SEPARATORS, DEFAULT_SORT_COMPARATOR, } from './constants'; +import { oneLineTagRender } from './CustomTag'; /** * This component is a customized version of the Antdesign 4.X Select component @@ -96,6 +97,8 @@ const Select = forwardRef( tokenSeparators, value, getPopupContainer, + oneLine, + maxTagCount: propsMaxTagCount, ...props }: SelectProps, ref: RefObject, @@ -106,6 +109,16 @@ const Select = forwardRef( const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(loading); const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [maxTagCount, setMaxTagCount] = useState( + propsMaxTagCount ?? MAX_TAG_COUNT, + ); + + useEffect(() => { + if (oneLine) { + setMaxTagCount(isDropdownVisible ? 0 : 1); + } + }, [isDropdownVisible, oneLine]); + const mappedMode = isSingleMode ? undefined : allowNewOptions @@ -280,7 +293,7 @@ const Select = forwardRef( } headerPosition={headerPosition} labelInValue={labelInValue} - maxTagCount={MAX_TAG_COUNT} + maxTagCount={maxTagCount} mode={mappedMode} notFoundContent={isLoading ? t('Loading...') : notFoundContent} onDeselect={handleOnDeselect} @@ -308,6 +321,8 @@ const Select = forwardRef( ) } + oneLine={oneLine} + tagRender={oneLine ? oneLineTagRender : undefined} {...props} ref={ref} > diff --git a/superset-frontend/src/components/Select/styles.tsx b/superset-frontend/src/components/Select/styles.tsx index 2da49d7d6..784bdc8ae 100644 --- a/superset-frontend/src/components/Select/styles.tsx +++ b/superset-frontend/src/components/Select/styles.tsx @@ -40,9 +40,9 @@ export const StyledContainer = styled.div<{ headerPosition: string }>` `; export const StyledSelect = styled(AntdSelect, { - shouldForwardProp: prop => prop !== 'headerPosition', -})<{ headerPosition: string }>` - ${({ theme, headerPosition }) => ` + shouldForwardProp: prop => prop !== 'headerPosition' && prop !== 'oneLine', +})<{ headerPosition: string; oneLine?: boolean }>` + ${({ theme, headerPosition, oneLine }) => ` flex: ${headerPosition === 'left' ? 1 : 0}; && .ant-select-selector { border-radius: ${theme.gridUnit}px; @@ -52,6 +52,25 @@ export const StyledSelect = styled(AntdSelect, { .ant-select-arrow .anticon:not(.ant-select-suffix) { pointer-events: none; } + + ${ + oneLine && + ` + .ant-select-selection-overflow { + flex-wrap: nowrap; + } + + .ant-select-selection-overflow-item:not(.ant-select-selection-overflow-item-rest):not(.ant-select-selection-overflow-item-suffix) { + flex-shrink: 1; + min-width: ${theme.gridUnit * 13}px; + } + + .ant-select-selection-overflow-item-suffix { + flex: unset; + min-width: 0px; + } + ` + } `} `; diff --git a/superset-frontend/src/components/Select/types.ts b/superset-frontend/src/components/Select/types.ts index 2c4ccad39..76f7acd0a 100644 --- a/superset-frontend/src/components/Select/types.ts +++ b/superset-frontend/src/components/Select/types.ts @@ -27,6 +27,7 @@ import { SelectValue as AntdSelectValue, LabeledValue as AntdLabeledValue, } from 'antd/lib/select'; +import { TagProps } from 'antd/lib/tag'; export type RawValue = string | number; @@ -140,6 +141,13 @@ export interface BaseSelectProps extends AntdExposedProps { b: AntdLabeledValue, search?: string, ) => number; + /** + * Sets maxTagCount to 1. The overflow tag is always displayed in + * the same line, line wrapping is disabled. + * When the dropdown is open, sets maxTagCount to 0, + * displays only the overflow tag. + */ + oneLine?: boolean; suffixIcon?: ReactNode; @@ -203,3 +211,8 @@ export interface AsyncSelectProps extends BaseSelectProps { */ onError?: (error: string) => void; } + +export type CustomTagProps = HTMLSpanElement & + TagProps & { + label: ReactNode; + }; 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 47ca4b4a5..1206755f9 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx @@ -136,7 +136,7 @@ const HorizontalFormItem = styled(StyledFormItem)` } .ant-form-item-control { - width: ${({ theme }) => theme.gridUnit * 40}px; + width: ${({ theme }) => theme.gridUnit * 41}px; } `; 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 a08200d83..5cc9d804e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx @@ -253,9 +253,12 @@ const FilterValue: React.FC = ({ [filter.dataMask?.filterState, isMissingRequiredValue], ); - const formDataWithDisplayParams = useMemo( - () => ({ ...formData, orientation, overflow }), - [formData, orientation, overflow], + const displaySettings = useMemo( + () => ({ + filterBarOrientation: orientation, + isOverflowingFilterBar: overflow, + }), + [orientation, overflow], ); if (error) { @@ -277,7 +280,8 @@ const FilterValue: React.FC = ({ height={HEIGHT} width="100%" showOverflow={showOverflow} - formData={formDataWithDisplayParams} + formData={formData} + displaySettings={displaySettings} parentRef={parentRef} inputRef={inputRef} // For charts that don't have datasource we need workaround for empty placeholder diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx index f3673f223..9c60e6081 100644 --- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx @@ -25,8 +25,9 @@ import { t, } from '@superset-ui/core'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { AntdSlider } from 'src/components'; import { rgba } from 'emotion-rgba'; +import { AntdSlider } from 'src/components'; +import { FilterBarOrientation } from 'src/dashboard/types'; import { PluginFilterRangeProps } from './types'; import { StatusMessage, StyledFormItem, FilterPluginStyle } from '../common'; import { getRangeExtraFormData } from '../../utils'; @@ -65,8 +66,12 @@ const StyledMinSlider = styled(AntdSlider)<{ `} `; -const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>` - ${({ theme, validateStatus }) => ` +const Wrapper = styled.div<{ + validateStatus?: 'error' | 'warning' | 'info'; + orientation?: FilterBarOrientation; + isOverflowing?: boolean; +}>` + ${({ theme, validateStatus, orientation, isOverflowing }) => ` border: 1px solid transparent; &:focus { border: 1px solid @@ -76,8 +81,18 @@ const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>` ${rgba(theme.colors[validateStatus || 'primary']?.base, 0.2)}; } & .ant-slider { - margin-top: ${theme.gridUnit}px; - margin-bottom: ${theme.gridUnit * 5}px; + margin-top: ${ + orientation === FilterBarOrientation.HORIZONTAL ? 0 : theme.gridUnit + }px; + margin-bottom: ${ + orientation === FilterBarOrientation.HORIZONTAL ? 0 : theme.gridUnit * 5 + }px; + + ${ + orientation === FilterBarOrientation.HORIZONTAL && + !isOverflowing && + `line-height: 1.2;` + } & .ant-slider-track { background-color: ${ @@ -93,6 +108,10 @@ const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>` ${rgba(theme.colors[validateStatus || 'primary']?.base, 0.2)}; } } + & .ant-slider-mark { + font-size: ${theme.typography.sizes.s}px; + } + &:hover { & .ant-slider-track { background-color: ${ @@ -155,6 +174,8 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) { setFilterActive, filterState, inputRef, + filterBarOrientation, + isOverflowingFilterBar, } = props; const [row] = data; // @ts-ignore @@ -287,6 +308,8 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) { tabIndex={-1} ref={inputRef} validateStatus={filterState.validateStatus} + orientation={filterBarOrientation} + isOverflowing={isOverflowingFilterBar} onFocus={setFocusedFilter} onBlur={unsetFocusedFilter} onMouseEnter={setFocusedFilter} diff --git a/superset-frontend/src/filters/components/Range/transformProps.ts b/superset-frontend/src/filters/components/Range/transformProps.ts index f4c47cec2..48def72ca 100644 --- a/superset-frontend/src/filters/components/Range/transformProps.ts +++ b/superset-frontend/src/filters/components/Range/transformProps.ts @@ -29,6 +29,7 @@ export default function transformProps(chartProps: ChartProps) { behaviors, filterState, inputRef, + displaySettings, } = chartProps; const { setDataMask = noOp, @@ -50,5 +51,7 @@ export default function transformProps(chartProps: ChartProps) { unsetFocusedFilter, setFilterActive, inputRef, + isOverflowingFilterBar: displaySettings?.isOverflowingFilterBar, + filterBarOrientation: displaySettings?.filterBarOrientation, }; } diff --git a/superset-frontend/src/filters/components/Range/types.ts b/superset-frontend/src/filters/components/Range/types.ts index 69ad4d53d..25a18bb3e 100644 --- a/superset-frontend/src/filters/components/Range/types.ts +++ b/superset-frontend/src/filters/components/Range/types.ts @@ -24,6 +24,7 @@ import { } from '@superset-ui/core'; import { RefObject } from 'react'; import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; +import { FilterBarOrientation } from '../../../dashboard/types'; interface PluginFilterSelectCustomizeProps { max?: number; @@ -40,4 +41,6 @@ export type PluginFilterRangeProps = PluginFilterStylesProps & { filterState: FilterState; behaviors: Behavior[]; inputRef: RefObject; + filterBarOrientation?: FilterBarOrientation; + isOverflowingFilterBar?: boolean; } & PluginFilterHooks; diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 9cea856d4..7337f5168 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -17,6 +17,7 @@ * under the License. */ /* eslint-disable no-param-reassign */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AppSection, DataMask, @@ -31,14 +32,14 @@ import { tn, } from '@superset-ui/core'; import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; -import React, { useCallback, useEffect, useState, useMemo } from 'react'; -import { Select } from 'src/components'; import debounce from 'lodash/debounce'; -import { SLOW_DEBOUNCE } from 'src/constants'; import { useImmerReducer } from 'use-immer'; +import { Select } from 'src/components'; +import { SLOW_DEBOUNCE } from 'src/constants'; import { propertyComparator } from 'src/components/Select/utils'; +import { FilterBarOrientation } from 'src/dashboard/types'; import { PluginFilterSelectProps, SelectValue } from './types'; -import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common'; +import { FilterPluginStyle, StatusMessage, StyledFormItem } from '../common'; import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils'; type DataMaskAction = @@ -89,6 +90,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { showOverflow, parentRef, inputRef, + filterBarOrientation, } = props; const { enableEmptyFilter, @@ -323,11 +325,14 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { onChange={handleChange} ref={inputRef} loading={isRefreshing} - maxTagCount={5} + oneLine={filterBarOrientation === FilterBarOrientation.HORIZONTAL} invertSelection={inverseSelection} // @ts-ignore options={options} sortComparator={sortComparator} + maxTagPlaceholder={(val: AntdLabeledValue[]) => ( + +{val.length} + )} onDropdownVisibleChange={setFilterActive} /> diff --git a/superset-frontend/src/filters/components/Select/transformProps.ts b/superset-frontend/src/filters/components/Select/transformProps.ts index 59d9adc57..a0fafa4a4 100644 --- a/superset-frontend/src/filters/components/Select/transformProps.ts +++ b/superset-frontend/src/filters/components/Select/transformProps.ts @@ -29,6 +29,7 @@ export default function transformProps( hooks, queriesData, width, + displaySettings, behaviors, appSection, filterState, @@ -64,5 +65,7 @@ export default function transformProps( unsetFocusedFilter, setFilterActive, inputRef, + filterBarOrientation: displaySettings?.filterBarOrientation, + isOverflowingFilterBar: displaySettings?.isOverflowingFilterBar, }; } diff --git a/superset-frontend/src/filters/components/Select/types.ts b/superset-frontend/src/filters/components/Select/types.ts index 0497b58e5..e608f5964 100644 --- a/superset-frontend/src/filters/components/Select/types.ts +++ b/superset-frontend/src/filters/components/Select/types.ts @@ -27,6 +27,7 @@ import { ChartDataResponseResult, } from '@superset-ui/core'; import { RefObject } from 'react'; +import { FilterBarOrientation } from 'src/dashboard/types'; import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; export type SelectValue = (number | string | null)[] | null | undefined; @@ -61,6 +62,8 @@ export type PluginFilterSelectProps = PluginFilterStylesProps & { showOverflow: boolean; parentRef?: RefObject; inputRef?: RefObject; + filterBarOrientation?: FilterBarOrientation; + isOverflowingFilterBar?: boolean; } & PluginFilterHooks; export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = { diff --git a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx index 82bfc678f..0d4185b75 100644 --- a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx @@ -23,12 +23,21 @@ import { PluginFilterTimeProps } from './types'; import { FilterPluginStyle } from '../common'; const TimeFilterStyles = styled(FilterPluginStyle)` + display: flex; + align-items: center; overflow-x: auto; + + & .ant-tag { + margin-right: 0; + } `; const ControlContainer = styled.div<{ validateStatus?: 'error' | 'warning' | 'info'; }>` + display: flex; + height: 100%; + max-width: 100%; padding: 2px; & > span, & > span:hover { diff --git a/superset-frontend/src/filters/components/Time/transformProps.ts b/superset-frontend/src/filters/components/Time/transformProps.ts index 883b6002d..8262b8f63 100644 --- a/superset-frontend/src/filters/components/Time/transformProps.ts +++ b/superset-frontend/src/filters/components/Time/transformProps.ts @@ -30,6 +30,7 @@ export default function transformProps(chartProps: ChartProps) { behaviors, filterState, inputRef, + displaySettings, } = chartProps; const { setDataMask = noOp, @@ -54,5 +55,6 @@ export default function transformProps(chartProps: ChartProps) { setFilterActive, width, inputRef, + filterBarOrientation: displaySettings?.filterBarOrientation, }; }