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:
parent
b2fcdc56c1
commit
64939f2872
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue