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 */
import cx from 'classnames';
import React, { FC, useEffect, useState } from 'react';
import React, { FC } from 'react';
import { Sticky, StickyContainer } from 'react-sticky';
import { JsonObject, styled } from '@superset-ui/core';
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 WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { URL_PARAMS } from 'src/constants';
import { useDispatch, useSelector } from 'react-redux';
import { getUrlParam } from 'src/utils/urlUtils';
@ -47,11 +46,11 @@ import {
DashboardStandaloneMode,
} from 'src/dashboard/util/constants';
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import Loading from 'src/components/Loading';
import { StickyVerticalBar } from '../StickyVerticalBar';
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
import { useFilters } from '../nativeFilters/FilterBar/state';
import { Filter } from '../nativeFilters/types';
import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
const TABS_HEIGHT = 47;
const HEADER_HEIGHT = 67;
@ -99,12 +98,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dashboardLayout = useSelector<RootState, DashboardLayout>(
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>(
state => state.dashboardState.editMode,
);
@ -112,22 +105,6 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
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 = ({
pathToTabIndex,
}: {
@ -161,15 +138,12 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
(hideDashboardHeader ? 0 : HEADER_HEIGHT) +
(topLevelTabs ? TABS_HEIGHT : 0);
useEffect(() => {
if (
filterValues.length === 0 &&
dashboardFiltersOpen &&
nativeFiltersEnabled
) {
toggleDashboardFiltersOpen(false);
}
}, [filterValues.length]);
const {
showDashboard,
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
} = useNativeFilters();
return (
<StickyContainer
@ -245,7 +219,11 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
</ErrorBoundary>
</StickyVerticalBar>
)}
<DashboardContainer topLevelTabs={topLevelTabs} />
{showDashboard ? (
<DashboardContainer topLevelTabs={topLevelTabs} />
) : (
<Loading />
)}
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
</StyledDashboardContent>
<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 hasDataSource = !!datasetId;
const [isLoading, setIsLoading] = useState<boolean>(hasDataSource);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(true);
const dispatch = useDispatch();
useEffect(() => {
const newFormData = getFormData({

View File

@ -18,7 +18,7 @@
*/
/* 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 { useDispatch } from 'react-redux';
import cx from 'classnames';
@ -26,11 +26,7 @@ import Icon from 'src/components/Icon';
import { Tabs } from 'src/common/components';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { updateDataMask } from 'src/dataMask/actions';
import {
DataMaskState,
DataMaskStateWithId,
DataMaskWithId,
} from 'src/dataMask/types';
import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types';
import { useImmer } from 'use-immer';
import { areObjectsEqual } from 'src/reduxUtils';
import { testWithId } from 'src/utils/testUtils';
@ -178,10 +174,21 @@ const FilterBar: React.FC<FiltersBarProps> = ({
const handleFilterSelectionChange = (
filter: Pick<Filter, 'id'> & Partial<Filter>,
dataMask: Partial<DataMaskState>,
dataMask: Partial<DataMask>,
) => {
setIsFilterSetChanged(tab !== TabIds.AllFilters);
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] = {
...(getInitialDataMask(filter.id) as DataMaskWithId),
...dataMask,

View File

@ -76,6 +76,7 @@ export const useFilterUpdates = (
// Load filters after charts loaded
export const useInitialization = () => {
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const filters = useFilters();
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
@ -90,6 +91,11 @@ export const useInitialization = () => {
return;
}
if (Object.values(filters).find(({ requiredFirst }) => requiredFirst)) {
setIsInitialized(true);
return;
}
// 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
let filterTimeout: NodeJS.Timeout;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -93,7 +93,10 @@ const config: ControlPanelConfig = {
resetConfig: true,
affectsDataMask: 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 { PluginFilterHooks, PluginFilterStylesProps } from '../types';
export type SelectValue = (number | string)[] | null;
export type SelectValue = (number | string)[] | null | undefined;
interface PluginFilterSelectCustomizeProps {
defaultValue?: SelectValue;