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:
@ -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([]);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user