[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:
M. Palanikannan 2024-12-02 14:26:36 +05:30 committed by GitHub
parent 8c04aa6f51
commit 3c6006d04a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 277 additions and 115 deletions

View file

@ -123,7 +123,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
onConnect: handleServerConnect,
onServerError: handleServerError,
}),
[]
[handleServerConnect, handleServerError]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {

View file

@ -3,16 +3,27 @@
import { useState } from "react";
import { observer } from "mobx-react";
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
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ExportPageModal } from "@/components/pages";
// helpers
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useQueryParams } from "@/hooks/use-query-params";
// store
@ -34,13 +45,9 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
archived_at,
is_locked,
id,
archive,
lock,
unlock,
canCurrentUserArchivePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
restore,
} = page;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
@ -50,49 +57,15 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { isFullWidth, handleFullWidth } = usePageFilters();
// update query params
const { updateQueryParams } = useQueryParams();
const handleArchivePage = async () =>
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.",
})
);
// collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
// menu items list
const MENU_ITEMS: {
key: string;
action: () => void;
label: string;
icon: React.FC<any>;
icon: LucideIcon | React.FC<ISvgIcons>;
shouldRender: boolean;
}[] = [
{
@ -138,14 +111,18 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
},
{
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",
icon: is_locked ? LockOpen : Lock,
shouldRender: canCurrentUserLockPage,
},
{
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",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,

View 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,
};
};

View file

@ -36,10 +36,10 @@ export interface IPage extends TPage {
updateDescription: (document: TDocumentPayload) => Promise<void>;
makePublic: () => Promise<void>;
makePrivate: () => Promise<void>;
lock: () => Promise<void>;
unlock: () => Promise<void>;
archive: () => Promise<void>;
restore: () => Promise<void>;
lock: (shouldSync?: boolean) => Promise<void>;
unlock: (shouldSync?: boolean) => Promise<void>;
archive: (shouldSync?: boolean) => Promise<void>;
restore: (shouldSync?: boolean) => Promise<void>;
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
@ -444,62 +444,94 @@ export class Page implements IPage {
/**
* @description lock the page
*/
lock = async () => {
lock = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageIsLocked = this.is_locked;
runInAction(() => (this.is_locked = true));
await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => {
runInAction(() => {
this.is_locked = pageIsLocked;
if (shouldSync) {
await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => {
runInAction(() => {
this.is_locked = pageIsLocked;
});
throw error;
});
throw error;
});
}
};
/**
* @description unlock the page
*/
unlock = async () => {
unlock = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageIsLocked = this.is_locked;
runInAction(() => (this.is_locked = false));
await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => {
runInAction(() => {
this.is_locked = pageIsLocked;
if (shouldSync) {
await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => {
runInAction(() => {
this.is_locked = pageIsLocked;
});
throw error;
});
throw error;
});
}
};
/**
* @description archive the page
*/
archive = async () => {
archive = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const response = await this.pageService.archive(workspaceSlug, projectId, this.id);
runInAction(() => {
this.archived_at = response.archived_at;
});
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
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);
runInAction(() => {
this.archived_at = response.archived_at;
});
}
} catch (error) {
console.error(error);
runInAction(() => {
this.archived_at = null;
});
}
};
/**
* @description restore the page
*/
restore = async () => {
restore = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
await this.pageService.restore(workspaceSlug, projectId, this.id);
runInAction(() => {
this.archived_at = null;
});
const archivedAtBeforeRestore = this.archived_at;
try {
runInAction(() => {
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) => {