[FEAT] RecordAction destroy many record (#9991)

# Introduction
Added the `RecordAction` destroy multiple record

## Repro
Select multiples `deletedRecords`, you should be able to see the
`Destroy` pinned CTA ( iso short label with the destroy one ), open
control panel and fin new CTA `Permanently delete records`


https://github.com/user-attachments/assets/31ee8738-9d61-4dec-9a1f-41bb6785e018

## TODO
- [ ] Gain granularity within tests to assert the action should be
registered only when filtering by deleted

## Conclusion
Closes https://github.com/twentyhq/core-team-issues/issues/110
This commit is contained in:
Paul Rastoin
2025-02-05 11:33:01 +01:00
committed by GitHub
parent aa003f25d9
commit 28a3f75946
12 changed files with 338 additions and 33 deletions

View File

@ -1,4 +1,5 @@
import { useDeleteMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction';
import { useDestroyMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useDestroyMultipleRecordsAction';
import { useExportMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { useCreateNewTableRecordNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useCreateNewTableRecordNoSelectionRecordAction';
@ -184,4 +185,17 @@ export const DEFAULT_ACTIONS_CONFIG_V2: Record<
availableOn: [ActionViewType.SHOW_PAGE],
useAction: useNavigateToNextRecordSingleRecordAction,
},
destroyMultipleRecords: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: MultipleRecordsActionKeys.DESTROY,
label: msg`Permanently destroy records`,
shortLabel: msg`Destroy`,
position: 10,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
availableOn: [ActionViewType.INDEX_PAGE_BULK_SELECTION],
useAction: useDestroyMultipleRecordsAction,
},
};

View File

@ -0,0 +1,145 @@
import { DestroyManyRecordsProps } from '@/object-record/hooks/useDestroyManyRecords';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { expect } from '@storybook/test';
import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react';
import {
GetJestMetadataAndApolloMocksAndActionMenuWrapperProps,
getJestMetadataAndApolloMocksAndActionMenuWrapper,
} from '~/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getPeopleMock } from '~/testing/mock-data/people';
import { useDestroyMultipleRecordsAction } from '../useDestroyMultipleRecordsAction';
const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const personMockObjectMetadataItemDeletedAtField =
personMockObjectMetadataItem.fields.find((el) => el.name === 'deletedAt');
if (personMockObjectMetadataItemDeletedAtField === undefined)
throw new Error('Should never occurs');
const [firstPeopleMock, secondPeopleMock] = getPeopleMock().map((record) => ({
...record,
deletedAt: new Date().toISOString(),
}));
const destroyManyRecordsMock = jest.fn();
const resetTableRowSelectionMock = jest.fn();
jest.mock('@/object-record/hooks/useDestroyManyRecords', () => ({
useDestroyManyRecords: () => ({
destroyManyRecords: destroyManyRecordsMock,
}),
}));
jest.mock('@/object-record/hooks/useLazyFetchAllRecords', () => ({
useLazyFetchAllRecords: () => {
return {
fetchAllRecords: () => [firstPeopleMock, secondPeopleMock],
};
},
}));
jest.mock('@/object-record/record-table/hooks/useRecordTable', () => ({
useRecordTable: () => ({
resetTableRowSelection: resetTableRowSelectionMock,
}),
}));
const getWrapper = (
overrides?: Partial<GetJestMetadataAndApolloMocksAndActionMenuWrapperProps>,
) =>
getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: '1',
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [firstPeopleMock.id, secondPeopleMock.id],
},
contextStoreFilters: [],
contextStoreNumberOfSelectedRecords: 2,
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(recordStoreFamilyState(firstPeopleMock.id), firstPeopleMock);
snapshot.set(
recordStoreFamilyState(secondPeopleMock.id),
secondPeopleMock,
);
},
...overrides,
});
const defaultWrapper = getWrapper();
describe('useDestroyMultipleRecordsAction', () => {
it('should call destroyManyRecords on click if records are filtered by deletedAt', async () => {
const { result } = renderHook(
() =>
useDestroyMultipleRecordsAction({
objectMetadataItem: personMockObjectMetadataItem,
}),
{
wrapper: getWrapper({
contextStoreFilters: [
{
id: '1553cda7-893d-4d89-b7ab-04969a4c2927',
fieldMetadataId: personMockObjectMetadataItemDeletedAtField.id,
value: '',
displayValue: '',
operand: ViewFilterOperand.IsNotEmpty,
definition: {
label: 'Deleted',
iconName: 'IconTrash',
fieldMetadataId: personMockObjectMetadataItemDeletedAtField.id,
type: 'DATE_TIME',
},
},
],
}),
},
);
expect(result.current.ConfirmationModal?.props?.isOpen).toBeFalsy();
act(() => {
result.current.onClick();
});
expect(result.current.ConfirmationModal?.props?.isOpen).toBe(true);
act(() => {
result.current.ConfirmationModal?.props?.onConfirmClick();
});
const expectedParams: DestroyManyRecordsProps = {
recordIdsToDestroy: [firstPeopleMock.id, secondPeopleMock.id],
};
await waitFor(() => {
expect(resetTableRowSelectionMock).toHaveBeenCalled();
expect(destroyManyRecordsMock).toHaveBeenCalledWith(expectedParams);
});
});
it('should not call destroyManyRecords on click if records are not filtered by deletedAt', async () => {
const { result } = renderHook(
() =>
useDestroyMultipleRecordsAction({
objectMetadataItem: personMockObjectMetadataItem,
}),
{
wrapper: defaultWrapper,
},
);
expect(result.current.ConfirmationModal?.props?.isOpen).toBeFalsy();
act(() => {
result.current.onClick();
});
expect(result.current.ConfirmationModal?.props?.isOpen).toBeFalsy();
});
});

View File

@ -4,8 +4,8 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { BACKEND_BATCH_REQUEST_MAX_COUNT } from '@/object-record/constants/BackendBatchRequestMaxCount';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@ -84,7 +84,7 @@ export const useDeleteMultipleRecordsAction: ActionHookWithObjectMetadataItem =
!isRemoteObject &&
!isDeletedFilterActive &&
isDefined(contextStoreNumberOfSelectedRecords) &&
contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT &&
contextStoreNumberOfSelectedRecords < BACKEND_BATCH_REQUEST_MAX_COUNT &&
contextStoreNumberOfSelectedRecords > 0;
const onClick = () => {

View File

@ -0,0 +1,121 @@
import { ActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/ActionHook';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { BACKEND_BATCH_REQUEST_MAX_COUNT } from '@/object-record/constants/BackendBatchRequestMaxCount';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { useDestroyManyRecords } from '@/object-record/hooks/useDestroyManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared';
export const useDestroyMultipleRecordsAction: ActionHookWithObjectMetadataItem =
({ objectMetadataItem }) => {
const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] =
useState(false);
const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural,
});
const { destroyManyRecords } = useDestroyManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreFilters = useRecoilComponentValueV2(
contextStoreFiltersComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const deletedAtFilter: RecordGqlOperationFilter = {
deletedAt: { is: 'NOT_NULL' },
};
const graphqlFilter = {
...computeContextStoreFilters(
contextStoreTargetedRecordsRule,
contextStoreFilters,
objectMetadataItem,
filterValueDependencies,
),
...deletedAtFilter,
};
const deletedAtFieldMetadata = objectMetadataItem.fields.find(
(field) => field.name === 'deletedAt',
);
const isDeletedFilterActive = contextStoreFilters.some(
(filter) =>
filter.fieldMetadataId === deletedAtFieldMetadata?.id &&
filter.operand === RecordFilterOperand.IsNotEmpty,
);
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: graphqlFilter,
limit: DEFAULT_QUERY_PAGE_SIZE,
recordGqlFields: { id: true },
});
const handleDestroyClick = useCallback(async () => {
const recordsToDestroy = await fetchAllRecordIds();
const recordIdsToDestroy = recordsToDestroy.map((record) => record.id);
resetTableRowSelection();
await destroyManyRecords({ recordIdsToDestroy });
}, [destroyManyRecords, fetchAllRecordIds, resetTableRowSelection]);
const isRemoteObject = objectMetadataItem.isRemote;
const shouldBeRegistered =
!isRemoteObject &&
isDeletedFilterActive &&
isDefined(contextStoreNumberOfSelectedRecords) &&
contextStoreNumberOfSelectedRecords < BACKEND_BATCH_REQUEST_MAX_COUNT &&
contextStoreNumberOfSelectedRecords > 0;
const onClick = () => {
if (!shouldBeRegistered) {
return;
}
setIsDestroyRecordsModalOpen(true);
};
const confirmationModal = (
<ConfirmationModal
isOpen={isDestroyRecordsModalOpen}
setIsOpen={setIsDestroyRecordsModalOpen}
title={'Permanently Destroy Records'}
subtitle={
"Are you sure you want to destroy these records? They won't be recoverable anymore."
}
onConfirmClick={handleDestroyClick}
deleteButtonText={'Destroy Records'}
/>
);
return {
shouldBeRegistered,
onClick,
ConfirmationModal: confirmationModal,
};
};

View File

@ -1,4 +1,5 @@
export enum MultipleRecordsActionKeys {
DELETE = 'delete-multiple-records',
EXPORT = 'export-multiple-records',
DESTROY = 'destroy-multiple-records',
}

View File

@ -7,8 +7,9 @@ export type ActionHook =
export type ActionHookWithoutObjectMetadataItem = () => ActionHookResult;
export type ActionHookWithObjectMetadataItem = ({
objectMetadataItem,
}: {
type ActionHookWithObjectMetadataItemParams = {
objectMetadataItem: ObjectMetadataItem;
}) => ActionHookResult;
};
export type ActionHookWithObjectMetadataItem = (
params: ActionHookWithObjectMetadataItemParams,
) => ActionHookResult;

View File

@ -0,0 +1 @@
export const BACKEND_BATCH_REQUEST_MAX_COUNT = 10000;

View File

@ -1 +0,0 @@
export const DELETE_MAX_COUNT = 10000;

View File

@ -19,7 +19,8 @@ type useDestroyManyRecordProps = {
refetchFindManyQuery?: boolean;
};
type DestroyManyRecordsOptions = {
export type DestroyManyRecordsProps = {
recordIdsToDestroy: string[];
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
@ -56,16 +57,19 @@ export const useDestroyManyRecords = ({
objectMetadataItem.namePlural,
);
const destroyManyRecords = async (
idsToDestroy: string[],
options?: DestroyManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToDestroy.length / mutationPageSize);
const destroyManyRecords = async ({
recordIdsToDestroy,
delayInMsBetweenRequests,
skipOptimisticEffect = false,
}: DestroyManyRecordsProps) => {
const numberOfBatches = Math.ceil(
recordIdsToDestroy.length / mutationPageSize,
);
const destroyedRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchedIdToDestroy = idsToDestroy.slice(
const batchedIdToDestroy = recordIdsToDestroy.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
@ -80,7 +84,7 @@ export const useDestroyManyRecords = ({
variables: {
filter: { id: { in: batchedIdToDestroy } },
},
optimisticResponse: options?.skipOptimisticEffect
optimisticResponse: skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchedIdToDestroy.map(
@ -90,7 +94,7 @@ export const useDestroyManyRecords = ({
}),
),
},
update: options?.skipOptimisticEffect
update: skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
@ -126,8 +130,8 @@ export const useDestroyManyRecords = ({
destroyedRecords.push(...destroyedRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
if (isDefined(delayInMsBetweenRequests)) {
await sleep(delayInMsBetweenRequests);
}
}

View File

@ -87,9 +87,12 @@ export const usePersistViewGroupRecords = () => {
async (viewGroupsToDelete: ViewGroup[]) => {
if (!viewGroupsToDelete.length) return;
return destroyManyRecords(
viewGroupsToDelete.map((viewGroup) => viewGroup.id),
const recordIdsToDestroy = viewGroupsToDelete.map(
(viewGroup) => viewGroup.id,
);
return destroyManyRecords({
recordIdsToDestroy,
});
},
[destroyManyRecords],
);

View File

@ -1,14 +1,25 @@
import { ReactNode, useEffect, useState } from 'react';
import { PropsWithChildren, useEffect, useState } from 'react';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import {
ContextStoreTargetedRecordsRule,
contextStoreTargetedRecordsRuleComponentState,
} from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
export type JestContextStoreSetterMocks = {
contextStoreTargetedRecordsRule?: ContextStoreTargetedRecordsRule;
contextStoreNumberOfSelectedRecords?: number;
contextStoreFilters?: RecordFilter[];
contextStoreCurrentObjectMetadataNameSingular?: string;
};
type JestContextStoreSetterProps =
PropsWithChildren<JestContextStoreSetterMocks>;
export const JestContextStoreSetter = ({
contextStoreTargetedRecordsRule = {
mode: 'selection',
@ -16,13 +27,9 @@ export const JestContextStoreSetter = ({
},
contextStoreNumberOfSelectedRecords = 0,
contextStoreCurrentObjectMetadataNameSingular = '',
contextStoreFilters = [],
children,
}: {
contextStoreTargetedRecordsRule?: ContextStoreTargetedRecordsRule;
contextStoreNumberOfSelectedRecords?: number;
contextStoreCurrentObjectMetadataNameSingular?: string;
children: ReactNode;
}) => {
}: JestContextStoreSetterProps) => {
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
contextStoreTargetedRecordsRuleComponentState,
);
@ -35,6 +42,10 @@ export const JestContextStoreSetter = ({
contextStoreNumberOfSelectedRecordsComponentState,
);
const setcontextStoreFiltersComponentState = useSetRecoilComponentStateV2(
contextStoreFiltersComponentState,
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: contextStoreCurrentObjectMetadataNameSingular,
});
@ -46,6 +57,8 @@ export const JestContextStoreSetter = ({
setContextStoreTargetedRecordsRule(contextStoreTargetedRecordsRule);
setContextStoreCurrentObjectMetadataId(contextStoreCurrentObjectMetadataId);
setContextStoreNumberOfSelectedRecords(contextStoreNumberOfSelectedRecords);
setcontextStoreFiltersComponentState(contextStoreFilters);
setIsLoaded(true);
}, [
setContextStoreTargetedRecordsRule,
@ -54,6 +67,8 @@ export const JestContextStoreSetter = ({
contextStoreCurrentObjectMetadataId,
setContextStoreNumberOfSelectedRecords,
contextStoreNumberOfSelectedRecords,
setcontextStoreFiltersComponentState,
contextStoreFilters,
]);
return isLoaded ? <>{children}</> : null;

View File

@ -1,23 +1,22 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { MockedResponse } from '@apollo/client/testing';
import { ReactNode } from 'react';
import { MutableSnapshot } from 'recoil';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
import {
JestContextStoreSetter,
JestContextStoreSetterMocks,
} from '~/testing/jest/JestContextStoreSetter';
export type GetJestMetadataAndApolloMocksAndActionMenuWrapperProps = {
apolloMocks:
| readonly MockedResponse<Record<string, any>, Record<string, any>>[]
| undefined;
onInitializeRecoilSnapshot?: (snapshot: MutableSnapshot) => void;
contextStoreTargetedRecordsRule?: ContextStoreTargetedRecordsRule;
contextStoreNumberOfSelectedRecords?: number;
contextStoreCurrentObjectMetadataNameSingular?: string;
componentInstanceId: string;
};
} & JestContextStoreSetterMocks;
export const getJestMetadataAndApolloMocksAndActionMenuWrapper = ({
apolloMocks,
@ -25,6 +24,7 @@ export const getJestMetadataAndApolloMocksAndActionMenuWrapper = ({
contextStoreTargetedRecordsRule,
contextStoreNumberOfSelectedRecords,
contextStoreCurrentObjectMetadataNameSingular,
contextStoreFilters,
componentInstanceId,
}: GetJestMetadataAndApolloMocksAndActionMenuWrapperProps) => {
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@ -48,6 +48,7 @@ export const getJestMetadataAndApolloMocksAndActionMenuWrapper = ({
}}
>
<JestContextStoreSetter
contextStoreFilters={contextStoreFilters}
contextStoreTargetedRecordsRule={contextStoreTargetedRecordsRule}
contextStoreNumberOfSelectedRecords={
contextStoreNumberOfSelectedRecords