[WEB-1322] dev: conflict free pages collaboration (#4463)

* chore: pages realtime

* chore: empty binary response

* chore: added a ypy package

* feat: pages collaboration

* chore: update fetching logic

* chore: degrade ypy version

* chore: replace useEffect fetch logic with useSWR

* chore: move all the update logic to the page store

* refactor: remove react-hook-form

* chore: save description_html as well

* chore: migrate old data logic

* fix: added description_binary as field name

* fix: code cleanup

* refactor: create separate hook to handle page description

* fix: build errors

* chore: combine updates instead of using the whole document

* chore: removed ypy package

* chore: added conflict resolving logic to the client side

* chore: add a save changes button

* chore: add read-only validation

* chore: remove saving state information

* chore: added permission class

* chore: removed the migration file

* chore: corrected the model field

* chore: rename pageStore to page

* chore: update collaboration provider

* chore: add try catch to handle error

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-05-26 16:37:10 +05:30 committed by sriram veeraghanta
parent a04ce5abfc
commit ff03c0b718
42 changed files with 1134 additions and 509 deletions

View file

@ -1,30 +1,26 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { FileText } from "lucide-react";
// hooks
// ui
import { Breadcrumbs, Button } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common";
// components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { useCommandPalette, usePage, useProject } from "@/hooks/store";
// hooks
import { usePage, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
export interface IPagesHeaderProps {
showButton?: boolean;
}
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { showButton = false } = props;
export const PageDetailsHeader = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, pageId } = router.query;
// store hooks
const { toggleCreatePageModal } = useCommandPalette();
const { currentProjectDetails } = useProject();
const { name } = usePage(pageId?.toString() ?? "");
const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? "");
// use platform
const { platform } = usePlatformOS();
// derived values
const isMac = platform === "MacOS";
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
@ -77,12 +73,24 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
</Breadcrumbs>
</div>
</div>
{showButton && (
<div className="flex items-center gap-2">
<Button variant="primary" size="sm" onClick={() => toggleCreatePageModal(true)}>
Add Page
</Button>
</div>
{isContentEditable && (
<Button
variant="primary"
size="sm"
onClick={() => {
// ctrl/cmd + s to save the changes
const event = new KeyboardEvent("keydown", {
key: "s",
ctrlKey: !isMac,
metaKey: isMac,
});
window.dispatchEvent(event);
}}
className="flex-shrink-0"
loading={isSubmitting === "submitting"}
>
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
</Button>
)}
</div>
);

View file

@ -1,8 +1,7 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { Control, Controller } from "react-hook-form";
// document editor
// document-editor
import {
DocumentEditorWithRef,
DocumentReadOnlyEditorWithRef,
@ -11,15 +10,15 @@ import {
IMarking,
} from "@plane/document-editor";
// types
import { IUserLite, TPage } from "@plane/types";
import { IUserLite } from "@plane/types";
// components
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { usePageDescription } from "@/hooks/use-page-description";
import { usePageFilters } from "@/hooks/use-page-filters";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { FileService } from "@/services/file.service";
// store
@ -28,13 +27,10 @@ import { IPageStore } from "@/store/pages/page.store";
const fileService = new FileService();
type Props = {
control: Control<TPage, any>;
editorRef: React.RefObject<EditorRefApi>;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
swrPageDetails: TPage | undefined;
handleSubmit: () => void;
markings: IMarking[];
pageStore: IPageStore;
page: IPageStore;
sidePeekVisible: boolean;
handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void;
@ -43,15 +39,12 @@ type Props = {
export const PageEditorBody: React.FC<Props> = observer((props) => {
const {
control,
handleReadOnlyEditorReady,
handleEditorReady,
editorRef,
markings,
readOnlyEditorRef,
handleSubmit,
pageStore,
swrPageDetails,
page,
sidePeekVisible,
updateMarkings,
} = props;
@ -67,11 +60,19 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
} = useMember();
// derived values
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
const pageTitle = pageStore?.name ?? "";
const pageDescription = pageStore?.description_html;
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore;
const pageId = page?.id;
const pageTitle = page?.name ?? "";
const pageDescription = page?.description_html;
const { isContentEditable, updateTitle, setIsSubmitting } = page;
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
// project-description
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({
editorRef,
page,
projectId,
workspaceSlug,
});
// use-mention
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug?.toString() ?? "",
@ -82,13 +83,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
// page filters
const { isFullWidth } = usePageFilters();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => {
updateMarkings(description_html ?? "<p></p>");
}, [description_html, updateMarkings]);
updateMarkings(pageDescription ?? "<p></p>");
}, [pageDescription, updateMarkings]);
if (pageDescription === undefined) return <PageContentLoader />;
if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return <PageContentLoader />;
return (
<div className="flex items-center h-full w-full overflow-y-auto">
@ -122,35 +121,24 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
/>
</div>
{isContentEditable ? (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<DocumentEditorWithRef
fileHandler={{
cancel: fileService.cancelUpload,
delete: fileService.getDeleteImageFunction(workspaceId),
restore: fileService.getRestoreImageFunction(workspaceId),
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
}}
handleEditorReady={handleEditorReady}
initialValue={pageDescription ?? "<p></p>"}
value={swrPageDetails?.description_html ?? "<p></p>"}
ref={editorRef}
containerClassName="p-0 pb-64"
editorClassName="lg:px-10 pl-8"
onChange={(_description_json, description_html) => {
setIsSubmitting("submitting");
setShowAlert(true);
onChange(description_html);
handleSubmit();
}}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
/>
)}
<DocumentEditorWithRef
id={pageId}
fileHandler={{
cancel: fileService.cancelUpload,
delete: fileService.getDeleteImageFunction(workspaceId),
restore: fileService.getRestoreImageFunction(workspaceId),
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
}}
handleEditorReady={handleEditorReady}
value={pageDescriptionYJS}
ref={editorRef}
containerClassName="p-0 pb-64"
editorClassName="pl-10"
onChange={handleDescriptionChange}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
/>
) : (
<DocumentReadOnlyEditorWithRef
@ -158,7 +146,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
initialValue={pageDescription ?? "<p></p>"}
handleEditorReady={handleReadOnlyEditorReady}
containerClassName="p-0 pb-64 border-none"
editorClassName="lg:px-10 pl-8"
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
}}

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Lock, RefreshCw, Sparkle } from "lucide-react";
import { Lock, Sparkle } from "lucide-react";
// editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor";
// ui
@ -9,7 +9,6 @@ import { ArchiveIcon } from "@plane/ui";
import { GptAssistantPopover } from "@/components/core";
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useInstance } from "@/hooks/store";
@ -19,20 +18,19 @@ import { IPageStore } from "@/store/pages/page.store";
type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
isSyncing: boolean;
pageStore: IPageStore;
page: IPageStore;
projectId: string;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, isSyncing, pageStore, projectId, readOnlyEditorRef } = props;
const { editorRef, handleDuplicatePage, page, projectId, readOnlyEditorRef } = props;
// states
const [gptModalOpen, setGptModal] = useState(false);
// store hooks
const { config } = useInstance();
// derived values
const { archived_at, isContentEditable, isSubmitting, is_locked } = pageStore;
const { archived_at, isContentEditable, is_locked } = page;
const handleAiAssistance = async (response: string) => {
if (!editorRef) return;
@ -41,22 +39,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
return (
<div className="flex flex-grow items-center justify-end gap-3">
{isContentEditable && (
<div
className={cn("fade-in flex items-center gap-x-2 transition-all duration-300", {
"fade-out": isSubmitting === "saved",
})}
>
{isSubmitting === "submitting" && <RefreshCw className="h-4 w-4 stroke-custom-text-300" />}
<span className="text-sm text-custom-text-300">{isSubmitting === "submitting" ? "Saving..." : "Saved"}</span>
</div>
)}
{isSyncing && (
<div className="flex items-center gap-x-2">
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
<span className="text-sm text-custom-text-300">Syncing...</span>
</div>
)}
{is_locked && (
<div className="flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<Lock className="h-3 w-3" />
@ -93,11 +75,11 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
className="!min-w-[38rem]"
/>
)}
<PageInfoPopover pageStore={pageStore} />
<PageInfoPopover page={page} />
<PageOptionsDropdown
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage}
pageStore={pageStore}
page={page}
/>
</div>
);

View file

@ -7,11 +7,11 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
import { IPageStore } from "@/store/pages/page.store";
type Props = {
pageStore: IPageStore;
page: IPageStore;
};
export const PageInfoPopover: React.FC<Props> = (props) => {
const { pageStore } = props;
const { page } = props;
// states
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
// refs
@ -22,7 +22,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
placement: "bottom-start",
});
// derived values
const { created_at, updated_at } = pageStore;
const { created_at, updated_at } = page;
return (
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>

View file

@ -11,9 +11,8 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
handleDuplicatePage: () => void;
isSyncing: boolean;
markings: IMarking[];
pageStore: IPageStore;
page: IPageStore;
projectId: string;
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
@ -29,14 +28,13 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
markings,
readOnlyEditorReady,
handleDuplicatePage,
isSyncing,
pageStore,
page,
projectId,
sidePeekVisible,
setSidePeekVisible,
} = props;
// derived values
const { isContentEditable } = pageStore;
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
@ -57,8 +55,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
isSyncing={isSyncing}
pageStore={pageStore}
page={page}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
/>

View file

@ -16,11 +16,11 @@ import { IPageStore } from "@/store/pages/page.store";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
handleDuplicatePage: () => void;
pageStore: IPageStore;
page: IPageStore;
};
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, pageStore } = props;
const { editorRef, handleDuplicatePage, page } = props;
// store values
const {
archived_at,
@ -33,7 +33,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
restore,
} = pageStore;
} = page;
// store hooks
const { workspaceSlug, projectId } = useAppRouter();
// page filters

View file

@ -13,9 +13,8 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
handleDuplicatePage: () => void;
isSyncing: boolean;
markings: IMarking[];
pageStore: IPageStore;
page: IPageStore;
projectId: string;
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
@ -31,14 +30,13 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
markings,
readOnlyEditorReady,
handleDuplicatePage,
isSyncing,
pageStore,
page,
projectId,
sidePeekVisible,
setSidePeekVisible,
} = props;
// derived values
const { isContentEditable } = pageStore;
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
@ -67,8 +65,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
isSyncing={isSyncing}
pageStore={pageStore}
page={page}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
/>
@ -81,8 +78,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
readOnlyEditorReady={readOnlyEditorReady}
markings={markings}
handleDuplicatePage={handleDuplicatePage}
isSyncing={isSyncing}
pageStore={pageStore}
page={page}
projectId={projectId}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}

View file

@ -33,7 +33,6 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
) : (
<>
<TextArea
onChange={(e) => updateTitle(e.target.value)}
className="w-full bg-custom-background text-[1.75rem] font-semibold outline-none p-0 border-none resize-none rounded-none"
style={{
lineHeight: "1.2",
@ -46,6 +45,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
}
}}
value={title}
onChange={(e) => updateTitle(e.target.value)}
maxLength={255}
onFocus={() => setIsLengthVisible(true)}
onBlur={() => setIsLengthVisible(false)}

View file

@ -4,6 +4,5 @@ export * from "./header";
export * from "./list";
export * from "./loaders";
export * from "./modals";
export * from "./page-detail";
export * from "./pages-list-main-content";
export * from "./pages-list-view";

View file

@ -2,27 +2,116 @@
import { Loader } from "@plane/ui";
export const PageContentLoader = () => (
<div className="flex">
<div className="w-[5%]" />
<Loader className="flex-shrink-0 flex-grow">
<div className="mt-10 space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" width="80%" />
<Loader.Item height="20px" width="80%" />
<div className="relative w-full h-full flex flex-col">
{/* header */}
<div className="px-4 flex-shrink-0 relative flex items-center justify-between h-12 border-b border-custom-border-100">
{/* left options */}
<Loader className="flex-shrink-0 w-[280px]">
<Loader.Item width="26px" height="26px" />
</Loader>
{/* editor options */}
<div className="w-full relative flex items-center divide-x divide-custom-border-100">
<Loader className="relative flex items-center gap-1 pr-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
<Loader className="relative flex items-center gap-1 px-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
<Loader className="relative flex items-center gap-1 px-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
<Loader className="relative flex items-center gap-1 pl-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
</div>
<div className="mt-12 space-y-10">
{Array.from(Array(4)).map((i) => (
<div key={i}>
<Loader.Item height="25px" width="20%" />
<div className="mt-5 space-y-3">
<Loader.Item height="15px" width="40%" />
<Loader.Item height="15px" width="30%" />
<Loader.Item height="15px" width="35%" />
{/* right options */}
<Loader className="w-full relative flex justify-end items-center gap-1">
<Loader.Item width="60px" height="26px" />
<Loader.Item width="40px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
</div>
{/* content */}
<div className="px-4 w-full h-full overflow-hidden relative flex">
{/* table of content loader */}
<div className="flex-shrink-0 w-[280px] pr-5 py-5">
<Loader className="w-full space-y-4">
<Loader.Item width="100%" height="24px" />
<div className="space-y-2">
<Loader.Item width="60%" height="12px" />
<div className="ml-6 space-y-2">
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
<Loader.Item width="60%" height="12px" />
<div className="ml-6 space-y-2">
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
<Loader.Item width="100%" height="12px" />
<Loader.Item width="60%" height="12px" />
<div className="ml-6 space-y-2">
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
</Loader>
</div>
{/* editor loader */}
<div className="w-full h-full py-5">
<Loader className="relative space-y-4">
<Loader.Item width="50%" height="36px" />
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
))}
</Loader>
</div>
</Loader>
<div className="w-[5%]" />
</div>
</div>
);

View file

@ -23,11 +23,11 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
// store hooks
const { removePage } = useProjectPages(projectId);
const { capturePageEvent } = useEventTracker();
const pageStore = usePage(pageId);
const page = usePage(pageId);
if (!pageStore) return null;
if (!page) return null;
const { name } = pageStore;
const { name } = page;
const handleClose = () => {
setIsDeleting(false);
@ -41,7 +41,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
capturePageEvent({
eventName: PAGE_DELETED,
payload: {
...pageStore,
...page,
state: "SUCCESS",
},
});
@ -56,7 +56,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
capturePageEvent({
eventName: PAGE_DELETED,
payload: {
...pageStore,
...page,
state: "FAILED",
},
});

View file

@ -1,3 +0,0 @@
export * from "./loader";
export * from "./root";

View file

@ -1,118 +0,0 @@
import { FC } from "react";
// components/ui
import { Loader } from "@plane/ui";
export const PageDetailRootLoader: FC = () => (
<div className=" relative w-full h-full flex flex-col">
{/* header */}
<div className="px-4 flex-shrink-0 relative flex items-center justify-between h-12 border-b border-custom-border-100">
{/* left options */}
<Loader className="flex-shrink-0 w-[280px]">
<Loader.Item width="26px" height="26px" />
</Loader>
{/* editor options */}
<div className="w-full relative flex items-center divide-x divide-custom-border-100">
<Loader className="relative flex items-center gap-1 pr-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
<Loader className="relative flex items-center gap-1 px-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
<Loader className="relative flex items-center gap-1 px-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
<Loader className="relative flex items-center gap-1 pl-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
</div>
{/* right options */}
<Loader className="w-full relative flex justify-end items-center gap-1">
<Loader.Item width="60px" height="26px" />
<Loader.Item width="40px" height="26px" />
<Loader.Item width="26px" height="26px" />
<Loader.Item width="26px" height="26px" />
</Loader>
</div>
{/* content */}
<div className="px-4 w-full h-full overflow-hidden relative flex">
{/* table of content loader */}
<div className="flex-shrink-0 w-[280px] pr-5 py-5">
<Loader className="w-full space-y-4">
<Loader.Item width="100%" height="24px" />
<div className="space-y-2">
<Loader.Item width="60%" height="12px" />
<div className="ml-6 space-y-2">
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
<Loader.Item width="60%" height="12px" />
<div className="ml-6 space-y-2">
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
<Loader.Item width="100%" height="12px" />
<Loader.Item width="60%" height="12px" />
<div className="ml-6 space-y-2">
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
<Loader.Item width="80%" height="12px" />
<Loader.Item width="100%" height="12px" />
</div>
</Loader>
</div>
{/* editor loader */}
<div className="w-full h-full py-5">
<Loader className="relative space-y-4">
<Loader.Item width="50%" height="36px" />
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
</div>
</div>
);

View file

@ -1,54 +0,0 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { PageHead } from "@/components/core";
import { useProjectPages, usePage } from "@/hooks/store";
// components
import { PageDetailRootLoader } from "./";
type TPageDetailRoot = {
projectId: string;
pageId: string;
};
export const PageDetailRoot: FC<TPageDetailRoot> = observer((props) => {
const { projectId, pageId } = props;
// hooks
const { loader } = useProjectPages(projectId);
const { id, name } = usePage(pageId);
if (loader === "init-loader") return <PageDetailRootLoader />;
if (!id) return <div className="">No page is available.</div>;
return (
<Fragment>
<PageHead title={name || "Pages"} />
<div className="relative w-full h-full flex flex-col">
<div className="flex-shrink-0 px-4 relative flex items-center justify-between h-12 border-b border-custom-border-100">
{/* header left container */}
<div className="flex-shrink-0 w-[280px]">Icon</div>
{/* header editor tool container */}
<div className="w-full relative hidden md:flex items-center divide-x divide-custom-border-100 ">
Editor keys
</div>
{/* header right operations container */}
<div className="w-full relative flex justify-end">right saved</div>
</div>
{/* editor container for small screens */}
<div className="px-4 h-12 relative flex md:hidden items-center border-b border-custom-border-100">
Editor keys
</div>
<div className="px-4 w-full h-full overflow-hidden relative flex">
{/* editor table of content content container */}
<div className="flex-shrink-0 w-[280px] pr-5 py-5">Table of content</div>
{/* editor container */}
<div className="w-full h-full py-5">Editor Container</div>
</div>
</div>
</Fragment>
);
});

View file

@ -0,0 +1,153 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
// editor
import { applyUpdates, mergeUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core";
// hooks
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { PageService } from "@/services/page.service";
import { IPageStore } from "@/store/pages/page.store";
const pageService = new PageService();
type Props = {
editorRef: React.RefObject<EditorRefApi>;
page: IPageStore;
projectId: string | string[] | undefined;
workspaceSlug: string | string[] | undefined;
};
const AUTO_SAVE_TIME = 10000;
export const usePageDescription = (props: Props) => {
const { editorRef, page, projectId, workspaceSlug } = props;
// states
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
const [descriptionUpdates, setDescriptionUpdates] = useState<Uint8Array[]>([]);
// derived values
const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page;
const pageDescription = page.description_html;
const pageId = page.id;
const { data: descriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
workspaceSlug && projectId && pageId
? () => pageService.fetchDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
}
);
// description in Uint8Array format
const pageDescriptionYJS = useMemo(
() => (descriptionYJS ? new Uint8Array(descriptionYJS) : undefined),
[descriptionYJS]
);
// push the new updates to the updates array
const handleDescriptionChange = useCallback((updates: Uint8Array) => {
setDescriptionUpdates((prev) => [...prev, updates]);
}, []);
// if description_binary field is empty, convert description_html to yDoc and update the DB
// TODO: this is a one-time operation, and needs to be removed once all the pages are updated
useEffect(() => {
const changeHTMLToBinary = async () => {
if (!pageDescriptionYJS || !pageDescription) return;
if (pageDescriptionYJS.byteLength === 0) {
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
await mutateDescriptionYJS();
setIsDescriptionReady(true);
} else setIsDescriptionReady(true);
};
changeHTMLToBinary();
}, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]);
const handleSaveDescription = useCallback(async () => {
if (!isContentEditable) return;
const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => {
if (!workspaceSlug || !projectId || !pageId || !latestDescription) return;
// convert description to Uint8Array
const descriptionArray = new Uint8Array(latestDescription);
// apply the updates to the description
const combinedBinaryString = applyUpdates(descriptionArray, updates);
// get the latest html content
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
// make a request to update the descriptions
await updateDescription(combinedBinaryString, descriptionHTML).finally(() => setIsSubmitting("saved"));
};
try {
setIsSubmitting("submitting");
// fetch the latest description
const latestDescription = await mutateDescriptionYJS();
// return if there are no updates
if (descriptionUpdates.length <= 0) {
setIsSubmitting("saved");
return;
}
// merge the updates array into one single update
const mergedUpdates = mergeUpdates(descriptionUpdates);
await applyUpdatesAndSave(latestDescription, mergedUpdates);
// reset the updates array to empty
setDescriptionUpdates([]);
} catch (error) {
setIsSubmitting("saved");
throw error;
}
}, [
descriptionUpdates,
editorRef,
isContentEditable,
mutateDescriptionYJS,
pageId,
projectId,
setIsSubmitting,
updateDescription,
workspaceSlug,
]);
// auto-save updates every 10 seconds
// handle ctrl/cmd + S to save the description
useEffect(() => {
const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME);
const handleSave = (e: KeyboardEvent) => {
const { ctrlKey, metaKey, key } = e;
const cmdClicked = ctrlKey || metaKey;
if (cmdClicked && key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
handleSaveDescription();
// reset interval timer
clearInterval(intervalId);
}
};
window.addEventListener("keydown", handleSave);
return () => {
clearInterval(intervalId);
window.removeEventListener("keydown", handleSave);
};
}, [handleSaveDescription]);
// show a confirm dialog if there are any unsaved changes, or saving is going on
const { setShowAlert } = useReloadConfirmations(descriptionUpdates.length > 0 || isSubmitting === "submitting");
useEffect(() => {
if (descriptionUpdates.length > 0 || isSubmitting === "submitting") setShowAlert(true);
else setShowAlert(false);
}, [descriptionUpdates, isSubmitting, setShowAlert]);
return {
handleDescriptionChange,
isDescriptionReady,
pageDescriptionYJS,
};
};

View file

@ -1,8 +1,7 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import { ReactElement, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import useSWR from "swr";
// document-editor
import { EditorRefApi, useEditorMarkings } from "@plane/document-editor";
@ -38,38 +37,24 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const { workspaceSlug, projectId, pageId } = router.query;
// store hooks
const { createPage, getPageById } = useProjectPages(projectId?.toString() ?? "");
const pageStore = usePage(pageId?.toString() ?? "");
const page = usePage(pageId?.toString() ?? "");
const { description_html, id, name } = page;
// editor markings hook
const { markings, updateMarkings } = useEditorMarkings();
// form info
const { handleSubmit, getValues, control } = useForm<TPage>({
defaultValues: {
name: "",
description_html: "",
},
});
// fetching page details
const {
data: swrPageDetails,
isValidating,
error: pageDetailsError,
} = useSWR(pageId ? `PAGE_DETAILS_${pageId}` : null, pageId ? () => getPageById(pageId.toString()) : null, {
revalidateIfStale: false,
revalidateOnFocus: true,
revalidateOnReconnect: true,
});
useEffect(
() => () => {
if (pageStore.cleanup) pageStore.cleanup();
},
[pageStore]
// fetch page details
const { error: pageDetailsError } = useSWR(
pageId ? `PAGE_DETAILS_${pageId}` : null,
pageId ? () => getPageById(pageId.toString()) : null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
if ((!pageStore || !pageStore.id) && !pageDetailsError)
if ((!page || !id) && !pageDetailsError)
return (
<div className="h-full w-full grid place-items-center">
<div className="size-full grid place-items-center">
<LogoSpinner />
</div>
);
@ -90,28 +75,12 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
</div>
);
// we need to get the values of title and description from the page store but we don't have to subscribe to those values
const pageTitle = pageStore?.name;
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
const handleUpdatePage = async (formData: TPage) => {
let updatedDescription = formData.description_html;
if (!updatedDescription || updatedDescription.trim() === "") updatedDescription = "<p></p>";
pageStore.updateDescription(updatedDescription);
};
const handleDuplicatePage = async () => {
const currentPageValues = getValues();
if (!currentPageValues?.description_html) {
// TODO: We need to get latest data the above variable will give us stale data
currentPageValues.description_html = pageStore.description_html;
}
const formData: Partial<TPage> = {
name: "Copy of " + pageStore.name,
description_html: currentPageValues.description_html,
name: "Copy of " + name,
description_html: description_html ?? "<p></p>",
};
await handleCreatePage(formData)
@ -127,7 +96,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<PageHead title={name} />
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
{projectId && (
@ -137,24 +106,20 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
handleDuplicatePage={handleDuplicatePage}
isSyncing={isValidating}
markings={markings}
pageStore={pageStore}
page={page}
projectId={projectId.toString()}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
/>
)}
<PageEditorBody
swrPageDetails={swrPageDetails}
control={control}
editorRef={editorRef}
handleEditorReady={(val) => setEditorReady(val)}
readOnlyEditorRef={readOnlyEditorRef}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
handleSubmit={() => handleSubmit(handleUpdatePage)()}
markings={markings}
pageStore={pageStore}
page={page}
sidePeekVisible={sidePeekVisible}
updateMarkings={updateMarkings}
/>

View file

@ -31,8 +31,11 @@ export abstract class APIService {
);
}
get(url: string, params = {}) {
return this.axiosInstance.get(url, params);
get(url: string, params = {}, config = {}) {
return this.axiosInstance.get(url, {
...params,
...config,
});
}
post(url: string, data = {}, config = {}) {

View file

@ -119,4 +119,33 @@ export class PageService extends APIService {
throw error?.response?.data;
});
}
async fetchDescriptionYJS(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
headers: {
"Content-Type": "application/octet-stream",
},
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateDescriptionYJS(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
}
): Promise<any> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -9,12 +9,11 @@ import { EUserProjectRoles } from "@/constants/project";
import { PageService } from "@/services/page.service";
import { RootStore } from "../root.store";
export type TLoader = "submitting" | "submitted" | "saved" | undefined;
export type TLoader = "submitting" | "submitted" | "saved";
export interface IPageStore extends TPage {
// observables
isSubmitting: "submitting" | "submitted" | "saved";
loader: TLoader;
isSubmitting: TLoader;
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean; // it will give the user is the owner of the page or not
@ -27,12 +26,12 @@ export interface IPageStore extends TPage {
isContentEditable: boolean;
// helpers
oldName: string;
updateTitle: (name: string) => void;
updateDescription: (description: string) => void;
setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting: (value: TLoader) => void;
cleanup: () => void;
// actions
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
updateTitle: (title: string) => void;
updateDescription: (binaryString: string, descriptionHTML: string) => Promise<void>;
makePublic: () => Promise<void>;
makePrivate: () => Promise<void>;
lock: () => Promise<void>;
@ -45,8 +44,7 @@ export interface IPageStore extends TPage {
export class PageStore implements IPageStore {
// loaders
isSubmitting: "submitting" | "submitted" | "saved" = "saved";
loader: TLoader = undefined;
isSubmitting: TLoader = "saved";
// page properties
id: string | undefined;
name: string | undefined;
@ -68,7 +66,7 @@ export class PageStore implements IPageStore {
oldName: string = "";
// reactions
disposers: Array<() => void> = [];
// service
// services
pageService: PageService;
constructor(
@ -96,7 +94,6 @@ export class PageStore implements IPageStore {
makeObservable(this, {
// loaders
isSubmitting: observable.ref,
loader: observable.ref,
// page properties
id: observable.ref,
name: observable.ref,
@ -115,7 +112,9 @@ export class PageStore implements IPageStore {
created_at: observable.ref,
updated_at: observable.ref,
// helpers
oldName: observable,
oldName: observable.ref,
setIsSubmitting: action,
cleanup: action,
// computed
asJSON: computed,
isCurrentUserOwner: computed,
@ -126,13 +125,10 @@ export class PageStore implements IPageStore {
canCurrentUserArchivePage: computed,
canCurrentUserDeletePage: computed,
isContentEditable: computed,
// helper actions
updateTitle: action,
updateDescription: action.bound,
setIsSubmitting: action,
cleanup: action,
// actions
update: action,
updateTitle: action,
updateDescription: action,
makePublic: action,
makePrivate: action,
lock: action,
@ -169,27 +165,7 @@ export class PageStore implements IPageStore {
{ delay: 2000 }
);
const descriptionDisposer = reaction(
() => this.description_html,
(description_html) => {
//TODO: Fix reaction to only run when the data is changed, not when the page is loaded
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return;
this.isSubmitting = "submitting";
this.pageService
.update(workspaceSlug, projectId, this.id, {
description_html,
})
.finally(() =>
runInAction(() => {
this.isSubmitting = "submitted";
})
);
},
{ delay: 3000 }
);
this.disposers.push(titleDisposer, descriptionDisposer);
this.disposers.push(titleDisposer);
}
// computed
@ -284,24 +260,21 @@ export class PageStore implements IPageStore {
);
}
updateTitle = action("updateTitle", (name: string) => {
this.oldName = this.name ?? "";
this.name = name;
});
/**
* @description update the submitting state
* @param value
*/
setIsSubmitting = (value: TLoader) => {
runInAction(() => {
this.isSubmitting = value;
});
};
updateDescription = action("updateDescription", (description_html: string) => {
this.description_html = description_html;
});
setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => {
this.isSubmitting = isSubmitting;
});
cleanup = action("cleanup", () => {
cleanup = () => {
this.disposers.forEach((disposer) => {
disposer();
});
});
};
/**
* @description update the page
@ -313,14 +286,14 @@ export class PageStore implements IPageStore {
const currentPage = this.asJSON;
try {
const currentPageResponse = await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
if (currentPageResponse)
runInAction(() => {
Object.keys(pageData).forEach((key) => {
const currentPageKey = key as keyof TPage;
set(this, key, currentPageResponse?.[currentPageKey] || undefined);
});
runInAction(() => {
Object.keys(pageData).forEach((key) => {
const currentPageKey = key as keyof TPage;
set(this, key, pageData[currentPageKey] || undefined);
});
});
await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
} catch (error) {
runInAction(() => {
Object.keys(pageData).forEach((key) => {
@ -332,6 +305,42 @@ export class PageStore implements IPageStore {
}
};
/**
* @description update the page title
* @param title
*/
updateTitle = (title: string) => {
this.oldName = this.name ?? "";
this.name = title;
};
/**
* @description update the page description
* @param {string} binaryString
* @param {string} descriptionHTML
*/
updateDescription = async (binaryString: string, descriptionHTML: string) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const currentDescription = this.description_html;
runInAction(() => {
this.description_html = descriptionHTML;
});
try {
await this.pageService.updateDescriptionYJS(workspaceSlug, projectId, this.id, {
description_binary: binaryString,
description_html: descriptionHTML,
});
} catch (error) {
runInAction(() => {
this.description_html = currentDescription;
});
throw error;
}
};
/**
* @description make the page public
*/