Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/react/components/OpBlockNoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,6 +101,7 @@ export function OpBlockNoteEditor({
extensions: [
ExternalLinkA11yExtension,
HeadingLinkExtension,
...(attachmentsEnabled ? [FailedUploadCleanupExtension] : []),
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
...(hocuspocusProvider ? [CollaborationCursorLabelFitExtension] : []),
],
Expand Down
136 changes: 136 additions & 0 deletions frontend/src/react/extensions/failed-upload-cleanup.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
//
// 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<string>, 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();
});
});
44 changes: 44 additions & 0 deletions frontend/src/react/extensions/failed-upload-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
//
// 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]);
});
}),
}))();
10 changes: 10 additions & 0 deletions modules/documents/spec/features/attachment_upload_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions spec/support/form_fields/primerized/block_note_editor_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading