[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
|
|
@ -123,7 +123,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||
onConnect: handleServerConnect,
|
||||
onServerError: handleServerError,
|
||||
}),
|
||||
[]
|
||||
[handleServerConnect, handleServerError]
|
||||
);
|
||||
|
||||
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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>;
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue