Implements new record sort CRUD (#10448)

This PR implements new record sorts CRUD as already done on record
filters, which is based on record sorts state instead of combined view
sorts.

It implements a new useSaveRecordSortsToViewSorts with its underlying
utils, to compute diff between two view sorts array.

The associated unit tests have also been written.

This PR also fixes the bug where the view bar disappeared when deleting
the already saved record sort of a view.
This commit is contained in:
Lucas Bordeau
2025-02-24 16:46:00 +01:00
committed by GitHub
parent dc7a9fc767
commit 970aa4c5a1
13 changed files with 463 additions and 77 deletions

View File

@ -179,8 +179,8 @@ export const ViewBarDetails = ({
const shouldExpandViewBar =
viewFiltersAreDifferentFromRecordFilters ||
((currentViewWithCombinedFiltersAndSorts?.viewSorts?.length ||
currentRecordFilters?.length) &&
viewSortsAreDifferentFromRecordSorts ||
((currentRecordSorts?.length || currentRecordFilters?.length) &&
isViewBarExpanded);
if (!shouldExpandViewBar) {

View File

@ -1,14 +1,46 @@
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { areViewSortsDifferentFromRecordSortsSelector } from '@/views/states/selectors/areViewSortsDifferentFromRecordSortsFamilySelector';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useMemo } from 'react';
export const useAreViewSortsDifferentFromRecordSorts = () => {
const { currentView } = useGetCurrentViewOnly();
const viewSortsAreDifferentFromRecordSorts = useRecoilComponentFamilyValueV2(
areViewSortsDifferentFromRecordSortsSelector,
{ viewId: currentView?.id },
const currentRecordSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
const viewSortsAreDifferentFromRecordSorts = useMemo(() => {
const currentViewSorts = currentView?.viewSorts ?? [];
const viewSortsFromCurrentRecordSorts = currentRecordSorts.map(
mapRecordSortToViewSort,
);
const viewSortsToCreate = getViewSortsToCreate(
currentViewSorts,
viewSortsFromCurrentRecordSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
currentViewSorts,
viewSortsFromCurrentRecordSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
currentViewSorts,
viewSortsFromCurrentRecordSorts,
);
const sortsHaveChanged =
viewSortsToCreate.length > 0 ||
viewSortsToDelete.length > 0 ||
viewSortsToUpdate.length > 0;
return sortsHaveChanged;
}, [currentRecordSorts, currentView]);
return { viewSortsAreDifferentFromRecordSorts };
};

View File

@ -4,14 +4,12 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroupRecords';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState';
import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates';
import { useSaveRecordFiltersToViewFilters } from '@/views/hooks/useSaveRecordFiltersToViewFilters';
import { useSaveRecordSortsToViewSorts } from '@/views/hooks/useSaveRecordSortsToViewSorts';
import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState';
import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState';
import { isDefined } from 'twenty-shared';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -25,18 +23,6 @@ export const useSaveCurrentViewFiltersAndSorts = (
viewBarComponentId,
);
const unsavedToDeleteViewSortIdsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToDeleteViewSortIdsComponentFamilyState,
viewBarComponentId,
);
const unsavedToUpsertViewSortsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewSortsComponentFamilyState,
viewBarComponentId,
);
const unsavedToUpsertViewFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
@ -49,12 +35,6 @@ export const useSaveCurrentViewFiltersAndSorts = (
viewBarComponentId,
);
const {
createViewSortRecords,
updateViewSortRecords,
deleteViewSortRecords,
} = usePersistViewSortRecords();
const {
createViewFilterGroupRecords,
deleteViewFilterGroupRecords,
@ -64,52 +44,6 @@ export const useSaveCurrentViewFiltersAndSorts = (
const { resetUnsavedViewStates } =
useResetUnsavedViewStates(viewBarComponentId);
const saveViewSorts = useRecoilCallback(
({ snapshot }) =>
async (viewId: string) => {
const unsavedToDeleteViewSortIds = getSnapshotValue(
snapshot,
unsavedToDeleteViewSortIdsCallbackState({ viewId }),
);
const unsavedToUpsertViewSorts = getSnapshotValue(
snapshot,
unsavedToUpsertViewSortsCallbackState({ viewId }),
);
const view = await getViewFromPrefetchState(viewId);
if (isUndefinedOrNull(view)) {
return;
}
const viewSortsToCreate = unsavedToUpsertViewSorts.filter(
(viewSort) =>
!view.viewSorts.some(
(vs) => vs.fieldMetadataId === viewSort.fieldMetadataId,
),
);
const viewSortsToUpdate = unsavedToUpsertViewSorts.filter((viewSort) =>
view.viewSorts.some(
(vs) => vs.fieldMetadataId === viewSort.fieldMetadataId,
),
);
await createViewSortRecords(viewSortsToCreate, view);
await updateViewSortRecords(viewSortsToUpdate);
await deleteViewSortRecords(unsavedToDeleteViewSortIds);
},
[
createViewSortRecords,
deleteViewSortRecords,
getViewFromPrefetchState,
unsavedToDeleteViewSortIdsCallbackState,
unsavedToUpsertViewSortsCallbackState,
updateViewSortRecords,
],
);
const saveViewFilterGroups = useRecoilCallback(
({ snapshot }) =>
async (viewId: string) => {
@ -162,6 +96,8 @@ export const useSaveCurrentViewFiltersAndSorts = (
const { saveRecordFiltersToViewFilters } =
useSaveRecordFiltersToViewFilters();
const { saveRecordSortsToViewSorts } = useSaveRecordSortsToViewSorts();
const saveCurrentViewFilterAndSorts = useRecoilCallback(
({ snapshot }) =>
async (viewIdFromProps?: string) => {
@ -176,8 +112,8 @@ export const useSaveCurrentViewFiltersAndSorts = (
const viewId = viewIdFromProps ?? currentViewId;
await saveViewFilterGroups(viewId);
await saveViewSorts(viewId);
await saveRecordSortsToViewSorts();
await saveRecordFiltersToViewFilters();
resetUnsavedViewStates(viewId);
@ -185,9 +121,9 @@ export const useSaveCurrentViewFiltersAndSorts = (
[
currentViewIdCallbackState,
resetUnsavedViewStates,
saveViewSorts,
saveViewFilterGroups,
saveRecordFiltersToViewFilters,
saveRecordSortsToViewSorts,
],
);

View File

@ -0,0 +1,77 @@
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 { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useSaveRecordSortsToViewSorts = () => {
const {
createViewSortRecords,
updateViewSortRecords,
deleteViewSortRecords,
} = usePersistViewSortRecords();
const { currentView } = useGetCurrentViewOnly();
const currentRecordSortsCallbackState = useRecoilComponentCallbackStateV2(
currentRecordSortsComponentState,
);
const saveRecordSortsToViewSorts = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!isDefined(currentView)) {
return;
}
const currentViewSorts = currentView?.viewSorts ?? [];
const currentRecordSorts = getSnapshotValue(
snapshot,
currentRecordSortsCallbackState,
);
const newViewSorts = currentRecordSorts.map(mapRecordSortToViewSort);
const viewSortsToCreate = getViewSortsToCreate(
currentViewSorts,
newViewSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
currentViewSorts,
newViewSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
currentViewSorts,
newViewSorts,
);
const viewSortIdsToDelete = viewSortsToDelete.map(
(viewSort) => viewSort.id,
);
await createViewSortRecords(viewSortsToCreate, currentView);
await updateViewSortRecords(viewSortsToUpdate);
await deleteViewSortRecords(viewSortIdsToDelete);
},
[
createViewSortRecords,
deleteViewSortRecords,
updateViewSortRecords,
currentRecordSortsCallbackState,
currentView,
],
);
return {
saveRecordSortsToViewSorts,
};
};

View File

@ -0,0 +1,40 @@
import { RecordSortDirection } from '@/object-record/record-sort/types/RecordSortDirection';
import { ViewSort } from '@/views/types/ViewSort';
import { areViewSortsEqual } from '@/views/utils/areViewSortsEqual';
describe('areViewSortsEqual', () => {
const baseSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: 'field-1',
direction: 'asc',
};
it('should return true when all comparable properties are equal', () => {
const sortA = { ...baseSort };
const sortB = { ...baseSort };
expect(areViewSortsEqual(sortA, sortB)).toBe(true);
});
it('should return false when displayValue is different', () => {
const sortA = { ...baseSort };
const sortB = { ...baseSort, direction: 'desc' as RecordSortDirection };
expect(areViewSortsEqual(sortA, sortB)).toBe(false);
});
it('should return false when fieldMetadataId is different', () => {
const sortA = { ...baseSort };
const sortB = { ...baseSort, fieldMetadataId: 'field-2' };
expect(areViewSortsEqual(sortA, sortB)).toBe(false);
});
it('should ignore non-comparable properties', () => {
const sortA = { ...baseSort, id: 'id-1', createdAt: '2023-01-01' };
const sortB = { ...baseSort, id: 'id-2', createdAt: '2023-01-02' };
expect(areViewSortsEqual(sortA, sortB)).toBe(true);
});
});

View File

@ -0,0 +1,84 @@
import { ViewSort } from '@/views/types/ViewSort';
import { getViewSortsToCreate } from '../getViewSortsToCreate';
describe('getViewSortsToCreate', () => {
const baseSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: 'field-1',
direction: 'asc',
};
it('should return all sorts when current sorts array is empty', () => {
const currentViewSorts: ViewSort[] = [];
const newViewSorts: ViewSort[] = [
{ ...baseSort },
{
...baseSort,
id: 'sort-2',
fieldMetadataId: 'field-2',
} satisfies ViewSort,
];
const result = getViewSortsToCreate(currentViewSorts, newViewSorts);
expect(result).toEqual(newViewSorts);
});
it('should return empty array when new sorts array is empty', () => {
const currentViewSorts: ViewSort[] = [baseSort];
const newViewSorts: ViewSort[] = [];
const result = getViewSortsToCreate(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
it('should return only sorts that do not exist in current sorts', () => {
const existingSort = { ...baseSort };
const newSortWithDifferentFieldMetadataId = {
...baseSort,
id: 'sort-2',
fieldMetadataId: 'field-2',
} satisfies ViewSort;
const currentViewSorts: ViewSort[] = [existingSort];
const newViewSorts: ViewSort[] = [
existingSort,
newSortWithDifferentFieldMetadataId,
];
const result = getViewSortsToCreate(currentViewSorts, newViewSorts);
expect(result).toEqual([newSortWithDifferentFieldMetadataId]);
});
it('should handle sorts with different fieldMetadataIds', () => {
const existingSort = { ...baseSort };
const sortWithDifferentFieldMetadataId = {
...baseSort,
fieldMetadataId: 'group-2',
} satisfies ViewSort;
const currentViewSorts: ViewSort[] = [existingSort];
const newViewSorts: ViewSort[] = [
existingSort,
sortWithDifferentFieldMetadataId,
];
const result = getViewSortsToCreate(currentViewSorts, newViewSorts);
expect(result).toEqual([sortWithDifferentFieldMetadataId]);
});
it('should handle empty arrays for both inputs', () => {
const currentViewSorts: ViewSort[] = [];
const newViewSorts: ViewSort[] = [];
const result = getViewSortsToCreate(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,72 @@
import { ViewSort } from '@/views/types/ViewSort';
import { getViewSortsToDelete } from '../getViewSortsToDelete';
describe('getViewSortsToDelete', () => {
const baseSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: 'field-1',
direction: 'asc',
};
it('should return empty array when current sorts array is empty', () => {
const currentViewSorts: ViewSort[] = [];
const newViewSorts: ViewSort[] = [baseSort];
const result = getViewSortsToDelete(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
it('should return all current sorts when new sorts array is empty', () => {
const existingSort = { ...baseSort };
const currentViewSorts: ViewSort[] = [existingSort];
const newViewSorts: ViewSort[] = [];
const result = getViewSortsToDelete(currentViewSorts, newViewSorts);
expect(result).toEqual([existingSort]);
});
it('should return sorts that exist in current but not in new sorts', () => {
const sortToDelete = { ...baseSort };
const sortToKeep = {
...baseSort,
id: 'filter-2',
fieldMetadataId: 'field-2',
} satisfies ViewSort;
const currentViewSorts: ViewSort[] = [sortToDelete, sortToKeep];
const newViewSorts: ViewSort[] = [sortToKeep];
const result = getViewSortsToDelete(currentViewSorts, newViewSorts);
expect(result).toEqual([sortToDelete]);
});
it('should handle empty arrays for both inputs', () => {
const currentViewSorts: ViewSort[] = [];
const newViewSorts: ViewSort[] = [];
const result = getViewSortsToDelete(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
it('should not delete sorts that match in both fieldMetadataId and direction', () => {
const existingSort = { ...baseSort };
const matchingSort = {
__typename: 'ViewSort',
id: 'sort-2',
fieldMetadataId: 'field-1',
direction: 'asc',
} satisfies ViewSort;
const currentViewSorts: ViewSort[] = [existingSort];
const newViewSorts: ViewSort[] = [matchingSort];
const result = getViewSortsToDelete(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,66 @@
import { RecordSortDirection } from '@/object-record/record-sort/types/RecordSortDirection';
import { ViewSort } from '@/views/types/ViewSort';
import { getViewSortsToUpdate } from '../getViewSortsToUpdate';
describe('getViewSortsToUpdate', () => {
const baseSort: ViewSort = {
__typename: 'ViewSort',
id: 'sort-1',
fieldMetadataId: 'field-1',
direction: 'asc' as RecordSortDirection,
};
it('should return empty array when current sorts array is empty', () => {
const currentViewSorts: ViewSort[] = [];
const newViewSorts: ViewSort[] = [baseSort];
const result = getViewSortsToUpdate(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
it('should return empty array when new sorts array is empty', () => {
const currentViewSorts: ViewSort[] = [baseSort];
const newViewSorts: ViewSort[] = [];
const result = getViewSortsToUpdate(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
it('should return sorts that exist in both arrays but have different direction', () => {
const existingSort = { ...baseSort };
const updatedSort = {
...baseSort,
direction: 'desc',
} satisfies ViewSort;
const currentViewSorts: ViewSort[] = [existingSort];
const newViewSorts: ViewSort[] = [updatedSort];
const result = getViewSortsToUpdate(currentViewSorts, newViewSorts);
expect(result).toEqual([updatedSort]);
});
it('should not return sorts that exist in both arrays with same values', () => {
const existingSort = { ...baseSort };
const sameSort = { ...baseSort };
const currentViewSorts: ViewSort[] = [existingSort];
const newViewSorts: ViewSort[] = [sameSort];
const result = getViewSortsToUpdate(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
it('should handle empty arrays for both inputs', () => {
const currentViewSorts: ViewSort[] = [];
const newViewSorts: ViewSort[] = [];
const result = getViewSortsToUpdate(currentViewSorts, newViewSorts);
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,12 @@
import { ViewSort } from '@/views/types/ViewSort';
export const areViewSortsEqual = (viewSortA: ViewSort, viewSortB: ViewSort) => {
const propertiesToCompare: (keyof ViewSort)[] = [
'fieldMetadataId',
'direction',
];
return propertiesToCompare.every(
(property) => viewSortA[property] === viewSortB[property],
);
};

View File

@ -0,0 +1,18 @@
import { ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared';
export const getViewSortsToCreate = (
currentViewSorts: ViewSort[],
newViewSorts: ViewSort[],
) => {
return newViewSorts.filter((newViewSort) => {
const correspondingViewSort = currentViewSorts.find(
(currentViewSort) =>
currentViewSort.fieldMetadataId === newViewSort.fieldMetadataId,
);
const shouldCreateBecauseViewSortIsNew = !isDefined(correspondingViewSort);
return shouldCreateBecauseViewSortIsNew;
});
};

View File

@ -0,0 +1,14 @@
import { ViewSort } from '@/views/types/ViewSort';
export const getViewSortsToDelete = (
currentViewSorts: ViewSort[],
newViewSorts: ViewSort[],
) => {
return currentViewSorts.filter(
(currentViewSort) =>
!newViewSorts.some(
(newViewSort) =>
newViewSort.fieldMetadataId === currentViewSort.fieldMetadataId,
),
);
};

View File

@ -0,0 +1,26 @@
import { ViewSort } from '@/views/types/ViewSort';
import { areViewSortsEqual } from '@/views/utils/areViewSortsEqual';
import { isDefined } from 'twenty-shared';
export const getViewSortsToUpdate = (
currentViewSorts: ViewSort[],
newViewSorts: ViewSort[],
) => {
return newViewSorts.filter((newViewSort) => {
const correspondingViewSort = currentViewSorts.find(
(currentViewSort) =>
currentViewSort.fieldMetadataId === newViewSort.fieldMetadataId,
);
if (!isDefined(correspondingViewSort)) {
return false;
}
const shouldUpdateBecauseViewSortIsDifferent = !areViewSortsEqual(
newViewSort,
correspondingViewSort,
);
return shouldUpdateBecauseViewSortIsDifferent;
});
};

View File

@ -0,0 +1,9 @@
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { ViewSort } from '@/views/types/ViewSort';
export const mapRecordSortToViewSort = (recordSort: RecordSort): ViewSort => {
return {
__typename: 'ViewSort',
...recordSort,
} satisfies ViewSort;
};