feat(native-filters): Support default to first value in select filter (#14869)

* fix:fix get permission function

* feat: add async filters support

* revert: revert ff

* feat: add async filters support

* fix: merge with master

* fix: remove tests

* lint: fix lint

* fix: fix CR notes

* fix: fix with master

* test: fix tests

* refactor: update logic for default first value

* fix: get requiredFirst

* fix: support instant

* docs: update text

* docs: fix comments

* docs: update texts
This commit is contained in:
simcha90 2021-06-07 13:41:19 +03:00 committed by GitHub
parent a90e16850d
commit 1fc08523af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 296 additions and 144 deletions

View File

@ -18,7 +18,7 @@
*/ */
/* eslint-env browser */ /* eslint-env browser */
import cx from 'classnames'; import cx from 'classnames';
import React, { FC, useEffect, useState } from 'react'; import React, { FC } from 'react';
import { Sticky, StickyContainer } from 'react-sticky'; import { Sticky, StickyContainer } from 'react-sticky';
import { JsonObject, styled } from '@superset-ui/core'; import { JsonObject, styled } from '@superset-ui/core';
import ErrorBoundary from 'src/components/ErrorBoundary'; import ErrorBoundary from 'src/components/ErrorBoundary';
@ -30,7 +30,6 @@ import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import ToastPresenter from 'src/messageToasts/containers/ToastPresenter'; import ToastPresenter from 'src/messageToasts/containers/ToastPresenter';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu'; import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex'; import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { URL_PARAMS } from 'src/constants'; import { URL_PARAMS } from 'src/constants';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { getUrlParam } from 'src/utils/urlUtils'; import { getUrlParam } from 'src/utils/urlUtils';
@ -47,11 +46,11 @@ import {
DashboardStandaloneMode, DashboardStandaloneMode,
} from 'src/dashboard/util/constants'; } from 'src/dashboard/util/constants';
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar'; import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import Loading from 'src/components/Loading';
import { StickyVerticalBar } from '../StickyVerticalBar'; import { StickyVerticalBar } from '../StickyVerticalBar';
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils'; import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
import { useFilters } from '../nativeFilters/FilterBar/state';
import { Filter } from '../nativeFilters/types';
import DashboardContainer from './DashboardContainer'; import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
const TABS_HEIGHT = 47; const TABS_HEIGHT = 47;
const HEADER_HEIGHT = 67; const HEADER_HEIGHT = 67;
@ -99,12 +98,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dashboardLayout = useSelector<RootState, DashboardLayout>( const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present, state => state.dashboardLayout.present,
); );
const showNativeFilters = useSelector<RootState, boolean>(
state => state.dashboardInfo.metadata?.show_native_filters,
);
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const editMode = useSelector<RootState, boolean>( const editMode = useSelector<RootState, boolean>(
state => state.dashboardState.editMode, state => state.dashboardState.editMode,
); );
@ -112,22 +105,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
state => state.dashboardState.directPathToChild, state => state.dashboardState.directPathToChild,
); );
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
const nativeFiltersEnabled =
showNativeFilters &&
isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
(canEdit || (!canEdit && filterValues.length !== 0));
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
getUrlParam(URL_PARAMS.showFilters) ?? true,
);
const toggleDashboardFiltersOpen = (visible?: boolean) => {
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
};
const handleChangeTab = ({ const handleChangeTab = ({
pathToTabIndex, pathToTabIndex,
}: { }: {
@ -161,15 +138,12 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
(hideDashboardHeader ? 0 : HEADER_HEIGHT) + (hideDashboardHeader ? 0 : HEADER_HEIGHT) +
(topLevelTabs ? TABS_HEIGHT : 0); (topLevelTabs ? TABS_HEIGHT : 0);
useEffect(() => { const {
if ( showDashboard,
filterValues.length === 0 && dashboardFiltersOpen,
dashboardFiltersOpen && toggleDashboardFiltersOpen,
nativeFiltersEnabled nativeFiltersEnabled,
) { } = useNativeFilters();
toggleDashboardFiltersOpen(false);
}
}, [filterValues.length]);
return ( return (
<StickyContainer <StickyContainer
@ -245,7 +219,11 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
</ErrorBoundary> </ErrorBoundary>
</StickyVerticalBar> </StickyVerticalBar>
)} )}
<DashboardContainer topLevelTabs={topLevelTabs} /> {showDashboard ? (
<DashboardContainer topLevelTabs={topLevelTabs} />
) : (
<Loading />
)}
{editMode && <BuilderComponentPane topOffset={barTopOffset} />} {editMode && <BuilderComponentPane topOffset={barTopOffset} />}
</StyledDashboardContent> </StyledDashboardContent>
<ToastPresenter /> <ToastPresenter />

View File

@ -0,0 +1,93 @@
/**
* 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 { useSelector } from 'react-redux';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { useEffect, useState } from 'react';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { RootState } from 'src/dashboard/types';
import {
useFilters,
useNativeFiltersDataMask,
} from '../nativeFilters/FilterBar/state';
import { Filter } from '../nativeFilters/types';
// eslint-disable-next-line import/prefer-default-export
export const useNativeFilters = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
getUrlParam(URL_PARAMS.showFilters) ?? true,
);
const showNativeFilters = useSelector<RootState, boolean>(
state => state.dashboardInfo.metadata?.show_native_filters,
);
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
const nativeFiltersEnabled =
showNativeFilters &&
isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
(canEdit || (!canEdit && filterValues.length !== 0));
const requiredFirstFilter = filterValues.filter(
({ requiredFirst }) => requiredFirst,
);
const dataMask = useNativeFiltersDataMask();
const showDashboard =
isInitialized ||
!nativeFiltersEnabled ||
!(
nativeFiltersEnabled &&
requiredFirstFilter.length &&
requiredFirstFilter.find(
({ id }) => dataMask[id]?.filterState?.value === undefined,
)
);
const toggleDashboardFiltersOpen = (visible?: boolean) => {
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
};
useEffect(() => {
if (
filterValues.length === 0 &&
dashboardFiltersOpen &&
nativeFiltersEnabled
) {
toggleDashboardFiltersOpen(false);
}
}, [filterValues.length]);
useEffect(() => {
if (showDashboard) {
setIsInitialized(true);
}
}, [showDashboard]);
return {
showDashboard,
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
};
};

View File

@ -74,7 +74,7 @@ const FilterValue: React.FC<FilterProps> = ({
const { name: groupby } = column; const { name: groupby } = column;
const hasDataSource = !!datasetId; const hasDataSource = !!datasetId;
const [isLoading, setIsLoading] = useState<boolean>(hasDataSource); const [isLoading, setIsLoading] = useState<boolean>(hasDataSource);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false); const [isRefreshing, setIsRefreshing] = useState<boolean>(true);
const dispatch = useDispatch(); const dispatch = useDispatch();
useEffect(() => { useEffect(() => {
const newFormData = getFormData({ const newFormData = getFormData({

View File

@ -18,7 +18,7 @@
*/ */
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { HandlerFunction, styled, t } from '@superset-ui/core'; import { DataMask, HandlerFunction, styled, t } from '@superset-ui/core';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import cx from 'classnames'; import cx from 'classnames';
@ -26,11 +26,7 @@ import Icon from 'src/components/Icon';
import { Tabs } from 'src/common/components'; import { Tabs } from 'src/common/components';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { updateDataMask } from 'src/dataMask/actions'; import { updateDataMask } from 'src/dataMask/actions';
import { import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types';
DataMaskState,
DataMaskStateWithId,
DataMaskWithId,
} from 'src/dataMask/types';
import { useImmer } from 'use-immer'; import { useImmer } from 'use-immer';
import { areObjectsEqual } from 'src/reduxUtils'; import { areObjectsEqual } from 'src/reduxUtils';
import { testWithId } from 'src/utils/testUtils'; import { testWithId } from 'src/utils/testUtils';
@ -178,10 +174,21 @@ const FilterBar: React.FC<FiltersBarProps> = ({
const handleFilterSelectionChange = ( const handleFilterSelectionChange = (
filter: Pick<Filter, 'id'> & Partial<Filter>, filter: Pick<Filter, 'id'> & Partial<Filter>,
dataMask: Partial<DataMaskState>, dataMask: Partial<DataMask>,
) => { ) => {
setIsFilterSetChanged(tab !== TabIds.AllFilters); setIsFilterSetChanged(tab !== TabIds.AllFilters);
setDataMaskSelected(draft => { setDataMaskSelected(draft => {
// force instant updating on initialization for filters with `requiredFirst` is true or instant filters
if (
(dataMaskSelected[filter.id] && filter.isInstant) ||
// 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] = { draft[filter.id] = {
...(getInitialDataMask(filter.id) as DataMaskWithId), ...(getInitialDataMask(filter.id) as DataMaskWithId),
...dataMask, ...dataMask,

View File

@ -76,6 +76,7 @@ export const useFilterUpdates = (
// Load filters after charts loaded // Load filters after charts loaded
export const useInitialization = () => { export const useInitialization = () => {
const [isInitialized, setIsInitialized] = useState<boolean>(false); const [isInitialized, setIsInitialized] = useState<boolean>(false);
const filters = useFilters();
const charts = useSelector<RootState, ChartsState>(state => state.charts); const charts = useSelector<RootState, ChartsState>(state => state.charts);
// We need to know how much charts now shown on dashboard to know how many of all charts should be loaded // We need to know how much charts now shown on dashboard to know how many of all charts should be loaded
@ -90,6 +91,11 @@ export const useInitialization = () => {
return; return;
} }
if (Object.values(filters).find(({ requiredFirst }) => requiredFirst)) {
setIsInitialized(true);
return;
}
// For some dashboards may be there are no charts on first page, // For some dashboards may be there are no charts on first page,
// so we check up to 1 sec if there is at least on chart to load // so we check up to 1 sec if there is at least on chart to load
let filterTimeout: NodeJS.Timeout; let filterTimeout: NodeJS.Timeout;

View File

@ -443,6 +443,7 @@ const FiltersConfigForm = (
filterId, filterId,
filterType: formFilter.filterType, filterType: formFilter.filterType,
filterToEdit, filterToEdit,
formFilter,
}) })
: {}; : {};
@ -592,6 +593,7 @@ const FiltersConfigForm = (
expandIconPosition="right" expandIconPosition="right"
> >
<Collapse.Panel <Collapse.Panel
forceRender
header={FilterPanels.basic.name} header={FilterPanels.basic.name}
key={FilterPanels.basic.key} key={FilterPanels.basic.key}
> >
@ -625,7 +627,11 @@ const FiltersConfigForm = (
{ {
validator: (rule, value) => { validator: (rule, value) => {
const hasValue = !!value.filterState?.value; const hasValue = !!value.filterState?.value;
if (hasValue) { if (
hasValue ||
// TODO: do more generic
formFilter.controlValues?.defaultToFirstItem
) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject( return Promise.reject(
@ -673,6 +679,7 @@ const FiltersConfigForm = (
</Collapse.Panel> </Collapse.Panel>
{((hasDataset && hasAdditionalFilters) || hasMetrics) && ( {((hasDataset && hasAdditionalFilters) || hasMetrics) && (
<Collapse.Panel <Collapse.Panel
forceRender
header={FilterPanels.advanced.name} header={FilterPanels.advanced.name}
key={FilterPanels.advanced.key} key={FilterPanels.advanced.key}
> >

View File

@ -23,10 +23,11 @@ import {
import React from 'react'; import React from 'react';
import { Checkbox } from 'src/common/components'; import { Checkbox } from 'src/common/components';
import { FormInstance } from 'antd/lib/form'; import { FormInstance } from 'antd/lib/form';
import { getChartControlPanelRegistry, t } from '@superset-ui/core'; import { getChartControlPanelRegistry, styled, t } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { FormItem } from 'src/components/Form';
import { getControlItems, setNativeFilterFieldValues } from './utils'; import { getControlItems, setNativeFilterFieldValues } from './utils';
import { NativeFiltersForm } from '../types'; import { NativeFiltersForm, NativeFiltersFormItem } from '../types';
import { StyledRowFormItem } from './FiltersConfigForm'; import { StyledRowFormItem } from './FiltersConfigForm';
import { Filter } from '../../types'; import { Filter } from '../../types';
@ -37,8 +38,13 @@ export interface ControlItemsProps {
filterId: string; filterId: string;
filterType: string; filterType: string;
filterToEdit?: Filter; filterToEdit?: Filter;
formFilter?: NativeFiltersFormItem;
} }
const CleanFormItem = styled(FormItem)`
margin-bottom: 0;
`;
export default function getControlItemsMap({ export default function getControlItemsMap({
disabled, disabled,
forceUpdate, forceUpdate,
@ -46,6 +52,7 @@ export default function getControlItemsMap({
filterId, filterId,
filterType, filterType,
filterToEdit, filterToEdit,
formFilter,
}: ControlItemsProps) { }: ControlItemsProps) {
const controlPanelRegistry = getChartControlPanelRegistry(); const controlPanelRegistry = getChartControlPanelRegistry();
const controlItems = const controlItems =
@ -66,46 +73,61 @@ export default function getControlItemsMap({
filterToEdit?.controlValues?.[controlItem.name] ?? filterToEdit?.controlValues?.[controlItem.name] ??
controlItem?.config?.default; controlItem?.config?.default;
const element = ( const element = (
<Tooltip <>
key={controlItem.name} <CleanFormItem
placement="left" name={['filters', filterId, 'requiredFirst', controlItem.name]}
title={ hidden
controlItem.config.affectsDataMask && initialValue={
disabled && controlItem?.config?.requiredFirst && filterToEdit?.requiredFirst
t('Populate "Default value" to enable this control') }
} />
> <Tooltip
<StyledRowFormItem
key={controlItem.name} key={controlItem.name}
name={['filters', filterId, 'controlValues', controlItem.name]} placement="left"
initialValue={initialValue} title={
valuePropName="checked" controlItem.config.affectsDataMask &&
colon={false} disabled &&
t('Populate "Default value" to enable this control')
}
> >
<Checkbox <StyledRowFormItem
disabled={controlItem.config.affectsDataMask && disabled} key={controlItem.name}
onChange={() => { name={['filters', filterId, 'controlValues', controlItem.name]}
if (!controlItem.config.resetConfig) { initialValue={initialValue}
forceUpdate(); valuePropName="checked"
return; colon={false}
}
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
forceUpdate();
}}
> >
{controlItem.config.label}{' '} <Checkbox
{controlItem.config.description && ( disabled={controlItem.config.affectsDataMask && disabled}
<InfoTooltipWithTrigger onChange={({ target: { checked } }) => {
placement="top" if (controlItem.config.requiredFirst) {
label={controlItem.config.name} setNativeFilterFieldValues(form, filterId, {
tooltip={controlItem.config.description} requiredFirst: {
/> ...formFilter?.requiredFirst,
)} [controlItem.name]: checked,
</Checkbox> },
</StyledRowFormItem> });
</Tooltip> }
if (controlItem.config.resetConfig) {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
}
forceUpdate();
}}
>
{controlItem.config.label}{' '}
{controlItem.config.description && (
<InfoTooltipWithTrigger
placement="top"
label={controlItem.config.name}
tooltip={controlItem.config.description}
/>
)}
</Checkbox>
</StyledRowFormItem>
</Tooltip>
</>
); );
map[controlItem.name] = { element, checked: initialValue }; map[controlItem.name] = { element, checked: initialValue };
}); });

View File

@ -31,6 +31,9 @@ export interface NativeFiltersFormItem {
controlValues: { controlValues: {
[key: string]: any; [key: string]: any;
}; };
requiredFirst: {
[key: string]: boolean;
};
defaultValue: any; defaultValue: any;
defaultDataMask: DataMask; defaultDataMask: DataMask;
parentFilter: { parentFilter: {

View File

@ -140,6 +140,9 @@ export const createHandleSave = (
adhoc_filters: formInputs.adhoc_filters, adhoc_filters: formInputs.adhoc_filters,
time_range: formInputs.time_range, time_range: formInputs.time_range,
controlValues: formInputs.controlValues ?? {}, controlValues: formInputs.controlValues ?? {},
requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find(
rf => rf,
),
name: formInputs.name, name: formInputs.name,
filterType: formInputs.filterType, filterType: formInputs.filterType,
// for now there will only ever be one target // for now there will only ever be one target

View File

@ -56,6 +56,7 @@ export interface Filter {
sortMetric?: string | null; sortMetric?: string | null;
adhoc_filters?: AdhocFilter[]; adhoc_filters?: AdhocFilter[];
time_range?: string; time_range?: string;
requiredFirst?: boolean;
tabsInScope?: string[]; tabsInScope?: string[];
chartsInScope?: number[]; chartsInScope?: number[];
} }

View File

@ -49,7 +49,7 @@ export function getInitialDataMask(id: string): DataMaskWithId {
...otherProps, ...otherProps,
extraFormData: {}, extraFormData: {},
filterState: { filterState: {
value: null, value: undefined,
}, },
ownState: {}, ownState: {},
} as DataMaskWithId; } as DataMaskWithId;

View File

@ -88,6 +88,7 @@ describe('SelectFilterPlugin', () => {
it('Add multiple values with first render', () => { it('Add multiple values with first render', () => {
getWrapper(); getWrapper();
expect(setDataMask).toHaveBeenCalledWith({ expect(setDataMask).toHaveBeenCalledWith({
extraFormData: {},
filterState: { filterState: {
value: ['boy'], value: ['boy'],
}, },
@ -98,6 +99,9 @@ describe('SelectFilterPlugin', () => {
}, },
}); });
expect(setDataMask).toHaveBeenCalledWith({ expect(setDataMask).toHaveBeenCalledWith({
__cache: {
value: ['boy'],
},
extraFormData: { extraFormData: {
filters: [ filters: [
{ {
@ -120,6 +124,9 @@ describe('SelectFilterPlugin', () => {
userEvent.click(screen.getByRole('combobox')); userEvent.click(screen.getByRole('combobox'));
userEvent.click(screen.getByTitle('girl')); userEvent.click(screen.getByTitle('girl'));
expect(setDataMask).toHaveBeenCalledWith({ expect(setDataMask).toHaveBeenCalledWith({
__cache: {
value: ['boy'],
},
extraFormData: { extraFormData: {
filters: [ filters: [
{ {
@ -146,6 +153,9 @@ describe('SelectFilterPlugin', () => {
getWrapper(); getWrapper();
userEvent.click(document.querySelector('[data-icon="close"]')!); userEvent.click(document.querySelector('[data-icon="close"]')!);
expect(setDataMask).toHaveBeenCalledWith({ expect(setDataMask).toHaveBeenCalledWith({
__cache: {
value: ['boy'],
},
extraFormData: { extraFormData: {
adhoc_filters: [ adhoc_filters: [
{ {
@ -171,6 +181,9 @@ describe('SelectFilterPlugin', () => {
getWrapper({ enableEmptyFilter: false }); getWrapper({ enableEmptyFilter: false });
userEvent.click(document.querySelector('[data-icon="close"]')!); userEvent.click(document.querySelector('[data-icon="close"]')!);
expect(setDataMask).toHaveBeenCalledWith({ expect(setDataMask).toHaveBeenCalledWith({
__cache: {
value: ['boy'],
},
extraFormData: {}, extraFormData: {},
filterState: { filterState: {
label: '', label: '',
@ -189,6 +202,9 @@ describe('SelectFilterPlugin', () => {
userEvent.click(screen.getByRole('combobox')); userEvent.click(screen.getByRole('combobox'));
userEvent.click(screen.getByTitle('girl')); userEvent.click(screen.getByTitle('girl'));
expect(setDataMask).toHaveBeenCalledWith({ expect(setDataMask).toHaveBeenCalledWith({
__cache: {
value: ['boy'],
},
extraFormData: { extraFormData: {
filters: [ filters: [
{ {

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
/* eslint-disable no-param-reassign */
import { import {
AppSection, AppSection,
DataMask, DataMask,
@ -28,16 +29,11 @@ import {
t, t,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import React, { import React, { useCallback, useEffect, useMemo, useState } from 'react';
useCallback,
useEffect,
useMemo,
useReducer,
useState,
} from 'react';
import { Select } from 'src/common/components'; import { Select } from 'src/common/components';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { SLOW_DEBOUNCE } from 'src/constants'; import { SLOW_DEBOUNCE } from 'src/constants';
import { useImmerReducer } from 'use-immer';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { PluginFilterSelectProps, SelectValue } from './types'; import { PluginFilterSelectProps, SelectValue } from './types';
import { StyledSelect, Styles } from '../common'; import { StyledSelect, Styles } from '../common';
@ -49,41 +45,33 @@ type DataMaskAction =
| { type: 'ownState'; ownState: JsonObject } | { type: 'ownState'; ownState: JsonObject }
| { | {
type: 'filterState'; type: 'filterState';
__cache: JsonObject;
extraFormData: ExtraFormData; extraFormData: ExtraFormData;
filterState: { value: SelectValue; label?: string }; filterState: { value: SelectValue; label?: string };
}; };
function reducer(state: DataMask, action: DataMaskAction): DataMask { function reducer(
draft: Required<DataMask> & { __cache?: JsonObject },
action: DataMaskAction,
) {
switch (action.type) { switch (action.type) {
case 'ownState': case 'ownState':
return { draft.ownState = {
...state, ...draft.ownState,
ownState: { ...action.ownState,
...(state.ownState || {}),
...action.ownState,
},
}; };
return draft;
case 'filterState': case 'filterState':
return { draft.extraFormData = action.extraFormData;
...state, // eslint-disable-next-line no-underscore-dangle
extraFormData: action.extraFormData, draft.__cache = action.__cache;
filterState: { draft.filterState = { ...draft.filterState, ...action.filterState };
...(state.filterState || {}), return draft;
...action.filterState,
},
};
default: default:
return { return draft;
...state,
};
} }
} }
type DataMaskReducer = (
prevState: DataMask,
action: DataMaskAction,
) => DataMask;
export default function PluginFilterSelect(props: PluginFilterSelectProps) { export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const { const {
coltypeMap, coltypeMap,
@ -127,32 +115,49 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
}, [col, selectedValues, data]); }, [col, selectedValues, data]);
const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState(''); const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState('');
const [dataMask, dispatchDataMask] = useReducer<DataMaskReducer>(reducer, { const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
extraFormData: {},
filterState, filterState,
ownState: { ownState: {
coltypeMap, coltypeMap,
}, },
}); });
const updateDataMask = (values: SelectValue) => { const updateDataMask = useCallback(
const emptyFilter = (values: SelectValue) => {
enableEmptyFilter && !inverseSelection && !values?.length; const emptyFilter =
const suffix = enableEmptyFilter && !inverseSelection && !values?.length;
inverseSelection && values?.length ? ` (${t('excluded')})` : '';
dispatchDataMask({ const suffix =
type: 'filterState', inverseSelection && values?.length ? ` (${t('excluded')})` : '';
extraFormData: getSelectExtraFormData(
col, dispatchDataMask({
values, type: 'filterState',
emptyFilter, __cache: filterState,
inverseSelection, extraFormData: getSelectExtraFormData(
), col,
filterState: { values,
value: values, emptyFilter,
label: `${(values || []).join(', ')}${suffix}`, inverseSelection,
}, ),
}); filterState: {
}; label: `${(values || []).join(', ')}${suffix}`,
value:
appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem
? undefined
: values,
},
});
},
[
appSection,
col,
defaultToFirstItem,
dispatchDataMask,
enableEmptyFilter,
inverseSelection,
JSON.stringify(filterState),
],
);
useEffect(() => { useEffect(() => {
if (!isDropdownVisible) { if (!isDropdownVisible) {
@ -216,15 +221,19 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
}; };
useEffect(() => { useEffect(() => {
const firstItem: SelectValue = data[0] if (defaultToFirstItem && filterState.value === undefined) {
? (groupby.map(col => data[0][col]) as string[]) // initialize to first value if set to default to first item
: null; const firstItem: SelectValue = data[0]
if (isDisabled) { ? (groupby.map(col => data[0][col]) as string[])
: null;
// firstItem[0] !== undefined for a case when groupby changed but new data still not fetched
// TODO: still need repopulate default value in config modal when column changed
if (firstItem && firstItem[0] !== undefined) {
updateDataMask(firstItem);
}
} else if (isDisabled) {
// empty selection if filter is disabled // empty selection if filter is disabled
updateDataMask(null); updateDataMask(null);
} else if (!isDisabled && defaultToFirstItem && firstItem) {
// initialize to first value if set to default to first item
updateDataMask(firstItem);
} else { } else {
// reset data mask based on filter state // reset data mask based on filter state
updateDataMask(filterState.value); updateDataMask(filterState.value);
@ -235,6 +244,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
defaultToFirstItem, defaultToFirstItem,
enableEmptyFilter, enableEmptyFilter,
inverseSelection, inverseSelection,
updateDataMask,
data,
groupby,
JSON.stringify(filterState),
]); ]);
useEffect(() => { useEffect(() => {

View File

@ -93,7 +93,10 @@ const config: ControlPanelConfig = {
resetConfig: true, resetConfig: true,
affectsDataMask: true, affectsDataMask: true,
renderTrigger: true, renderTrigger: true,
description: t('Select first item by default'), requiredFirst: true,
description: t(
'Select first item by default (when using this option, default value cant be set)',
),
}, },
}, },
], ],

View File

@ -29,7 +29,7 @@ import {
import { RefObject } from 'react'; import { RefObject } from 'react';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
export type SelectValue = (number | string)[] | null; export type SelectValue = (number | string)[] | null | undefined;
interface PluginFilterSelectCustomizeProps { interface PluginFilterSelectCustomizeProps {
defaultValue?: SelectValue; defaultValue?: SelectValue;