Implementation/72687 new connection error restore banner v2 (#22591)

---------

Co-authored-by: Kabiru Mwenja <k.mwenja@openproject.com>
This commit is contained in:
ihordubas99
2026-03-31 16:25:18 +03:00
committed by GitHub
parent 8272768057
commit 1e09c24945
17 changed files with 285 additions and 216 deletions
+6 -29
View File
@@ -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,
}
)
);
+3 -10
View File
@@ -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 <DocumentLoadingSkeleton />;
@@ -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<void> {
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);
}
}
@@ -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<typeof setTimeout>|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;
}
}
}
@@ -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;
}
}
@@ -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
@@ -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
%>
@@ -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
@@ -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
%>
@@ -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
@@ -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
@@ -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
+4 -7
View File
@@ -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"
-2
View File
@@ -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
@@ -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]
@@ -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] }) }
@@ -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