Add initialization of new record sorts state and added remove record sorts util (#10358)

This PR adds the same synchronization utils and hooks from view to
record sorts, as we did with view and record filters.

The goal is to apply what's in the view sorts only when needed.

Also added tests for those utils and hooks.

We also add useRemoveRecordSorts.
This commit is contained in:
Lucas Bordeau
2025-02-20 16:24:02 +01:00
committed by GitHub
parent aeb8806f0d
commit 3c80e2601f
12 changed files with 456 additions and 12 deletions

View File

@ -6,7 +6,7 @@ import { useCloseSortDropdown } from '@/object-record/object-sort-dropdown/hooks
import { useResetRecordSortDropdownSearchInput } from '@/object-record/object-sort-dropdown/hooks/useResetRecordSortDropdownSearchInput';
import { useResetSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useResetSortDropdown';
import { useToggleSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useToggleSortDropdown';
import { isRecordSortDirectionMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionMenuUnfoldedComponentState';
import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionDropdownMenuUnfoldedComponentState';
import { objectSortDropdownSearchInputComponentState } from '@/object-record/object-sort-dropdown/states/objectSortDropdownSearchInputComponentState';
import { onSortSelectComponentState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState';
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
@ -84,7 +84,7 @@ export const ObjectSortDropdownButton = ({
);
const isRecordSortDirectionMenuUnfolded = useRecoilComponentValueV2(
isRecordSortDirectionMenuUnfoldedComponentState,
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
);
const { resetSortDropdown } = useResetSortDropdown();
@ -168,7 +168,7 @@ export const ObjectSortDropdownButton = ({
useRecoilComponentStateV2(selectedRecordSortDirectionComponentState);
const setIsRecordSortDirectionMenuUnfolded = useSetRecoilComponentStateV2(
isRecordSortDirectionMenuUnfoldedComponentState,
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
);
const handleSortDirectionClick = (sortDirection: RecordSortDirection) => {

View File

@ -1,18 +1,19 @@
import { isRecordSortDirectionMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionMenuUnfoldedComponentState';
import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionDropdownMenuUnfoldedComponentState';
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
export const useResetSortDropdown = () => {
const setIsRecordSortDirectionMenuUnfolded = useSetRecoilComponentStateV2(
isRecordSortDirectionMenuUnfoldedComponentState,
);
const setIsRecordSortDirectionDropdownMenuUnfolded =
useSetRecoilComponentStateV2(
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
);
const setSelectedRecordSortDirection = useSetRecoilComponentStateV2(
selectedRecordSortDirectionComponentState,
);
const resetSortDropdown = () => {
setIsRecordSortDirectionMenuUnfolded(false);
setIsRecordSortDirectionDropdownMenuUnfolded(false);
setSelectedRecordSortDirection('asc');
};

View File

@ -1,9 +1,9 @@
import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isRecordSortDirectionMenuUnfoldedComponentState =
export const isRecordSortDirectionDropdownMenuUnfoldedComponentState =
createComponentStateV2<boolean>({
key: 'isRecordSortDirectionMenuUnfoldedComponentState',
key: 'isRecordSortDirectionDropdownMenuUnfoldedComponentState',
defaultValue: false,
componentInstanceContext: ObjectSortDropdownComponentInstanceContext,
});

View File

@ -1,5 +1,6 @@
import { useRecoilState } from 'recoil';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useDeleteCombinedViewSorts } from '@/views/hooks/useDeleteCombinedViewSorts';
@ -22,9 +23,12 @@ export const RecordIndexRemoveSortingModal = ({
const { deleteCombinedViewSort } = useDeleteCombinedViewSorts(recordIndexId);
const { removeRecordSort } = useRemoveRecordSort();
const handleRemoveClick = () => {
fieldMetadataIds.forEach((id) => {
deleteCombinedViewSort(id);
removeRecordSort(id);
});
};

View File

@ -29,7 +29,7 @@ export const useHandleToggleColumnSort = ({
const { upsertRecordSort } = useUpsertRecordSort();
const handleToggleColumnSort = useCallback(
(fieldMetadataId: string) => {
async (fieldMetadataId: string) => {
const correspondingColumnDefinition = columnDefinitions.find(
(columnDefinition) =>
columnDefinition.fieldMetadataId === fieldMetadataId,
@ -48,8 +48,9 @@ export const useHandleToggleColumnSort = ({
direction: 'asc',
};
upsertCombinedViewSort(newSort);
upsertRecordSort(newSort);
await upsertCombinedViewSort(newSort);
},
[columnDefinitions, upsertCombinedViewSort, upsertRecordSort],
);

View File

@ -0,0 +1,48 @@
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useRecoilCallback } from 'recoil';
export const useRemoveRecordSort = () => {
const currentRecordSortsCallbackState = useRecoilComponentCallbackStateV2(
currentRecordSortsComponentState,
);
const removeRecordSort = useRecoilCallback(
({ set, snapshot }) =>
(fieldMetadataId: string) => {
const currentRecordSorts = getSnapshotValue(
snapshot,
currentRecordSortsCallbackState,
);
const hasFoundRecordSortInCurrentRecordSorts = currentRecordSorts.some(
(existingSort) => existingSort.fieldMetadataId === fieldMetadataId,
);
if (hasFoundRecordSortInCurrentRecordSorts) {
set(currentRecordSortsCallbackState, (currentRecordSorts) => {
const newCurrentRecordSorts = [...currentRecordSorts];
const indexOfSortToRemove = newCurrentRecordSorts.findIndex(
(existingSort) =>
existingSort.fieldMetadataId === fieldMetadataId,
);
if (indexOfSortToRemove < 0) {
return newCurrentRecordSorts;
}
newCurrentRecordSorts.splice(indexOfSortToRemove, 1);
return newCurrentRecordSorts;
});
}
},
[currentRecordSortsCallbackState],
);
return {
removeRecordSort,
};
};

View File

@ -1,5 +1,6 @@
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { useUpsertRecordSort } from '@/object-record/record-sort/hooks/useUpsertRecordSort';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
@ -13,12 +14,15 @@ type EditableSortChipProps = {
export const EditableSortChip = ({ recordSort }: EditableSortChipProps) => {
const { deleteCombinedViewSort } = useDeleteCombinedViewSorts();
const { removeRecordSort } = useRemoveRecordSort();
const { upsertCombinedViewSort } = useUpsertCombinedViewSorts();
const { upsertRecordSort } = useUpsertRecordSort();
const handleRemoveClick = () => {
deleteCombinedViewSort(recordSort.fieldMetadataId);
removeRecordSort(recordSort.fieldMetadataId);
};
const handleClick = () => {

View File

@ -17,6 +17,7 @@ import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { SoftDeleteFilterChip } from '@/views/components/SoftDeleteFilterChip';
import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters';
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '@/views/hooks/useApplyCurrentViewSortsToCurrentRecordSorts';
import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters';
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
@ -163,10 +164,14 @@ export const ViewBarDetails = ({
const { applyCurrentViewFiltersToCurrentRecordFilters } =
useApplyCurrentViewFiltersToCurrentRecordFilters();
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const handleCancelClick = () => {
if (isDefined(viewId)) {
resetUnsavedViewStates(viewId);
applyCurrentViewFiltersToCurrentRecordFilters();
applyCurrentViewSortsToCurrentRecordSorts();
toggleSoftDeleteFilterState(false);
}
};

View File

@ -0,0 +1,196 @@
import { act, renderHook } from '@testing-library/react';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState';
import { View } from '@/views/types/View';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { mockedViewsData } from '~/testing/mock-data/views';
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '../useApplyCurrentViewSortsToCurrentRecordSorts';
const mockObjectMetadataItemNameSingular = 'company';
describe('useApplyCurrentViewSortsToCurrentRecordSorts', () => {
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
);
if (!isDefined(mockObjectMetadataItem)) {
throw new Error(
'Missing mock object metadata item with name singular "company"',
);
}
const mockFieldMetadataItem = mockObjectMetadataItem.fields.find(
(field) => field.name === 'name',
);
if (!isDefined(mockFieldMetadataItem)) {
throw new Error('Missing mock field metadata item with type TEXT');
}
const mockViewSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: mockFieldMetadataItem.id,
direction: 'asc',
};
const allCompaniesView = mockedViewsData[0];
const mockView = {
...allCompaniesView,
viewSorts: [mockViewSort],
} satisfies View;
it('should apply sorts from current view', () => {
const { result } = renderHook(
() => {
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return {
applyCurrentViewSortsToCurrentRecordSorts,
currentSorts,
};
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'instanceId',
}),
mockView.id,
);
snapshot.set(prefetchViewsState, [mockView]);
},
}),
},
);
act(() => {
result.current.applyCurrentViewSortsToCurrentRecordSorts();
});
expect(result.current.currentSorts).toEqual([
{
id: mockViewSort.id,
fieldMetadataId: mockViewSort.fieldMetadataId,
direction: mockViewSort.direction,
definition: {
fieldMetadataId: mockViewSort.fieldMetadataId,
iconName: mockFieldMetadataItem.icon ?? '',
label: mockFieldMetadataItem.label,
},
} satisfies RecordSort,
]);
});
it('should not apply sorts when current view is not found', () => {
const { result } = renderHook(
() => {
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return {
applyCurrentViewSortsToCurrentRecordSorts,
currentSorts,
};
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'instanceId',
}),
mockView.id,
);
snapshot.set(prefetchViewsState, []);
},
}),
},
);
act(() => {
result.current.applyCurrentViewSortsToCurrentRecordSorts();
});
expect(result.current.currentSorts).toEqual([]);
});
it('should handle view with empty sorts', () => {
const viewWithNoSorts = {
...mockView,
viewSorts: [],
};
const { result } = renderHook(
() => {
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return {
applyCurrentViewSortsToCurrentRecordSorts,
currentSorts,
};
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'instanceId',
}),
mockView.id,
);
snapshot.set(prefetchViewsState, [viewWithNoSorts]);
},
}),
},
);
act(() => {
result.current.applyCurrentViewSortsToCurrentRecordSorts();
});
expect(result.current.currentSorts).toEqual([]);
});
});

View File

@ -0,0 +1,110 @@
import { act, renderHook } from '@testing-library/react';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { useApplyViewSortsToCurrentRecordSorts } from '../useApplyViewSortsToCurrentRecordSorts';
const mockObjectMetadataItemNameSingular = 'company';
describe('useApplyViewSortsToCurrentRecordSorts', () => {
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
);
if (!isDefined(mockObjectMetadataItem)) {
throw new Error(
`Missing mock object metadata item with name singular ${mockObjectMetadataItemNameSingular}`,
);
}
const mockFieldMetadataItem = mockObjectMetadataItem.fields.find(
(field) => field.name === 'name',
);
if (!isDefined(mockFieldMetadataItem)) {
throw new Error(`Missing mock field metadata Name`);
}
const mockViewSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: mockFieldMetadataItem.id,
direction: 'asc',
};
it('should apply view sorts to current record sorts', () => {
const { result } = renderHook(
() => {
const { applyViewSortsToCurrentRecordSorts } =
useApplyViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return { applyViewSortsToCurrentRecordSorts, currentSorts };
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
}),
},
);
act(() => {
result.current.applyViewSortsToCurrentRecordSorts([mockViewSort]);
});
expect(result.current.currentSorts).toEqual([
{
id: mockViewSort.id,
fieldMetadataId: mockViewSort.fieldMetadataId,
direction: mockViewSort.direction,
definition: {
fieldMetadataId: mockViewSort.fieldMetadataId,
label: mockFieldMetadataItem.label,
iconName: mockFieldMetadataItem.icon ?? '',
},
} satisfies RecordSort,
]);
});
it('should handle empty view sorts array', () => {
const { result } = renderHook(
() => {
const { applyViewSortsToCurrentRecordSorts } =
useApplyViewSortsToCurrentRecordSorts();
const currentSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
return { applyViewSortsToCurrentRecordSorts, currentSorts };
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
}),
},
);
act(() => {
result.current.applyViewSortsToCurrentRecordSorts([]);
});
expect(result.current.currentSorts).toEqual([]);
});
});

View File

@ -0,0 +1,46 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useApplyCurrentViewSortsToCurrentRecordSorts = () => {
const currentViewId = useRecoilComponentValueV2(
contextStoreCurrentViewIdComponentState,
);
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
const setCurrentRecordSorts = useSetRecoilComponentStateV2(
currentRecordSortsComponentState,
);
const { sortableFieldMetadataItems } =
useSortableFieldMetadataItemsInRecordIndexContext();
const applyCurrentViewSortsToCurrentRecordSorts = () => {
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
if (isDefined(currentView)) {
setCurrentRecordSorts(
mapViewSortsToSorts(currentView.viewSorts, sortDefinitions),
);
}
};
return {
applyCurrentViewSortsToCurrentRecordSorts,
};
};

View File

@ -0,0 +1,29 @@
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewSort } from '@/views/types/ViewSort';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
export const useApplyViewSortsToCurrentRecordSorts = () => {
const setCurrentRecordSorts = useSetRecoilComponentStateV2(
currentRecordSortsComponentState,
);
const { sortableFieldMetadataItems } =
useSortableFieldMetadataItemsInRecordIndexContext();
const applyViewSortsToCurrentRecordSorts = (viewSorts: ViewSort[]) => {
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
const recordSortsToApply = mapViewSortsToSorts(viewSorts, sortDefinitions);
setCurrentRecordSorts(recordSortsToApply);
};
return {
applyViewSortsToCurrentRecordSorts,
};
};