feat(native-filters): Disable Apply button if filter required (#15222)

* fix:fix get permission function

* fix: fix select first value by clear all

* lint: fix lint

* feat: disable select on missed value

* fix: refactor ff

* feat: connect nulls for line chart

* lint: fix lint

* docs: fix message

* fix: fix CR comments

* fix: fix Collapsed items

* fix: fix Collapsed items
This commit is contained in:
simcha90 2021-06-17 16:35:33 +03:00 committed by GitHub
parent 1269cc2f88
commit 388eb01f06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 180 additions and 78 deletions

View File

@ -43,6 +43,7 @@ import { ClientErrorObject } from 'src/utils/getClientErrorObject';
import { FilterProps } from './types'; import { FilterProps } from './types';
import { getFormData } from '../../utils'; import { getFormData } from '../../utils';
import { useCascadingFilters } from './state'; import { useCascadingFilters } from './state';
import { checkIsMissingRequiredValue } from '../utils';
const FilterItem = styled.div` const FilterItem = styled.div`
min-height: ${({ theme }) => theme.gridUnit * 11}px; min-height: ${({ theme }) => theme.gridUnit * 11}px;
@ -181,6 +182,11 @@ const FilterValue: React.FC<FilterProps> = ({
); );
} }
const isMissingRequiredValue = checkIsMissingRequiredValue(
filter,
filter.dataMask?.filterState,
);
return ( return (
<FilterItem data-test="form-item-value"> <FilterItem data-test="form-item-value">
{isLoading ? ( {isLoading ? (
@ -194,7 +200,11 @@ const FilterValue: React.FC<FilterProps> = ({
queriesData={hasDataSource ? state : [{ data: [{}] }]} queriesData={hasDataSource ? state : [{ data: [{}] }]}
chartType={filterType} chartType={filterType}
behaviors={[Behavior.NATIVE_FILTER]} behaviors={[Behavior.NATIVE_FILTER]}
filterState={filter.dataMask?.filterState} filterState={{
...filter.dataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
validateStatus: isMissingRequiredValue && 'error',
}}
ownState={filter.dataMask?.ownState} ownState={filter.dataMask?.ownState}
enableNoResults={metadata?.enableNoResults} enableNoResults={metadata?.enableNoResults}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}

View File

@ -29,12 +29,11 @@ import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { updateDataMask } from 'src/dataMask/actions'; import { updateDataMask } from 'src/dataMask/actions';
import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types'; import { DataMaskStateWithId, DataMaskWithId } from 'src/dataMask/types';
import { useImmer } from 'use-immer'; import { useImmer } from 'use-immer';
import { areObjectsEqual } from 'src/reduxUtils';
import { testWithId } from 'src/utils/testUtils'; import { testWithId } from 'src/utils/testUtils';
import { Filter } from 'src/dashboard/components/nativeFilters/types'; import { Filter } from 'src/dashboard/components/nativeFilters/types';
import Loading from 'src/components/Loading'; import Loading from 'src/components/Loading';
import { getInitialDataMask } from 'src/dataMask/reducer'; import { getInitialDataMask } from 'src/dataMask/reducer';
import { getOnlyExtraFormData, TabIds } from './utils'; import { checkIsApplyDisabled, TabIds } from './utils';
import FilterSets from './FilterSets'; import FilterSets from './FilterSets';
import { import {
useNativeFiltersDataMask, useNativeFiltersDataMask,
@ -214,16 +213,11 @@ const FilterBar: React.FC<FiltersBarProps> = ({
}; };
useFilterUpdates(dataMaskSelected, setDataMaskSelected); useFilterUpdates(dataMaskSelected, setDataMaskSelected);
const isApplyDisabled = checkIsApplyDisabled(
const dataSelectedValues = Object.values(dataMaskSelected); dataMaskSelected,
const dataAppliedValues = Object.values(dataMaskApplied); dataMaskApplied,
const isApplyDisabled = filterValues,
areObjectsEqual( );
getOnlyExtraFormData(dataMaskSelected),
getOnlyExtraFormData(dataMaskApplied),
{ ignoreUndefined: true },
) || dataSelectedValues.length !== dataAppliedValues.length;
const isInitialized = useInitialization(); const isInitialized = useInitialization();
return ( return (

View File

@ -18,6 +18,8 @@
*/ */
import { DataMaskStateWithId } from 'src/dataMask/types'; import { DataMaskStateWithId } from 'src/dataMask/types';
import { areObjectsEqual } from 'src/reduxUtils';
import { FilterState } from '@superset-ui/core';
import { Filter } from '../types'; import { Filter } from '../types';
export enum TabIds { export enum TabIds {
@ -46,3 +48,39 @@ export const getOnlyExtraFormData = (data: DataMaskStateWithId) =>
(prev, next) => ({ ...prev, [next.id]: next.extraFormData }), (prev, next) => ({ ...prev, [next.id]: next.extraFormData }),
{}, {},
); );
export const checkIsMissingRequiredValue = (
filter: Filter,
filterState?: FilterState,
) => {
const value = filterState?.value;
// TODO: this property should be unhardcoded
return (
filter.controlValues.enableEmptyFilter &&
(value === null || value === undefined)
);
};
export const checkIsApplyDisabled = (
dataMaskSelected: DataMaskStateWithId,
dataMaskApplied: DataMaskStateWithId,
filters: Filter[],
) => {
const dataSelectedValues = Object.values(dataMaskSelected);
const dataAppliedValues = Object.values(dataMaskApplied);
return (
areObjectsEqual(
getOnlyExtraFormData(dataMaskSelected),
getOnlyExtraFormData(dataMaskApplied),
{ ignoreUndefined: true },
) ||
dataSelectedValues.length !== dataAppliedValues.length ||
filters.some(filter =>
checkIsMissingRequiredValue(
filter,
dataMaskSelected?.[filter?.id]?.filterState,
),
)
);
};

View File

@ -16,11 +16,12 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { ReactNode, useState } from 'react'; import React, { ReactNode, useEffect, useState } from 'react';
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import { Checkbox } from 'src/common/components'; import { Checkbox } from 'src/common/components';
interface CollapsibleControlProps { interface CollapsibleControlProps {
initialValue?: boolean;
checked?: boolean; checked?: boolean;
title: string; title: string;
children: ReactNode; children: ReactNode;
@ -45,8 +46,23 @@ const StyledContainer = styled.div<{ checked: boolean }>`
`; `;
const CollapsibleControl = (props: CollapsibleControlProps) => { const CollapsibleControl = (props: CollapsibleControlProps) => {
const { checked = false, title, children, onChange } = props; const {
const [isChecked, setIsChecked] = useState(checked); checked,
title,
children,
onChange = () => {},
initialValue = false,
} = props;
const [isChecked, setIsChecked] = useState(initialValue);
useEffect(() => {
// if external `checked` changed to `undefined`, it means that we work now in uncontrolled mode with local state
// and we need ignore external value
if (checked !== undefined) {
setIsChecked(checked);
}
}, [checked]);
return ( return (
<StyledContainer checked={isChecked}> <StyledContainer checked={isChecked}>
<Checkbox <Checkbox
@ -54,10 +70,12 @@ const CollapsibleControl = (props: CollapsibleControlProps) => {
checked={isChecked} checked={isChecked}
onChange={e => { onChange={e => {
const value = e.target.checked; const value = e.target.checked;
setIsChecked(value); // external `checked` value has more priority then local state
if (onChange) { if (checked === undefined) {
onChange(value); // uncontrolled mode
setIsChecked(value);
} }
onChange(value);
}} }}
> >
{title} {title}

View File

@ -69,7 +69,7 @@ import {
setNativeFilterFieldValues, setNativeFilterFieldValues,
useForceUpdate, useForceUpdate,
} from './utils'; } from './utils';
import { useBackendFormUpdate } from './state'; import { useBackendFormUpdate, useDefaultValue } from './state';
import { getFormData } from '../../utils'; import { getFormData } from '../../utils';
import { Filter } from '../../types'; import { Filter } from '../../types';
import getControlItemsMap from './getControlItemsMap'; import getControlItemsMap from './getControlItemsMap';
@ -280,14 +280,13 @@ const FiltersConfigForm = (
const [activeFilterPanelKey, setActiveFilterPanelKey] = useState< const [activeFilterPanelKey, setActiveFilterPanelKey] = useState<
string | string[] string | string[]
>(FilterPanels.basic.key); >(FilterPanels.basic.key);
const [hasDefaultValue, setHasDefaultValue] = useState(
!!filterToEdit?.defaultDataMask?.filterState?.value,
);
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
const [datasetDetails, setDatasetDetails] = useState<Record<string, any>>(); const [datasetDetails, setDatasetDetails] = useState<Record<string, any>>();
const defaultFormFilter = useMemo(() => {}, []); const defaultFormFilter = useMemo(() => {}, []);
const formFilter = const formFilter =
form.getFieldValue('filters')?.[filterId] || defaultFormFilter; form.getFieldValue('filters')?.[filterId] || defaultFormFilter;
const nativeFilterItems = getChartMetadataRegistry().items; const nativeFilterItems = getChartMetadataRegistry().items;
const nativeFilterVizTypes = Object.entries(nativeFilterItems) const nativeFilterVizTypes = Object.entries(nativeFilterItems)
// @ts-ignore // @ts-ignore
@ -431,6 +430,11 @@ const FiltersConfigForm = (
...formFilter, ...formFilter,
}); });
const [hasDefaultValue, setHasDefaultValue] = useDefaultValue(
formFilter,
filterToEdit,
);
useEffect(() => { useEffect(() => {
if (hasDataset && hasFilledDataset && hasDefaultValue && isDataDirty) { if (hasDataset && hasFilledDataset && hasDefaultValue && isDataDirty) {
refreshHandler(); refreshHandler();
@ -672,6 +676,7 @@ const FiltersConfigForm = (
/> />
<CollapsibleControl <CollapsibleControl
title={t('Filter has default value')} title={t('Filter has default value')}
initialValue={hasDefaultValue}
checked={hasDefaultValue} checked={hasDefaultValue}
onChange={value => setHasDefaultValue(value)} onChange={value => setHasDefaultValue(value)}
> >
@ -680,18 +685,17 @@ const FiltersConfigForm = (
initialValue={filterToEdit?.defaultDataMask} initialValue={filterToEdit?.defaultDataMask}
data-test="default-input" data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>} label={<StyledLabel>{t('Default Value')}</StyledLabel>}
required required={formFilter?.controlValues?.enableEmptyFilter}
rules={[ rules={[
{
required: true,
},
{ {
validator: (rule, value) => { validator: (rule, value) => {
const hasValue = !!value?.filterState?.value; const hasValue = !!value?.filterState?.value;
if ( if (
hasValue || hasValue ||
// TODO: do more generic // TODO: do more generic
formFilter.controlValues?.defaultToFirstItem formFilter.controlValues?.defaultToFirstItem ||
// Not marked as required
!formFilter.controlValues?.enableEmptyFilter
) { ) {
return Promise.resolve(); return Promise.resolve();
} }
@ -760,7 +764,7 @@ const FiltersConfigForm = (
{isCascadingFilter && ( {isCascadingFilter && (
<CollapsibleControl <CollapsibleControl
title={t('Filter is hierarchical')} title={t('Filter is hierarchical')}
checked={hasParentFilter} initialValue={hasParentFilter}
onChange={checked => { onChange={checked => {
if (checked) { if (checked) {
// execute after render // execute after render
@ -801,7 +805,7 @@ const FiltersConfigForm = (
{hasDataset && hasAdditionalFilters && ( {hasDataset && hasAdditionalFilters && (
<CollapsibleControl <CollapsibleControl
title={t('Pre-filter available values')} title={t('Pre-filter available values')}
checked={hasPreFilter} initialValue={hasPreFilter}
onChange={checked => { onChange={checked => {
if (checked) { if (checked) {
validatePreFilter(); validatePreFilter();
@ -902,7 +906,7 @@ const FiltersConfigForm = (
<CollapsibleControl <CollapsibleControl
title={t('Sort filter values')} title={t('Sort filter values')}
onChange={checked => onSortChanged(checked || undefined)} onChange={checked => onSortChanged(checked || undefined)}
checked={hasSorting} initialValue={hasSorting}
> >
<StyledFormItem <StyledFormItem
name={[ name={[

View File

@ -16,10 +16,11 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { FormInstance } from 'antd/lib/form'; import { FormInstance } from 'antd/lib/form';
import { NativeFiltersForm } from '../types'; import { NativeFiltersForm, NativeFiltersFormItem } from '../types';
import { setNativeFilterFieldValues, useForceUpdate } from './utils'; import { setNativeFilterFieldValues, useForceUpdate } from './utils';
import { Filter } from '../../types';
// When some fields in form changed we need re-fetch data for Filter defaultValue // When some fields in form changed we need re-fetch data for Filter defaultValue
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
@ -46,3 +47,29 @@ export const useBackendFormUpdate = (
filterId, filterId,
]); ]);
}; };
export const useDefaultValue = (
formFilter?: NativeFiltersFormItem,
filterToEdit?: Filter,
) => {
const [hasDefaultValue, setHasPartialDefaultValue] = useState(
!!filterToEdit?.defaultDataMask?.filterState?.value ||
formFilter?.controlValues?.enableEmptyFilter,
);
const setHasDefaultValue = useCallback(
(value?) => {
setHasPartialDefaultValue(
value || formFilter?.controlValues?.enableEmptyFilter
? true
: undefined,
);
},
[formFilter?.controlValues?.enableEmptyFilter],
);
useEffect(() => {
setHasDefaultValue();
}, [setHasDefaultValue]);
return [hasDefaultValue, setHasDefaultValue];
};

View File

@ -26,9 +26,11 @@ import {
GenericDataType, GenericDataType,
JsonObject, JsonObject,
smartDateDetailedFormatter, smartDateDetailedFormatter,
styled,
t, t,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { FormItem } from 'src/components/Form';
import React, { import React, {
RefObject, RefObject,
ReactElement, ReactElement,
@ -80,6 +82,10 @@ function reducer(
} }
} }
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterSelect(props: PluginFilterSelectProps) { export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const { const {
coltypeMap, coltypeMap,
@ -273,52 +279,57 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
<StyledSelect <FormItem
allowClear={!enableEmptyFilter} validateStatus={filterState.validateStatus}
// @ts-ignore extra={<Error>{filterState.validateMessage}</Error>}
value={filterState.value || []}
disabled={isDisabled}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : undefined}
placeholder={placeholderText}
onSearch={searchWrapper}
onSelect={clearSuggestionSearch}
onBlur={handleBlur}
onDropdownVisibleChange={setIsDropdownVisible}
dropdownRender={(
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) => {
if (isDropdownVisible && !wasDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return originNode;
}}
onFocus={setFocusedFilter}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
maxTagCount={5}
menuItemSelectedIcon={<Icon iconSize="m" />}
> >
{sortedData.map(row => { <StyledSelect
const [value] = groupby.map(col => row[col]); allowClear={!enableEmptyFilter}
return ( // @ts-ignore
// @ts-ignore value={filterState.value || []}
<Option key={`${value}`} value={value}> disabled={isDisabled}
{labelFormatter(value, datatype)} showSearch={showSearch}
</Option> mode={multiSelect ? 'multiple' : undefined}
); placeholder={placeholderText}
})} onSearch={searchWrapper}
{currentSuggestionSearch && onSelect={clearSuggestionSearch}
!ensureIsArray(filterState.value).some( onBlur={handleBlur}
suggestion => suggestion === currentSuggestionSearch, onDropdownVisibleChange={setIsDropdownVisible}
) && ( dropdownRender={(
<Option value={currentSuggestionSearch}> originNode: ReactElement & { ref?: RefObject<HTMLElement> },
{`${t('Create "%s"', currentSuggestionSearch)}`} ) => {
</Option> if (isDropdownVisible && !wasDropdownVisible) {
)} originNode.ref?.current?.scrollTo({ top: 0 });
</StyledSelect> }
return originNode;
}}
onFocus={setFocusedFilter}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
maxTagCount={5}
menuItemSelectedIcon={<Icon iconSize="m" />}
>
{sortedData.map(row => {
const [value] = groupby.map(col => row[col]);
return (
// @ts-ignore
<Option key={`${value}`} value={value}>
{labelFormatter(value, datatype)}
</Option>
);
})}
{currentSuggestionSearch &&
!ensureIsArray(filterState.value).some(
suggestion => suggestion === currentSuggestionSearch,
) && (
<Option value={currentSuggestionSearch}>
{`${t('Create "%s"', currentSuggestionSearch)}`}
</Option>
)}
</StyledSelect>
</FormItem>
</Styles> </Styles>
); );
} }