Advanced filter bug bash (#11327)

This PR fixes some bugs on advanced filters dropdown.

See reference master issue :
https://github.com/twentyhq/core-team-issues/issues/648

Fixes https://github.com/twentyhq/core-team-issues/issues/726
Fixes https://github.com/twentyhq/core-team-issues/issues/724
Fixes https://github.com/twentyhq/core-team-issues/issues/655
This commit is contained in:
Lucas Bordeau
2025-04-02 10:25:51 +02:00
committed by GitHub
parent 6e92b19e01
commit e47c19e86f
6 changed files with 292 additions and 2 deletions

View File

@ -2,6 +2,7 @@ import { AdvancedFilterValueInputDropdownButtonClickableSelect } from '@/object-
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
@ -35,6 +36,10 @@ export const AdvancedFilterValueInputDropdownButton = ({
const isDisabled = !filter?.fieldMetadataId || !filter.operand;
const setObjectFilterDropdownSearchInput = useSetRecoilComponentStateV2(
objectFilterDropdownSearchInputComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetRecoilComponentStateV2(
fieldMetadataItemIdUsedInDropdownComponentState,
);
@ -50,6 +55,10 @@ export const AdvancedFilterValueInputDropdownButton = ({
const operandHasNoInput =
filter && !configurableViewFilterOperands.has(filter.operand);
const handleFilterValueDropdownClose = () => {
setObjectFilterDropdownSearchInput('');
};
return (
<StyledValueDropdownContainer>
{operandHasNoInput ? (
@ -78,6 +87,7 @@ export const AdvancedFilterValueInputDropdownButton = ({
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}
dropdownPlacement="bottom-start"
dropdownMenuWidth={280}
onClose={handleFilterValueDropdownClose}
/>
)}
</StyledValueDropdownContainer>

View File

@ -0,0 +1 @@
export const SOFT_DELETE_FILTER_FIELD_NAME = 'deletedAt';

View File

@ -1,4 +1,5 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SOFT_DELETE_FILTER_FIELD_NAME } from '@/object-record/record-filter/constants/SoftDeleteFilterFieldName';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState';
@ -29,7 +30,7 @@ export const useCheckIsSoftDeleteFilter = () => {
}
return (
foundFieldMetadataItem.name === 'deletedAt' &&
foundFieldMetadataItem.name === SOFT_DELETE_FILTER_FIELD_NAME &&
isSoftDeleteFilterActive &&
recordFilter.operand === RecordFilterOperand.IsNotEmpty
);

View File

@ -0,0 +1,184 @@
import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { sortByProperty } from '~/utils/array/sortByProperty';
import { getAllRecordFilterDescendantsOfRecordFilterGroup } from '../getAllRecordFilterDescendantsOfRecordFilterGroup';
const MOCK_RECORD_FILTER_FIELDS: RecordFilter = {
id: 'filter-1',
recordFilterGroupId: 'root-group',
fieldMetadataId: 'field-1',
operand: RecordFilterOperand.Contains,
value: 'value-1',
displayValue: 'Display Value 1',
label: 'Label 1',
type: 'TEXT',
};
describe('getAllRecordFilterDescendantsOfRecordFilterGroup', () => {
it('should return an empty array if the recordFilterGroupId does not exist', () => {
const recordFilterGroups: RecordFilterGroup[] = [];
const recordFilters: RecordFilter[] = [];
const recordFilterGroupId = 'nonexistent-id';
const result = getAllRecordFilterDescendantsOfRecordFilterGroup({
recordFilterGroupId,
recordFilterGroups,
recordFilters,
});
expect(result).toEqual([]);
});
it('should return all direct child record filters of the given recordFilterGroupId', () => {
const recordFilterGroups: RecordFilterGroup[] = [
{
id: 'root-group',
parentRecordFilterGroupId: null,
logicalOperator: RecordFilterGroupLogicalOperator.AND,
},
];
const recordFiltersDescendants: RecordFilter[] = [
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-1',
recordFilterGroupId: 'root-group',
},
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-2',
recordFilterGroupId: 'root-group',
},
];
const recordFilterGroupId = 'root-group';
const result = getAllRecordFilterDescendantsOfRecordFilterGroup({
recordFilterGroupId,
recordFilterGroups,
recordFilters: recordFiltersDescendants,
});
expect(result).toEqual(recordFiltersDescendants);
});
it('should return all descendant record filters recursively', () => {
const recordFilterGroups: RecordFilterGroup[] = [
{
id: 'root-group',
parentRecordFilterGroupId: null,
logicalOperator: RecordFilterGroupLogicalOperator.AND,
},
{
id: 'child-group-1',
parentRecordFilterGroupId: 'root-group',
logicalOperator: RecordFilterGroupLogicalOperator.OR,
},
{
id: 'grand-child-group-1',
parentRecordFilterGroupId: 'child-group-1',
logicalOperator: RecordFilterGroupLogicalOperator.AND,
},
{
id: 'grand-child-group-2',
parentRecordFilterGroupId: 'child-group-1',
logicalOperator: RecordFilterGroupLogicalOperator.OR,
},
{
id: 'child-group-2',
parentRecordFilterGroupId: 'root-group',
logicalOperator: RecordFilterGroupLogicalOperator.AND,
},
];
const recordFiltersWithoutGroup: RecordFilter[] = [
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-1',
recordFilterGroupId: undefined,
},
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-2',
recordFilterGroupId: undefined,
},
];
const recordFiltersDescendants: RecordFilter[] = [
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-3',
recordFilterGroupId: 'root-group',
},
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-4',
recordFilterGroupId: 'child-group-1',
},
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-5',
recordFilterGroupId: 'child-group-2',
},
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-6',
recordFilterGroupId: 'grand-child-group-1',
},
{
...MOCK_RECORD_FILTER_FIELDS,
id: 'filter-7',
recordFilterGroupId: 'grand-child-group-2',
},
];
const combinedRecordFilters = [
...recordFiltersWithoutGroup,
...recordFiltersDescendants,
];
const recordFilterGroupId = 'root-group';
const allDescendantOfRootRecordFilterGroup =
getAllRecordFilterDescendantsOfRecordFilterGroup({
recordFilterGroupId,
recordFilterGroups,
recordFilters: combinedRecordFilters,
});
const result = [...allDescendantOfRootRecordFilterGroup].sort(
sortByProperty('id'),
);
const expectedResult = [...recordFiltersDescendants].sort(
sortByProperty('id'),
);
expect(result).toEqual(expectedResult);
});
it('should return an empty array if the group has no children', () => {
const recordFilterGroups: RecordFilterGroup[] = [
{
id: 'empty-group-id',
parentRecordFilterGroupId: 'parent-group-id',
logicalOperator: RecordFilterGroupLogicalOperator.AND,
positionInRecordFilterGroup: 0,
},
];
const recordFilters: RecordFilter[] = [];
const recordFilterGroupId = 'empty-group-id';
const result = getAllRecordFilterDescendantsOfRecordFilterGroup({
recordFilterGroupId,
recordFilterGroups,
recordFilters,
});
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,43 @@
import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
export const getAllRecordFilterDescendantsOfRecordFilterGroup = ({
recordFilterGroupId,
recordFilterGroups,
recordFilters,
}: {
recordFilterGroupId: string;
recordFilterGroups: RecordFilterGroup[];
recordFilters: RecordFilter[];
}): RecordFilter[] => {
const foundRecordFilterGroup = recordFilterGroups.find(
(recordFilterGroup) => recordFilterGroup.id === recordFilterGroupId,
);
if (!foundRecordFilterGroup) {
return [];
}
const childRecordFilters = recordFilters.filter(
(recordFilter) =>
recordFilter.recordFilterGroupId === foundRecordFilterGroup.id,
);
const childRecordFilterGroups = recordFilterGroups.filter(
(recordFilterGroup) =>
recordFilterGroup.parentRecordFilterGroupId === foundRecordFilterGroup.id,
);
for (const childRecordFilterGroup of childRecordFilterGroups) {
const childRecordFilterGroupDescendants =
getAllRecordFilterDescendantsOfRecordFilterGroup({
recordFilterGroupId: childRecordFilterGroup.id,
recordFilterGroups,
recordFilters,
});
childRecordFilters.push(...childRecordFilterGroupDescendants);
}
return childRecordFilters;
};

View File

@ -1,15 +1,22 @@
import { IconFilter } from 'twenty-ui';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useChildRecordFiltersAndRecordFilterGroups } from '@/object-record/advanced-filter/hooks/useChildRecordFiltersAndRecordFilterGroups';
import { rootLevelRecordFilterGroupComponentSelector } from '@/object-record/advanced-filter/states/rootLevelRecordFilterGroupComponentSelector';
import { useRemoveRecordFilterGroup } from '@/object-record/record-filter-group/hooks/useRemoveRecordFilterGroup';
import { useRemoveRootRecordFilterGroupIfEmpty } from '@/object-record/record-filter-group/hooks/useRemoveRootRecordFilterGroupIfEmpty';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { SOFT_DELETE_FILTER_FIELD_NAME } from '@/object-record/record-filter/constants/SoftDeleteFilterFieldName';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { getAllRecordFilterDescendantsOfRecordFilterGroup } from '@/object-record/record-filter/utils/getAllRecordFilterDescendantsOfRecordFilterGroup';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { plural } from 'pluralize';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const AdvancedFilterChip = () => {
@ -33,6 +40,15 @@ export const AdvancedFilterChip = () => {
const { removeRootRecordFilterGroupIfEmpty } =
useRemoveRootRecordFilterGroupIfEmpty();
const rootRecordFilterGroup = useRecoilComponentValueV2(
rootLevelRecordFilterGroupComponentSelector,
);
const { childRecordFiltersAndRecordFilterGroups } =
useChildRecordFiltersAndRecordFilterGroups({
recordFilterGroupId: rootRecordFilterGroup?.id,
});
const handleRemoveClick = () => {
closeDropdown();
@ -51,11 +67,45 @@ export const AdvancedFilterChip = () => {
removeRootRecordFilterGroupIfEmpty();
};
const advancedFilterCount = advancedRecordFilterIds.length;
const advancedFilterCount = childRecordFiltersAndRecordFilterGroups.length;
const labelText = 'advanced rule';
const chipLabel = `${advancedFilterCount} ${advancedFilterCount === 1 ? labelText : plural(labelText)}`;
const { objectMetadataItems } = useObjectMetadataItems();
const hasAnyDeletedAtFilterInAdvancedFilters = useMemo(() => {
const recordFiltersDescendantOfRootGroup = rootRecordFilterGroup?.id
? getAllRecordFilterDescendantsOfRecordFilterGroup({
recordFilterGroupId: rootRecordFilterGroup?.id,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
})
: [];
const fieldMetadataItems = objectMetadataItems.flatMap(
(item) => item.fields,
);
return recordFiltersDescendantOfRootGroup.some((recordFilter) => {
const correspondingMetadataItem = fieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === recordFilter.fieldMetadataId,
);
if (isDefined(correspondingMetadataItem)) {
return correspondingMetadataItem.name === SOFT_DELETE_FILTER_FIELD_NAME;
}
return false;
});
}, [
currentRecordFilterGroups,
currentRecordFilters,
rootRecordFilterGroup,
objectMetadataItems,
]);
return (
<SortOrFilterChip
testId={ADVANCED_FILTER_DROPDOWN_ID}
@ -63,6 +113,7 @@ export const AdvancedFilterChip = () => {
labelValue=""
Icon={IconFilter}
onRemove={handleRemoveClick}
variant={hasAnyDeletedAtFilterInAdvancedFilters ? 'danger' : 'default'}
/>
);
};