mirror of
https://github.com/apache/superset.git
synced 2024-09-12 00:29:39 -04:00
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:
parent
a90e16850d
commit
1fc08523af
@ -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 />
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -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({
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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 };
|
||||||
});
|
});
|
||||||
|
@ -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: {
|
||||||
|
@ -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
|
||||||
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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 can’t be set)',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user