From 8272768057b80de85b81ebc53ff1703850b3cf05 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Sat, 28 Mar 2026 10:39:36 +0300 Subject: [PATCH] Remove test-mode fallback, require HocuspocusProvider, add context-aware error messages Extract non-IndexedDB refinements from PR #22125 so they can ship independently while IndexedDB offline persistence is evaluated separately. - Gate collaboration on Setting.real_time_text_collaboration_enabled? instead of hardcoding it to true - Remove the test-mode fallback that created a standalone Y.Doc without a provider; HocuspocusProvider is now required for document editing - Refactor useCollaboration hooks: callback-based timeout with proactive cancel on sync, extracted useProviderAuthError hook, JSDoc comments - Add read/write context-aware connection error messages (readonly users see "real-time updates will resume" vs writers see "changes will sync") - Add blocked offline mode: when the server is unreachable and there is no local cache, hide the editor entirely to prevent an empty Y.Doc from being synced as authoritative content on reconnect - Update feature specs to use real hocuspocus shared context instead of stubbing collaboration_enabled, add offline blocking tests --- frontend/src/elements/block-note-element.ts | 21 +-- frontend/src/react/OpBlockNoteContainer.tsx | 55 ++---- .../helpers/connection-template-fetcher.ts | 4 +- frontend/src/react/hooks/useCollaboration.ts | 165 ++++++++++-------- .../documents/init-yjs-provider.controller.ts | 44 ++--- .../open_project/forms/block_note_editor.rb | 2 +- ...oration_disabled_notice_component.html.erb | 4 +- ...connection_error_notice_component.html.erb | 11 +- .../connection_error_notice_component.rb | 16 ++ .../app/controllers/documents_controller.rb | 5 +- modules/documents/config/locales/en.yml | 7 +- .../spec/features/attachment_upload_spec.rb | 7 +- .../spec/features/block_note_editor_spec.rb | 22 ++- .../project/show_edit_document_spec.rb | 13 +- .../features/real_time_collaboration_spec.rb | 109 ++++++++---- 15 files changed, 277 insertions(+), 208 deletions(-) diff --git a/frontend/src/elements/block-note-element.ts b/frontend/src/elements/block-note-element.ts index 8ee18f9937a..85244875e54 100644 --- a/frontend/src/elements/block-note-element.ts +++ b/frontend/src/elements/block-note-element.ts @@ -45,7 +45,7 @@ class BlockNoteElement extends HTMLElement { private errorContainer:HTMLDivElement; private reactRoot:Root|null = null; private stimulusApp:Application|null = null; - private renderCallback:((provider?:HocuspocusProvider) => void) | null = null; + private renderCallback:((provider:HocuspocusProvider) => void) | null = null; constructor() { super(); @@ -95,6 +95,9 @@ class BlockNoteElement extends HTMLElement { } connectedCallback() { + const collaborationEnabled = this.getAttribute('collaboration-enabled') === 'true'; + if (!collaborationEnabled) return; + // Initialize Stimulus application within shadow DOM this.stimulusApp = Application.start(this.stimulusRoot); this.stimulusApp.register('flash', FlashController); @@ -102,19 +105,13 @@ class BlockNoteElement extends HTMLElement { // Initialize React application within shadow DOM this.reactRoot = createRoot(this.editorMount); - const collaborationEnabled = this.getAttribute('collaboration-enabled') === 'true'; - - this.renderCallback = (provider?:HocuspocusProvider) => { + this.renderCallback = (provider:HocuspocusProvider) => { this.reactRoot?.render( React.createElement(React.StrictMode, null, this.BlockNoteReactContainer(provider)) ); }; - if (collaborationEnabled) { - LiveCollaborationManager.onReady(this.renderCallback); - } else { - this.renderCallback(); - } + LiveCollaborationManager.onReady(this.renderCallback); } disconnectedCallback() { @@ -135,20 +132,19 @@ class BlockNoteElement extends HTMLElement { } } - private BlockNoteReactContainer = (hocuspocusProvider?:HocuspocusProvider) => { + private BlockNoteReactContainer = (hocuspocusProvider:HocuspocusProvider) => { return React.createElement( ShadowDomWrapper, { target: this.editorMount }, React.createElement( OpBlockNoteContainer, { - inputField: document.createElement('input'), activeUser: this.parseActiveUser()!, readOnly: this.getAttribute('read-only') === 'true', openProjectUrl: this.getAttribute('open-project-url') ?? '', attachmentsUploadUrl: this.getAttribute('attachments-upload-url') ?? '', attachmentsCollectionKey: this.getAttribute('attachments-collection-key') ?? '', - hocuspocusProvider: hocuspocusProvider, + hocuspocusProvider, errorContainer: this.errorContainer, } ) @@ -167,7 +163,6 @@ class BlockNoteElement extends HTMLElement { } return null; } - } if (!customElements.get('op-block-note')) { diff --git a/frontend/src/react/OpBlockNoteContainer.tsx b/frontend/src/react/OpBlockNoteContainer.tsx index 3da7c64d167..37946b9038c 100644 --- a/frontend/src/react/OpBlockNoteContainer.tsx +++ b/frontend/src/react/OpBlockNoteContainer.tsx @@ -38,65 +38,49 @@ import { fetchConnectionTemplate } from './helpers/connection-template-fetcher'; import { useCollaboration } from './hooks/useCollaboration'; export interface OpBlockNoteContainerProps { - inputField:HTMLInputElement; - inputText?:string; activeUser:User; readOnly:boolean; openProjectUrl:string; attachmentsUploadUrl:string; attachmentsCollectionKey:string; - hocuspocusProvider?:HocuspocusProvider; + hocuspocusProvider:HocuspocusProvider; errorContainer?:HTMLElement; } -export default function OpBlockNoteContainer({ inputField, - inputText, - activeUser, - readOnly, - openProjectUrl, - attachmentsUploadUrl, - attachmentsCollectionKey, - hocuspocusProvider, - errorContainer }:OpBlockNoteContainerProps) { - const doc:Y.Doc = hocuspocusProvider - ? hocuspocusProvider.document - : (() => { - // NOTE: This should only be used in TEST environments where there is no provider. - const newDoc = new Y.Doc(); - if (inputText) { - try { - const update = Uint8Array.from(atob(inputText), c => c.charCodeAt(0)); - Y.applyUpdate(newDoc, update); - } catch (e) { - console.error('Failed to load document binary', e); - return new Y.Doc(); - } - } - return newDoc; - })(); - - const { isLoading, connectionError } = useCollaboration(hocuspocusProvider, doc, inputField); +export default function OpBlockNoteContainer({ + activeUser, + readOnly, + openProjectUrl, + attachmentsUploadUrl, + attachmentsCollectionKey, + hocuspocusProvider, + errorContainer, +}:OpBlockNoteContainerProps) { + const doc:Y.Doc = hocuspocusProvider.document; + const { isLoading, offlineMode } = useCollaboration(hocuspocusProvider); const hadErrorRef = useRef(false); // Fetch error/recovery template based on connection state useEffect(() => { if (!errorContainer) return; - if (connectionError) { + if (offlineMode) { hadErrorRef.current = true; - void fetchConnectionTemplate('error', errorContainer); + void fetchConnectionTemplate('error', errorContainer, { blocking: true }); } else if (hadErrorRef.current) { // Only fetch recovery if we previously had an error (avoid fetching on initial render) void fetchConnectionTemplate('recovery', errorContainer); } - }, [connectionError, errorContainer]); + }, [offlineMode, errorContainer]); if (isLoading) { return ; } - if (connectionError) { - // Error UI is rendered in errorContainer via fetchConnectionTemplate (outside React tree) + // Without IndexedDB offline persistence, all offline is blocking — hide the + // editor entirely to prevent a fresh empty Y.Doc from being synced as + // authoritative server state on reconnect. + if (offlineMode) { return null; } @@ -112,4 +96,3 @@ export default function OpBlockNoteContainer({ inputField, /> ); } - diff --git a/frontend/src/react/helpers/connection-template-fetcher.ts b/frontend/src/react/helpers/connection-template-fetcher.ts index 090bc6df265..860b8e55d49 100644 --- a/frontend/src/react/helpers/connection-template-fetcher.ts +++ b/frontend/src/react/helpers/connection-template-fetcher.ts @@ -66,6 +66,7 @@ function parseTurboStreamContent(html:string):string|null { export async function fetchConnectionTemplate( type:'error'|'recovery', targetElement:HTMLElement, + options:{ blocking?:boolean } = {}, ):Promise { const documentId = getDocumentIdFromUrl(); if (!documentId) { @@ -73,7 +74,8 @@ export async function fetchConnectionTemplate( return; } - const url = `/documents/${documentId}/render_connection_${type}`; + const url = new URL(`/documents/${documentId}/render_connection_${type}`, window.location.origin); + if (options.blocking) url.searchParams.set('blocking', 'true'); try { const response = await fetch(url, { diff --git a/frontend/src/react/hooks/useCollaboration.ts b/frontend/src/react/hooks/useCollaboration.ts index 6a04bd9edfe..9fae5f292db 100644 --- a/frontend/src/react/hooks/useCollaboration.ts +++ b/frontend/src/react/hooks/useCollaboration.ts @@ -30,55 +30,70 @@ import { HocuspocusProvider } from '@hocuspocus/provider'; import { debugLog } from 'core-app/shared/helpers/debug_output'; -import { PROVIDER_AUTH_ERROR_EVENT, ProviderAuthErrorKind } from 'core-stimulus/services/documents/token-refresh.service'; +import { + PROVIDER_AUTH_ERROR_EVENT, + ProviderAuthErrorKind, +} from 'core-stimulus/services/documents/token-refresh.service'; import { useCallback, useEffect, useRef, useState } from 'react'; -import * as Y from 'yjs'; -function useConnectionTimeout(provider:HocuspocusProvider | undefined, timeoutMs = 5000) { - const [hasTimedOut, setHasTimedOut] = useState(false); - const timeoutRef = useRef | null>(null); +/** + * Calls `onTimeout` if the provider has not synced within `timeoutMs`. + * The timer is cancelled proactively when the provider emits 'synced', + * so it never fires after a successful connection. + */ +function useConnectionTimeout(provider:HocuspocusProvider, onTimeout:() => void, timeoutMs = 5000) { + const timeoutRef = useRef|null>(null); useEffect(() => { - setHasTimedOut(false); - if (!provider) return; - if (provider.synced) { - setHasTimedOut(false); return; } - timeoutRef.current = setTimeout(() => { - if (!provider.synced) { - setHasTimedOut(true); - } - }, timeoutMs); - - return () => { - if (timeoutRef.current) { + const cancel = () => { + if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }; - }, [provider, timeoutMs]); - return hasTimedOut; + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + onTimeout(); + }, timeoutMs); + + // Cancel the timer as soon as the provider syncs rather than waiting + // for the full timeout to elapse. + provider.on('synced', cancel); + + return () => { + provider.off('synced', cancel); + cancel(); + }; + }, [provider, onTimeout, timeoutMs]); } +/** + * Subscribes to the provider's 'synced' and 'disconnect' events and + * forwards them to the supplied callbacks. + * + * Listeners are registered before the initial synced check so that a + * sync event emitted between registration and the check is never lost. + * If the provider is already synced on mount, `onSynced` is called + * immediately. + */ function useCollaborationProvider( - provider:HocuspocusProvider | undefined, + provider:HocuspocusProvider, onSynced:() => void, onDisconnect:() => void, ) { useEffect(() => { - if (!provider) return; + provider.on('synced', onSynced); + provider.on('disconnect', onDisconnect); if (provider.synced) { onSynced(); } - provider.on('synced', onSynced); - provider.on('disconnect', onDisconnect); - return () => { provider.off('synced', onSynced); provider.off('disconnect', onDisconnect); @@ -86,74 +101,72 @@ function useCollaborationProvider( }, [provider, onSynced, onDisconnect]); } -function useLocalDocumentSync(doc:Y.Doc, inputField:HTMLInputElement, enabled:boolean) { +/** + * Listens for PROVIDER_AUTH_ERROR_EVENT on the document and calls `onAuthError` when it fires. + * The event is dispatched when authentication fails on the Hocuspocus WebSocket connection. + */ +function useProviderAuthError(onAuthError:() => void) { useEffect(() => { - if (!enabled) return; - - const updateInput = () => { - const update = Y.encodeStateAsUpdate(doc); - const b64 = btoa(String.fromCharCode(...update)); - inputField.value = b64; + const handler = (event:Event) => { + const { kind, message } = (event as CustomEvent<{ kind:ProviderAuthErrorKind; message:string }>).detail; + debugLog(`(BlockNote Editor) Provider auth error: ${kind} - ${message}`); + onAuthError(); }; - doc.on('update', updateInput); - - return () => { - doc.off('update', updateInput); - doc.destroy(); - }; - }, [doc, inputField, enabled]); + document.addEventListener(PROVIDER_AUTH_ERROR_EVENT, handler); + return () => document.removeEventListener(PROVIDER_AUTH_ERROR_EVENT, handler); + }, [onAuthError]); } -export function useCollaboration( - provider:HocuspocusProvider | undefined, - doc:Y.Doc, - inputField:HTMLInputElement, -) { +/** + * Tracks the real-time connection state of a HocuspocusProvider and + * exposes it as React state for the BlockNote editor. + * + * Returns: + * - `isLoading` — true while waiting for the first sync after mount. + * - `offlineMode` — true when the connection is lost or timed out; the editor + * is hidden entirely (blocking) because there is no local + * cache to edit from. + * + * Transitions: + * mount → synced : isLoading false, offlineMode false + * mount → timeout (5s) : isLoading false, offlineMode true + * connected → disconnect : offlineMode true + * offline → re-synced : offlineMode false + * any → auth error : isLoading false, offlineMode true + */ +function useCollaboration(provider:HocuspocusProvider) { const [isLoading, setIsLoading] = useState(true); - const [connectionError, setConnectionError] = useState(false); + const [offlineMode, setOfflineMode] = useState(false); const handleSynced = useCallback(() => { debugLog('(BlockNote Editor) synced with collaboration server'); setIsLoading(false); - setConnectionError(false); + setOfflineMode(false); }, []); const handleDisconnect = useCallback(() => { - debugLog('(BlockNote Editor) Disconnected from collaboration server'); - setConnectionError(true); + debugLog('(BlockNote Editor) Disconnected - offline mode'); + setIsLoading(false); + setOfflineMode(true); }, []); - const hasTimedOut = useConnectionTimeout(provider); + const handleTimeout = useCallback(() => { + debugLog('(BlockNote Editor) Connection to collaboration server timed out - now in offline mode'); + setIsLoading(false); + setOfflineMode(true); + }, []); + + const handleAuthError = useCallback(() => { + setOfflineMode(true); + setIsLoading(false); + }, []); + + useConnectionTimeout(provider, handleTimeout); useCollaborationProvider(provider, handleSynced, handleDisconnect); - useLocalDocumentSync(doc, inputField, !provider); + useProviderAuthError(handleAuthError); - useEffect(() => { - if (!provider) { - setIsLoading(false); - } - }, [provider]); - - useEffect(() => { - if (hasTimedOut) { - debugLog('(BlockNote Editor) Connection to collaboration server timed out'); - setConnectionError(true); - setIsLoading(false); - } - }, [hasTimedOut]); - - useEffect(() => { - const handleProviderAuthError = (event:Event) => { - const customEvent = event as CustomEvent<{ kind:ProviderAuthErrorKind; message:string }>; - debugLog(`(BlockNote Editor) Provider auth error: ${customEvent.detail.kind} - ${customEvent.detail.message}`); - setConnectionError(true); - }; - - document.addEventListener(PROVIDER_AUTH_ERROR_EVENT, handleProviderAuthError); - return () => document.removeEventListener(PROVIDER_AUTH_ERROR_EVENT, handleProviderAuthError); - }, []); - - return { isLoading, connectionError } as const; + return { isLoading, offlineMode } as const; } -export { useCollaborationProvider }; +export { useCollaboration }; diff --git a/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts b/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts index ab450c4342a..e935131f9f8 100644 --- a/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts @@ -1,37 +1,41 @@ /* * -- copyright - * openproject is an open source project management software. - * copyright (c) the openproject gmbh + * 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. + * 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 + * 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 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. + * 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, write to the free software - * foundation, inc., 51 franklin street, fifth floor, boston, ma 02110-1301, usa. + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * - * see copyright and license files for more details. + * See COPYRIGHT and LICENSE files for more details. * ++ */ import { HocuspocusProvider } from '@hocuspocus/provider'; import { Controller } from '@hotwired/stimulus'; import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers'; -import { PROVIDER_AUTH_ERROR_EVENT, ProviderAuthErrorKind, TokenRefreshService } from 'core-stimulus/services/documents/token-refresh.service'; +import { + PROVIDER_AUTH_ERROR_EVENT, + ProviderAuthErrorKind, + TokenRefreshService, +} from 'core-stimulus/services/documents/token-refresh.service'; import type { Doc } from 'yjs'; import * as Y from 'yjs'; diff --git a/lib/primer/open_project/forms/block_note_editor.rb b/lib/primer/open_project/forms/block_note_editor.rb index 226d9cb5d90..c89a7127ef0 100644 --- a/lib/primer/open_project/forms/block_note_editor.rb +++ b/lib/primer/open_project/forms/block_note_editor.rb @@ -63,7 +63,7 @@ module Primer @blocknote_stylesheet_url = variable_asset_path("blocknote.css") @shadow_dom_stylesheet_url = variable_asset_path("styles.css") - @collaboration_enabled = true + @collaboration_enabled = Setting.real_time_text_collaboration_enabled? end end end diff --git a/modules/documents/app/components/documents/show_edit_view/collaboration_disabled_notice_component.html.erb b/modules/documents/app/components/documents/show_edit_view/collaboration_disabled_notice_component.html.erb index 26005879dbe..c26f72331e2 100644 --- a/modules/documents/app/components/documents/show_edit_view/collaboration_disabled_notice_component.html.erb +++ b/modules/documents/app/components/documents/show_edit_view/collaboration_disabled_notice_component.html.erb @@ -38,7 +38,9 @@ end flex.with_row(classes: "op-document-view--editor") do - render(Primer::Alpha::Banner.new(icon: :alert, scheme: :warning)) do + render( + Primer::Alpha::Banner.new(icon: :alert, scheme: :warning, test_selector: "collaboration-disabled-notice") + ) do I18n.t("documents.text_collaboration_disabled_notice.description") end end diff --git a/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.html.erb b/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.html.erb index 5e3f7c0e034..1dca7a5e8b5 100644 --- a/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.html.erb +++ b/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.html.erb @@ -31,13 +31,20 @@ <%= component_wrapper do - render(Primer::Alpha::Banner.new(icon: :stop, scheme: :danger)) do |banner| + render(Primer::Alpha::Banner.new(icon: :stop, scheme: :danger, test_selector: "connection-error-notice")) do |banner| banner.with_action_button( id: "connection-error-reload-button", data: { turbo: false }, size: :medium ) { I18n.t("documents.show_edit_view.connection_error_notice.action") } - I18n.t("documents.show_edit_view.connection_error_notice.description") + description_key = if blocking + "documents.show_edit_view.connection_error_notice.description_server_unavailable" + elsif readonly + "documents.show_edit_view.connection_error_notice.description_readonly" + else + "documents.show_edit_view.connection_error_notice.description" + end + I18n.t(description_key) end end %> diff --git a/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.rb b/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.rb index 9dff8a62b22..7f06068473e 100644 --- a/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.rb +++ b/modules/documents/app/components/documents/show_edit_view/connection_error_notice_component.rb @@ -32,6 +32,22 @@ module Documents module ShowEditView class ConnectionErrorNoticeComponent < ApplicationComponent include OpTurbo::Streamable + + alias_method :document, :model + + def initialize(document, blocking: false) + super(document) + @blocking = blocking + end + + private + + attr_reader :blocking + + def readonly + @readonly ||= User.current.allowed_in_project?(:view_documents, document.project) && + !User.current.allowed_in_project?(:manage_documents, document.project) + end end end end diff --git a/modules/documents/app/controllers/documents_controller.rb b/modules/documents/app/controllers/documents_controller.rb index b6b8bcc4fd3..a5af68d90d5 100644 --- a/modules/documents/app/controllers/documents_controller.rb +++ b/modules/documents/app/controllers/documents_controller.rb @@ -79,7 +79,10 @@ class DocumentsController < ApplicationController end def render_connection_error - update_via_turbo_stream(component: Documents::ShowEditView::ConnectionErrorNoticeComponent.new) + blocking = ActiveRecord::Type::Boolean.new.cast(params[:blocking].presence || false) + update_via_turbo_stream( + component: Documents::ShowEditView::ConnectionErrorNoticeComponent.new(@document, blocking:) + ) respond_with_turbo_streams end diff --git a/modules/documents/config/locales/en.yml b/modules/documents/config/locales/en.yml index f5091717b07..7a309d5a42c 100644 --- a/modules/documents/config/locales/en.yml +++ b/modules/documents/config/locales/en.yml @@ -47,7 +47,6 @@ en: content_binary: "Content binary" title: "Title" - activity: filter: document: "Documents" @@ -91,6 +90,11 @@ en: show_edit_view: connection_error_notice: description: |- + You are currently offline. You can continue editing. Your changes will be synced when the connection is restored. + description_readonly: |- + You are currently offline. + Real-time updates will resume once the connection is restored. + description_server_unavailable: |- Unable to open document because the real-time text collaboration server is unreachable. Please contact the administrator if the problem persists. action: Try again @@ -167,7 +171,6 @@ en: primary_action: Enable real-time collaboration success: Real-time collaboration has been enabled. - disable_text_collaboration_dialog: title: Disable real-time collaboration heading: Disable real-time collaboration? diff --git a/modules/documents/spec/features/attachment_upload_spec.rb b/modules/documents/spec/features/attachment_upload_spec.rb index 8b99d0f264b..66b893e47c6 100644 --- a/modules/documents/spec/features/attachment_upload_spec.rb +++ b/modules/documents/spec/features/attachment_upload_spec.rb @@ -55,11 +55,6 @@ RSpec.describe "Upload attachment to documents", before do login_as(user) - - # This is here while we don't have a setting defined for enabling/disabling collaboration - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(Primer::OpenProject::Forms::BlockNoteEditor).to receive(:collaboration_enabled).and_return(false) - # rubocop:enable RSpec/AnyInstance end shared_examples "can upload an image in CKEditor" do @@ -209,6 +204,8 @@ RSpec.describe "Upload attachment to documents", end context "for collaborative documents", with_settings: { real_time_text_collaboration_enabled: true } do + include_context "with hocuspocus" + let(:document) { create(:document, :collaborative, project:) } let(:editor) { FormFields::Primerized::BlockNoteEditorInput.new } let(:attachments_list) { Components::AttachmentsList.new } diff --git a/modules/documents/spec/features/block_note_editor_spec.rb b/modules/documents/spec/features/block_note_editor_spec.rb index f73b8aa8b27..9bdc09de03f 100644 --- a/modules/documents/spec/features/block_note_editor_spec.rb +++ b/modules/documents/spec/features/block_note_editor_spec.rb @@ -31,17 +31,14 @@ require "rails_helper" RSpec.describe "BlockNote editor rendering", :js, :selenium, with_settings: { real_time_text_collaboration_enabled: true } do + include_context "with hocuspocus" + let(:admin) { create(:admin) } let(:document) { create(:document, :collaborative) } let(:editor) { FormFields::Primerized::BlockNoteEditorInput.new } before do login_as(admin) - - # This is here while we don't have a setting defined for enabling/disabling collaboration - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(Primer::OpenProject::Forms::BlockNoteEditor).to receive(:collaboration_enabled).and_return(false) - # rubocop:enable RSpec/AnyInstance end it "renders the BlockNote editor in the users locale" do @@ -66,6 +63,21 @@ RSpec.describe "BlockNote editor rendering", :js, :selenium, with_settings: { re expect(editor.content).to include("Heading") end + context "when real time text collaboration is disabled", + with_settings: { real_time_text_collaboration_enabled: false } do + it "does not render the BlockNote editor" do + visit document_path(document) + + expect(page).to have_no_test_selector("blocknote-document-description") + expect(page).to have_test_selector( + "collaboration-disabled-notice", + text: "Unable to open document because real-time text collaboration is disabled. " \ + "Please contact your administrator to enable real-time text collaboration " \ + "if you want to access this document." + ) + end + end + describe "with op-blocknote-extensions" do it "renders the BlockNote editor with custom menu entries for work package linking" do visit document_path(document) diff --git a/modules/documents/spec/features/documents/project/show_edit_document_spec.rb b/modules/documents/spec/features/documents/project/show_edit_document_spec.rb index 6bd9fdf309f..972f38e123d 100644 --- a/modules/documents/spec/features/documents/project/show_edit_document_spec.rb +++ b/modules/documents/spec/features/documents/project/show_edit_document_spec.rb @@ -34,6 +34,8 @@ require_module_spec_helper RSpec.describe "Show/Edit Document View", :js, :selenium do + include_context "with hocuspocus" + shared_let(:project) { create(:project) } shared_let(:member_role) { create(:existing_project_role, permissions: %i[view_documents manage_documents]) } shared_let(:member) { create(:user, member_with_roles: { project => member_role }) } @@ -41,17 +43,12 @@ RSpec.describe "Show/Edit Document View", let(:document_types) do %w[Specification Report].map { create(:document_type, name: it) } end - let(:document) { create(:document, :collaborative, project:, title: "Collaborative document", type: document_types.first) } + let(:document) do + create(:document, :collaborative, project:, title: "Collaborative document", type: document_types.first) + end current_user { member } - before do - # This is here while we don't have a setting defined for enabling/disabling collaboration - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(Primer::OpenProject::Forms::BlockNoteEditor).to receive(:collaboration_enabled).and_return(false) - # rubocop:enable RSpec/AnyInstance - end - it "renders a collaborative document", with_settings: { real_time_text_collaboration_enabled: true } do visit document_path(document) diff --git a/modules/documents/spec/features/real_time_collaboration_spec.rb b/modules/documents/spec/features/real_time_collaboration_spec.rb index b3e0cc42fc4..a9640210866 100644 --- a/modules/documents/spec/features/real_time_collaboration_spec.rb +++ b/modules/documents/spec/features/real_time_collaboration_spec.rb @@ -33,7 +33,8 @@ require_module_spec_helper RSpec.describe "Real-time collaboration with Hocuspocus for documents", :js, - :selenium do + :selenium, + with_settings: { real_time_text_collaboration_enabled: true } do let(:editor) { FormFields::Primerized::BlockNoteEditorInput.new } let(:admin) { create(:admin, firstname: "Armin", lastname: "Admin") } let(:member_role) { create(:existing_project_role, permissions: [:view_documents]) } @@ -41,51 +42,85 @@ RSpec.describe "Real-time collaboration with Hocuspocus for documents", let(:project) { create(:project) } let(:document) { create(:document, :collaborative, project:) } - include_context "with hocuspocus" + context "with hocuspocus server" do + include_context "with hocuspocus" - context "with write permission" do - before do - login_as(admin) + context "with write permission" do + before do + login_as(admin) + end + + it "shows the editor" do + visit document_path(document) + expect(page).to have_test_selector("blocknote-document-description") + expect(page).to have_no_content("Armin Admin") + page.find_link("1 active editor").click + expect(page).to have_content("Armin Admin") + end + + it "renders a collaborative document and saves changes to the database" do + visit document_path(document) + expect(document.content_binary).to be_nil + expect(page).to have_test_selector("blocknote-document-description") + + editor.fill_in("Hello Hocuspocus") + wait_for { document.reload.content_binary }.to be_present + expect(document.description).to eq("Hello Hocuspocus\n") + end end - it "shows the editor" do - visit document_path(document) - expect(page).to have_test_selector("blocknote-document-description") - expect(page).to have_no_content("Armin Admin") - page.find_link("1 active editor").click - expect(page).to have_content("Armin Admin") - end + context "with readonly permission" do + before do + login_as(readonly_user) + end - it "renders a collaborative document and saves changes to the database" do - visit document_path(document) - expect(document.content_binary).to be_nil - expect(page).to have_test_selector("blocknote-document-description") - - editor.fill_in("Hello Hocuspocus") - wait_for { document.reload.content_binary }.to be_present - expect(document.description).to eq("Hello Hocuspocus\n") + it "shows the editor but does not accept writes" do + # rubocop:disable Layout/LineLength + binary = "ARL108iNDAAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9dPIjQwAAw5ibG9ja0NvbnRhaW5lcgcA9dPIjQwBAwlwYXJhZ3JhcGgHAPXTyI0MAgYEAPXTyI0MAwFIKAD108iNDAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA9dPIjQwCCXRleHRDb2xvcgF3B2RlZmF1bHQoAPXTyI0MAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9dPIjQwBAmlkAXcOaW5pdGlhbEJsb2NrSWSH9dPIjQwBAw5ibG9ja0NvbnRhaW5lcgcA9dPIjQwJAwlwYXJhZ3JhcGgoAPXTyI0MCg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD108iNDAoJdGV4dENvbG9yAXcHZGVmYXVsdCgA9dPIjQwKDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD108iNDAkCaWQBdyQ4OTc0Yzk0YS1kZWZiLTRmMjEtYTc4Yi1mN2MyZTg5ZjUxZmKE9dPIjQwEBGVsbG+B9dPIjQwSAYT108iNDBMLIEhvY3VzcG9jdXMB9dPIjQwBEwE=" + # rubocop:enable Layout/LineLength + document.update!(content_binary: binary, description: "Hello Hocuspocus\n") + visit document_path(document) + expect(page).to have_test_selector("blocknote-document-description") + wait_for { editor.content }.to have_content("Hello Hocuspocus") + editor.fill_in("Nothing changes") + # Hocuspocus takes some time before saving. It's hard to wait for + # something to not change, so this sleeps, although it's bad to do that in tests. + sleep 5 # rubocop:disable OpenProject/NoSleepInFeatureSpecs + expect(document.reload.content_binary).to eql(binary) # nothing changed + expect(document.description).to eq("Hello Hocuspocus\n") + end end end - context "with readonly permission" do - before do - login_as(readonly_user) + context "when in offline mode (without a connection to the hocuspocus server)" do + # No Hocuspocus server is started here. On the first visit there is no + # local cache, so the editor is blocked entirely to prevent an empty + # Y.Doc from being synced as authoritative state on reconnect. + shared_examples "a blocked offline editor" do + it "blocks the editor and shows a server-unavailable message" do + visit document_path(document) + + expect(page).to have_test_selector("blocknote-document-description") + expect(editor.shadow_root).to have_css( + "[data-test-selector='connection-error-notice']", + text: "Unable to open document because the real-time text collaboration server " \ + "is unreachable. Please contact the administrator if the problem persists.", + wait: 10 + ) + expect(editor.shadow_root).to have_no_css("div[role='textbox']") + end end - it "shows the editor but does not accept writes" do - # rubocop:disable Layout/LineLength - binary = "ARL108iNDAAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9dPIjQwAAw5ibG9ja0NvbnRhaW5lcgcA9dPIjQwBAwlwYXJhZ3JhcGgHAPXTyI0MAgYEAPXTyI0MAwFIKAD108iNDAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA9dPIjQwCCXRleHRDb2xvcgF3B2RlZmF1bHQoAPXTyI0MAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9dPIjQwBAmlkAXcOaW5pdGlhbEJsb2NrSWSH9dPIjQwBAw5ibG9ja0NvbnRhaW5lcgcA9dPIjQwJAwlwYXJhZ3JhcGgoAPXTyI0MCg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD108iNDAoJdGV4dENvbG9yAXcHZGVmYXVsdCgA9dPIjQwKDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD108iNDAkCaWQBdyQ4OTc0Yzk0YS1kZWZiLTRmMjEtYTc4Yi1mN2MyZTg5ZjUxZmKE9dPIjQwEBGVsbG+B9dPIjQwSAYT108iNDBMLIEhvY3VzcG9jdXMB9dPIjQwBEwE=" - # rubocop:enable Layout/LineLength - document.update!(content_binary: binary, description: "Hello Hocuspocus\n") - visit document_path(document) - expect(page).to have_test_selector("blocknote-document-description") - wait_for { editor.content }.to have_content("Hello Hocuspocus") - editor.fill_in("Nothing changes") - # Hocuspocus takes some time before saving. It's hard to wait for - # something to not change, so this sleeps, although it's bad to do that in tests. - sleep 5 # rubocop:disable OpenProject/NoSleepInFeatureSpecs - expect(document.reload.content_binary).to eql(binary) # nothing changed - expect(document.description).to eq("Hello Hocuspocus\n") + context "with write permission" do + before { login_as(admin) } + + it_behaves_like "a blocked offline editor" + end + + context "with readonly permission" do + before { login_as(readonly_user) } + + it_behaves_like "a blocked offline editor" end end end