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
This commit is contained in:
Kabiru Mwenja
2026-03-28 10:39:36 +03:00
parent 6c2a51d1cd
commit 8272768057
15 changed files with 277 additions and 208 deletions
+8 -13
View File
@@ -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')) {
+19 -36
View File
@@ -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 <DocumentLoadingSkeleton />;
}
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,
/>
);
}
@@ -66,6 +66,7 @@ function parseTurboStreamContent(html:string):string|null {
export async function fetchConnectionTemplate(
type:'error'|'recovery',
targetElement:HTMLElement,
options:{ blocking?:boolean } = {},
):Promise<void> {
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, {
+89 -76
View File
@@ -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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout>|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 };
@@ -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';
@@ -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
@@ -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
@@ -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
%>
@@ -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
@@ -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
+5 -2
View File
@@ -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?
@@ -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 }
@@ -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)
@@ -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)
@@ -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