feat(native-filters): Adjust filter components for horizontal mode (#22273)

This commit is contained in:
Kamil Gabryjelski 2022-12-01 21:41:19 +01:00 committed by GitHub
parent 2bdf22b215
commit eb6045adfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 265 additions and 19 deletions

View File

@ -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<FormData extends RawFormData = RawFormData> {
behaviors: Behavior[];
displaySettings?: JsonObject;
appSection?: AppSection;
isRefreshing?: boolean;
@ -153,6 +157,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
initialValues = {},
queriesData = [],
behaviors = [],
displaySettings = {},
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
appSection,
@ -174,6 +179,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
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,

View File

@ -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<HTMLSpanElement>();
return (
<Tooltip title={tagIsTruncated ? props.children : null}>
<StyledTag {...props} className="ant-select-selection-item">
<span className="tag-content" ref={tagRef}>
{props.children}
</span>
</StyledTag>
</Tooltip>
);
};
/**
* Custom tag renderer dedicated for oneLine mode
*/
export const oneLineTagRender = (props: CustomTagProps) => {
const { label } = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLElement>) => {
// 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 (
<Tag onMouseDown={onPreventMouseDown} {...props}>
{label}
</Tag>
);
};

View File

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

View File

@ -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(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
expect(screen.getByText(OPTIONS[0].label)).toBeVisible();
expect(screen.queryByText(OPTIONS[1].label)).not.toBeInTheDocument();
expect(screen.queryByText(OPTIONS[2].label)).not.toBeInTheDocument();
expect(screen.getByText('+ 2 ...')).toBeVisible();
});
test('Renders only an overflow tag if dropdown is open in oneLine mode', async () => {
render(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
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

View File

@ -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<HTMLInputElement>,
@ -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(
<StyledCheckOutlined iconSize="m" />
)
}
oneLine={oneLine}
tagRender={oneLine ? oneLineTagRender : undefined}
{...props}
ref={ref}
>

View File

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

View File

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

View File

@ -136,7 +136,7 @@ const HorizontalFormItem = styled(StyledFormItem)`
}
.ant-form-item-control {
width: ${({ theme }) => theme.gridUnit * 40}px;
width: ${({ theme }) => theme.gridUnit * 41}px;
}
`;

View File

@ -253,9 +253,12 @@ const FilterValue: React.FC<FilterControlProps> = ({
[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<FilterControlProps> = ({
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

View File

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

View File

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

View File

@ -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<any>;
filterBarOrientation?: FilterBarOrientation;
isOverflowingFilterBar?: boolean;
} & PluginFilterHooks;

View File

@ -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[]) => (
<span>+{val.length}</span>
)}
onDropdownVisibleChange={setFilterActive}
/>
</StyledFormItem>

View File

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

View File

@ -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<any>;
inputRef?: RefObject<any>;
filterBarOrientation?: FilterBarOrientation;
isOverflowingFilterBar?: boolean;
} & PluginFilterHooks;
export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {

View File

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

View File

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