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:
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
};
|
||||
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user