diff --git a/frontend/src/elements/block-note-element.ts b/frontend/src/elements/block-note-element.ts index 85244875e54..e9ceb8c65b9 100644 --- a/frontend/src/elements/block-note-element.ts +++ b/frontend/src/elements/block-note-element.ts @@ -30,8 +30,6 @@ import { User } from '@blocknote/core/comments'; import { HocuspocusProvider } from '@hocuspocus/provider'; -import { Application } from '@hotwired/stimulus'; -import FlashController from 'core-stimulus/controllers/flash.controller'; import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers'; import { ShadowDomWrapper } from 'op-blocknote-extensions'; import React from 'react'; @@ -40,11 +38,9 @@ import { createRoot } from 'react-dom/client'; import OpBlockNoteContainer from '../react/OpBlockNoteContainer'; class BlockNoteElement extends HTMLElement { - private stimulusRoot:HTMLDivElement; + private editorRoot:HTMLDivElement; private editorMount:HTMLDivElement; - private errorContainer:HTMLDivElement; private reactRoot:Root|null = null; - private stimulusApp:Application|null = null; private renderCallback:((provider:HocuspocusProvider) => void) | null = null; constructor() { @@ -52,30 +48,22 @@ class BlockNoteElement extends HTMLElement { const shadowRoot = this.attachShadow({ mode: 'open' }); - // Wrapper div as Stimulus root so both errorContainer and editorMount are in scope - this.stimulusRoot = document.createElement('div'); + this.editorRoot = document.createElement('div'); const browserSpecificClasses = this.getAttribute('browser-specific-classes')?.split(' ') ?? []; if (browserSpecificClasses.length > 0) { - this.stimulusRoot.classList.add(...browserSpecificClasses); + this.editorRoot.classList.add(...browserSpecificClasses); } // Clone the blank-target link description into the shadow DOM // so aria-describedby references resolve for links inside the editor const blankLinkDesc = document.getElementById('open-blank-target-link-description'); if (blankLinkDesc) { - this.stimulusRoot.appendChild(blankLinkDesc.cloneNode(true)); + this.editorRoot.appendChild(blankLinkDesc.cloneNode(true)); } - // Container for connection error/recovery messages (rendered by React via fetchConnectionTemplate) - this.errorContainer = document.createElement('div'); - this.errorContainer.id = 'documents-show-edit-view-connection-error-notice-component'; - this.errorContainer.dataset.controller = 'flash'; - this.errorContainer.dataset.flashAutohideValue = 'true'; - this.editorMount = document.createElement('div'); - this.stimulusRoot.appendChild(this.errorContainer); - this.stimulusRoot.appendChild(this.editorMount); - shadowRoot.appendChild(this.stimulusRoot); + this.editorRoot.appendChild(this.editorMount); + shadowRoot.appendChild(this.editorRoot); const blockNoteStylesheetUrl = this.getAttribute('blocknote-stylesheet-url'); if (blockNoteStylesheetUrl) { @@ -98,11 +86,6 @@ class BlockNoteElement extends HTMLElement { 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); - - // Initialize React application within shadow DOM this.reactRoot = createRoot(this.editorMount); this.renderCallback = (provider:HocuspocusProvider) => { @@ -125,11 +108,6 @@ class BlockNoteElement extends HTMLElement { this.reactRoot.unmount(); this.reactRoot = null; } - - if (this.stimulusApp) { - this.stimulusApp.stop(); - this.stimulusApp = null; - } } private BlockNoteReactContainer = (hocuspocusProvider:HocuspocusProvider) => { @@ -145,7 +123,6 @@ class BlockNoteElement extends HTMLElement { attachmentsUploadUrl: this.getAttribute('attachments-upload-url') ?? '', attachmentsCollectionKey: this.getAttribute('attachments-collection-key') ?? '', hocuspocusProvider, - errorContainer: this.errorContainer, } ) ); diff --git a/frontend/src/react/OpBlockNoteContainer.tsx b/frontend/src/react/OpBlockNoteContainer.tsx index 37946b9038c..c4b42cf5931 100644 --- a/frontend/src/react/OpBlockNoteContainer.tsx +++ b/frontend/src/react/OpBlockNoteContainer.tsx @@ -34,7 +34,6 @@ import { useEffect, useRef } from 'react'; import * as Y from 'yjs'; import { DocumentLoadingSkeleton } from './components/DocumentLoadingSkeleton'; import { OpBlockNoteEditor } from './components/OpBlockNoteEditor'; -import { fetchConnectionTemplate } from './helpers/connection-template-fetcher'; import { useCollaboration } from './hooks/useCollaboration'; export interface OpBlockNoteContainerProps { @@ -44,7 +43,6 @@ export interface OpBlockNoteContainerProps { attachmentsUploadUrl:string; attachmentsCollectionKey:string; hocuspocusProvider:HocuspocusProvider; - errorContainer?:HTMLElement; } export default function OpBlockNoteContainer({ @@ -54,24 +52,19 @@ export default function OpBlockNoteContainer({ 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 (offlineMode) { hadErrorRef.current = true; - void fetchConnectionTemplate('error', errorContainer, { blocking: true }); + window.dispatchEvent(new CustomEvent('documents:connection-error')); } else if (hadErrorRef.current) { - // Only fetch recovery if we previously had an error (avoid fetching on initial render) - void fetchConnectionTemplate('recovery', errorContainer); + window.dispatchEvent(new CustomEvent('documents:connection-recovery')); } - }, [offlineMode, errorContainer]); + }, [offlineMode]); if (isLoading) { return ; diff --git a/frontend/src/react/helpers/connection-template-fetcher.ts b/frontend/src/react/helpers/connection-template-fetcher.ts deleted file mode 100644 index 860b8e55d49..00000000000 --- a/frontend/src/react/helpers/connection-template-fetcher.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * -- 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, 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. - * ++ - */ - -/** - * Fetches connection error/recovery templates from the server. - * Used by the BlockNote editor to display server-rendered error messages - * when the collaboration connection fails. - */ - -function getDocumentIdFromUrl():string|null { - const match = /\/documents\/(\d+)/.exec(window.location.pathname); - return match ? match[1] : null; -} - -/** - * Parses Turbo Stream response and extracts the template content. - * Standard Turbo.renderStreamMessage() uses document.getElementById() - * which can't find Shadow DOM elements, so we parse manually. - */ -function parseTurboStreamContent(html:string):string|null { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const turboStream = doc.querySelector('turbo-stream'); - - if (!turboStream) { - console.error('No turbo-stream element found in response'); - return null; - } - - const template = turboStream.querySelector('template'); - if (!template) { - console.error('No template element found in turbo-stream'); - return null; - } - - return template.innerHTML; -} - -export async function fetchConnectionTemplate( - type:'error'|'recovery', - targetElement:HTMLElement, - options:{ blocking?:boolean } = {}, -):Promise { - const documentId = getDocumentIdFromUrl(); - if (!documentId) { - console.error('Could not extract document ID from URL'); - return; - } - - 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, { - method: 'GET', - headers: { - Accept: 'text/vnd.turbo-stream.html', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status}`); - } - - const html = await response.text(); - const content = parseTurboStreamContent(html); - - if (content !== null) { - targetElement.innerHTML = content; - - // Attach reload handler to the error button (Stimulus not available in Shadow DOM) - const reloadButton = targetElement.querySelector('#connection-error-reload-button'); - if (reloadButton) { - reloadButton.addEventListener('click', () => window.location.reload()); - } - } - } catch (error) { - console.error('Error fetching connection template:', error); - } -} diff --git a/frontend/src/stimulus/controllers/dynamic/documents/connection-status.controller.ts b/frontend/src/stimulus/controllers/dynamic/documents/connection-status.controller.ts new file mode 100644 index 00000000000..5d61e075afd --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/documents/connection-status.controller.ts @@ -0,0 +1,72 @@ +/* + * -- 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, 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +type ConnectionState = 'live' | 'offline' | 'recovered'; + +export default class ConnectionStatusController extends Controller { + static targets = ['live', 'offline', 'recovered']; + + declare readonly offlineTarget:HTMLElement; + declare readonly recoveredTarget:HTMLElement; + declare readonly liveTargets:HTMLElement[]; + + private recoveryTimeout:ReturnType|null = null; + + showOffline():void { + this.clearRecoveryTimeout(); + this.activateState('offline'); + } + + showRecovered():void { + this.clearRecoveryTimeout(); + this.activateState('recovered'); + + this.recoveryTimeout = setTimeout(() => this.activateState('live'), 5000); + } + + disconnect():void { + this.clearRecoveryTimeout(); + } + + private activateState(state:ConnectionState):void { + this.offlineTarget.hidden = state !== 'offline'; + this.recoveredTarget.hidden = state !== 'recovered'; + this.liveTargets.forEach((el) => { el.hidden = state !== 'live'; }); + } + + private clearRecoveryTimeout():void { + if (this.recoveryTimeout !== null) { + clearTimeout(this.recoveryTimeout); + this.recoveryTimeout = null; + } + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/documents/editor-connection.controller.ts b/frontend/src/stimulus/controllers/dynamic/documents/editor-connection.controller.ts new file mode 100644 index 00000000000..5caba4647f0 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/documents/editor-connection.controller.ts @@ -0,0 +1,48 @@ +/* + * -- 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, 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class EditorConnectionController extends Controller { + static targets = ['error', 'editor']; + + declare readonly errorTarget:HTMLElement; + declare readonly editorTarget:HTMLElement; + + showError():void { + this.errorTarget.hidden = false; + this.editorTarget.hidden = true; + } + + showEditor():void { + this.errorTarget.hidden = true; + this.editorTarget.hidden = false; + } +} diff --git a/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb b/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb index e081ed9065f..7177c56a881 100644 --- a/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb +++ b/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb @@ -54,13 +54,32 @@ end layout.with_tab(name: I18n.t("activerecord.models.document"), show_left: true) do - primer_form_with( - model: document, - url: document_path(document), - method: :patch, - data: { turbo: false } - ) do |form| - render Documents::BlockNoteEditorForm.new(form, token_payload:, readonly:) + tag.div( + data: { + controller: "documents--editor-connection", + action: "documents:connection-error@window->documents--editor-connection#showError " \ + "documents:connection-recovery@window->documents--editor-connection#showEditor" + } + ) do + safe_join( + [ + tag.div( + render(Documents::ShowEditView::ConnectionErrorNoticeComponent.new(document)), + hidden: true, + data: { "documents--editor-connection-target": "error" } + ), + tag.div(data: { "documents--editor-connection-target": "editor" }) do + primer_form_with( + model: document, + url: document_path(document), + method: :patch, + data: { turbo: false } + ) do |form| + render Documents::BlockNoteEditorForm.new(form, token_payload:, readonly:) + end + end + ] + ) 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 1dca7a5e8b5..7f02470df25 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 @@ -33,18 +33,12 @@ component_wrapper do 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 + tag: :a, + href: document_path(document), + size: :medium, + data: { turbo: false } ) { I18n.t("documents.show_edit_view.connection_error_notice.action") } - 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) + I18n.t("documents.show_edit_view.connection_error_notice.description_server_unavailable") 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 7f06068473e..d390af57bac 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 @@ -34,20 +34,6 @@ module Documents 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/components/documents/show_edit_view/page_header/connection_status_component.html.erb b/modules/documents/app/components/documents/show_edit_view/page_header/connection_status_component.html.erb new file mode 100644 index 00000000000..a4a07d07993 --- /dev/null +++ b/modules/documents/app/components/documents/show_edit_view/page_header/connection_status_component.html.erb @@ -0,0 +1,73 @@ +<%# + -- 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, 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. + + ++# +%> + +<%= + component_wrapper( + tag: :div, + data: { + controller: "documents--connection-status", + action: "documents:connection-error@window->documents--connection-status#showOffline " \ + "documents:connection-recovery@window->documents--connection-status#showRecovered" + } + ) do + flex_layout(align_items: :center) do |flex| + flex.with_column(mr: 3, data: { "documents--connection-status-target": "live" }) do + render(Documents::ShowEditView::PageHeader::LiveUsersComponent.new) + end + + flex.with_column(data: { "documents--connection-status-target": "live" }) do + render(Documents::ShowEditView::PageHeader::LiveSavedAtComponent.new(model)) + end + + flex.with_column(data: { "documents--connection-status-target": "offline" }, hidden: true) do + flex_layout(align_items: :center) do |row| + row.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: :alert, color: :attention, size: :small)) + end + row.with_column do + render(Primer::Beta::Text.new(color: :attention)) { t("documents.info_line.currently_offline") } + end + end + end + + flex.with_column(data: { "documents--connection-status-target": "recovered" }, hidden: true) do + flex_layout(align_items: :center) do |row| + row.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: :"check-circle", color: :success, size: :small)) + end + row.with_column do + render(Primer::Beta::Text.new(color: :success)) { t("documents.info_line.connection_restored") } + end + end + end + end + end +%> diff --git a/modules/documents/app/components/documents/show_edit_view/page_header/connection_status_component.rb b/modules/documents/app/components/documents/show_edit_view/page_header/connection_status_component.rb new file mode 100644 index 00000000000..b8b9afa3612 --- /dev/null +++ b/modules/documents/app/components/documents/show_edit_view/page_header/connection_status_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- 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, 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. +#++ +# + +module Documents + module ShowEditView + module PageHeader + class ConnectionStatusComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + end + end + end +end diff --git a/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb b/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb index 521a9cc5458..1ba9b6eb253 100644 --- a/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb +++ b/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb @@ -59,12 +59,8 @@ end end - flex.with_column(mr: 3) do - render(Documents::ShowEditView::PageHeader::LiveUsersComponent.new) - end - flex.with_column do - render(Documents::ShowEditView::PageHeader::LiveSavedAtComponent.new(document)) + render(Documents::ShowEditView::PageHeader::ConnectionStatusComponent.new(document)) end end end diff --git a/modules/documents/app/controllers/documents_controller.rb b/modules/documents/app/controllers/documents_controller.rb index a5af68d90d5..4d05b12cdc6 100644 --- a/modules/documents/app/controllers/documents_controller.rb +++ b/modules/documents/app/controllers/documents_controller.rb @@ -78,24 +78,6 @@ class DocumentsController < ApplicationController respond_with_turbo_streams end - def render_connection_error - 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 - - def render_connection_recovery - render_success_flash_message_via_turbo_stream( - message: I18n.t("documents.show_edit_view.connection_recovery_notice.description"), - unique_key: "document-connection-recovery-notice-#{@document.id}" - ) - - respond_with_turbo_streams - end - def new @document = @project.documents.build @document.attributes = document_params diff --git a/modules/documents/config/locales/en.yml b/modules/documents/config/locales/en.yml index 7a309d5a42c..a40fbc42bd5 100644 --- a/modules/documents/config/locales/en.yml +++ b/modules/documents/config/locales/en.yml @@ -89,17 +89,10 @@ 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 - connection_recovery_notice: - description: "The connection to the real-time text collaboration server has been restored." tabs: "Document tabs" index_page: @@ -115,6 +108,10 @@ en: last_updated_at: "Last saved %{time}." + info_line: + currently_offline: "You are currently offline." + connection_restored: "You are now back online." + active_editors: "Active editors" active_editors_count: one: "1 active editor" diff --git a/modules/documents/config/routes.rb b/modules/documents/config/routes.rb index 92d91965aa2..768ba16e279 100644 --- a/modules/documents/config/routes.rb +++ b/modules/documents/config/routes.rb @@ -49,8 +49,6 @@ Rails.application.routes.draw do get :delete_dialog get :render_avatars, defaults: { format: :turbo_stream } get :render_last_saved_at, defaults: { format: :turbo_stream } - get :render_connection_error, defaults: { format: :turbo_stream } - get :render_connection_recovery, defaults: { format: :turbo_stream } end end diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index a7affa56423..eca2a63beca 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -57,7 +57,6 @@ module OpenProject::Documents documents: %i[ index search show download render_avatars render_last_saved_at - render_connection_error render_connection_recovery ], "documents/menus": %i[show], "documents/refresh_tokens": %i[create] diff --git a/modules/documents/spec/controllers/documents_controller_spec.rb b/modules/documents/spec/controllers/documents_controller_spec.rb index ff8e4aaea72..9d762982d41 100644 --- a/modules/documents/spec/controllers/documents_controller_spec.rb +++ b/modules/documents/spec/controllers/documents_controller_spec.rb @@ -183,8 +183,9 @@ RSpec.describe DocumentsController do end describe "setup_collaboration_context", - with_config: { - collaborative_editing_hocuspocus_url: "wss://hocuspocus.local", + with_settings: { + real_time_text_collaboration_enabled: true, + collaborative_editing_hocuspocus_url: "wss://hocuspocus.example.com", collaborative_editing_hocuspocus_secret: "secret1234" } do let(:user_with_manage) { create(:user, member_with_permissions: { project => %i[view_documents manage_documents] }) } diff --git a/modules/documents/spec/features/real_time_collaboration_spec.rb b/modules/documents/spec/features/real_time_collaboration_spec.rb index a9640210866..015ecfcdde6 100644 --- a/modules/documents/spec/features/real_time_collaboration_spec.rb +++ b/modules/documents/spec/features/real_time_collaboration_spec.rb @@ -100,14 +100,14 @@ RSpec.describe "Real-time collaboration with Hocuspocus for documents", 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( + expect(page).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']") + expect(page).to have_no_test_selector("blocknote-document-description", wait: 0) + expect(page).to have_test_selector("document-info-line", text: "You are currently offline.") end end