mirror of
https://github.com/apache/superset.git
synced 2024-09-12 08:39:45 -04:00
perf(native-filters): Decrease number of unnecessary rerenders in native filters (#17115)
* perf(native-filters): decrease number of redundant rerenders * More perf improvements * lint fix
This commit is contained in:
parent
57f869cf22
commit
2ad9101d1e
@ -45,9 +45,9 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
const dashboardLayout = useSelector<RootState, DashboardLayout>(
|
||||
state => state.dashboardLayout.present,
|
||||
);
|
||||
const nativeFilters =
|
||||
useSelector<RootState, Filters>(state => state.nativeFilters?.filters) ??
|
||||
{};
|
||||
const nativeFilters = useSelector<RootState, Filters>(
|
||||
state => state.nativeFilters?.filters,
|
||||
);
|
||||
const directPathToChild = useSelector<RootState, string[]>(
|
||||
state => state.dashboardState.directPathToChild,
|
||||
);
|
||||
@ -68,7 +68,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
}, [getLeafComponentIdFromPath(directPathToChild)]);
|
||||
|
||||
// recalculate charts and tabs in scopes of native filters only when a scope or dashboard layout changes
|
||||
const filterScopes = Object.values(nativeFilters).map(filter => ({
|
||||
const filterScopes = Object.values(nativeFilters ?? {}).map(filter => ({
|
||||
id: filter.id,
|
||||
scope: filter.scope,
|
||||
}));
|
||||
|
@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
@ -64,9 +64,12 @@ export const useNativeFilters = () => {
|
||||
)
|
||||
);
|
||||
|
||||
const toggleDashboardFiltersOpen = (visible?: boolean) => {
|
||||
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
|
||||
};
|
||||
const toggleDashboardFiltersOpen = useCallback(
|
||||
(visible?: boolean) => {
|
||||
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
|
||||
},
|
||||
[dashboardFiltersOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
@ -225,5 +225,4 @@ const CascadePopover: React.FC<CascadePopoverProps> = ({
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default CascadePopover;
|
||||
export default React.memo(CascadePopover);
|
||||
|
@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { Form, FormItem } from 'src/components/Form';
|
||||
import FilterValue from './FilterValue';
|
||||
@ -67,17 +67,22 @@ const FilterControl: React.FC<FilterProps> = ({
|
||||
filter.dataMask?.filterState,
|
||||
);
|
||||
|
||||
const label = useMemo(
|
||||
() => (
|
||||
<StyledFilterControlTitleBox>
|
||||
<StyledFilterControlTitle data-test="filter-control-name">
|
||||
{name}
|
||||
</StyledFilterControlTitle>
|
||||
<StyledIcon data-test="filter-icon">{icon}</StyledIcon>
|
||||
</StyledFilterControlTitleBox>
|
||||
),
|
||||
[icon, name],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledFilterControlContainer layout="vertical">
|
||||
<FormItem
|
||||
label={
|
||||
<StyledFilterControlTitleBox>
|
||||
<StyledFilterControlTitle data-test="filter-control-name">
|
||||
{name}
|
||||
</StyledFilterControlTitle>
|
||||
<StyledIcon data-test="filter-icon">{icon}</StyledIcon>
|
||||
</StyledFilterControlTitleBox>
|
||||
}
|
||||
label={label}
|
||||
required={filter?.controlValues?.enableEmptyFilter}
|
||||
validateStatus={isMissingRequiredValue ? 'error' : undefined}
|
||||
>
|
||||
@ -92,5 +97,4 @@ const FilterControl: React.FC<FilterProps> = ({
|
||||
</StyledFilterControlContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterControl;
|
||||
export default React.memo(FilterControl);
|
||||
|
@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { FC, useMemo, useState } from 'react';
|
||||
import React, { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { DataMask, styled, t } from '@superset-ui/core';
|
||||
import {
|
||||
@ -55,8 +55,8 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
}) => {
|
||||
const [visiblePopoverId, setVisiblePopoverId] = useState<string | null>(null);
|
||||
const filters = useFilters();
|
||||
const filterValues = Object.values<Filter>(filters);
|
||||
const portalNodes = React.useMemo(() => {
|
||||
const filterValues = useMemo(() => Object.values<Filter>(filters), [filters]);
|
||||
const portalNodes = useMemo(() => {
|
||||
const nodes = new Array(filterValues.length);
|
||||
for (let i = 0; i < filterValues.length; i += 1) {
|
||||
nodes[i] = createHtmlPortalNode();
|
||||
@ -70,8 +70,7 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
dataMask: dataMaskSelected[filter.id],
|
||||
}));
|
||||
return buildCascadeFiltersTree(filtersWithValue);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(filterValues), dataMaskSelected]);
|
||||
}, [filterValues, dataMaskSelected]);
|
||||
const cascadeFilterIds = new Set(cascadeFilters.map(item => item.id));
|
||||
|
||||
const [filtersInScope, filtersOutOfScope] = useSelectFiltersInScope(
|
||||
@ -80,26 +79,36 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
const dashboardHasTabs = useDashboardHasTabs();
|
||||
const showCollapsePanel = dashboardHasTabs && cascadeFilters.length > 0;
|
||||
|
||||
const cascadePopoverFactory = useCallback(
|
||||
index => (
|
||||
<CascadePopover
|
||||
data-test="cascade-filters-control"
|
||||
key={cascadeFilters[index].id}
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
visible={visiblePopoverId === cascadeFilters[index].id}
|
||||
onVisibleChange={visible =>
|
||||
setVisiblePopoverId(visible ? cascadeFilters[index].id : null)
|
||||
}
|
||||
filter={cascadeFilters[index]}
|
||||
onFilterSelectionChange={onFilterSelectionChange}
|
||||
directPathToChild={directPathToChild}
|
||||
inView={false}
|
||||
/>
|
||||
),
|
||||
[
|
||||
cascadeFilters,
|
||||
JSON.stringify(dataMaskSelected),
|
||||
directPathToChild,
|
||||
onFilterSelectionChange,
|
||||
visiblePopoverId,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<Wrapper>
|
||||
{portalNodes
|
||||
.filter((node, index) => cascadeFilterIds.has(filterValues[index].id))
|
||||
.map((node, index) => (
|
||||
<InPortal node={node}>
|
||||
<CascadePopover
|
||||
data-test="cascade-filters-control"
|
||||
key={cascadeFilters[index].id}
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
visible={visiblePopoverId === cascadeFilters[index].id}
|
||||
onVisibleChange={visible =>
|
||||
setVisiblePopoverId(visible ? cascadeFilters[index].id : null)
|
||||
}
|
||||
filter={cascadeFilters[index]}
|
||||
onFilterSelectionChange={onFilterSelectionChange}
|
||||
directPathToChild={directPathToChild}
|
||||
inView={false}
|
||||
/>
|
||||
</InPortal>
|
||||
<InPortal node={node}>{cascadePopoverFactory(index)}</InPortal>
|
||||
))}
|
||||
{filtersInScope.map(filter => {
|
||||
const index = filterValues.findIndex(f => f.id === filter.id);
|
||||
@ -150,4 +159,4 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterControls;
|
||||
export default React.memo(FilterControls);
|
||||
|
@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
QueryFormData,
|
||||
SuperChart,
|
||||
@ -53,6 +59,9 @@ const StyledDiv = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const queriesDataPlaceholder = [{ data: [{}] }];
|
||||
const behaviors = [Behavior.NATIVE_FILTER];
|
||||
|
||||
const FilterValue: React.FC<FilterProps> = ({
|
||||
dataMaskSelected,
|
||||
filter,
|
||||
@ -193,11 +202,36 @@ const FilterValue: React.FC<FilterProps> = ({
|
||||
return undefined;
|
||||
}, [inputRef, directPathToChild, filter.id]);
|
||||
|
||||
const setDataMask = (dataMask: DataMask) =>
|
||||
onFilterSelectionChange(filter, dataMask);
|
||||
const setDataMask = useCallback(
|
||||
(dataMask: DataMask) => onFilterSelectionChange(filter, dataMask),
|
||||
[filter, onFilterSelectionChange],
|
||||
);
|
||||
|
||||
const setFocusedFilter = () => dispatchFocusAction(dispatch, id);
|
||||
const unsetFocusedFilter = () => dispatchFocusAction(dispatch);
|
||||
const setFocusedFilter = useCallback(
|
||||
() => dispatchFocusAction(dispatch, id),
|
||||
[dispatch, id],
|
||||
);
|
||||
const unsetFocusedFilter = useCallback(() => dispatchFocusAction(dispatch), [
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const hooks = useMemo(
|
||||
() => ({ setDataMask, setFocusedFilter, unsetFocusedFilter }),
|
||||
[setDataMask, setFocusedFilter, unsetFocusedFilter],
|
||||
);
|
||||
|
||||
const isMissingRequiredValue = checkIsMissingRequiredValue(
|
||||
filter,
|
||||
filter.dataMask?.filterState,
|
||||
);
|
||||
|
||||
const filterState = useMemo(
|
||||
() => ({
|
||||
...filter.dataMask?.filterState,
|
||||
validateStatus: isMissingRequiredValue && 'error',
|
||||
}),
|
||||
[filter.dataMask?.filterState, isMissingRequiredValue],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@ -208,14 +242,6 @@ const FilterValue: React.FC<FilterProps> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isMissingRequiredValue = checkIsMissingRequiredValue(
|
||||
filter,
|
||||
filter.dataMask?.filterState,
|
||||
);
|
||||
const filterState = {
|
||||
...filter.dataMask?.filterState,
|
||||
validateStatus: isMissingRequiredValue && 'error',
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDiv data-test="form-item-value">
|
||||
@ -227,18 +253,17 @@ const FilterValue: React.FC<FilterProps> = ({
|
||||
width="100%"
|
||||
formData={formData}
|
||||
// For charts that don't have datasource we need workaround for empty placeholder
|
||||
queriesData={hasDataSource ? state : [{ data: [{}] }]}
|
||||
queriesData={hasDataSource ? state : queriesDataPlaceholder}
|
||||
chartType={filterType}
|
||||
behaviors={[Behavior.NATIVE_FILTER]}
|
||||
behaviors={behaviors}
|
||||
filterState={filterState}
|
||||
ownState={filter.dataMask?.ownState}
|
||||
enableNoResults={metadata?.enableNoResults}
|
||||
isRefreshing={isRefreshing}
|
||||
hooks={{ setDataMask, setFocusedFilter, unsetFocusedFilter }}
|
||||
hooks={hooks}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterValue;
|
||||
|
@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { NativeFiltersState } from 'src/dashboard/reducers/types';
|
||||
import { DataMaskStateWithId } from 'src/dataMask/types';
|
||||
@ -31,14 +32,16 @@ export function useCascadingFilters(
|
||||
state => state.nativeFilters,
|
||||
);
|
||||
const filter = filters[id];
|
||||
const cascadeParentIds: string[] = filter?.cascadeParentIds ?? [];
|
||||
let cascadedFilters = {};
|
||||
cascadeParentIds.forEach(parentId => {
|
||||
const parentState = dataMaskSelected?.[parentId];
|
||||
cascadedFilters = mergeExtraFormData(
|
||||
cascadedFilters,
|
||||
parentState?.extraFormData,
|
||||
);
|
||||
});
|
||||
return cascadedFilters;
|
||||
return useMemo(() => {
|
||||
const cascadeParentIds: string[] = filter?.cascadeParentIds ?? [];
|
||||
let cascadedFilters = {};
|
||||
cascadeParentIds.forEach(parentId => {
|
||||
const parentState = dataMaskSelected?.[parentId];
|
||||
cascadedFilters = mergeExtraFormData(
|
||||
cascadedFilters,
|
||||
parentState?.extraFormData,
|
||||
);
|
||||
});
|
||||
return cascadedFilters;
|
||||
}, [dataMaskSelected, filter?.cascadeParentIds]);
|
||||
}
|
||||
|
@ -19,7 +19,7 @@
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { DataMask, HandlerFunction, styled, t } from '@superset-ui/core';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import cx from 'classnames';
|
||||
import Icons from 'src/components/Icons';
|
||||
@ -162,27 +162,30 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
const previousFilters = usePrevious(filters);
|
||||
const filterValues = Object.values<Filter>(filters);
|
||||
|
||||
const handleFilterSelectionChange = (
|
||||
filter: Pick<Filter, 'id'> & Partial<Filter>,
|
||||
dataMask: Partial<DataMask>,
|
||||
) => {
|
||||
setDataMaskSelected(draft => {
|
||||
// force instant updating on initialization for filters with `requiredFirst` is true or instant filters
|
||||
if (
|
||||
// filterState.value === undefined - means that value not initialized
|
||||
dataMask.filterState?.value !== undefined &&
|
||||
dataMaskSelected[filter.id]?.filterState?.value === undefined &&
|
||||
filter.requiredFirst
|
||||
) {
|
||||
dispatch(updateDataMask(filter.id, dataMask));
|
||||
}
|
||||
const handleFilterSelectionChange = useCallback(
|
||||
(
|
||||
filter: Pick<Filter, 'id'> & Partial<Filter>,
|
||||
dataMask: Partial<DataMask>,
|
||||
) => {
|
||||
setDataMaskSelected(draft => {
|
||||
// force instant updating on initialization for filters with `requiredFirst` is true or instant filters
|
||||
if (
|
||||
// filterState.value === undefined - means that value not initialized
|
||||
dataMask.filterState?.value !== undefined &&
|
||||
dataMaskSelected[filter.id]?.filterState?.value === undefined &&
|
||||
filter.requiredFirst
|
||||
) {
|
||||
dispatch(updateDataMask(filter.id, dataMask));
|
||||
}
|
||||
|
||||
draft[filter.id] = {
|
||||
...(getInitialDataMask(filter.id) as DataMaskWithId),
|
||||
...dataMask,
|
||||
};
|
||||
});
|
||||
};
|
||||
draft[filter.id] = {
|
||||
...(getInitialDataMask(filter.id) as DataMaskWithId),
|
||||
...dataMask,
|
||||
};
|
||||
});
|
||||
},
|
||||
[dataMaskSelected, dispatch, setDataMaskSelected, tab],
|
||||
);
|
||||
|
||||
const publishDataMask = useCallback(
|
||||
(dataMaskSelected: DataMaskStateWithId) => {
|
||||
@ -246,23 +249,27 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataMaskAppliedText, publishDataMask]);
|
||||
|
||||
const handleApply = () => {
|
||||
const handleApply = useCallback(() => {
|
||||
const filterIds = Object.keys(dataMaskSelected);
|
||||
filterIds.forEach(filterId => {
|
||||
if (dataMaskSelected[filterId]) {
|
||||
dispatch(updateDataMask(filterId, dataMaskSelected[filterId]));
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [dataMaskSelected, dispatch]);
|
||||
|
||||
const handleClearAll = () => {
|
||||
const handleClearAll = useCallback(() => {
|
||||
const filterIds = Object.keys(dataMaskSelected);
|
||||
filterIds.forEach(filterId => {
|
||||
if (dataMaskSelected[filterId]) {
|
||||
dispatch(clearDataMask(filterId));
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [dataMaskSelected, dispatch]);
|
||||
|
||||
const openFiltersBar = useCallback(() => toggleFiltersBar(true), [
|
||||
toggleFiltersBar,
|
||||
]);
|
||||
|
||||
useFilterUpdates(dataMaskSelected, setDataMaskSelected);
|
||||
const isApplyDisabled = checkIsApplyDisabled(
|
||||
@ -272,6 +279,8 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
);
|
||||
const isInitialized = useInitialization();
|
||||
|
||||
const tabPaneStyle = useMemo(() => ({ overflow: 'auto', height }), [height]);
|
||||
|
||||
return (
|
||||
<BarWrapper
|
||||
{...getFilterBarTestId()}
|
||||
@ -281,7 +290,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
<CollapsedBar
|
||||
{...getFilterBarTestId('collapsable')}
|
||||
className={cx({ open: !filtersOpen })}
|
||||
onClick={() => toggleFiltersBar(true)}
|
||||
onClick={openFiltersBar}
|
||||
offset={offset}
|
||||
>
|
||||
<StyledCollapseIcon
|
||||
@ -313,7 +322,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
<Tabs.TabPane
|
||||
tab={t(`All Filters (${filterValues.length})`)}
|
||||
key={TabIds.AllFilters}
|
||||
css={{ overflow: 'auto', height }}
|
||||
css={tabPaneStyle}
|
||||
>
|
||||
{editFilterSetId && (
|
||||
<EditSection
|
||||
@ -333,7 +342,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
disabled={!!editFilterSetId}
|
||||
tab={t(`Filter Sets (${filterSetFilterValues.length})`)}
|
||||
key={TabIds.FilterSets}
|
||||
css={{ overflow: 'auto', height }}
|
||||
css={tabPaneStyle}
|
||||
>
|
||||
<FilterSets
|
||||
onEditFilterSet={setEditFilterSetId}
|
||||
@ -345,7 +354,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
</Tabs.TabPane>
|
||||
</StyledTabs>
|
||||
) : (
|
||||
<div css={{ overflow: 'auto', height }}>
|
||||
<div css={tabPaneStyle}>
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
directPathToChild={directPathToChild}
|
||||
@ -357,5 +366,4 @@ const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
</BarWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterBar;
|
||||
export default React.memo(FilterBar);
|
||||
|
@ -27,7 +27,7 @@ import {
|
||||
DataMaskStateWithId,
|
||||
DataMaskWithId,
|
||||
} from 'src/dataMask/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChartsState, RootState } from 'src/dashboard/types';
|
||||
import { NATIVE_FILTER_PREFIX } from '../FiltersConfigModal/utils';
|
||||
import { Filter } from '../types';
|
||||
@ -37,35 +37,46 @@ export const useFilterSets = () =>
|
||||
state => state.nativeFilters.filterSets || {},
|
||||
);
|
||||
|
||||
export const useFilters = () =>
|
||||
useSelector<any, Filters>(state => {
|
||||
const preselectNativeFilters =
|
||||
state.dashboardState?.preselectNativeFilters || {};
|
||||
return Object.entries(state.nativeFilters.filters).reduce(
|
||||
(acc, [filterId, filter]: [string, Filter]) => ({
|
||||
...acc,
|
||||
[filterId]: {
|
||||
...filter,
|
||||
preselect: preselectNativeFilters[filterId],
|
||||
},
|
||||
}),
|
||||
{} as Filters,
|
||||
);
|
||||
});
|
||||
export const useFilters = () => {
|
||||
const preselectedNativeFilters = useSelector<any, Filters>(
|
||||
state => state.dashboardState?.preselectNativeFilters,
|
||||
);
|
||||
const nativeFilters = useSelector<any, Filters>(
|
||||
state => state.nativeFilters.filters,
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.entries(nativeFilters).reduce(
|
||||
(acc, [filterId, filter]: [string, Filter]) => ({
|
||||
...acc,
|
||||
[filterId]: {
|
||||
...filter,
|
||||
preselect: preselectedNativeFilters?.[filterId],
|
||||
},
|
||||
}),
|
||||
{} as Filters,
|
||||
),
|
||||
[nativeFilters, preselectedNativeFilters],
|
||||
);
|
||||
};
|
||||
|
||||
export const useNativeFiltersDataMask = () => {
|
||||
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
||||
state => state.dataMask,
|
||||
);
|
||||
|
||||
return Object.values(dataMask)
|
||||
.filter((item: DataMaskWithId) =>
|
||||
String(item.id).startsWith(NATIVE_FILTER_PREFIX),
|
||||
)
|
||||
.reduce(
|
||||
(prev, next: DataMaskWithId) => ({ ...prev, [next.id]: next }),
|
||||
{},
|
||||
) as DataMaskStateWithId;
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(dataMask)
|
||||
.filter((item: DataMaskWithId) =>
|
||||
String(item.id).startsWith(NATIVE_FILTER_PREFIX),
|
||||
)
|
||||
.reduce(
|
||||
(prev, next: DataMaskWithId) => ({ ...prev, [next.id]: next }),
|
||||
{},
|
||||
) as DataMaskStateWithId,
|
||||
[dataMask],
|
||||
);
|
||||
};
|
||||
|
||||
export const useFilterUpdates = (
|
||||
|
@ -22,10 +22,9 @@ import {
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Slider } from 'src/common/components';
|
||||
import { rgba } from 'emotion-rgba';
|
||||
import { FormItemProps } from 'antd/lib/form';
|
||||
import { PluginFilterRangeProps } from './types';
|
||||
import { StatusMessage, StyledFormItem, FilterPluginStyle } from '../common';
|
||||
import { getRangeExtraFormData } from '../../utils';
|
||||
@ -74,6 +73,37 @@ const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
const numberFormatter = getNumberFormatter(NumberFormats.SMART_NUMBER);
|
||||
|
||||
const tipFormatter = (value: number) => numberFormatter(value);
|
||||
|
||||
const getLabel = (lower: number | null, upper: number | null): string => {
|
||||
if (lower !== null && upper !== null) {
|
||||
return `${numberFormatter(lower)} ≤ x ≤ ${numberFormatter(upper)}`;
|
||||
}
|
||||
if (lower !== null) {
|
||||
return `x ≥ ${numberFormatter(lower)}`;
|
||||
}
|
||||
if (upper !== null) {
|
||||
return `x ≤ ${numberFormatter(upper)}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getMarks = (
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
): { [key: number]: string } => {
|
||||
const newMarks: { [key: number]: string } = {};
|
||||
if (lower !== null) {
|
||||
newMarks[lower] = numberFormatter(lower);
|
||||
}
|
||||
if (upper !== null) {
|
||||
newMarks[upper] = numberFormatter(upper);
|
||||
}
|
||||
return newMarks;
|
||||
};
|
||||
|
||||
export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
const {
|
||||
data,
|
||||
@ -85,8 +115,6 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
unsetFocusedFilter,
|
||||
filterState,
|
||||
} = props;
|
||||
const numberFormatter = getNumberFormatter(NumberFormats.SMART_NUMBER);
|
||||
|
||||
const [row] = data;
|
||||
// @ts-ignore
|
||||
const { min, max }: { min: number; max: number } = row;
|
||||
@ -97,60 +125,39 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
);
|
||||
const [marks, setMarks] = useState<{ [key: number]: string }>({});
|
||||
|
||||
const getBounds = (
|
||||
value: [number, number],
|
||||
): { lower: number | null; upper: number | null } => {
|
||||
const [lowerRaw, upperRaw] = value;
|
||||
return {
|
||||
lower: lowerRaw > min ? lowerRaw : null,
|
||||
upper: upperRaw < max ? upperRaw : null,
|
||||
};
|
||||
};
|
||||
const getBounds = useCallback(
|
||||
(
|
||||
value: [number, number],
|
||||
): { lower: number | null; upper: number | null } => {
|
||||
const [lowerRaw, upperRaw] = value;
|
||||
return {
|
||||
lower: lowerRaw > min ? lowerRaw : null,
|
||||
upper: upperRaw < max ? upperRaw : null,
|
||||
};
|
||||
},
|
||||
[max, min],
|
||||
);
|
||||
|
||||
const getLabel = (lower: number | null, upper: number | null): string => {
|
||||
if (lower !== null && upper !== null) {
|
||||
return `${numberFormatter(lower)} ≤ x ≤ ${numberFormatter(upper)}`;
|
||||
}
|
||||
if (lower !== null) {
|
||||
return `x ≥ ${numberFormatter(lower)}`;
|
||||
}
|
||||
if (upper !== null) {
|
||||
return `x ≤ ${numberFormatter(upper)}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const handleAfterChange = useCallback(
|
||||
(value: [number, number]): void => {
|
||||
setValue(value);
|
||||
const { lower, upper } = getBounds(value);
|
||||
setMarks(getMarks(lower, upper));
|
||||
|
||||
const getMarks = (
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
): { [key: number]: string } => {
|
||||
const newMarks: { [key: number]: string } = {};
|
||||
if (lower !== null) {
|
||||
newMarks[lower] = numberFormatter(lower);
|
||||
}
|
||||
if (upper !== null) {
|
||||
newMarks[upper] = numberFormatter(upper);
|
||||
}
|
||||
return newMarks;
|
||||
};
|
||||
setDataMask({
|
||||
extraFormData: getRangeExtraFormData(col, lower, upper),
|
||||
filterState: {
|
||||
value: lower !== null || upper !== null ? value : null,
|
||||
label: getLabel(lower, upper),
|
||||
},
|
||||
});
|
||||
},
|
||||
[col, getBounds, setDataMask],
|
||||
);
|
||||
|
||||
const handleAfterChange = (value: [number, number]): void => {
|
||||
const handleChange = useCallback((value: [number, number]) => {
|
||||
setValue(value);
|
||||
const { lower, upper } = getBounds(value);
|
||||
setMarks(getMarks(lower, upper));
|
||||
|
||||
setDataMask({
|
||||
extraFormData: getRangeExtraFormData(col, lower, upper),
|
||||
filterState: {
|
||||
value: lower !== null || upper !== null ? value : null,
|
||||
label: getLabel(lower, upper),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (value: [number, number]) => {
|
||||
setValue(value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// when switch filter type and queriesData still not updated we need ignore this case (in FilterBar)
|
||||
@ -160,20 +167,25 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
handleAfterChange(filterState.value ?? [min, max]);
|
||||
}, [JSON.stringify(filterState.value), JSON.stringify(data)]);
|
||||
|
||||
const formItemData: FormItemProps = {};
|
||||
if (filterState.validateMessage) {
|
||||
formItemData.extra = (
|
||||
<StatusMessage status={filterState.validateStatus}>
|
||||
{filterState.validateMessage}
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
const formItemExtra = useMemo(() => {
|
||||
if (filterState.validateMessage) {
|
||||
return (
|
||||
<StatusMessage status={filterState.validateStatus}>
|
||||
{filterState.validateMessage}
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, [filterState.validateMessage, filterState.validateStatus]);
|
||||
|
||||
const minMax = useMemo(() => value ?? [min, max], [max, min, value]);
|
||||
|
||||
return (
|
||||
<FilterPluginStyle height={height} width={width}>
|
||||
{Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
|
||||
<h4>{t('Chosen non-numeric column')}</h4>
|
||||
) : (
|
||||
<StyledFormItem {...formItemData}>
|
||||
<StyledFormItem extra={formItemExtra}>
|
||||
<Wrapper
|
||||
tabIndex={-1}
|
||||
ref={inputRef}
|
||||
@ -187,10 +199,10 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
range
|
||||
min={min}
|
||||
max={max}
|
||||
value={value ?? [min, max]}
|
||||
value={minMax}
|
||||
onAfterChange={handleAfterChange}
|
||||
onChange={handleChange}
|
||||
tipFormatter={value => numberFormatter(value)}
|
||||
tipFormatter={tipFormatter}
|
||||
marks={marks}
|
||||
/>
|
||||
</Wrapper>
|
||||
|
@ -34,7 +34,6 @@ import { Select } from 'src/components';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { SLOW_DEBOUNCE } from 'src/constants';
|
||||
import { useImmerReducer } from 'use-immer';
|
||||
import { FormItemProps } from 'antd/lib/form';
|
||||
import { PluginFilterSelectProps, SelectValue } from './types';
|
||||
import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common';
|
||||
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
|
||||
@ -174,13 +173,16 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const searchWrapper = (val: string) => {
|
||||
if (searchAllOptions) {
|
||||
debouncedOwnStateFunc(val);
|
||||
}
|
||||
};
|
||||
const searchWrapper = useCallback(
|
||||
(val: string) => {
|
||||
if (searchAllOptions) {
|
||||
debouncedOwnStateFunc(val);
|
||||
}
|
||||
},
|
||||
[debouncedOwnStateFunc, searchAllOptions],
|
||||
);
|
||||
|
||||
const clearSuggestionSearch = () => {
|
||||
const clearSuggestionSearch = useCallback(() => {
|
||||
if (searchAllOptions) {
|
||||
dispatchDataMask({
|
||||
type: 'ownState',
|
||||
@ -190,21 +192,24 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [dispatchDataMask, initialColtypeMap, searchAllOptions]);
|
||||
|
||||
const handleBlur = () => {
|
||||
const handleBlur = useCallback(() => {
|
||||
clearSuggestionSearch();
|
||||
unsetFocusedFilter();
|
||||
};
|
||||
}, [clearSuggestionSearch, unsetFocusedFilter]);
|
||||
|
||||
const handleChange = (value?: SelectValue | number | string) => {
|
||||
const values = ensureIsArray(value);
|
||||
if (values.length === 0) {
|
||||
updateDataMask(null);
|
||||
} else {
|
||||
updateDataMask(values);
|
||||
}
|
||||
};
|
||||
const handleChange = useCallback(
|
||||
(value?: SelectValue | number | string) => {
|
||||
const values = ensureIsArray(value);
|
||||
if (values.length === 0) {
|
||||
updateDataMask(null);
|
||||
} else {
|
||||
updateDataMask(values);
|
||||
}
|
||||
},
|
||||
[updateDataMask],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultToFirstItem && filterState.value === undefined) {
|
||||
@ -245,14 +250,16 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
? t('No data')
|
||||
: tn('%s option', '%s options', data.length, data.length);
|
||||
|
||||
const formItemData: FormItemProps = {};
|
||||
if (filterState.validateMessage) {
|
||||
formItemData.extra = (
|
||||
<StatusMessage status={filterState.validateStatus}>
|
||||
{filterState.validateMessage}
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
const formItemExtra = useMemo(() => {
|
||||
if (filterState.validateMessage) {
|
||||
return (
|
||||
<StatusMessage status={filterState.validateStatus}>
|
||||
{filterState.validateMessage}
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, [filterState.validateMessage, filterState.validateStatus]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const options: { label: string; value: DataRecordValue }[] = [];
|
||||
@ -270,7 +277,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
<FilterPluginStyle height={height} width={width}>
|
||||
<StyledFormItem
|
||||
validateStatus={filterState.validateStatus}
|
||||
{...formItemData}
|
||||
extra={formItemExtra}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
|
@ -16,8 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { styled } from '@superset-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { styled, TimeRangeEndpoint } from '@superset-ui/core';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
|
||||
import { NO_TIME_RANGE } from 'src/explore/constants';
|
||||
import { PluginFilterTimeProps } from './types';
|
||||
@ -55,6 +55,11 @@ const ControlContainer = styled.div<{
|
||||
}
|
||||
`;
|
||||
|
||||
const endpoints = ['inclusive', 'exclusive'] as [
|
||||
TimeRangeEndpoint,
|
||||
TimeRangeEndpoint,
|
||||
];
|
||||
|
||||
export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
|
||||
const {
|
||||
setDataMask,
|
||||
@ -66,19 +71,22 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
|
||||
formData: { inputRef },
|
||||
} = props;
|
||||
|
||||
const handleTimeRangeChange = (timeRange?: string): void => {
|
||||
const isSet = timeRange && timeRange !== NO_TIME_RANGE;
|
||||
setDataMask({
|
||||
extraFormData: isSet
|
||||
? {
|
||||
time_range: timeRange,
|
||||
}
|
||||
: {},
|
||||
filterState: {
|
||||
value: isSet ? timeRange : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(timeRange?: string): void => {
|
||||
const isSet = timeRange && timeRange !== NO_TIME_RANGE;
|
||||
setDataMask({
|
||||
extraFormData: isSet
|
||||
? {
|
||||
time_range: timeRange,
|
||||
}
|
||||
: {},
|
||||
filterState: {
|
||||
value: isSet ? timeRange : undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
[setDataMask],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleTimeRangeChange(filterState.value);
|
||||
@ -97,7 +105,7 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
|
||||
onMouseLeave={unsetFocusedFilter}
|
||||
>
|
||||
<DateFilterControl
|
||||
endpoints={['inclusive', 'exclusive']}
|
||||
endpoints={endpoints}
|
||||
value={filterState.value || NO_TIME_RANGE}
|
||||
name="time_range"
|
||||
onChange={handleTimeRangeChange}
|
||||
|
Loading…
Reference in New Issue
Block a user