diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index db57a613e..d25355bf1 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -16,6 +16,7 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { UPDATE_VIEW_BUTTON_DROPDOWN_ID } from '@/views/constants/UpdateViewButtonDropdownId'; import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; +import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups'; import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters'; import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; @@ -87,6 +88,9 @@ export const UpdateViewButtonGroup = ({ const { hasFiltersQueryParams } = useViewFromQueryParams(); + const { viewFilterGroupsAreDifferentFromRecordFilterGroups } = + useAreViewFilterGroupsDifferentFromRecordFilterGroups(); + const { viewFiltersAreDifferentFromRecordFilters } = useAreViewFiltersDifferentFromRecordFilters(); @@ -95,7 +99,8 @@ export const UpdateViewButtonGroup = ({ const canShowButton = (viewFiltersAreDifferentFromRecordFilters || - viewSortsAreDifferentFromRecordSorts) && + viewSortsAreDifferentFromRecordSorts || + viewFilterGroupsAreDifferentFromRecordFilterGroups) && !hasFiltersQueryParams; if (!canShowButton) { diff --git a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx index 4fbf82de5..cb2fde916 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx @@ -24,6 +24,9 @@ import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreVie import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups } from '@/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups'; +import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups'; import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState'; import { t } from '@lingui/core/macro'; import { isNonEmptyArray } from '@sniptt/guards'; @@ -122,6 +125,10 @@ export const ViewBarDetails = ({ const { hasFiltersQueryParams } = useViewFromQueryParams(); + const currentRecordFilterGroups = useRecoilComponentValueV2( + currentRecordFilterGroupsComponentState, + ); + const currentRecordFilters = useRecoilComponentValueV2( currentRecordFiltersComponentState, ); @@ -139,6 +146,9 @@ export const ViewBarDetails = ({ }); const { resetUnsavedViewStates } = useResetUnsavedViewStates(); + const { viewFilterGroupsAreDifferentFromRecordFilterGroups } = + useAreViewFilterGroupsDifferentFromRecordFilterGroups(); + const { viewFiltersAreDifferentFromRecordFilters } = useAreViewFiltersDifferentFromRecordFilters(); @@ -147,7 +157,8 @@ export const ViewBarDetails = ({ const canResetView = (viewFiltersAreDifferentFromRecordFilters || - viewSortsAreDifferentFromRecordSorts) && + viewSortsAreDifferentFromRecordSorts || + viewFilterGroupsAreDifferentFromRecordFilterGroups) && !hasFiltersQueryParams; const { checkIsSoftDeleteFilter } = useCheckIsSoftDeleteFilter(); @@ -164,6 +175,9 @@ export const ViewBarDetails = ({ ); }, [currentRecordFilters, checkIsSoftDeleteFilter]); + const { applyCurrentViewFilterGroupsToCurrentRecordFilterGroups } = + useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + const { applyCurrentViewFiltersToCurrentRecordFilters } = useApplyCurrentViewFiltersToCurrentRecordFilters(); @@ -173,6 +187,7 @@ export const ViewBarDetails = ({ const handleCancelClick = () => { if (isDefined(viewId)) { resetUnsavedViewStates(viewId); + applyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); applyCurrentViewFiltersToCurrentRecordFilters(); applyCurrentViewSortsToCurrentRecordSorts(); toggleSoftDeleteFilterState(false); @@ -182,7 +197,10 @@ export const ViewBarDetails = ({ const shouldExpandViewBar = viewFiltersAreDifferentFromRecordFilters || viewSortsAreDifferentFromRecordSorts || - ((currentRecordSorts?.length || currentRecordFilters?.length) && + viewFilterGroupsAreDifferentFromRecordFilterGroups || + ((currentRecordSorts.length > 0 || + currentRecordFilters.length > 0 || + currentRecordFilterGroups.length > 0) && isViewBarExpanded); if (!shouldExpandViewBar) { diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups.test.tsx new file mode 100644 index 000000000..0a7c90b38 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups.test.tsx @@ -0,0 +1,205 @@ +import { renderHook } from '@testing-library/react'; + +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; +import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups } from '@/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups'; +import { View } from '@/views/types/View'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator'; +import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType'; +import { ViewType } from '@/views/types/ViewType'; +import { mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator } from '@/views/utils/mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator'; +import { act } from 'react'; +import { isDefined } from 'twenty-shared'; +import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper'; +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; + +const mockObjectMetadataItemNameSingular = 'company'; + +describe('useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups', () => { + const mockObjectMetadataItem = generatedMockObjectMetadataItems.find( + (item) => item.nameSingular === mockObjectMetadataItemNameSingular, + ); + + if (!isDefined(mockObjectMetadataItem)) { + throw new Error( + 'Missing mock object metadata item with name singular "company"', + ); + } + + const mockViewFilterGroup: ViewFilterGroup = { + __typename: 'ViewFilterGroup', + id: 'filter-group-1', + logicalOperator: ViewFilterGroupLogicalOperator.AND, + viewId: 'view-1', + parentViewFilterGroupId: null, + positionInViewFilterGroup: 0, + }; + + const mockView: View = { + id: 'view-1', + name: 'Test View', + objectMetadataId: mockObjectMetadataItem.id, + viewFilters: [], + viewFilterGroups: [mockViewFilterGroup], + type: ViewType.Table, + key: null, + isCompact: false, + openRecordIn: ViewOpenRecordInType.SIDE_PANEL, + viewFields: [], + viewGroups: [], + viewSorts: [], + kanbanFieldMetadataId: '', + kanbanAggregateOperation: AGGREGATE_OPERATIONS.count, + icon: '', + kanbanAggregateOperationFieldMetadataId: '', + position: 0, + __typename: 'View', + }; + + it('should apply view filter groups from current view', () => { + const { result } = renderHook( + () => { + const { applyCurrentViewFilterGroupsToCurrentRecordFilterGroups } = + useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + + const currentRecordFilterGroups = useRecoilComponentValueV2( + currentRecordFilterGroupsComponentState, + ); + + return { + applyCurrentViewFilterGroupsToCurrentRecordFilterGroups, + currentRecordFilterGroups, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({ + apolloMocks: [], + componentInstanceId: 'instanceId', + contextStoreCurrentObjectMetadataNameSingular: + mockObjectMetadataItemNameSingular, + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + contextStoreCurrentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set(prefetchViewsState, [mockView]); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + }); + + const expectedRecordFilterGroups: RecordFilterGroup[] = [ + { + id: mockViewFilterGroup.id, + logicalOperator: + mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator({ + viewFilterGroupLogicalOperator: mockViewFilterGroup.logicalOperator, + }), + parentRecordFilterGroupId: mockViewFilterGroup.parentViewFilterGroupId, + positionInRecordFilterGroup: + mockViewFilterGroup.positionInViewFilterGroup, + }, + ]; + + expect(result.current.currentRecordFilterGroups).toEqual( + expectedRecordFilterGroups, + ); + }); + + it('should not apply view filter groups when current view is not found', () => { + const { result } = renderHook( + () => { + const { applyCurrentViewFilterGroupsToCurrentRecordFilterGroups } = + useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + + const currentRecordFilterGroups = useRecoilComponentValueV2( + currentRecordFilterGroupsComponentState, + ); + + return { + applyCurrentViewFilterGroupsToCurrentRecordFilterGroups, + currentRecordFilterGroups, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({ + apolloMocks: [], + componentInstanceId: 'instanceId', + contextStoreCurrentObjectMetadataNameSingular: + mockObjectMetadataItemNameSingular, + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + contextStoreCurrentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + + snapshot.set(prefetchViewsState, []); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + }); + + expect(result.current.currentRecordFilterGroups).toEqual([]); + }); + + it('should handle view with empty view filter groups', () => { + const { result } = renderHook( + () => { + const { applyCurrentViewFilterGroupsToCurrentRecordFilterGroups } = + useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + + const currentRecordFilterGroups = useRecoilComponentValueV2( + currentRecordFilterGroupsComponentState, + ); + + return { + applyCurrentViewFilterGroupsToCurrentRecordFilterGroups, + currentRecordFilterGroups, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({ + apolloMocks: [], + componentInstanceId: 'instanceId', + contextStoreCurrentObjectMetadataNameSingular: + mockObjectMetadataItemNameSingular, + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + contextStoreCurrentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + + snapshot.set(prefetchViewsState, [ + { ...mockView, viewFilterGroups: [] }, + ]); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); + }); + + expect(result.current.currentRecordFilterGroups).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups.ts b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups.ts new file mode 100644 index 000000000..0eecb7bd6 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups.ts @@ -0,0 +1,67 @@ +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups'; +import { useRecoilCallback } from 'recoil'; + +import { isDefined } from 'twenty-shared'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups = + () => { + const currentViewId = useRecoilComponentValueV2( + contextStoreCurrentViewIdComponentState, + ); + + const setCurrentRecordFilterGroups = useSetRecoilComponentStateV2( + currentRecordFilterGroupsComponentState, + ); + + const currentRecordFilterGroupsCallbackState = + useRecoilComponentCallbackStateV2( + currentRecordFilterGroupsComponentState, + ); + + const applyCurrentViewFilterGroupsToCurrentRecordFilterGroups = + useRecoilCallback( + ({ snapshot }) => + () => { + const currentView = snapshot + .getLoadable( + prefetchViewFromViewIdFamilySelector({ + viewId: currentViewId ?? '', + }), + ) + .getValue(); + + if (isDefined(currentView)) { + const currentRecordFilterGroups = snapshot + .getLoadable(currentRecordFilterGroupsCallbackState) + .getValue(); + + const newRecordFilterGroups = + mapViewFilterGroupsToRecordFilterGroups( + currentView.viewFilterGroups ?? [], + ); + + if ( + !isDeeplyEqual(currentRecordFilterGroups, newRecordFilterGroups) + ) { + setCurrentRecordFilterGroups(newRecordFilterGroups); + } + } + }, + [ + currentViewId, + setCurrentRecordFilterGroups, + currentRecordFilterGroupsCallbackState, + ], + ); + + return { + applyCurrentViewFilterGroupsToCurrentRecordFilterGroups, + }; + }; diff --git a/packages/twenty-front/src/modules/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups.ts b/packages/twenty-front/src/modules/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups.ts new file mode 100644 index 000000000..0e64b950d --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups.ts @@ -0,0 +1,51 @@ +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate'; +import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete'; +import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate'; +import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup'; +import { isDefined } from 'twenty-shared'; + +export const useAreViewFilterGroupsDifferentFromRecordFilterGroups = () => { + const { currentView } = useGetCurrentViewOnly(); + + const currentRecordFilterGroups = useRecoilComponentValueV2( + currentRecordFilterGroupsComponentState, + ); + + const currentViewFilterGroups = currentView?.viewFilterGroups ?? []; + + const viewFilterGroupsFromCurrentRecordFilterGroups = isDefined(currentView) + ? currentRecordFilterGroups.map((recordFilterGroup) => + mapRecordFilterGroupToViewFilterGroup({ + recordFilterGroup, + view: currentView, + }), + ) + : []; + + const viewFilterGroupsToCreate = getViewFilterGroupsToCreate( + currentViewFilterGroups, + viewFilterGroupsFromCurrentRecordFilterGroups, + ); + + const viewFilterGroupsToDelete = getViewFilterGroupsToDelete( + currentViewFilterGroups, + viewFilterGroupsFromCurrentRecordFilterGroups, + ); + + const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate( + currentViewFilterGroups, + viewFilterGroupsFromCurrentRecordFilterGroups, + ); + + const viewFilterGroupsAreDifferentFromRecordFilterGroups = + viewFilterGroupsToCreate.length > 0 || + viewFilterGroupsToDelete.length > 0 || + viewFilterGroupsToUpdate.length > 0; + + return { + viewFilterGroupsAreDifferentFromRecordFilterGroups, + }; +}; diff --git a/packages/twenty-front/src/modules/views/utils/areViewFilterGroupsEqual.ts b/packages/twenty-front/src/modules/views/utils/areViewFilterGroupsEqual.ts new file mode 100644 index 000000000..9f47fac16 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/areViewFilterGroupsEqual.ts @@ -0,0 +1,21 @@ +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; + +export const areViewFilterGroupsEqual = ( + viewFilterGroupA: ViewFilterGroup, + viewFilterGroupB: ViewFilterGroup, +) => { + const propertiesToCompare: (keyof ViewFilterGroup)[] = [ + 'positionInViewFilterGroup', + 'logicalOperator', + 'parentViewFilterGroupId', + 'viewId', + ]; + + return propertiesToCompare.every((property) => + compareStrictlyExceptForNullAndUndefined( + viewFilterGroupA[property], + viewFilterGroupB[property], + ), + ); +}; diff --git a/packages/twenty-front/src/modules/views/utils/areViewFiltersEqual.ts b/packages/twenty-front/src/modules/views/utils/areViewFiltersEqual.ts index 5087bf44a..2ceee15f0 100644 --- a/packages/twenty-front/src/modules/views/utils/areViewFiltersEqual.ts +++ b/packages/twenty-front/src/modules/views/utils/areViewFiltersEqual.ts @@ -1,4 +1,5 @@ import { ViewFilter } from '@/views/types/ViewFilter'; +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; export const areViewFiltersEqual = ( viewFilterA: ViewFilter, @@ -13,7 +14,10 @@ export const areViewFiltersEqual = ( 'operand', ]; - return propertiesToCompare.every( - (property) => viewFilterA[property] === viewFilterB[property], + return propertiesToCompare.every((property) => + compareStrictlyExceptForNullAndUndefined( + viewFilterA[property], + viewFilterB[property], + ), ); }; diff --git a/packages/twenty-front/src/modules/views/utils/areViewSortsEqual.ts b/packages/twenty-front/src/modules/views/utils/areViewSortsEqual.ts index d028c2375..6f807446e 100644 --- a/packages/twenty-front/src/modules/views/utils/areViewSortsEqual.ts +++ b/packages/twenty-front/src/modules/views/utils/areViewSortsEqual.ts @@ -1,4 +1,5 @@ import { ViewSort } from '@/views/types/ViewSort'; +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; export const areViewSortsEqual = (viewSortA: ViewSort, viewSortB: ViewSort) => { const propertiesToCompare: (keyof ViewSort)[] = [ @@ -6,7 +7,10 @@ export const areViewSortsEqual = (viewSortA: ViewSort, viewSortB: ViewSort) => { 'direction', ]; - return propertiesToCompare.every( - (property) => viewSortA[property] === viewSortB[property], + return propertiesToCompare.every((property) => + compareStrictlyExceptForNullAndUndefined( + viewSortA[property], + viewSortB[property], + ), ); }; diff --git a/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToCreate.ts b/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToCreate.ts new file mode 100644 index 000000000..f82ef4854 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToCreate.ts @@ -0,0 +1,24 @@ +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { isDefined } from 'twenty-shared'; +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; + +export const getViewFilterGroupsToCreate = ( + currentViewFilterGroups: ViewFilterGroup[], + newViewFilterGroups: ViewFilterGroup[], +) => { + return newViewFilterGroups.filter((newViewFilterGroup) => { + const correspondingViewFilterGroup = currentViewFilterGroups.find( + (currentViewFilterGroup) => + compareStrictlyExceptForNullAndUndefined( + currentViewFilterGroup.id, + newViewFilterGroup.id, + ), + ); + + const shouldCreateBecauseViewFilterGroupIsNew = !isDefined( + correspondingViewFilterGroup, + ); + + return shouldCreateBecauseViewFilterGroupIsNew; + }); +}; diff --git a/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToDelete.ts b/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToDelete.ts new file mode 100644 index 000000000..d038f78bc --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToDelete.ts @@ -0,0 +1,17 @@ +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; + +export const getViewFilterGroupsToDelete = ( + currentViewFilterGroups: ViewFilterGroup[], + newViewFilterGroups: ViewFilterGroup[], +) => { + return currentViewFilterGroups.filter( + (currentViewFilterGroup) => + !newViewFilterGroups.some((newViewFilterGroup) => + compareStrictlyExceptForNullAndUndefined( + newViewFilterGroup.id, + currentViewFilterGroup.id, + ), + ), + ); +}; diff --git a/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToUpdate.ts b/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToUpdate.ts new file mode 100644 index 000000000..06e0f13d2 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/getViewFilterGroupsToUpdate.ts @@ -0,0 +1,31 @@ +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { areViewFilterGroupsEqual } from '@/views/utils/areViewFilterGroupsEqual'; +import { isDefined } from 'twenty-shared'; +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; + +export const getViewFilterGroupsToUpdate = ( + currentViewFilterGroups: ViewFilterGroup[], + newViewFilterGroups: ViewFilterGroup[], +) => { + return newViewFilterGroups.filter((newViewFilterGroup) => { + const correspondingViewFilterGroup = currentViewFilterGroups.find( + (currentViewFilterGroup) => + compareStrictlyExceptForNullAndUndefined( + currentViewFilterGroup.id, + newViewFilterGroup.id, + ), + ); + + if (!isDefined(correspondingViewFilterGroup)) { + return false; + } + + const shouldUpdateBecauseViewFilterGroupIsDifferent = + !areViewFilterGroupsEqual( + newViewFilterGroup, + correspondingViewFilterGroup, + ); + + return shouldUpdateBecauseViewFilterGroupIsDifferent; + }); +}; diff --git a/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts b/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts new file mode 100644 index 000000000..08e2529e0 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts @@ -0,0 +1,49 @@ +import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; + +describe('compareStrictlyExceptForNullAndUndefined', () => { + it('should return true for undefined === null', () => { + expect(compareStrictlyExceptForNullAndUndefined(undefined, null)).toBe( + true, + ); + }); + + it('should return true for null === undefined', () => { + expect(compareStrictlyExceptForNullAndUndefined(null, undefined)).toBe( + true, + ); + }); + + it('should return true for undefined === undefined', () => { + expect(compareStrictlyExceptForNullAndUndefined(undefined, undefined)).toBe( + true, + ); + }); + + it('should return true for null === null', () => { + expect(compareStrictlyExceptForNullAndUndefined(null, null)).toBe(true); + }); + + it('should return true for 2 === 2', () => { + expect(compareStrictlyExceptForNullAndUndefined(2, 2)).toBe(true); + }); + + it('should return false for 2 === 3', () => { + expect(compareStrictlyExceptForNullAndUndefined(2, 3)).toBe(false); + }); + + it('should return false for undefined === 2', () => { + expect(compareStrictlyExceptForNullAndUndefined(undefined, 2)).toBe(false); + }); + + it('should return false for null === 2', () => { + expect(compareStrictlyExceptForNullAndUndefined(null, 2)).toBe(false); + }); + + it('should return false for 2 === "2"', () => { + expect(compareStrictlyExceptForNullAndUndefined(2, '2')).toBe(false); + }); + + it('should return true for "2" === "2"', () => { + expect(compareStrictlyExceptForNullAndUndefined('2', '2')).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/utils/compareStrictlyExceptForNullAndUndefined.ts b/packages/twenty-front/src/utils/compareStrictlyExceptForNullAndUndefined.ts new file mode 100644 index 000000000..509fee39e --- /dev/null +++ b/packages/twenty-front/src/utils/compareStrictlyExceptForNullAndUndefined.ts @@ -0,0 +1,15 @@ +import { isDefined } from 'twenty-shared'; +import { Nullable } from 'twenty-ui'; + +// TODO: we should create a custom eslint rule that enforces the use of this function +// instead of using the `===` operator where a and b are | undefined | null +export const compareStrictlyExceptForNullAndUndefined = ( + valueA: Nullable, + valueB: Nullable, +) => { + if (!isDefined(valueA) && !isDefined(valueB)) { + return true; + } + + return valueA === valueB; +};