diff --git a/frontend/src/react/components/OpBlockNoteEditor.tsx b/frontend/src/react/components/OpBlockNoteEditor.tsx index 55e1f385173b..12efd08a77b4 100644 --- a/frontend/src/react/components/OpBlockNoteEditor.tsx +++ b/frontend/src/react/components/OpBlockNoteEditor.tsx @@ -29,6 +29,7 @@ import { BlockNoteEditorOptions, BlockNoteSchema, User } from '@blocknote/core'; import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y'; import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture'; import { CollaborationCursorLabelFitExtension } from '../extensions/collaboration-cursor-label-fit'; +import { FailedUploadCleanupExtension } from '../extensions/failed-upload-cleanup'; import { HeadingLinkExtension } from '../extensions/heading-link'; import { filterSuggestionItems } from '@blocknote/core/extensions'; import { withCollaboration } from '@blocknote/core/yjs'; @@ -100,6 +101,7 @@ export function OpBlockNoteEditor({ extensions: [ ExternalLinkA11yExtension, HeadingLinkExtension, + ...(attachmentsEnabled ? [FailedUploadCleanupExtension] : []), ...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []), ...(hocuspocusProvider ? [CollaborationCursorLabelFitExtension] : []), ], diff --git a/frontend/src/react/extensions/failed-upload-cleanup.spec.ts b/frontend/src/react/extensions/failed-upload-cleanup.spec.ts new file mode 100644 index 000000000000..dee1bd617eaf --- /dev/null +++ b/frontend/src/react/extensions/failed-upload-cleanup.spec.ts @@ -0,0 +1,136 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { BlockNoteEditor, PartialBlock } from '@blocknote/core'; +import { FailedUploadCleanupExtension } from './failed-upload-cleanup'; + +const UPLOADED_URL = 'https://openproject.example/attachments/1/content'; + +const cleanups:(() => void)[] = []; + +function mountEditor(uploadFile:(file:File) => Promise, initialContent?:PartialBlock[]) { + const container = document.createElement('div'); + document.body.appendChild(container); + const editor = BlockNoteEditor.create({ initialContent, uploadFile, extensions: [FailedUploadCleanupExtension] }); + editor.mount(container); + cleanups.push(() => { + editor.unmount(); + container.remove(); + }); + + return { editor, container }; +} + +function filesTransfer():DataTransfer { + const transfer = new DataTransfer(); + transfer.items.add(new File(['content'], 'notes.odt', { type: 'application/vnd.oasis.opendocument.text' })); + return transfer; +} + +function dropFile(container:HTMLElement):void { + const target = container.querySelector('.bn-block-content')!; + const { left, top } = target.getBoundingClientRect(); + target.dispatchEvent(new DragEvent('drop', { + dataTransfer: filesTransfer(), clientX: left + 5, clientY: top + 2, bubbles: true, cancelable: true, + })); +} + +function pasteFile(editor:BlockNoteEditor):void { + editor.prosemirrorView.dom.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: filesTransfer(), bubbles: true, cancelable: true, + })); +} + +const settled = () => new Promise((resolve) => { setTimeout(resolve, 50); }); +const blockTypes = (editor:BlockNoteEditor) => editor.document.map((block) => block.type); + +describe('FailedUploadCleanupExtension', () => { + afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + }); + + it('removes the placeholder of a dropped file whose upload failed', async () => { + const { editor, container } = mountEditor(() => Promise.resolve(''), [{ type: 'paragraph', content: 'Text' }]); + + dropFile(container); + await settled(); + + expect(blockTypes(editor)).toEqual(['paragraph']); + expect(container.querySelector('.bn-file-loading-preview')).toBeNull(); + }); + + it('keeps the block of a dropped file that was uploaded', async () => { + const { editor, container } = mountEditor(() => Promise.resolve(UPLOADED_URL), [{ type: 'paragraph', content: 'Text' }]); + + dropFile(container); + await settled(); + + expect(editor.document.find((block) => block.type === 'file')?.props).toMatchObject({ url: UPLOADED_URL }); + }); + + it('removes the placeholder of a pasted file whose upload failed', async () => { + const { editor, container } = mountEditor(() => Promise.resolve(''), [{ type: 'paragraph' }]); + editor.focus(); + + pasteFile(editor); + await settled(); + + expect(blockTypes(editor)).not.toContain('file'); + expect(container.querySelector('.bn-file-loading-preview')).toBeNull(); + }); + + it('keeps the block of a file panel upload that failed, which offers to add a file again', async () => { + const { editor, container } = mountEditor(() => Promise.resolve(''), [{ id: 'image', type: 'image' }, { type: 'paragraph' }]); + const file = new File(['content'], 'photo.png', { type: 'image/png' }); + + const url = await editor.uploadFile!(file, 'image'); + editor.updateBlock('image', { props: { name: file.name, url: url as string } }); + await settled(); + + expect(blockTypes(editor)).toEqual(['image', 'paragraph']); + expect(container.querySelector('.bn-add-file-button')).not.toBeNull(); + }); + + it('leaves an empty file block nobody uploaded to', async () => { + const { editor } = mountEditor(() => Promise.resolve(''), [{ type: 'file' }, { type: 'paragraph' }]); + + await settled(); + + expect(blockTypes(editor)).toEqual(['file', 'paragraph']); + }); + + it('does nothing once the editor is gone', async () => { + let finishUpload:((url:string) => void)|undefined; + const { container } = mountEditor(() => new Promise((resolve) => { finishUpload = resolve; }), [{ type: 'paragraph' }]); + dropFile(container); + + cleanups.splice(0).forEach((cleanup) => cleanup()); + finishUpload!(''); + + await expect(settled()).resolves.toBeUndefined(); + }); +}); diff --git a/frontend/src/react/extensions/failed-upload-cleanup.ts b/frontend/src/react/extensions/failed-upload-cleanup.ts new file mode 100644 index 000000000000..42aa970200ae --- /dev/null +++ b/frontend/src/react/extensions/failed-upload-cleanup.ts @@ -0,0 +1,44 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { createExtension } from '@blocknote/core'; + +// A file block turns into "Loading..." when its upload starts and only renders again once +// its props change. After a failed drop or paste nothing changes, so the placeholder would +// stay stuck (https://github.com/TypeCellOS/BlockNote/issues/3246). BlockNote updates the +// block after the upload ends, hence the timeout. +export const FailedUploadCleanupExtension = createExtension(({ editor }) => ({ + key: 'failedUploadCleanup', + mount: ({ signal }) => editor.onUploadEnd((blockId) => { + if (!blockId) return; + + setTimeout(() => { + const stuck = !signal.aborted && editor.domElement?.querySelector(`[data-id="${blockId}"] .bn-file-loading-preview`); + if (stuck) editor.removeBlocks([blockId]); + }); + }), +}))(); diff --git a/modules/documents/spec/features/attachment_upload_spec.rb b/modules/documents/spec/features/attachment_upload_spec.rb index bd8f294883d0..07b52ecdc906 100644 --- a/modules/documents/spec/features/attachment_upload_spec.rb +++ b/modules/documents/spec/features/attachment_upload_spec.rb @@ -169,6 +169,16 @@ expect(editor.element).to have_no_css("img[alt='image.png']") end.not_to change { document.attachments.count } end + + it "leaves no placeholder behind for a dropped file" do + expect do + editor.drop_file(image_fixture.path) + expect(page).to have_text I18n.t("activerecord.errors.models.attachment.attributes.content_type.not_allowlisted", + value: "image/png") + expect(editor.element).to have_no_css("[data-content-type='image']") + expect(editor.element).to have_no_text("Loading...") + end.not_to change { document.attachments.count } + end end end diff --git a/spec/support/form_fields/primerized/block_note_editor_input.rb b/spec/support/form_fields/primerized/block_note_editor_input.rb index 15572d2e79bf..a5ab47e182bc 100644 --- a/spec/support/form_fields/primerized/block_note_editor_input.rb +++ b/spec/support/form_fields/primerized/block_note_editor_input.rb @@ -163,6 +163,31 @@ def paste_links(*links) JS end + # Capybara's own `drop` sends no coordinates, and BlockNote places a dropped file + # by them, so it would ignore the drop. + def drop_file(path) + input_id = "block-note-drop-file" + page.execute_script(<<~JS, input_id) + const input = document.createElement('input'); + input.type = 'file'; + input.id = arguments[0]; + document.body.appendChild(input); + JS + page.find_by_id(input_id, visible: :all).set(path) + + page.execute_script(<<~JS, element.native, input_id) + const input = document.getElementById(arguments[1]); + const block = arguments[0].querySelector('.bn-block-content'); + const { left, top, height } = block.getBoundingClientRect(); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(input.files[0]); + input.remove(); + block.dispatchEvent(new DragEvent('drop', { + dataTransfer, clientX: left + 1, clientY: top + height / 2, bubbles: true, cancelable: true + })); + JS + end + def element shadow_root.find("div[role='textbox']") end