diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index efef785616c969bc196dec534c28737f4323b6b8..71ca92663244e9240acc81605563cde9e6c252c4 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -440,7 +440,7 @@ const Select = forwardRef( const bulkSelectComponent = useMemo( () => ( - <StyledBulkActionsContainer size={0}> + <StyledBulkActionsContainer className="select-bulk-actions" size={0}> <Button type="link" buttonSize="xsmall" diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx index 76a162e7c589602ce552b3db7c2dc23da712b784..4a0de520e69ae2b1da942a057278a6e47203f28f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx @@ -128,6 +128,14 @@ const VerticalFormItem = styled(StyledFormItem)<{ width: 140px; `} } + + .select-bulk-actions { + ${({ inverseSelection }) => + inverseSelection && + ` + flex-direction: column; + `} + } `; const HorizontalFormItem = styled(StyledFormItem)<{ @@ -164,6 +172,10 @@ const HorizontalFormItem = styled(StyledFormItem)<{ width: 164px; `} } + + .select-bulk-actions { + flex-direction: column; + } `; const HorizontalOverflowFormItem = VerticalFormItem; diff --git a/superset-frontend/src/dataMask/reducer.ts b/superset-frontend/src/dataMask/reducer.ts index c13b23fa433d04233ccf35a7baca3eeabd109bbb..bb5c3a5d1c306b7834d8adb2468b65cab9a4b539 100644 --- a/superset-frontend/src/dataMask/reducer.ts +++ b/superset-frontend/src/dataMask/reducer.ts @@ -27,10 +27,13 @@ import { Filter, FilterConfiguration, Filters, + FilterState, + ExtraFormData, } from '@superset-ui/core'; import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils'; import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types'; +import { isEqual } from 'lodash'; import { AnyDataMaskAction, CLEAR_DATA_MASK_STATE, @@ -39,6 +42,11 @@ import { } from './actions'; import { areObjectsEqual } from '../reduxUtils'; +type FilterWithExtaFromData = Filter & { + extraFormData?: ExtraFormData; + filterState?: FilterState; +}; + export function getInitialDataMask( id?: string | number, moreProps: DataMask = {}, @@ -106,10 +114,27 @@ function updateDataMaskForFilterChanges( }); filterChanges.modified.forEach((filter: Filter) => { + const existingFilter = draftDataMask[filter.id] as FilterWithExtaFromData; + + // Check if targets are equal + const areTargetsEqual = isEqual(existingFilter?.targets, filter?.targets); + + // Preserve state only if filter exists, has enableEmptyFilter=true and targets match + const shouldPreserveState = + existingFilter && + areTargetsEqual && + (filter.controlValues?.enableEmptyFilter || + filter.controlValues?.defaultToFirstItem); + mergedDataMask[filter.id] = { ...getInitialDataMask(filter.id), ...filter.defaultDataMask, ...filter, + // Preserve extraFormData and filterState if conditions match + ...(shouldPreserveState && { + extraFormData: existingFilter.extraFormData, + filterState: existingFilter.filterState, + }), }; }); diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 0efb93a89bd51d2dbaedee6b8599eb0d9e9dac4d..67911ca29648cf601bfe4216cd037ad63cb182bb 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -17,7 +17,7 @@ * under the License. */ /* eslint-disable no-param-reassign */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppSection, DataMask, @@ -84,7 +84,10 @@ function reducer(draft: DataMask, action: DataMaskAction) { } } -const StyledSpace = styled(Space)<{ $inverseSelection: boolean }>` +const StyledSpace = styled(Space)<{ + inverseSelection: boolean; + appSection: AppSection; +}>` display: flex; align-items: center; width: 100%; @@ -96,12 +99,17 @@ const StyledSpace = styled(Space)<{ $inverseSelection: boolean }>` &.ant-space { .ant-space-item { - width: ${({ $inverseSelection }) => - !$inverseSelection ? '100%' : 'auto'}; + width: ${({ inverseSelection, appSection }) => + !inverseSelection || appSection === AppSection.FilterConfigModal + ? '100%' + : 'auto'}; } } `; +// Keep track of orientation changes outside component with filter ID +const orientationMap = new Map<string, FilterBarOrientation>(); + export default function PluginFilterSelect(props: PluginFilterSelectProps) { const { coltypeMap, @@ -158,6 +166,30 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { : filterState?.excludeFilterValues, ); + const prevExcludeFilterValues = useRef(excludeFilterValues); + + const hasOnlyOrientationChanged = useRef(false); + + useEffect(() => { + // Get previous orientation for this specific filter + const previousOrientation = orientationMap.get(formData.nativeFilterId); + + // Check if only orientation changed for this filter + if ( + previousOrientation !== undefined && + previousOrientation !== filterBarOrientation + ) { + hasOnlyOrientationChanged.current = true; + } else { + hasOnlyOrientationChanged.current = false; + } + + // Update orientation for this filter + if (filterBarOrientation) { + orientationMap.set(formData.nativeFilterId, filterBarOrientation); + } + }, [filterBarOrientation]); + const updateDataMask = useCallback( (values: SelectValue) => { const emptyFilter = @@ -287,35 +319,44 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { [formData.sortAscending], ); + // Use effect for initialisation for filter plugin + // this should run only once when filter is configured & saved + // & shouldnt run when the component is remounted on change of + // orientation of filter bar useEffect(() => { - if (defaultToFirstItem && filterState.value === undefined) { - // initialize to first value if set to default to first item + // Skip if only orientation changed + if (hasOnlyOrientationChanged.current) { + return; + } + + // Case 1: Handle disabled state first + if (isDisabled) { + updateDataMask(null); + return; + } + + // Case 2: Handle the default to first Value case + if (defaultToFirstItem) { + // Set to first item if defaultToFirstItem is true 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?.[0] !== undefined) { updateDataMask(firstItem); } - } else if (isDisabled) { - // empty selection if filter is disabled - updateDataMask(null); - } else { - // reset data mask based on filter state - updateDataMask(filterState.value); + } else if (formData?.defaultValue) { + // Case 3 : Handle defalut value case + updateDataMask(formData.defaultValue); } }, [ - col, isDisabled, - defaultToFirstItem, enableEmptyFilter, - inverseSelection, - excludeFilterValues, - updateDataMask, + defaultToFirstItem, + formData?.defaultValue, data, groupby, - JSON.stringify(filterState.value), + col, + inverseSelection, ]); useEffect(() => { @@ -323,23 +364,26 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { }, [JSON.stringify(dataMask)]); useEffect(() => { - dispatchDataMask({ - type: 'filterState', - extraFormData: getSelectExtraFormData( - col, - filterState.value, - !filterState.value?.length, - excludeFilterValues && inverseSelection, - ), - filterState: { - ...(filterState as { - value: SelectValue; - label?: string; - excludeFilterValues?: boolean; - }), - excludeFilterValues, - }, - }); + if (prevExcludeFilterValues.current !== excludeFilterValues) { + dispatchDataMask({ + type: 'filterState', + extraFormData: getSelectExtraFormData( + col, + filterState.value, + !filterState.value?.length, + excludeFilterValues && inverseSelection, + ), + filterState: { + ...(filterState as { + value: SelectValue; + label?: string; + excludeFilterValues?: boolean; + }), + excludeFilterValues, + }, + }); + prevExcludeFilterValues.current = excludeFilterValues; + } }, [excludeFilterValues]); const handleExclusionToggle = (value: string) => { @@ -352,8 +396,11 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { validateStatus={filterState.validateStatus} extra={formItemExtra} > - <StyledSpace $inverseSelection={inverseSelection}> - {inverseSelection && ( + <StyledSpace + appSection={appSection} + inverseSelection={inverseSelection} + > + {appSection !== AppSection.FilterConfigModal && inverseSelection && ( <Select className="exclude-select" value={`${excludeFilterValues}`}