mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
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:
@@ -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')) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+24
-20
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+9
-2
@@ -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
|
||||
%>
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user