mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge pull request #21025 from opf/impl/68764-awareness-live-updates
Impl/68764 awareness live updates
This commit is contained in:
@@ -36,21 +36,25 @@ import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlock
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { OpColorMode } from 'core-app/core/setup/globals/theme-utils';
|
||||
import { IUploadFile } from 'core-app/core/upload/upload.service';
|
||||
import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers';
|
||||
import { initOpenProjectApi, openProjectWorkPackageBlockSpec, openProjectWorkPackageSlashMenu } from 'op-blocknote-extensions';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
interface CollaborativeUser {
|
||||
name:string;
|
||||
color:string;
|
||||
}
|
||||
|
||||
export interface OpBlockNoteContainerProps {
|
||||
inputField:HTMLInputElement;
|
||||
inputText?:string;
|
||||
hocuspocusUrl:string;
|
||||
oauthToken:string,
|
||||
activeUser:User;
|
||||
documentName:string;
|
||||
documentId:string;
|
||||
openProjectUrl:string;
|
||||
attachmentsUploadUrl:string;
|
||||
attachmentsCollectionKey:string;
|
||||
hocuspocusProvider?:HocuspocusProvider;
|
||||
}
|
||||
|
||||
const schema = BlockNoteSchema.create().extend({
|
||||
@@ -63,14 +67,11 @@ const detectTheme = ():OpColorMode => { return window.OpenProject.theme.detectOp
|
||||
|
||||
export default function OpBlockNoteContainer({ inputField,
|
||||
inputText,
|
||||
hocuspocusUrl,
|
||||
oauthToken,
|
||||
activeUser,
|
||||
documentName,
|
||||
documentId,
|
||||
openProjectUrl,
|
||||
attachmentsUploadUrl,
|
||||
attachmentsCollectionKey }:OpBlockNoteContainerProps) {
|
||||
attachmentsCollectionKey,
|
||||
hocuspocusProvider }:OpBlockNoteContainerProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [theme, setTheme] = useState<OpColorMode>(detectTheme());
|
||||
|
||||
@@ -80,33 +81,20 @@ export default function OpBlockNoteContainer({ inputField,
|
||||
const blockNoteLocaleString = Object.keys(blockNoteLocales).includes(userLocale) ? userLocale : 'en';
|
||||
const blockNoteLocale = blockNoteLocales[blockNoteLocaleString as keyof typeof blockNoteLocales];
|
||||
|
||||
let doc = new Y.Doc();
|
||||
|
||||
const collaborationEnabled = Boolean(hocuspocusUrl && documentName && oauthToken && activeUser);
|
||||
let hocuspocusProvider:HocuspocusProvider | null = null;
|
||||
let doc = LiveCollaborationManager.ydoc;
|
||||
|
||||
let editorParams:Partial<BlockNoteEditorOptions<typeof schema.blockSchema, typeof schema.inlineContentSchema, typeof schema.styleSchema>>;
|
||||
if(collaborationEnabled) {
|
||||
const url = new URL(hocuspocusUrl);
|
||||
url.searchParams.set('document_id', documentId);
|
||||
url.searchParams.set('openproject_base_path', openProjectUrl);
|
||||
|
||||
hocuspocusProvider = new HocuspocusProvider({
|
||||
url: url.toString(),
|
||||
name: documentName,
|
||||
token: oauthToken,
|
||||
document: doc
|
||||
});
|
||||
|
||||
if(hocuspocusProvider) {
|
||||
editorParams = {
|
||||
schema,
|
||||
collaboration: {
|
||||
provider: hocuspocusProvider,
|
||||
fragment: doc.getXmlFragment('document-store'),
|
||||
user: {
|
||||
id: activeUser.id,
|
||||
name: activeUser.username,
|
||||
color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'),
|
||||
},
|
||||
} as unknown as CollaborativeUser,
|
||||
showCursorLabels: 'activity'
|
||||
},
|
||||
dictionary: blockNoteLocale,
|
||||
@@ -158,7 +146,9 @@ export default function OpBlockNoteContainer({ inputField,
|
||||
try {
|
||||
const service = pluginContext.services.attachmentsResourceService;
|
||||
const iUploadFile = fileToIUploadFile(file);
|
||||
const result = await service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, [iUploadFile]).toPromise();
|
||||
const result = await firstValueFrom(
|
||||
service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, [iUploadFile])
|
||||
);
|
||||
|
||||
return result?.[0]._links.staticDownloadLocation.href ?? '';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -185,7 +175,8 @@ export default function OpBlockNoteContainer({ inputField,
|
||||
inputField.value = b64;
|
||||
};
|
||||
|
||||
if(collaborationEnabled && hocuspocusProvider) {
|
||||
if(hocuspocusProvider) {
|
||||
if (hocuspocusProvider.synced) { setIsLoading(false); }
|
||||
hocuspocusProvider.on('synced', () => setIsLoading(false));
|
||||
hocuspocusProvider.on('disconnect', () => setIsLoading(true));
|
||||
} else {
|
||||
@@ -194,14 +185,14 @@ export default function OpBlockNoteContainer({ inputField,
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (collaborationEnabled && hocuspocusProvider) {
|
||||
if (hocuspocusProvider) {
|
||||
hocuspocusProvider.destroy();
|
||||
} else {
|
||||
// disable Yjs update listener. Opposite of doc.on('update', ...);
|
||||
doc.off('update', updateInput);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [hocuspocusProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = () => {
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
* ++
|
||||
*/
|
||||
|
||||
import { User } from '@blocknote/core/comments';
|
||||
import type { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import OpBlockNoteContainer from '../../../react/OpBlockNoteContainer';
|
||||
import { User } from '@blocknote/core/comments';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
@@ -40,47 +42,48 @@ export default class extends Controller {
|
||||
'blockNoteInputField',
|
||||
];
|
||||
|
||||
declare readonly blockNoteEditorTarget:HTMLElement;
|
||||
declare readonly blockNoteInputFieldTarget:HTMLInputElement;
|
||||
|
||||
static values = {
|
||||
inputText: String,
|
||||
activeUser: Object,
|
||||
hocuspocusUrl: String,
|
||||
oauthToken: String,
|
||||
documentName: String,
|
||||
documentId: String,
|
||||
openProjectUrl: String,
|
||||
attachmentsUploadUrl: String,
|
||||
attachmentsCollectionKey: String,
|
||||
|
||||
collaborationEnabled: Boolean,
|
||||
};
|
||||
|
||||
declare readonly blockNoteEditorTarget:HTMLElement;
|
||||
declare readonly blockNoteInputFieldTarget:HTMLInputElement;
|
||||
declare readonly inputTextValue:string;
|
||||
declare readonly activeUserValue:User;
|
||||
declare readonly hocuspocusUrlValue:string;
|
||||
declare readonly oauthTokenValue:string;
|
||||
declare readonly documentNameValue:string;
|
||||
declare readonly documentIdValue:string;
|
||||
declare readonly openProjectUrlValue:string;
|
||||
declare readonly attachmentsUploadUrlValue:string;
|
||||
declare readonly attachmentsCollectionKeyValue:string;
|
||||
|
||||
declare readonly collaborationEnabledValue:string;
|
||||
|
||||
connect() {
|
||||
const root = createRoot(this.blockNoteEditorTarget);
|
||||
root.render(this.BlockNoteReactContainer());
|
||||
|
||||
if (this.collaborationEnabledValue) {
|
||||
LiveCollaborationManager.onReady((hocuspocusProvider) => {
|
||||
root.render(this.BlockNoteReactContainer(hocuspocusProvider));
|
||||
});
|
||||
} else {
|
||||
root.render(this.BlockNoteReactContainer());
|
||||
}
|
||||
}
|
||||
|
||||
BlockNoteReactContainer() {
|
||||
BlockNoteReactContainer(hocuspocusProvider?:HocuspocusProvider) {
|
||||
return React.createElement(OpBlockNoteContainer, {
|
||||
inputField: this.blockNoteInputFieldTarget,
|
||||
inputText: this.inputTextValue,
|
||||
hocuspocusUrl: this.hocuspocusUrlValue,
|
||||
oauthToken: this.oauthTokenValue,
|
||||
activeUser: this.activeUserValue,
|
||||
documentName: this.documentNameValue,
|
||||
documentId: this.documentIdValue,
|
||||
openProjectUrl: this.openProjectUrlValue,
|
||||
attachmentsUploadUrl: this.attachmentsUploadUrlValue,
|
||||
attachmentsCollectionKey: this.attachmentsCollectionKeyValue,
|
||||
hocuspocusProvider: hocuspocusProvider,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* -- 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 { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
hocuspocusUrl: String,
|
||||
openProjectUrl: String,
|
||||
oauthToken: String,
|
||||
documentName: String,
|
||||
documentId: String,
|
||||
};
|
||||
|
||||
declare readonly hocuspocusUrlValue:string;
|
||||
declare readonly openProjectUrlValue:string;
|
||||
declare readonly oauthTokenValue:string;
|
||||
declare readonly documentNameValue:string;
|
||||
declare readonly documentIdValue:string;
|
||||
|
||||
connect():void {
|
||||
const url = new URL(this.hocuspocusUrlValue);
|
||||
url.searchParams.set('document_id', this.documentIdValue);
|
||||
url.searchParams.set('openproject_base_path', this.openProjectUrlValue);
|
||||
|
||||
LiveCollaborationManager.initializeYjsProvider(
|
||||
new HocuspocusProvider({
|
||||
url: url.toString(),
|
||||
name: this.documentNameValue,
|
||||
token: this.oauthTokenValue,
|
||||
document: LiveCollaborationManager.ydoc,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* -- 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 { HocuspocusProvider, onAwarenessUpdateParameters } from '@hocuspocus/provider';
|
||||
import * as Turbo from '@hotwired/turbo';
|
||||
import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers';
|
||||
import { ApplicationController, useDebounce } from 'stimulus-use';
|
||||
|
||||
interface LiveUser {id:string, name:string, avatarUrl:string}
|
||||
|
||||
export default class extends ApplicationController {
|
||||
static debounces = ['triggerUpdateUI'];
|
||||
static targets = ['users', 'popover'];
|
||||
|
||||
declare readonly usersTarget:HTMLElement;
|
||||
declare readonly popoverTarget:HTMLElement;
|
||||
|
||||
private provider:HocuspocusProvider|undefined;
|
||||
private currentUsers = new Map<number, LiveUser>();
|
||||
|
||||
connect() {
|
||||
LiveCollaborationManager.onReady((provider:HocuspocusProvider) => {
|
||||
this.provider = provider;
|
||||
this.provider.on('awarenessUpdate', this.onAwarenessUpdate);
|
||||
});
|
||||
|
||||
useDebounce(this, { wait: 1000 });
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.currentUsers.clear();
|
||||
this.provider?.off('awarenessUpdate', this.onAwarenessUpdate);
|
||||
}
|
||||
|
||||
toggle_popover() {
|
||||
this.popoverTarget.classList.toggle('d-none');
|
||||
}
|
||||
|
||||
private onAwarenessUpdate = (data:onAwarenessUpdateParameters) => {
|
||||
const changed = this.updateUsers(data.states);
|
||||
if (changed) {
|
||||
this.triggerUpdateUI();
|
||||
}
|
||||
};
|
||||
|
||||
private updateUsers(states:onAwarenessUpdateParameters['states']) {
|
||||
const nextState = new Map<number, LiveUser>();
|
||||
|
||||
states.forEach((state, clientId) => {
|
||||
if (state.user) {
|
||||
nextState.set(clientId, state.user as LiveUser);
|
||||
}
|
||||
});
|
||||
|
||||
const previousUsers = [...this.currentUsers.keys()];
|
||||
const nextUsers = [...nextState.keys()];
|
||||
|
||||
this.currentUsers = nextState;
|
||||
|
||||
return (
|
||||
previousUsers.length !== nextUsers.length || previousUsers.some(id => !nextUsers.includes(id))
|
||||
);
|
||||
}
|
||||
|
||||
private triggerUpdateUI() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const user of this.currentUsers.values()) {
|
||||
params.append('user_ids[]', user.id);
|
||||
}
|
||||
|
||||
const url = `${window.location.pathname}/render_avatars?${params}`;
|
||||
|
||||
void fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/vnd.turbo-stream.htm' },
|
||||
})
|
||||
.then((response:Response) => {
|
||||
if (response.ok) {
|
||||
return response.text();
|
||||
}
|
||||
return Promise.reject(new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`));
|
||||
})
|
||||
.then((html:string) => Turbo.renderStreamMessage(html))
|
||||
.catch((error:Error) => console.error('Error:', error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* -- 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 { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import type { Doc } from 'yjs';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
type Listener = (provider:HocuspocusProvider) => void;
|
||||
|
||||
class LiveCollaborationManagerClass {
|
||||
ydocInstance:Doc|null = null;
|
||||
yjsProviderInstance:HocuspocusProvider|null = null;
|
||||
|
||||
private listeners:Listener[] = [];
|
||||
|
||||
/**
|
||||
* Initializes the YJS Provider
|
||||
* @param provider The provider to use
|
||||
* @returns void
|
||||
*/
|
||||
initializeYjsProvider(provider:HocuspocusProvider) {
|
||||
this.yjsProviderInstance = provider;
|
||||
this.listeners.forEach((listener) => listener(this.yjsProviderInstance!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a shared Y.Doc instance
|
||||
*/
|
||||
get ydoc():Doc {
|
||||
this.ydocInstance ??= new Y.Doc();
|
||||
|
||||
return this.ydocInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a shared YJS Provider
|
||||
* @throws Error if no provider is configured
|
||||
*/
|
||||
get yjsProvider():HocuspocusProvider {
|
||||
if (!this.yjsProviderInstance) {
|
||||
throw new Error('No YJS Provider configured');
|
||||
}
|
||||
return this.yjsProviderInstance;
|
||||
}
|
||||
|
||||
onReady(listener:Listener) {
|
||||
this.listeners.push(listener);
|
||||
if (this.yjsProviderInstance) {
|
||||
listener(this.yjsProviderInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LiveCollaborationManager = new LiveCollaborationManagerClass();
|
||||
@@ -37,13 +37,9 @@
|
||||
controller: "block-note",
|
||||
block_note_input_text_value: value,
|
||||
block_note_active_user_value: active_user,
|
||||
block_note_hocuspocus_url_value: hocuspocus_url,
|
||||
block_note_document_name_value: document_name,
|
||||
block_note_document_id_value: document_id,
|
||||
block_note_open_project_url_value: open_project_url,
|
||||
block_note_oauth_token_value: oauth_token,
|
||||
block_note_attachments_upload_url_value: attachments_upload_url,
|
||||
block_note_attachments_collection_key_value: attachments_collection_key,
|
||||
block_note_collaboration_enabled_value: collaboration_enabled,
|
||||
test_selector: "blocknote-document-description"
|
||||
}
|
||||
) do
|
||||
|
||||
@@ -38,18 +38,13 @@ module Primer
|
||||
attr_reader :input,
|
||||
:value,
|
||||
:active_user,
|
||||
:hocuspocus_url,
|
||||
:open_project_url,
|
||||
:document_name,
|
||||
:document_id,
|
||||
:oauth_token,
|
||||
:attachments_upload_url,
|
||||
:attachments_collection_key
|
||||
:attachments_collection_key,
|
||||
:collaboration_enabled
|
||||
|
||||
delegate :name, to: :@input
|
||||
|
||||
def initialize(input:, value:, document_name:, document_id:, attachments_upload_url: "",
|
||||
attachments_collection_key: "", oauth_token: nil)
|
||||
def initialize(input:, value:, attachments_upload_url: "", attachments_collection_key: "")
|
||||
super()
|
||||
@input = input
|
||||
@value = value
|
||||
@@ -57,13 +52,10 @@ module Primer
|
||||
id: User.current.id,
|
||||
username: User.current.name
|
||||
}
|
||||
@document_id = document_id
|
||||
@document_name = document_name
|
||||
@oauth_token = oauth_token
|
||||
@hocuspocus_url = Setting.collaborative_editing_hocuspocus_url
|
||||
@open_project_url = root_url
|
||||
@attachments_upload_url = attachments_upload_url
|
||||
@attachments_collection_key = attachments_collection_key
|
||||
|
||||
@collaboration_enabled = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,9 +37,6 @@ module Primer
|
||||
:label,
|
||||
:value,
|
||||
:classes,
|
||||
:document_id,
|
||||
:document_name,
|
||||
:oauth_token,
|
||||
:attachments_upload_url,
|
||||
:attachments_collection_key
|
||||
|
||||
@@ -47,18 +44,13 @@ module Primer
|
||||
# @param name [String] The name of the input field.
|
||||
# @param label [String] The label for the input field.
|
||||
# @param value [String] The initial value of the input in base64 format.
|
||||
# @param document_id [String] The ID of the document.
|
||||
# @param document_name [String] The name of the document for the collaborative YJS provider.
|
||||
# @param oauth_token [String, nil] The OAuth token for external server authentication.
|
||||
def initialize(name:, label:, value:, document_id:, document_name:, attachments_upload_url: "",
|
||||
attachments_collection_key: "", oauth_token: nil, **system_arguments)
|
||||
# @param attachments_upload_url [String] The URL to which attachments will be uploaded.
|
||||
# @param attachments_collection_key [String] The collection key for attachments.
|
||||
def initialize(name:, label:, value:, attachments_upload_url: "", attachments_collection_key: "", **system_arguments)
|
||||
@name = name
|
||||
@label = label
|
||||
@value = value
|
||||
@classes = system_arguments[:classes]
|
||||
@document_id = document_id
|
||||
@document_name = document_name
|
||||
@oauth_token = oauth_token
|
||||
@attachments_upload_url = attachments_upload_url
|
||||
@attachments_collection_key = attachments_collection_key
|
||||
|
||||
@@ -66,8 +58,7 @@ module Primer
|
||||
end
|
||||
|
||||
def to_component
|
||||
BlockNoteEditor.new(input: self, value:, document_id:, document_name:, oauth_token:, attachments_upload_url:,
|
||||
attachments_collection_key:)
|
||||
BlockNoteEditor.new(input: self, value:, attachments_upload_url:, attachments_collection_key:)
|
||||
end
|
||||
|
||||
def type
|
||||
|
||||
+11
@@ -29,6 +29,17 @@
|
||||
++#
|
||||
%>
|
||||
|
||||
<%
|
||||
helpers.content_controller(
|
||||
"documents--init-yjs-provider",
|
||||
"documents--init-yjs-provider-hocuspocus-url-value": Setting.collaborative_editing_hocuspocus_url,
|
||||
"documents--init-yjs-provider-open-project-url-value": root_url,
|
||||
"documents--init-yjs-provider-oauth-token-value": oauth_token,
|
||||
"documents--init-yjs-provider-document-name-value": document.title,
|
||||
"documents--init-yjs-provider-document-id-value": document.id
|
||||
)
|
||||
%>
|
||||
|
||||
<%=
|
||||
render(Primer::Alpha::Layout.new(stacking_breakpoint: :md, classes: "op-document-view")) do |component|
|
||||
component.with_main(classes: "op-document-view--main") do
|
||||
|
||||
+4
@@ -52,6 +52,10 @@
|
||||
end
|
||||
end
|
||||
|
||||
flex.with_column(mr: 2) do
|
||||
render(Documents::ShowEditView::PageHeader::LiveUsersComponent.new)
|
||||
end
|
||||
|
||||
flex.with_column do
|
||||
last_updated_at_content
|
||||
end
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<%#
|
||||
-- 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: "turbo-frame", refresh: :morph,
|
||||
data: {
|
||||
controller: "documents--live-users",
|
||||
action: "click->documents--live-users#toggle_popover",
|
||||
"test-selector": "live-users"
|
||||
}
|
||||
) do
|
||||
render Primer::BaseComponent.new(tag: :div, position: :relative) do
|
||||
concat(
|
||||
flex_layout do |flex_container|
|
||||
flex_container.with_column do
|
||||
render Primer::Beta::AvatarStack.new(
|
||||
data: { "documents--live-users-target": "users" }
|
||||
) do |component|
|
||||
users.map do |user|
|
||||
component.with_avatar(src: avatar_url(user), alt: user.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
flex_container.with_column { active_editors }
|
||||
end
|
||||
)
|
||||
|
||||
concat(
|
||||
render Primer::Beta::Popover.new(
|
||||
position: :absolute,
|
||||
display: :none, # hidden by default
|
||||
data: { "documents--live-users-target": "popover" }
|
||||
) do |component|
|
||||
component.with_heading { I18n.t("documents.active_editors") }
|
||||
component.with_body(caret: :top_left) do
|
||||
flex_layout do |container|
|
||||
users.each do |user|
|
||||
container.with_row do
|
||||
render Users::AvatarComponent.new(
|
||||
user: user,
|
||||
show_name: true,
|
||||
link: false,
|
||||
hover_card: { active: false },
|
||||
size: :mini
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
%>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# 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 LiveUsersComponent < ApplicationComponent
|
||||
include OpPrimer::ComponentHelpers
|
||||
include OpPrimer::FormHelpers
|
||||
include OpTurbo::Streamable
|
||||
include AvatarHelper
|
||||
|
||||
attr_reader :users
|
||||
|
||||
def initialize(users: [User.current])
|
||||
super
|
||||
@users = users
|
||||
end
|
||||
|
||||
def active_editors
|
||||
safe_join [
|
||||
I18n.t("documents.active_editors_count", count: users.count).html_safe
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -66,6 +66,14 @@ class DocumentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def render_avatars
|
||||
user_ids = params[:user_ids]
|
||||
@users = User.where(id: user_ids)
|
||||
update_via_turbo_stream(component: Documents::ShowEditView::PageHeader::LiveUsersComponent.new(users: @users))
|
||||
|
||||
respond_with_turbo_streams
|
||||
end
|
||||
|
||||
def new
|
||||
@document = @project.documents.build
|
||||
@document.attributes = document_params
|
||||
@@ -194,7 +202,7 @@ class DocumentsController < ApplicationController
|
||||
|
||||
def generate_oauth_token
|
||||
# do not generate a token if the user is not allowed to manage documents
|
||||
if !current_user.allowed_in_project?(:manage_documents, @project)
|
||||
if !current_user.allowed_in_project?(:view_documents, @project)
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -37,9 +37,6 @@ module Documents
|
||||
visually_hide_label: true,
|
||||
classes: "document-form--long-description",
|
||||
value: model.content_binary,
|
||||
document_id: model.id,
|
||||
document_name: model.title,
|
||||
oauth_token: @oauth_token,
|
||||
attachments_upload_url:,
|
||||
attachments_collection_key:
|
||||
)
|
||||
|
||||
@@ -40,3 +40,4 @@
|
||||
<% else %>
|
||||
<%= render partial: "classic_show", locals: { document: @document, project: @project, attachments: @attachments } %>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ en:
|
||||
|
||||
last_updated_at: "Last updated %{time}."
|
||||
|
||||
active_editors: "Active editors"
|
||||
active_editors_count: "%{count} active editors"
|
||||
|
||||
label_attachment_author: "Attachment author"
|
||||
label_categories: "Categories"
|
||||
new_category: "New category"
|
||||
|
||||
@@ -45,6 +45,7 @@ Rails.application.routes.draw do
|
||||
get :cancel_title_edit, defaults: { format: :turbo_stream }
|
||||
put :update_type, defaults: { format: :turbo_stream }
|
||||
get :delete_dialog
|
||||
get :render_avatars, defaults: { format: :turbo_stream }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ module OpenProject::Documents
|
||||
|
||||
project_module :documents do |_map|
|
||||
permission :view_documents,
|
||||
{ documents: %i[index search show download],
|
||||
{ documents: %i[index search show download render_avatars],
|
||||
"documents/menus": %i[show] },
|
||||
permissible_on: :project
|
||||
permission :manage_documents,
|
||||
|
||||
@@ -211,9 +211,9 @@ RSpec.describe DocumentsController do
|
||||
context "when user does not have manage_documents permission" do
|
||||
current_user { user_without_manage }
|
||||
|
||||
it "does not generate an OAuth token for show action" do
|
||||
it "generates an OAuth token for show action" do
|
||||
get :show, params: { id: document.id }
|
||||
expect(assigns(:oauth_token)).to be_nil
|
||||
expect(assigns(:oauth_token)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,6 +55,11 @@ 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
|
||||
|
||||
@@ -38,6 +38,11 @@ RSpec.describe "BlockNote editor rendering", :js, with_flag: { block_note_editor
|
||||
|
||||
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
|
||||
|
||||
@@ -46,11 +46,24 @@ RSpec.describe "Show/Edit Document View",
|
||||
|
||||
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" do
|
||||
visit document_path(document)
|
||||
|
||||
expect(page).to have_content("Collaborative document")
|
||||
|
||||
aggregate_failures "can see live users" do
|
||||
within_test_selector("live-users") do
|
||||
expect(page).to have_content("1 active editors")
|
||||
end
|
||||
end
|
||||
|
||||
aggregate_failures "can edit document title" do
|
||||
within_test_selector("document-page-header") do
|
||||
click_button accessible_name: "Document actions"
|
||||
|
||||
Reference in New Issue
Block a user