From d311809ad2287f67fbd4aac0e4583ed5aa81dfc2 Mon Sep 17 00:00:00 2001 From: Judith Roth Date: Mon, 3 Nov 2025 16:11:11 +0100 Subject: [PATCH] [#68710] Add Files section to existing document edit view https://community.openproject.org/work_packages/68710 In addition to adding the files section to the document edit view, this commit makes it optional to be able to attach files to blocknote. This is, for example, not necessary when blocknote is rendered in read-only mode (that's not happening yet, but planned). --- frontend/src/react/OpBlockNoteContainer.tsx | 20 ++++++--- .../dynamic/block-note.controller.ts | 3 ++ .../forms/block_note_editor.html.erb | 1 + .../open_project/forms/block_note_editor.rb | 7 +++- .../forms/dsl/block_note_editor_input.rb | 18 ++++++-- modules/documents/app/forms/document_form.rb | 3 +- .../app/views/documents/edit.html.erb | 42 +++++++++++++++---- .../spec/features/attachment_upload_spec.rb | 31 ++++++++++++++ .../forms/dsl/input_methods_spec.rb | 2 +- spec/support/components/attachments_list.rb | 9 ++++ 10 files changed, 115 insertions(+), 21 deletions(-) diff --git a/frontend/src/react/OpBlockNoteContainer.tsx b/frontend/src/react/OpBlockNoteContainer.tsx index 678de162fae..79a3512c45f 100644 --- a/frontend/src/react/OpBlockNoteContainer.tsx +++ b/frontend/src/react/OpBlockNoteContainer.tsx @@ -50,6 +50,7 @@ export interface OpBlockNoteContainerProps { documentId:string; openProjectUrl:string; attachmentsUploadUrl:string; + attachmentsCollectionKey:string; } const schema = BlockNoteSchema.create({ @@ -69,7 +70,8 @@ export default function OpBlockNoteContainer({ inputField, documentName, documentId, openProjectUrl, - attachmentsUploadUrl }:OpBlockNoteContainerProps) { + attachmentsUploadUrl, + attachmentsCollectionKey }:OpBlockNoteContainerProps) { const [isLoading, setIsLoading] = useState(true); initOpenProjectApi({ baseUrl: openProjectUrl }); @@ -109,7 +111,7 @@ export default function OpBlockNoteContainer({ inputField, showCursorLabels: 'activity' }, dictionary: blockNoteLocale, - uploadFile, + ...(isReadyForAttachmentUpload() && { uploadFile }), }; } else { // collaboration disabled if (inputText) { @@ -133,13 +135,21 @@ export default function OpBlockNoteContainer({ inputField, }, }, dictionary: blockNoteLocale, - uploadFile, + ...(isReadyForAttachmentUpload() && { uploadFile }), }; } const editor = useCreateBlockNote(editorParams, [activeUser]); type EditorType = typeof editor; + function isReadyForAttachmentUpload():boolean { + return ( + attachmentsCollectionKey !== undefined && + attachmentsCollectionKey !== '' && + attachmentsUploadUrl !== undefined && + attachmentsUploadUrl !== '' + ); +} const fileToIUploadFile = (file:File):IUploadFile => ({ file: file }); @@ -149,9 +159,9 @@ export default function OpBlockNoteContainer({ inputField, try { const service = pluginContext.services.attachmentsResourceService; const iUploadFile = fileToIUploadFile(file); - const result = await service.addAttachments('documents', attachmentsUploadUrl, [iUploadFile]).toPromise(); + const result = await service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, [iUploadFile]).toPromise(); - return result?.[0]._links.downloadLocation.href ?? ''; + return result?.[0]._links.staticDownloadLocation.href ?? ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch(error:any) { const toastService = pluginContext.services.notifications; diff --git a/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts b/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts index eec826fdd0e..81dd60f35a3 100644 --- a/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts @@ -49,6 +49,7 @@ export default class extends Controller { documentId: String, openProjectUrl: String, attachmentsUploadUrl: String, + attachmentsCollectionKey: String, }; declare readonly blockNoteEditorTarget:HTMLElement; @@ -61,6 +62,7 @@ export default class extends Controller { declare readonly documentIdValue:string; declare readonly openProjectUrlValue:string; declare readonly attachmentsUploadUrlValue:string; + declare readonly attachmentsCollectionKeyValue:string; connect() { const root = createRoot(this.blockNoteEditorTarget); @@ -78,6 +80,7 @@ export default class extends Controller { documentId: this.documentIdValue, openProjectUrl: this.openProjectUrlValue, attachmentsUploadUrl: this.attachmentsUploadUrlValue, + attachmentsCollectionKey: this.attachmentsCollectionKeyValue, }); } } diff --git a/lib/primer/open_project/forms/block_note_editor.html.erb b/lib/primer/open_project/forms/block_note_editor.html.erb index acb5b26bd1c..641a3360560 100644 --- a/lib/primer/open_project/forms/block_note_editor.html.erb +++ b/lib/primer/open_project/forms/block_note_editor.html.erb @@ -41,6 +41,7 @@ See COPYRIGHT and LICENSE files for more details. block_note_open_project_url_value: open_project_url, block_note_oauth_token_value: oauth_token, block_note_attachments_upload_url_value: attachments_upload_url, + block_note_attachments_collection_key_value: attachments_collection_key, test_selector: "blocknote-document-description" } ) do diff --git a/lib/primer/open_project/forms/block_note_editor.rb b/lib/primer/open_project/forms/block_note_editor.rb index 49f1c915cfb..a5890bd9960 100644 --- a/lib/primer/open_project/forms/block_note_editor.rb +++ b/lib/primer/open_project/forms/block_note_editor.rb @@ -43,11 +43,13 @@ module Primer :document_name, :document_id, :oauth_token, - :attachments_upload_url + :attachments_upload_url, + :attachments_collection_key delegate :name, to: :@input - def initialize(input:, value:, document_name:, document_id:, attachments_upload_url:, oauth_token: nil) + def initialize(input:, value:, document_name:, document_id:, attachments_upload_url: "", + attachments_collection_key: "", oauth_token: nil) super() @input = input @value = value @@ -61,6 +63,7 @@ module Primer @hocuspocus_url = Setting.collaborative_editing_hocuspocus_url @open_project_url = root_url @attachments_upload_url = attachments_upload_url + @attachments_collection_key = attachments_collection_key end end end diff --git a/lib/primer/open_project/forms/dsl/block_note_editor_input.rb b/lib/primer/open_project/forms/dsl/block_note_editor_input.rb index 0f5bb5bea14..f6286d92548 100644 --- a/lib/primer/open_project/forms/dsl/block_note_editor_input.rb +++ b/lib/primer/open_project/forms/dsl/block_note_editor_input.rb @@ -33,7 +33,15 @@ module Primer module Forms module Dsl class BlockNoteEditorInput < Primer::Forms::Dsl::Input - attr_reader :name, :label, :value, :classes, :document_id, :document_name, :oauth_token, :attachments_upload_url + attr_reader :name, + :label, + :value, + :classes, + :document_id, + :document_name, + :oauth_token, + :attachments_upload_url, + :attachments_collection_key ## # @param name [String] The name of the input field. @@ -42,8 +50,8 @@ module Primer # @param document_id [String] The ID of the document. # @param document_name [String] The name of the document for the collaborative YJS provider. # @param oauth_token [String, nil] The OAuth token for external server authentication. - def initialize(name:, label:, value:, document_id:, document_name:, attachments_upload_url:, oauth_token: nil, - **system_arguments) + def initialize(name:, label:, value:, document_id:, document_name:, attachments_upload_url: "", + attachments_collection_key: "", oauth_token: nil, **system_arguments) @name = name @label = label @value = value @@ -52,12 +60,14 @@ module Primer @document_name = document_name @oauth_token = oauth_token @attachments_upload_url = attachments_upload_url + @attachments_collection_key = attachments_collection_key super(**system_arguments) end def to_component - BlockNoteEditor.new(input: self, value:, document_id:, document_name:, oauth_token:, attachments_upload_url:) + BlockNoteEditor.new(input: self, value:, document_id:, document_name:, oauth_token:, attachments_upload_url:, + attachments_collection_key:) end def type diff --git a/modules/documents/app/forms/document_form.rb b/modules/documents/app/forms/document_form.rb index fcb9fdf8fd4..a6a02ba441d 100644 --- a/modules/documents/app/forms/document_form.rb +++ b/modules/documents/app/forms/document_form.rb @@ -63,7 +63,8 @@ class DocumentForm < ApplicationForm document_id: model.id, document_name: model.title, oauth_token: @oauth_token, - attachments_upload_url: uploads_url + attachments_upload_url: uploads_url, + attachments_collection_key: ::API::V3::Utilities::PathHelper::ApiV3Path.attachments_by_document(model.id) ) else f.rich_text_area( diff --git a/modules/documents/app/views/documents/edit.html.erb b/modules/documents/app/views/documents/edit.html.erb index fcb112b7c38..1a91cd3597a 100644 --- a/modules/documents/app/views/documents/edit.html.erb +++ b/modules/documents/app/views/documents/edit.html.erb @@ -28,7 +28,6 @@ ++# %> - <%= render Primer::OpenProject::PageHeader.new do |header| header.with_title { @document.title } @@ -41,12 +40,39 @@ %> <%= - settings_primer_form_with( - model: @document, - url: document_path(@document), - method: :patch, - data: { turbo: false } - ) do |f| - render DocumentForm.new(f, oauth_token: @oauth_token) + render(Primer::Alpha::Layout.new(stacking_breakpoint: :md)) do |component| + component.with_main do + settings_primer_form_with( + model: @document, + url: document_path(@document), + method: :patch, + data: { turbo: false } + ) do |f| + render DocumentForm.new(f, oauth_token: @oauth_token) + end + end + + component.with_sidebar(row_placement: :start, col_placement: :end) do + render(Primer::OpenProject::SidePanel.new) do |panel| + panel.with_section do |section| + section.with_title { t(:label_attachment_plural) } + section.with_footer_button( + color: :accent, + id: "documents-add-attachments", + classes: "hide-when-print" + ) do |button| + button.with_leading_visual_icon(icon: "op-add-attachment") + t("js.label_add_attachments") + end + list_attachments( + api_v3_document_resource(@document), + inputs: { + allowUploading: true, + externalUploadButton: "#documents-add-attachments" + } + ) + end + end + end end %> diff --git a/modules/documents/spec/features/attachment_upload_spec.rb b/modules/documents/spec/features/attachment_upload_spec.rb index 457a2747f16..14da8694ab9 100644 --- a/modules/documents/spec/features/attachment_upload_spec.rb +++ b/modules/documents/spec/features/attachment_upload_spec.rb @@ -186,10 +186,39 @@ RSpec.describe "Upload attachment to documents", end end + shared_examples "with attachments list in the sidebar" do + it "is possible to upload attachments from the sidebar" do + expect(page).to have_no_content("image.png") + expect do + attachments_list.drag_enter + attachments_list.drop(image_fixture.path) + expect(page).to have_no_css("op-toast") # wait for upload to finish + attachments_list.expect_attached("image.png") + end.to change { document.attachments.count }.by(1) + end + + context "when an attachment is present" do + let!(:attachment) { create(:attachment, filename: "test.jpg", container: document) } + + before do + visit edit_document_path(document) + end + + it "is possible to delete attachments from the sidebar" do + attachments_list.expect_attached("test.jpg") + expect do + attachments_list.delete("test.jpg") + attachments_list.expect_empty + end.to change { document.attachments.count }.by(-1) + end + end + end + context "for collaborative documents", with_flag: { block_note_editor: true } do let(:experimental_category) { create(:document_category, name: "Experimental", project:) } let(:document) { create(:document, category: experimental_category, project:) } let(:editor) { FormFields::Primerized::BlockNoteEditorInput.new } + let(:attachments_list) { Components::AttachmentsList.new } before do visit edit_document_path(document) @@ -200,11 +229,13 @@ RSpec.describe "Upload attachment to documents", context "with internal uploads" do it_behaves_like "can upload an image in BlockNote" it_behaves_like "with non-whitelisted file types" + it_behaves_like "with attachments list in the sidebar" end context "with uploads to an external storage", :with_direct_uploads do it_behaves_like "can upload an image in BlockNote" it_behaves_like "with non-whitelisted file types" + it_behaves_like "with attachments list in the sidebar" end end end diff --git a/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb b/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb index 982c0d0fc50..1b63d4e8a9f 100644 --- a/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb +++ b/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb @@ -236,7 +236,7 @@ RSpec.describe Primer::OpenProject::Forms::Dsl::InputMethods, type: :forms do let(:document_name) { "1234asdzxc" } let(:field_group) do form_dsl.block_note_editor(name:, label:, value: "", document_id: 8, document_name:, attachments_upload_url: "", - **options) + attachments_collection_key: "", **options) end include_examples "input class", Primer::OpenProject::Forms::Dsl::BlockNoteEditorInput diff --git a/spec/support/components/attachments_list.rb b/spec/support/components/attachments_list.rb index 6d0c971d1fc..67ebfa725a7 100644 --- a/spec/support/components/attachments_list.rb +++ b/spec/support/components/attachments_list.rb @@ -28,6 +28,15 @@ module Components drop_box_element.drop(path) end + def delete(file_name) + item = find("#{context_selector} [data-test-selector='op-attachment-list-item']", text: file_name) + item.hover + item.find(:button, title: I18n.t("js.label_remove_file", fileName: file_name)).click + # confirm delete confirmation dialog + expect(page).to have_content(I18n.t("js.attachments.delete_confirmation")) + find(:button, text: I18n.t("js.attachments.delete")).click + end + def expect_empty expect(page).to have_no_css("#{context_selector} [data-test-selector='op-attachment-list-item']") end