[#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).
This commit is contained in:
Judith Roth
2025-11-03 16:11:11 +01:00
parent a80840b8f5
commit d311809ad2
10 changed files with 115 additions and 21 deletions
+15 -5
View File
@@ -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;
@@ -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,
});
}
}
@@ -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
@@ -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
@@ -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
+2 -1
View File
@@ -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(
@@ -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
%>
@@ -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
@@ -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
@@ -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