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