[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:
parent
a04ce5abfc
commit
ff03c0b718
42 changed files with 1134 additions and 509 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./loader";
|
||||
|
||||
export * from "./root";
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
153
web/hooks/use-page-description.ts
Normal file
153
web/hooks/use-page-description.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue