[PE-31] feat: Add lock unlock archive restore realtime sync (#5629)
* fix: add lock unlock archive restore realtime sync * fix: show only after editor loads * fix: added strong types * fix: live events fixed * fix: remove unused vars and logs * fix: converted objects to enum * fix: error handling and removing the events in read only mode * fix: added check to only update if the image aspect ratio is not present already * fix: imports * fix: props order * revert: no need of these changes anymore * fix: updated type names * fix: order of things * fix: fixed types and renamed variables * fix: better typing for the real time updates * fix: trying multiplexing our socket connection * fix: multiplexing socket connection in read only editor as well * fix: remove single socket logic * fix: fixing the cleanup deps for the provider and localprovider * fix: add a better data structure for managing events * chore: refactored realtime events into hooks * feat: fetch page meta while focusing tabs * fix: cycling through items on slash command item in down arrow * fix: better naming convention for realtime events * fix: simplified localprovider initialization and cleaning * fix: types from ui * fix: abstracted away from exposing the provider directly * fix: coderabbit suggestions * regression: pass user in dependency array * fix: removed page action api calls by the other users the document is synced with * chore: removed unused imports
This commit is contained in:
parent
8c04aa6f51
commit
3c6006d04a
21 changed files with 277 additions and 115 deletions
|
|
@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid";
|
||||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||||
// extensions
|
// extensions
|
||||||
import { getExtensions } from "@/core/extensions/index.js";
|
import { getExtensions } from "@/core/extensions/index.js";
|
||||||
|
import {
|
||||||
|
DocumentCollaborativeEvents,
|
||||||
|
TDocumentEventsServer,
|
||||||
|
} from "@plane/editor/lib";
|
||||||
// editor types
|
// editor types
|
||||||
import { TUserDetails } from "@plane/editor";
|
import { TUserDetails } from "@plane/editor";
|
||||||
// types
|
// types
|
||||||
|
|
@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
|
||||||
throw Error("Authentication unsuccessful!");
|
throw Error("Authentication unsuccessful!");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async onStateless({ payload, document }) {
|
||||||
|
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||||
|
const response =
|
||||||
|
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||||
|
if (response) {
|
||||||
|
document.broadcastStateless(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
extensions,
|
extensions,
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const DocumentCollaborativeEvents = {
|
||||||
|
lock: { client: "locked", server: "lock" },
|
||||||
|
unlock: { client: "unlocked", server: "unlock" },
|
||||||
|
archive: { client: "archived", server: "archive" },
|
||||||
|
unarchive: { client: "unarchived", server: "unarchive" },
|
||||||
|
} as const;
|
||||||
|
|
@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||||
aspectRatio: aspectRatioCalculated,
|
aspectRatio: aspectRatioCalculated,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSize(initialComputedSize);
|
setSize(initialComputedSize);
|
||||||
updateAttributesSafely(
|
updateAttributesSafely(
|
||||||
initialComputedSize,
|
initialComputedSize,
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||||
if (!closestEditorContainer) {
|
if (closestEditorContainer) {
|
||||||
console.error("Editor container not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// the image is already uploaded if the image-component node has src attribute
|
// the image is already uploaded if the image-component node has src attribute
|
||||||
|
|
@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||||
setResolvedSrc(url as string);
|
setResolvedSrc(url as string);
|
||||||
};
|
};
|
||||||
getImageSource();
|
getImageSource();
|
||||||
}, [imageFromFileSystem, node.attrs.src]);
|
}, [imgNodeSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||||
if (nextItem < 0) {
|
if (nextItem < 0) {
|
||||||
nextSection = currentSection - 1;
|
nextSection = currentSection - 1;
|
||||||
if (nextSection < 0) nextSection = sections.length - 1;
|
if (nextSection < 0) nextSection = sections.length - 1;
|
||||||
nextItem = sections[nextSection].items.length - 1;
|
nextItem = sections[nextSection]?.items.length - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
|
||||||
|
import { TDocumentEventKey, TDocumentEventsClient, TDocumentEventsServer } from "@/types/document-collaborative-events";
|
||||||
|
|
||||||
|
export const getServerEventName = (clientEvent: TDocumentEventsClient): TDocumentEventsServer | undefined => {
|
||||||
|
for (const key in DocumentCollaborativeEvents) {
|
||||||
|
if (DocumentCollaborativeEvents[key as TDocumentEventKey].client === clientEvent) {
|
||||||
|
return DocumentCollaborativeEvents[key as TDocumentEventKey].server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import Collaboration from "@tiptap/extension-collaboration";
|
import Collaboration from "@tiptap/extension-collaboration";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
|
@ -58,21 +58,19 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||||
[id, realtimeConfig, serverHandler, user]
|
[id, realtimeConfig, serverHandler, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
// destroy and disconnect connection on unmount
|
const localProvider = useMemo(
|
||||||
|
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
||||||
|
[id, provider]
|
||||||
|
);
|
||||||
|
|
||||||
|
// destroy and disconnect all providers connection on unmount
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
provider.destroy();
|
provider?.destroy();
|
||||||
provider.disconnect();
|
|
||||||
},
|
|
||||||
[provider]
|
|
||||||
);
|
|
||||||
// indexed db integration for offline support
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
|
||||||
return () => {
|
|
||||||
localProvider?.destroy();
|
localProvider?.destroy();
|
||||||
};
|
},
|
||||||
}, [provider, id]);
|
[provider, localProvider]
|
||||||
|
);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
|
||||||
// props
|
// props
|
||||||
import { CoreEditorProps } from "@/props";
|
import { CoreEditorProps } from "@/props";
|
||||||
// types
|
// types
|
||||||
import {
|
import type {
|
||||||
|
TDocumentEventsServer,
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
TEditorCommands,
|
TEditorCommands,
|
||||||
TExtensions,
|
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
TExtensions,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
export interface CustomEditorProps {
|
export interface CustomEditorProps {
|
||||||
|
|
@ -67,9 +68,9 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
onChange,
|
onChange,
|
||||||
onTransaction,
|
onTransaction,
|
||||||
placeholder,
|
placeholder,
|
||||||
provider,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
value,
|
value,
|
||||||
|
provider,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
|
|
@ -257,7 +258,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
if (empty) return null;
|
if (empty) return null;
|
||||||
|
|
||||||
const nodesArray: string[] = [];
|
const nodesArray: string[] = [];
|
||||||
state.doc.nodesBetween(from, to, (node, pos, parent) => {
|
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||||||
if (parent === state.doc && editorRef.current) {
|
if (parent === state.doc && editorRef.current) {
|
||||||
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
|
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
|
||||||
const dom = serializer.serializeNode(node);
|
const dom = serializer.serializeNode(node);
|
||||||
|
|
@ -298,6 +299,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
if (!document) return;
|
if (!document) return;
|
||||||
Y.applyUpdate(document, value);
|
Y.applyUpdate(document, value);
|
||||||
},
|
},
|
||||||
|
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||||
|
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||||
}),
|
}),
|
||||||
[editorRef, savedSelection]
|
[editorRef, savedSelection]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import Collaboration from "@tiptap/extension-collaboration";
|
import Collaboration from "@tiptap/extension-collaboration";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
|
@ -31,8 +31,8 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
||||||
const provider = useMemo(
|
const provider = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new HocuspocusProvider({
|
new HocuspocusProvider({
|
||||||
url: realtimeConfig.url,
|
|
||||||
name: id,
|
name: id,
|
||||||
|
url: realtimeConfig.url,
|
||||||
token: JSON.stringify(user),
|
token: JSON.stringify(user),
|
||||||
parameters: realtimeConfig.queryParams,
|
parameters: realtimeConfig.queryParams,
|
||||||
onAuthenticationFailed: () => {
|
onAuthenticationFailed: () => {
|
||||||
|
|
@ -48,23 +48,23 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
||||||
},
|
},
|
||||||
onSynced: () => setHasServerSynced(true),
|
onSynced: () => setHasServerSynced(true),
|
||||||
}),
|
}),
|
||||||
[id, realtimeConfig, user]
|
[id, realtimeConfig, serverHandler, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// indexed db integration for offline support
|
||||||
|
const localProvider = useMemo(
|
||||||
|
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
||||||
|
[id, provider]
|
||||||
|
);
|
||||||
|
|
||||||
// destroy and disconnect connection on unmount
|
// destroy and disconnect connection on unmount
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
provider.destroy();
|
provider.destroy();
|
||||||
provider.disconnect();
|
|
||||||
},
|
|
||||||
[provider]
|
|
||||||
);
|
|
||||||
// indexed db integration for offline support
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
|
||||||
return () => {
|
|
||||||
localProvider?.destroy();
|
localProvider?.destroy();
|
||||||
};
|
},
|
||||||
}, [provider, id]);
|
[provider, localProvider]
|
||||||
|
);
|
||||||
|
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,13 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||||
// props
|
// props
|
||||||
import { CoreReadOnlyEditorProps } from "@/props";
|
import { CoreReadOnlyEditorProps } from "@/props";
|
||||||
// types
|
// types
|
||||||
import { EditorReadOnlyRefApi, IMentionHighlight, TExtensions, TFileHandler } from "@/types";
|
import type {
|
||||||
|
EditorReadOnlyRefApi,
|
||||||
|
IMentionHighlight,
|
||||||
|
TExtensions,
|
||||||
|
TDocumentEventsServer,
|
||||||
|
TFileHandler,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
interface CustomReadOnlyEditorProps {
|
interface CustomReadOnlyEditorProps {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
|
|
@ -120,6 +126,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||||
editorRef.current?.off("update");
|
editorRef.current?.off("update");
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||||
|
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
|
||||||
|
|
||||||
|
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
|
||||||
|
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
|
||||||
|
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
|
||||||
|
|
||||||
|
export type TDocumentEventEmitter = {
|
||||||
|
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||||
|
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
TAIHandler,
|
TAIHandler,
|
||||||
TDisplayConfig,
|
TDisplayConfig,
|
||||||
|
TDocumentEventEmitter,
|
||||||
|
TDocumentEventsServer,
|
||||||
TEmbedConfig,
|
TEmbedConfig,
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
|
@ -83,6 +85,8 @@ export type EditorReadOnlyRefApi = {
|
||||||
};
|
};
|
||||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||||
getHeadings: () => IMarking[];
|
getHeadings: () => IMarking[];
|
||||||
|
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||||
|
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,4 @@ export * from "./image";
|
||||||
export * from "./mention-suggestion";
|
export * from "./mention-suggestion";
|
||||||
export * from "./slash-commands-suggestion";
|
export * from "./slash-commands-suggestion";
|
||||||
export * from "@/plane-editor/types";
|
export * from "@/plane-editor/types";
|
||||||
|
export * from "./document-collaborative-events";
|
||||||
|
|
|
||||||
|
|
@ -1 +1,4 @@
|
||||||
export * from "@/extensions/core-without-props";
|
export * from "@/extensions/core-without-props";
|
||||||
|
export * from "@/constants/document-collaborative-events";
|
||||||
|
export * from "@/helpers/get-document-server-event";
|
||||||
|
export * from "@/types/document-collaborative-events";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export type { ISvgIcons } from "./type";
|
||||||
export * from "./cycle";
|
export * from "./cycle";
|
||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,9 @@ const PageDetailsPage = observer(() => {
|
||||||
? () => getPageById(workspaceSlug?.toString(), projectId?.toString(), pageId.toString())
|
? () => getPageById(workspaceSlug?.toString(), projectId?.toString(), pageId.toString())
|
||||||
: null,
|
: null,
|
||||||
{
|
{
|
||||||
revalidateIfStale: false,
|
revalidateIfStale: true,
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: true,
|
||||||
revalidateOnReconnect: false,
|
revalidateOnReconnect: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||||
onConnect: handleServerConnect,
|
onConnect: handleServerConnect,
|
||||||
onServerError: handleServerError,
|
onServerError: handleServerError,
|
||||||
}),
|
}),
|
||||||
[]
|
[handleServerConnect, handleServerError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
|
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,27 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
|
import {
|
||||||
|
ArchiveRestoreIcon,
|
||||||
|
ArrowUpToLine,
|
||||||
|
Clipboard,
|
||||||
|
Copy,
|
||||||
|
History,
|
||||||
|
Link,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
// document editor
|
// document editor
|
||||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { ExportPageModal } from "@/components/pages";
|
import { ExportPageModal } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
// hooks
|
// hooks
|
||||||
|
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
import { useQueryParams } from "@/hooks/use-query-params";
|
import { useQueryParams } from "@/hooks/use-query-params";
|
||||||
// store
|
// store
|
||||||
|
|
@ -34,13 +45,9 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
archived_at,
|
archived_at,
|
||||||
is_locked,
|
is_locked,
|
||||||
id,
|
id,
|
||||||
archive,
|
|
||||||
lock,
|
|
||||||
unlock,
|
|
||||||
canCurrentUserArchivePage,
|
canCurrentUserArchivePage,
|
||||||
canCurrentUserDuplicatePage,
|
canCurrentUserDuplicatePage,
|
||||||
canCurrentUserLockPage,
|
canCurrentUserLockPage,
|
||||||
restore,
|
|
||||||
} = page;
|
} = page;
|
||||||
// states
|
// states
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
|
|
@ -50,49 +57,15 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
const { isFullWidth, handleFullWidth } = usePageFilters();
|
const { isFullWidth, handleFullWidth } = usePageFilters();
|
||||||
// update query params
|
// update query params
|
||||||
const { updateQueryParams } = useQueryParams();
|
const { updateQueryParams } = useQueryParams();
|
||||||
|
// collaborative actions
|
||||||
const handleArchivePage = async () =>
|
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
||||||
await archive().catch(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Page could not be archived. Please try again later.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRestorePage = async () =>
|
|
||||||
await restore().catch(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Page could not be restored. Please try again later.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLockPage = async () =>
|
|
||||||
await lock().catch(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Page could not be locked. Please try again later.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUnlockPage = async () =>
|
|
||||||
await unlock().catch(() =>
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Page could not be unlocked. Please try again later.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// menu items list
|
// menu items list
|
||||||
const MENU_ITEMS: {
|
const MENU_ITEMS: {
|
||||||
key: string;
|
key: string;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.FC<any>;
|
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||||
shouldRender: boolean;
|
shouldRender: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -138,14 +111,18 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "lock-unlock-page",
|
key: "lock-unlock-page",
|
||||||
action: is_locked ? handleUnlockPage : handleLockPage,
|
action: is_locked
|
||||||
|
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" })
|
||||||
|
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" }),
|
||||||
label: is_locked ? "Unlock page" : "Lock page",
|
label: is_locked ? "Unlock page" : "Lock page",
|
||||||
icon: is_locked ? LockOpen : Lock,
|
icon: is_locked ? LockOpen : Lock,
|
||||||
shouldRender: canCurrentUserLockPage,
|
shouldRender: canCurrentUserLockPage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "archive-restore-page",
|
key: "archive-restore-page",
|
||||||
action: archived_at ? handleRestorePage : handleArchivePage,
|
action: archived_at
|
||||||
|
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" })
|
||||||
|
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" }),
|
||||||
label: archived_at ? "Restore page" : "Archive page",
|
label: archived_at ? "Restore page" : "Archive page",
|
||||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||||
shouldRender: canCurrentUserArchivePage,
|
shouldRender: canCurrentUserArchivePage,
|
||||||
|
|
|
||||||
100
web/core/hooks/use-collaborative-page-actions.tsx
Normal file
100
web/core/hooks/use-collaborative-page-actions.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor";
|
||||||
|
import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
|
||||||
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
import { IPage } from "@/store/pages/page";
|
||||||
|
|
||||||
|
// Better type naming and structure
|
||||||
|
type CollaborativeAction = {
|
||||||
|
execute: (shouldSync?: boolean) => Promise<void>;
|
||||||
|
errorMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollaborativeActionEvent =
|
||||||
|
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
||||||
|
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
||||||
|
|
||||||
|
export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorReadOnlyRefApi | null, page: IPage) => {
|
||||||
|
// currentUserAction local state to track if the current action is being processed, a
|
||||||
|
// local action is basically the action performed by the current user to avoid double operations
|
||||||
|
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
||||||
|
|
||||||
|
const actionHandlerMap: Record<TDocumentEventsClient, CollaborativeAction> = useMemo(
|
||||||
|
() => ({
|
||||||
|
[DocumentCollaborativeEvents.lock.client]: {
|
||||||
|
execute: (shouldSync) => page.lock(shouldSync),
|
||||||
|
errorMessage: "Page could not be locked. Please try again later.",
|
||||||
|
},
|
||||||
|
[DocumentCollaborativeEvents.unlock.client]: {
|
||||||
|
execute: (shouldSync) => page.unlock(shouldSync),
|
||||||
|
errorMessage: "Page could not be unlocked. Please try again later.",
|
||||||
|
},
|
||||||
|
[DocumentCollaborativeEvents.archive.client]: {
|
||||||
|
execute: (shouldSync) => page.archive(shouldSync),
|
||||||
|
errorMessage: "Page could not be archived. Please try again later.",
|
||||||
|
},
|
||||||
|
[DocumentCollaborativeEvents.unarchive.client]: {
|
||||||
|
execute: (shouldSync) => page.restore(shouldSync),
|
||||||
|
errorMessage: "Page could not be restored. Please try again later.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[page]
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeCollaborativeAction = useCallback(
|
||||||
|
async (event: CollaborativeActionEvent) => {
|
||||||
|
const isPerformedByCurrentUser = event.type === "sendMessageToServer";
|
||||||
|
const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message;
|
||||||
|
const actionDetails = actionHandlerMap[clientAction];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actionDetails.execute(isPerformedByCurrentUser);
|
||||||
|
if (isPerformedByCurrentUser) {
|
||||||
|
setCurrentActionBeingProcessed(clientAction);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Error!",
|
||||||
|
message: actionDetails.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[actionHandlerMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentActionBeingProcessed) {
|
||||||
|
const serverEventName = getServerEventName(currentActionBeingProcessed);
|
||||||
|
if (serverEventName) {
|
||||||
|
editorRef?.emitRealTimeUpdate(serverEventName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentActionBeingProcessed, editorRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
|
||||||
|
|
||||||
|
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
|
||||||
|
if (currentActionBeingProcessed === message.payload) {
|
||||||
|
setCurrentActionBeingProcessed(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.payload) {
|
||||||
|
executeCollaborativeAction({ type: "receivedMessageFromServer", message: message.payload });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
realTimeStatelessMessageListener?.on("stateless", handleStatelessMessage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
realTimeStatelessMessageListener?.off("stateless", handleStatelessMessage);
|
||||||
|
};
|
||||||
|
}, [editorRef, currentActionBeingProcessed, executeCollaborativeAction]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
executeCollaborativeAction,
|
||||||
|
EVENT_ACTION_DETAILS_MAP: actionHandlerMap,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -36,10 +36,10 @@ export interface IPage extends TPage {
|
||||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||||
makePublic: () => Promise<void>;
|
makePublic: () => Promise<void>;
|
||||||
makePrivate: () => Promise<void>;
|
makePrivate: () => Promise<void>;
|
||||||
lock: () => Promise<void>;
|
lock: (shouldSync?: boolean) => Promise<void>;
|
||||||
unlock: () => Promise<void>;
|
unlock: (shouldSync?: boolean) => Promise<void>;
|
||||||
archive: () => Promise<void>;
|
archive: (shouldSync?: boolean) => Promise<void>;
|
||||||
restore: () => Promise<void>;
|
restore: (shouldSync?: boolean) => Promise<void>;
|
||||||
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
|
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
|
||||||
addToFavorites: () => Promise<void>;
|
addToFavorites: () => Promise<void>;
|
||||||
removePageFromFavorites: () => Promise<void>;
|
removePageFromFavorites: () => Promise<void>;
|
||||||
|
|
@ -444,62 +444,94 @@ export class Page implements IPage {
|
||||||
/**
|
/**
|
||||||
* @description lock the page
|
* @description lock the page
|
||||||
*/
|
*/
|
||||||
lock = async () => {
|
lock = async (shouldSync: boolean = true) => {
|
||||||
const { workspaceSlug, projectId } = this.store.router;
|
const { workspaceSlug, projectId } = this.store.router;
|
||||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||||
|
|
||||||
const pageIsLocked = this.is_locked;
|
const pageIsLocked = this.is_locked;
|
||||||
runInAction(() => (this.is_locked = true));
|
runInAction(() => (this.is_locked = true));
|
||||||
|
|
||||||
|
if (shouldSync) {
|
||||||
await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => {
|
await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.is_locked = pageIsLocked;
|
this.is_locked = pageIsLocked;
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description unlock the page
|
* @description unlock the page
|
||||||
*/
|
*/
|
||||||
unlock = async () => {
|
unlock = async (shouldSync: boolean = true) => {
|
||||||
const { workspaceSlug, projectId } = this.store.router;
|
const { workspaceSlug, projectId } = this.store.router;
|
||||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||||
|
|
||||||
const pageIsLocked = this.is_locked;
|
const pageIsLocked = this.is_locked;
|
||||||
runInAction(() => (this.is_locked = false));
|
runInAction(() => (this.is_locked = false));
|
||||||
|
|
||||||
|
if (shouldSync) {
|
||||||
await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => {
|
await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.is_locked = pageIsLocked;
|
this.is_locked = pageIsLocked;
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description archive the page
|
* @description archive the page
|
||||||
*/
|
*/
|
||||||
archive = async () => {
|
archive = async (shouldSync: boolean = true) => {
|
||||||
const { workspaceSlug, projectId } = this.store.router;
|
const { workspaceSlug, projectId } = this.store.router;
|
||||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
runInAction(() => {
|
||||||
|
this.archived_at = new Date().toISOString();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
|
||||||
|
|
||||||
|
if (shouldSync) {
|
||||||
const response = await this.pageService.archive(workspaceSlug, projectId, this.id);
|
const response = await this.pageService.archive(workspaceSlug, projectId, this.id);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.archived_at = response.archived_at;
|
this.archived_at = response.archived_at;
|
||||||
});
|
});
|
||||||
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
runInAction(() => {
|
||||||
|
this.archived_at = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description restore the page
|
* @description restore the page
|
||||||
*/
|
*/
|
||||||
restore = async () => {
|
restore = async (shouldSync: boolean = true) => {
|
||||||
const { workspaceSlug, projectId } = this.store.router;
|
const { workspaceSlug, projectId } = this.store.router;
|
||||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||||
await this.pageService.restore(workspaceSlug, projectId, this.id);
|
|
||||||
|
const archivedAtBeforeRestore = this.archived_at;
|
||||||
|
|
||||||
|
try {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.archived_at = null;
|
this.archived_at = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldSync) {
|
||||||
|
await this.pageService.restore(workspaceSlug, projectId, this.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
runInAction(() => {
|
||||||
|
this.archived_at = archivedAtBeforeRestore;
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
updatePageLogo = async (logo_props: TLogoProps) => {
|
updatePageLogo = async (logo_props: TLogoProps) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue