delete attachment when file is removed from activity body (#11952)

Using useEffect triggered at ActivityRichTextEditor unmount, to delete
attachments only when note is closed (and not when file block is deleted
during note update to keep command + z shortcut)

closes : https://github.com/twentyhq/twenty/issues/11229
This commit is contained in:
Etienne
2025-05-13 19:18:38 +02:00
committed by GitHub
parent 3fe9c79967
commit c0a0214879
13 changed files with 347 additions and 13 deletions

View File

@ -20,9 +20,16 @@ import { useDebouncedCallback } from 'use-debounce';
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect';
import { Attachment } from '@/activities/files/types/Attachment';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeyScope';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { PartialBlock } from '@blocknote/core';
@ -40,6 +47,10 @@ type ActivityRichTextEditorProps = {
| CoreObjectNameSingular.Note;
};
type Activity = (Task | Note) & {
attachments: Attachment[];
};
export const ActivityRichTextEditor = ({
activityId,
activityObjectNameSingular,
@ -61,6 +72,24 @@ export const ActivityRichTextEditor = ({
isRecordReadOnly,
});
const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { fetchAllRecords: findSoftDeletedAttachments } =
useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
filter: {
deletedAt: {
is: 'NOT_NULL',
},
},
});
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
@ -137,8 +166,12 @@ export const ActivityRichTextEditor = ({
);
const handleBodyChange = useRecoilCallback(
({ set }) =>
(newStringifiedBody: string) => {
({ set, snapshot }) =>
async (newStringifiedBody: string) => {
const oldActivity = snapshot
.getLoadable(recordStoreFamilyState(activityId))
.getValue() as Activity;
set(recordStoreFamilyState(activityId), (oldActivity) => {
return {
...oldActivity,
@ -166,8 +199,46 @@ export const ActivityRichTextEditor = ({
});
handlePersistBody(newStringifiedBody);
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
newStringifiedBody,
oldActivity.attachments,
);
if (attachmentIdsToDelete.length > 0) {
await deleteAttachments({
recordIdsToDelete: attachmentIdsToDelete,
});
}
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newStringifiedBody,
oldActivity.attachments,
);
if (attachmentPathsToRestore.length > 0) {
const softDeletedAttachments =
(await findSoftDeletedAttachments()) as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore(
attachmentPathsToRestore,
softDeletedAttachments,
);
await restoreAttachments({
idsToRestore: attachmentIdsToRestore,
});
}
},
[activityId, cache, objectMetadataItemActivity, handlePersistBody],
[
activityId,
cache,
objectMetadataItemActivity,
handlePersistBody,
deleteAttachments,
restoreAttachments,
findSoftDeletedAttachments,
],
);
const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500);

View File

@ -2,8 +2,8 @@ import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useReplaceActivityBlockEditorContent = (
editor: typeof BLOCK_SCHEMA.BlockNoteEditor,

View File

@ -0,0 +1,44 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsToRestore } from '../filterAttachmentsToRestore';
describe('filterAttachmentsToRestore', () => {
it('should not return any ids if there are no attachment paths to restore', () => {
const softDeletedAttachments = [
{
id: '1',
fullPath: 'test.txt',
},
] as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore(
[],
softDeletedAttachments,
);
expect(attachmentIdsToRestore).toEqual([]);
});
it('should not return any ids if there are no soft deleted attachments', () => {
const attachmentIdsToRestore = filterAttachmentsToRestore(
['/files/attachment/test.txt'],
[],
);
expect(attachmentIdsToRestore).toEqual([]);
});
it('should return the ids of the soft deleted attachments that are present in the attachment paths to restore', () => {
const softDeletedAttachments = [
{
id: '1',
fullPath: '/files/attachment/test.txt',
},
{
id: '2',
fullPath: '/files/attachment/test2.txt',
},
] as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore(
['attachment/test.txt'],
softDeletedAttachments,
);
expect(attachmentIdsToRestore).toEqual(['1']);
});
});

View File

@ -0,0 +1,42 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
describe('getActivityAttachmentIdsToDelete', () => {
it('should not return any ids if attachment are present in the body', () => {
const attachments = [
{
id: '1',
fullPath: '/files/attachment/test.txt',
},
{
id: '2',
fullPath: '/files/attachment/test2.txt',
},
] as Attachment[];
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
'/files/attachment/test2.txt /files/attachment/test.txt',
attachments,
);
expect(attachmentIdsToDelete).toEqual([]);
});
it('should return the ids of the attachments that are not present in the body', () => {
const attachments = [
{
id: '1',
fullPath: '/files/attachment/test.txt',
},
{
id: '2',
fullPath: '/files/attachment/test2.txt',
},
] as Attachment[];
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
'/files/attachment/test2.txt',
attachments,
);
expect(attachmentIdsToDelete).toEqual(['1']);
});
});

View File

@ -0,0 +1,40 @@
import { getActivityAttachmentPaths } from '@/activities/utils/getActivityAttachmentPaths';
describe('getActivityAttachmentPaths', () => {
it('should return the file paths from the activity blocknote', () => {
const activityBlocknote = JSON.stringify([
{ type: 'paragraph', props: { text: 'test' } },
{
type: 'image',
props: {
url: 'https://example.com/files/attachment/image.jpg?queryParam=value',
},
},
{
type: 'file',
props: {
url: 'https://example.com/files/attachment/file.pdf?queryParam=value',
},
},
{
type: 'video',
props: {
url: 'https://example.com/files/attachment/video.mp4?queryParam=value',
},
},
{
type: 'audio',
props: {
url: 'https://example.com/files/attachment/audio.mp3?queryParam=value',
},
},
]);
const res = getActivityAttachmentPaths(activityBlocknote);
expect(res).toEqual([
'attachment/image.jpg',
'attachment/file.pdf',
'attachment/video.mp4',
'attachment/audio.mp3',
]);
});
});

View File

@ -0,0 +1,51 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
describe('getActivityAttachmentPathsToRestore', () => {
it('should not return any attachment paths to restore if there are no paths in body', () => {
const newActivityBody = JSON.stringify([
{
type: 'paragraph',
},
]);
const oldActivityAttachments = [
{
id: '1',
fullPath: '/files/attachment/test.txt',
},
] as Attachment[];
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newActivityBody,
oldActivityAttachments,
);
expect(attachmentPathsToRestore).toEqual([]);
});
it('should return the attachment paths to restore if paths in body are not present in attachments', () => {
const newActivityBody = JSON.stringify([
{
type: 'file',
props: { url: '/files/attachment/test.txt' },
},
{
type: 'file',
props: { url: '/files/attachment/test2.txt' },
},
]);
const oldActivityAttachments = [
{
id: '1',
fullPath: '/files/attachment/test.txt',
},
] as Attachment[];
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newActivityBody,
oldActivityAttachments,
);
expect(attachmentPathsToRestore).toEqual(['attachment/test2.txt']);
});
});

View File

@ -0,0 +1,10 @@
import { getAttachmentPath } from '@/activities/utils/getAttachmentPath';
describe('getAttachmentPath', () => {
it('should return the attachment path', () => {
const res = getAttachmentPath(
'https://example.com/files/attachment/image.jpg?queryParam=value',
);
expect(res).toEqual('attachment/image.jpg');
});
});

View File

@ -0,0 +1,15 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { getAttachmentPath } from '@/activities/utils/getAttachmentPath';
export const filterAttachmentsToRestore = (
attachmentPathsToRestore: string[],
softDeletedAttachments: Attachment[],
) => {
return softDeletedAttachments
.filter((attachment) =>
attachmentPathsToRestore.some(
(path) => getAttachmentPath(attachment.fullPath) === path,
),
)
.map((attachment) => attachment.id);
};

View File

@ -0,0 +1,15 @@
import { Attachment } from '@/activities/files/types/Attachment';
export const getActivityAttachmentIdsToDelete = (
newActivityBody: string,
oldActivityAttachments: Attachment[],
) => {
if (oldActivityAttachments.length === 0) return [];
return oldActivityAttachments
.filter(
(attachment) =>
!newActivityBody.includes(attachment.fullPath.split('?')[0]),
)
.map((attachment) => attachment.id);
};

View File

@ -0,0 +1,18 @@
import { getAttachmentPath } from '@/activities/utils/getAttachmentPath';
import { isNonEmptyString } from '@sniptt/guards';
export const getActivityAttachmentPaths = (
stringifiedActivityBlocknote: string,
): string[] => {
const activityBlocknote = JSON.parse(stringifiedActivityBlocknote ?? '{}');
return activityBlocknote.reduce((acc: string[], block: any) => {
if (
['image', 'file', 'video', 'audio'].includes(block.type) &&
isNonEmptyString(block.props.url)
) {
acc.push(getAttachmentPath(block.props.url));
}
return acc;
}, []);
};

View File

@ -0,0 +1,17 @@
import { Attachment } from '@/activities/files/types/Attachment';
import { getActivityAttachmentPaths } from '@/activities/utils/getActivityAttachmentPaths';
import { getAttachmentPath } from '@/activities/utils/getAttachmentPath';
export const getActivityAttachmentPathsToRestore = (
newActivityBody: string,
oldActivityAttachments: Attachment[],
) => {
const newActivityAttachmentPaths =
getActivityAttachmentPaths(newActivityBody);
return newActivityAttachmentPaths.filter((fullPath) =>
oldActivityAttachments.every(
(attachment) => getAttachmentPath(attachment.fullPath) !== fullPath,
),
);
};

View File

@ -0,0 +1,3 @@
export const getAttachmentPath = (attachmentFullPath: string) => {
return attachmentFullPath.split('/files/')[1].split('?')[0];
};

View File

@ -1,19 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { LinkMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import {
RichTextV2Metadata,
richTextV2ValueSchema,
} from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type';
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import { LinkMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
@Injectable()
export class RecordInputTransformerService {
@ -96,11 +96,19 @@ export class RecordInputTransformerService {
const serverBlockNoteEditor = ServerBlockNoteEditor.create();
const convertedMarkdown = parsedValue.blocknote
? await serverBlockNoteEditor.blocksToMarkdownLossy(
JSON.parse(parsedValue.blocknote),
)
: null;
// Patch: Handle cases where blocknote to markdown conversion fails for certain block types (custom/code blocks)
// Todo : This may be resolved once the server-utils library is updated with proper conversion support - #947
let convertedMarkdown: string | null = null;
try {
convertedMarkdown = parsedValue.blocknote
? await serverBlockNoteEditor.blocksToMarkdownLossy(
JSON.parse(parsedValue.blocknote),
)
: null;
} catch {
convertedMarkdown = parsedValue.blocknote;
}
const convertedBlocknote = parsedValue.markdown
? JSON.stringify(