Implemented view filter group CRUD hooks and utils (#10551)

This PR implements hooks and utils logic for handling CRUD and view
filter group comparison.

The main hook is useAreViewFilterGroupsDifferentFromRecordFilterGroups,
like view filters and view sorts.

Inside this hook we implement getViewFilterGroupsToCreate,
getViewFilterGroupsToDelete and getViewFilterGroupsToUpdate.

All of those come with their unit tests.

In this PR we also introduce a new util to prevent nasty bugs happening
when we compare undefined === null,

This util is called compareStrictlyExceptForNullAndUndefined and it
should replace every strict equality comparison between values that can
be null or undefined (which we have a lot)

This could be enforced by a custom ESLint rule, the autofix may also be
implemented (maybe the util should be put in twenty-shared ?)
This commit is contained in:
Lucas Bordeau
2025-02-28 13:32:54 +01:00
committed by GitHub
parent b7abaa242c
commit ea1ac3708c
13 changed files with 518 additions and 7 deletions

View File

@ -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) {

View File

@ -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) {

View File

@ -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([]);
});
});

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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],
),
);
};

View File

@ -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],
),
);
};

View File

@ -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],
),
);
};

View File

@ -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;
});
};

View File

@ -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,
),
),
);
};

View File

@ -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;
});
};

View File

@ -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);
});
});

View File

@ -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 = <A, B>(
valueA: Nullable<A>,
valueB: Nullable<B>,
) => {
if (!isDefined(valueA) && !isDefined(valueB)) {
return true;
}
return valueA === valueB;
};