feat: soft delete (#6576)

Implement soft delete on standards and custom objects.
This is a temporary solution, when we drop `pg_graphql` we should rely
on the `softDelete` functions of TypeORM.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Jérémy M
2024-08-16 21:20:02 +02:00
committed by GitHub
parent 20d84755bb
commit db54469c8a
118 changed files with 1675 additions and 492 deletions

View File

@ -13,7 +13,7 @@ import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = {
type useDeleteManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
@ -25,7 +25,7 @@ type DeleteManyRecordsOptions = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
}: useDeleteManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =

View File

@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useDestroyManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { destroyManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const destroyManyRecordsMutation = gql`
mutation DestroyMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
${mutationResponseField}(filter: $filter) {
id
}
}
`;
return {
destroyManyRecordsMutation,
};
};

View File

@ -0,0 +1,115 @@
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useDestroyManyRecordsMutation } from '@/object-record/hooks/useDestroyManyRecordMutation';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useDestroyManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
type DestroyManyRecordsOptions = {
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useDestroyManyRecords = ({
objectNameSingular,
}: useDestroyManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { destroyManyRecordsMutation } = useDestroyManyRecordsMutation({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const mutationResponseField = getDestroyManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const destroyManyRecords = async (
idsToDestroy: string[],
options?: DestroyManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToDestroy.length / mutationPageSize);
const destroyedRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToDestroy.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
const destroyedRecordsResponse = await apolloClient.mutate({
mutation: destroyManyRecordsMutation,
variables: {
filter: { id: { in: batchIds } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchIds.map((idToDestroy) => ({
__typename: capitalize(objectNameSingular),
id: idToDestroy,
})),
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
const cachedRecords = records
.map((record) => getRecordFromCache(record.id, cache))
.filter(isDefined);
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToDelete: cachedRecords,
objectMetadataItems,
});
},
});
const destroyedRecordsForThisBatch =
destroyedRecordsResponse.data?.[mutationResponseField] ?? [];
destroyedRecords.push(...destroyedRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
}
}
return destroyedRecords;
};
return { destroyManyRecords };
};

View File

@ -17,11 +17,13 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
recordGqlFields,
onCompleted,
skip,
withSoftDeleted = false,
}: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
onCompleted?: (data: T) => void;
skip?: boolean;
withSoftDeleted?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -33,6 +35,7 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
const { findOneRecordQuery } = useFindOneRecordQuery({
objectNameSingular,
recordGqlFields: computedRecordGqlFields,
withSoftDeleted,
});
const { data, loading, error } = useQuery<{

View File

@ -10,9 +10,11 @@ import { capitalize } from '~/utils/string/capitalize';
export const useFindOneRecordQuery = ({
objectNameSingular,
recordGqlFields,
withSoftDeleted = false,
}: {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
withSoftDeleted?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -25,6 +27,16 @@ export const useFindOneRecordQuery = ({
objectMetadataItem.nameSingular,
)}($objectRecordId: ID!) {
${objectMetadataItem.nameSingular}(filter: {
${
withSoftDeleted
? `
or: [
{ deletedAt: { is: NULL } },
{ deletedAt: { is: NOT_NULL } }
],
`
: ''
}
id: {
eq: $objectRecordId
}

View File

@ -0,0 +1,94 @@
import { useApolloClient } from '@apollo/client';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
type useRestoreManyRecordProps = {
objectNameSingular: string;
refetchFindManyQuery?: boolean;
};
type RestoreManyRecordsOptions = {
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useRestoreManyRecords = ({
objectNameSingular,
}: useRestoreManyRecordProps) => {
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { restoreManyRecordsMutation } = useRestoreManyRecordsMutation({
objectNameSingular,
});
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const restoreManyRecords = async (
idsToRestore: string[],
options?: RestoreManyRecordsOptions,
) => {
const numberOfBatches = Math.ceil(idsToRestore.length / mutationPageSize);
const restoredRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToRestore.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
// TODO: fix optimistic effect
const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`;
const findManyQueryName = `FindMany${capitalize(objectMetadataItem.namePlural)}`;
const restoredRecordsResponse = await apolloClient.mutate({
mutation: restoreManyRecordsMutation,
refetchQueries: [findOneQueryName, findManyQueryName],
variables: {
filter: { id: { in: batchIds } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: batchIds.map((idToRestore) => ({
__typename: capitalize(objectNameSingular),
id: idToRestore,
deletedAt: null,
})),
},
});
const restoredRecordsForThisBatch =
restoredRecordsResponse.data?.[mutationResponseField] ?? [];
restoredRecords.push(...restoredRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests);
}
}
return restoredRecords;
};
return { restoreManyRecords };
};

View File

@ -0,0 +1,41 @@
import gql from 'graphql-tag';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useRestoreManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { restoreManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getRestoreManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const restoreManyRecordsMutation = gql`
mutation RestoreMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
${mutationResponseField}(filter: $filter) {
id
}
}
`;
return {
restoreManyRecordsMutation,
};
};

View File

@ -4,6 +4,7 @@ import { FilterDefinition } from './FilterDefinition';
export type Filter = {
id: string;
variant?: 'default' | 'danger';
fieldMetadataId: string;
value: string;
displayValue: string;

View File

@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { useCombinedViewFilters } from '@/views/hooks/useCombinedViewFilters';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { isDefined } from '~/utils/isDefined';
type UseHandleToggleTrashColumnFilterProps = {
objectNameSingular: string;
viewBarId: string;
};
export const useHandleToggleTrashColumnFilter = ({
viewBarId,
objectNameSingular,
}: UseHandleToggleTrashColumnFilterProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { columnDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const { upsertCombinedViewFilter } = useCombinedViewFilters(viewBarId);
const handleToggleTrashColumnFilter = useCallback(() => {
const trashFieldMetadata = objectMetadataItem.fields.find(
(field) => field.name === 'deletedAt',
);
if (!isDefined(trashFieldMetadata)) return;
const correspondingColumnDefinition = columnDefinitions.find(
(columnDefinition) =>
columnDefinition.fieldMetadataId === trashFieldMetadata.id,
);
if (!isDefined(correspondingColumnDefinition)) return;
const filterType = getFilterTypeFromFieldType(
correspondingColumnDefinition?.type,
);
const newFilter: Filter = {
id: v4(),
variant: 'danger',
fieldMetadataId: trashFieldMetadata.id,
operand: ViewFilterOperand.IsNotEmpty,
displayValue: '',
definition: {
label: 'Trash',
iconName: 'IconTrash',
fieldMetadataId: trashFieldMetadata.id,
type: filterType,
},
value: '',
};
upsertCombinedViewFilter(newFilter);
}, [columnDefinitions, objectMetadataItem.fields, upsertCombinedViewFilter]);
return handleToggleTrashColumnFilter;
};

View File

@ -8,6 +8,7 @@ import {
IconFileImport,
IconSettings,
IconTag,
IconTrash,
} from 'twenty-ui';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
@ -37,6 +38,7 @@ import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewType } from '@/views/types/ViewType';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
type RecordIndexOptionsMenu = 'fields' | 'hiddenFields';
@ -88,6 +90,11 @@ export const RecordIndexOptionsDropdownContent = ({
hiddenTableColumns,
} = useRecordIndexOptionsForTable(recordIndexId);
const handleToggleTrashColumnFilter = useHandleToggleTrashColumnFilter({
objectNameSingular,
viewBarId: recordIndexId,
});
const {
visibleBoardFields,
hiddenBoardFields,
@ -153,6 +160,14 @@ export const RecordIndexOptionsDropdownContent = ({
LeftIcon={IconFileExport}
text={displayedExportProgress(progress)}
/>
<MenuItem
onClick={() => {
handleToggleTrashColumnFilter();
closeDropdown();
}}
LeftIcon={IconTrash}
text="Trash"
/>
</DropdownMenuItemsContainer>
)}
{currentMenu === 'fields' && (

View File

@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { InformationBannerDeletedRecord } from '@/information-banner/components/deleted-record/InformationBannerDeletedRecord';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -126,7 +127,11 @@ export const RecordShowContainer = ({
);
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
availableFieldMetadataItems,
availableFieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt',
),
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation
? 'relationFieldMetadataItems'
@ -301,25 +306,33 @@ export const RecordShowContainer = ({
);
return (
<ShowPageContainer>
<ShowPageLeftContainer forceMobile={isMobile}>
{!isMobile && summaryCard}
{!isMobile && fieldsBox}
</ShowPageLeftContainer>
<ShowPageRightContainer
targetableObject={{
id: objectRecordId,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
isInRightDrawer={isInRightDrawer}
summaryCard={isMobile ? summaryCard : <></>}
fieldsBox={fieldsBox}
loading={isPrefetchLoading || loading || recordLoading}
/>
</ShowPageContainer>
<>
{recordFromStore && recordFromStore.deletedAt && (
<InformationBannerDeletedRecord
recordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
)}
<ShowPageContainer>
<ShowPageLeftContainer forceMobile={isMobile}>
{!isMobile && summaryCard}
{!isMobile && fieldsBox}
</ShowPageLeftContainer>
<ShowPageRightContainer
targetableObject={{
id: objectRecordId,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
isInRightDrawer={isInRightDrawer}
summaryCard={isMobile ? summaryCard : <></>}
fieldsBox={fieldsBox}
loading={isPrefetchLoading || loading || recordLoading}
/>
</ShowPageContainer>
</>
);
};

View File

@ -29,7 +29,7 @@ export const buildFindOneRecordForShowPageOperationSignature: RecordGqlOperation
},
}
: {}),
...(objectMetadataItem.nameSingular === 'Note'
...(objectMetadataItem.nameSingular === CoreObjectNameSingular.Note
? {
noteTargets: {
id: true,

View File

@ -50,6 +50,7 @@ export const useRecordShowPage = (
objectRecordId,
objectNameSingular,
recordGqlFields: FIND_ONE_RECORD_FOR_SHOW_PAGE_OPERATION_SIGNATURE.fields,
withSoftDeleted: true,
});
useEffect(() => {

View File

@ -0,0 +1,5 @@
import { capitalize } from '~/utils/string/capitalize';
export const getDestroyManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `destroy${capitalize(objectNamePlural)}`;

View File

@ -0,0 +1,5 @@
import { capitalize } from '~/utils/string/capitalize';
export const getRestoreManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `restore${capitalize(objectNamePlural)}`;