Improvement: High Performance MobX Integration for Pages ✈︎ (#3397)

* fix: removed parameters `workspace`, `project` & `id` from the patch calls

* feat: modified components to work with new pages hooks

* feat: modified stores

* feat: modified initial component

* feat: component implementation changes

* feat: store implementation

* refactor pages store

* feat: updated page store to perform async operations faster

* fix: added types for archive and restore pages

* feat: implemented archive and restore pages

* fix: page creating twice when form submit

* feat: updated create-page-modal

* feat: updated page form and delete page modal

* fix: create page modal not updating isSubmitted prop

* feat: list items and list view refactored for pages

* feat: refactored project-page-store for inserting computed pagesids

* chore: renamed project pages hook

* feat: added favourite pages implementation

* fix: implemented store for archived pages

* fix: project page store for recent pages

* fix: issue suggestions breaking pages

* fix: issue embeds and suggestions breaking

* feat: implemented page store and project page store in page editor

* chore: lock file changes

* fix: modified page details header to catch mobx updates instead of swr calls

* fix: modified usePage hook to fetch page details when reloaded directly on page

* fix: fixed deleting pages

* fix: removed render on props changed

* feat: implemented page store inside page details

* fix: role change in pages archives

* fix: rerending of pages on tab change

* fix: reimplementation of peek overview inside pages

* chore: typo fixes

* fix: issue suggestion widget selecting wrong issues on click

* feat: added labels in pages

* fix: deepsource errors fixed

* fix: build errors

* fix: review comments

* fix: removed swr hooks from the `usePage` store hook and refactored `issueEmbed` hook

* fix: resolved reviewed comments

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
This commit is contained in:
Henit Chobisa 2024-01-19 15:18:47 +05:30 committed by sriram veeraghanta
parent d3dedc8e51
commit 06a7bdffd7
32 changed files with 960 additions and 1100 deletions

View File

@ -1,5 +1,5 @@
# Python imports
from datetime import timedelta, date, datetime
from datetime import date, datetime, timedelta
# Django imports
from django.db import connection
@ -7,30 +7,19 @@ from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Page,
PageFavorite,
Issue,
IssueAssignee,
IssueActivity,
PageLog,
ProjectMember,
)
from plane.app.serializers import (
PageSerializer,
PageFavoriteSerializer,
PageLogSerializer,
IssueLiteSerializer,
SubPageSerializer,
)
from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer,
PageLogSerializer, PageSerializer,
SubPageSerializer)
from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page,
PageFavorite, PageLog, ProjectMember)
# Module imports
from .base import BaseAPIView, BaseViewSet
def unarchive_archive_page_and_descendants(page_id, archived_at):
@ -175,7 +164,7 @@ class PageViewSet(BaseViewSet):
project_id=project_id,
member=request.user,
is_active=True,
role__gt=20,
role__gte=20,
).exists()
or request.user.id != page.owned_by_id
):

View File

@ -32,6 +32,7 @@
"@plane/editor-core": "*",
"@plane/editor-extensions": "*",
"@plane/ui": "*",
"@tippyjs/react": "^4.2.6",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/pm": "^2.1.13",

View File

@ -18,7 +18,7 @@ import {
type IPageRenderer = {
documentDetails: DocumentDetails;
updatePageTitle: (title: string) => Promise<void>;
updatePageTitle: (title: string) => void;
editor: Editor;
onActionCompleteHandler: (action: {
title: string;
@ -30,18 +30,6 @@ type IPageRenderer = {
readonly: boolean;
};
const debounce = (func: (...args: any[]) => void, wait: number) => {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: any[]) {
const later = () => {
if (timeout) clearTimeout(timeout);
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
export const PageRenderer = (props: IPageRenderer) => {
const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props;
@ -64,11 +52,9 @@ export const PageRenderer = (props: IPageRenderer) => {
const { getFloatingProps } = useInteractions([dismiss]);
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
const handlePageTitleChange = (title: string) => {
setPagetitle(title);
debouncedUpdatePageTitle(title);
updatePageTitle(title);
};
const [cleanup, setcleanup] = useState(() => () => {});

View File

@ -26,7 +26,7 @@ export const DocumentEditorExtensions = (
.focus()
.insertContentAt(
range,
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>\n"
)
.run();
},

View File

@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => {
title: suggestion.name,
priority: suggestion.priority.toString(),
identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`,
state: suggestion.state_detail.name,
state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
command: ({ editor, range }) => {
editor
.chain()

View File

@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({
addOptions() {
return {
suggestion: {
char: "#issue_",
allowSpaces: true,
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range });
},
@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({
addProseMirrorPlugins() {
return [
Suggestion({
char: "#issue_",
pluginKey: new PluginKey("issue-embed-suggestions"),
editor: this.editor,
allowSpaces: true,
...this.options.suggestion,
}),
];

View File

@ -53,7 +53,7 @@ const IssueSuggestionList = ({
const commandListContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
let totalLength = 0;
sections.forEach((section) => {
newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5);
@ -65,8 +65,8 @@ const IssueSuggestionList = ({
}, [items]);
const selectItem = useCallback(
(index: number) => {
const item = displayedItems[currentSection][index];
(section: string, index: number) => {
const item = displayedItems[section][index];
if (item) {
command(item);
}
@ -87,6 +87,7 @@ const IssueSuggestionList = ({
setSelectedIndex(
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
);
e.stopPropagation();
return true;
}
if (e.key === "ArrowDown") {
@ -101,10 +102,12 @@ const IssueSuggestionList = ({
[currentSection]: [...prevItems[currentSection], ...nextItems],
}));
}
e.stopPropagation();
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
selectItem(currentSection, selectedIndex);
e.stopPropagation();
return true;
}
if (e.key === "Tab") {
@ -112,6 +115,7 @@ const IssueSuggestionList = ({
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
setCurrentSection(sections[nextSectionIndex]);
setSelectedIndex(0);
e.stopPropagation();
return true;
}
return false;
@ -172,7 +176,7 @@ const IssueSuggestionList = ({
}
)}
key={item.identifier}
onClick={() => selectItem(index)}
onClick={() => selectItem(section, index)}
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} />
@ -195,7 +199,7 @@ export const IssueListRenderer = () => {
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(IssueSuggestionList, {
props,
// @ts-ignore
@ -210,10 +214,10 @@ export const IssueListRenderer = () => {
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "right",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
popup &&

View File

@ -15,8 +15,7 @@ export const IssueWidgetCard = (props) => {
setIssueDetails(issue);
setLoading(0);
})
.catch((error) => {
console.log(error);
.catch(() => {
setLoading(-1);
});
}, []);
@ -30,7 +29,9 @@ export const IssueWidgetCard = (props) => {
{loading == 0 ? (
<div
onClick={completeIssueEmbedAction}
className="w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs"
className={`${
props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 p-3 shadow-custom-shadow-2xs`}
>
<h5 className="text-xs text-custom-text-300">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}

View File

@ -16,7 +16,7 @@ interface IDocumentEditor {
// document info
documentDetails: DocumentDetails;
value: string;
rerenderOnPropsChange: {
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
@ -39,7 +39,7 @@ interface IDocumentEditor {
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
updatePageTitle: (title: string) => Promise<void>;
updatePageTitle: (title: string) => void;
debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved";

View File

@ -1,23 +1,17 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { FileText, Plus } from "lucide-react";
// hooks
import { useApplication, useProject } from "hooks/store";
// services
import { PageService } from "services/page.service";
import { useApplication, usePage, useProject } from "hooks/store";
// ui
import { Breadcrumbs, Button } from "@plane/ui";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
// fetch-keys
import { PAGE_DETAILS } from "constants/fetch-keys";
export interface IPagesHeaderProps {
showButton?: boolean;
}
const pageService = new PageService();
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { showButton = false } = props;
@ -28,12 +22,7 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { commandPalette: commandPaletteStore } = useApplication();
const { currentProjectDetails } = useProject();
const { data: pageDetails } = useSWR(
workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null,
workspaceSlug && currentProjectDetails?.id
? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string)
: null
);
const pageDetails = usePage(pageId as string);
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 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">

View File

@ -2,132 +2,56 @@ import React, { FC } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// hooks
import { useApplication, usePage, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
import { useApplication } from "hooks/store";
// components
import { PageForm } from "./page-form";
// types
import { IPage } from "@plane/types";
import { useProjectPages } from "hooks/store/use-project-page";
import { IPageStore } from "store/page.store";
type Props = {
data?: IPage | null;
// data?: IPage | null;
pageStore?: IPageStore;
handleClose: () => void;
isOpen: boolean;
projectId: string;
};
export const CreateUpdatePageModal: FC<Props> = (props) => {
const { isOpen, handleClose, data, projectId } = props;
const { isOpen, handleClose, projectId, pageStore } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { createPage } = useProjectPages();
// store hooks
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { currentWorkspace } = useWorkspace();
const { createPage, updatePage } = usePage();
// toast alert
const { setToastAlert } = useToast();
const onClose = () => {
handleClose();
};
const createProjectPage = async (payload: IPage) => {
if (!workspaceSlug) return;
// await createPage(workspaceSlug.toString(), projectId, payload)
// .then((res) => {
// router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
// onClose();
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Page created successfully.",
// });
// postHogEventTracker(
// "PAGE_CREATED",
// {
// ...res,
// state: "SUCCESS",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// })
// .catch((err) => {
// setToastAlert({
// type: "error",
// title: "Error!",
// message: err.detail ?? "Page could not be created. Please try again.",
// });
// postHogEventTracker(
// "PAGE_CREATED",
// {
// state: "FAILED",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// });
};
const updateProjectPage = async (payload: IPage) => {
if (!data || !workspaceSlug) return;
// await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
// .then((res) => {
// onClose();
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Page updated successfully.",
// });
// postHogEventTracker(
// "PAGE_UPDATED",
// {
// ...res,
// state: "SUCCESS",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// })
// .catch((err) => {
// setToastAlert({
// type: "error",
// title: "Error!",
// message: err.detail ?? "Page could not be updated. Please try again.",
// });
// postHogEventTracker(
// "PAGE_UPDATED",
// {
// state: "FAILED",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// });
await createPage(workspaceSlug.toString(), projectId, payload);
};
const handleFormSubmit = async (formData: IPage) => {
if (!workspaceSlug || !projectId) return;
if (!data) await createProjectPage(formData);
else await updateProjectPage(formData);
try {
if (pageStore) {
if (pageStore.name !== formData.name) {
await pageStore.updateName(formData.name);
}
if (pageStore.access !== formData.access) {
formData.access === 1 ? await pageStore.makePrivate() : await pageStore.makePublic();
}
} else {
await createProjectPage(formData);
}
handleClose();
} catch (error) {
console.log(error);
}
};
return (
@ -157,7 +81,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} pageStore={pageStore} />
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -9,37 +9,45 @@ import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import type { IPage } from "@plane/types";
import { useProjectPages } from "hooks/store/use-project-page";
type TConfirmPageDeletionProps = {
data?: IPage | null;
pageId: string;
isOpen: boolean;
onClose: () => void;
};
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
const { data, isOpen, onClose } = props;
const { pageId, isOpen, onClose } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const { deletePage } = usePage();
const { deletePage } = useProjectPages();
const pageStore = usePage(pageId);
// toast alert
const { setToastAlert } = useToast();
if (!pageStore) return null;
const { name } = pageStore;
const handleClose = () => {
setIsDeleting(false);
onClose();
};
const handleDelete = async () => {
if (!data || !workspaceSlug || !projectId) return;
if (!pageId || !workspaceSlug || !projectId) return;
setIsDeleting(true);
await deletePage(workspaceSlug.toString(), data.project, data.id)
// Delete Page will only delete the page from the archive page map, at this point only archived pages can be deleted
await deletePage(workspaceSlug.toString(), projectId as string, pageId)
.then(() => {
handleClose();
setToastAlert({
@ -99,8 +107,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
<div className="mt-2">
<p className="text-sm text-custom-text-200">
Are you sure you want to delete page-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page
will be deleted permanently. This action cannot be undone.
<span className="break-words font-medium text-custom-text-100">{name}</span>? The Page will be
deleted permanently. This action cannot be undone.
</p>
</div>
</div>

View File

@ -5,11 +5,12 @@ import { Button, Input, Tooltip } from "@plane/ui";
import { IPage } from "@plane/types";
// constants
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
import { IPageStore } from "store/page.store";
type Props = {
handleFormSubmit: (values: IPage) => Promise<void>;
handleClose: () => void;
data?: IPage | null;
pageStore?: IPageStore;
};
const defaultValues = {
@ -19,24 +20,24 @@ const defaultValues = {
};
export const PageForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, data } = props;
const { handleFormSubmit, handleClose, pageStore } = props;
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
} = useForm<IPage>({
defaultValues: { ...defaultValues, ...data },
defaultValues: pageStore
? { name: pageStore.name, description: pageStore.description, access: pageStore.access }
: defaultValues,
});
const handleCreateUpdatePage = async (formData: IPage) => {
await handleFormSubmit(formData);
};
const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData);
return (
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} Page</h3>
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{pageStore ? "Update" : "Create"} Page</h3>
<div className="space-y-3">
<div>
<Controller
@ -104,7 +105,7 @@ export const PageForm: React.FC<Props> = (props) => {
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
{data ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
{pageStore ? (isSubmitting ? "Updating..." : "Update page") : isSubmitting ? "Creating..." : "Create Page"}
</Button>
</div>
</div>

View File

@ -1,17 +1,17 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { usePage } from "hooks/store";
// components
import { PagesListView } from "components/pages/pages-list";
// ui
import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const AllPagesList: FC = observer(() => {
// store
const { projectPageIds } = usePage();
const pageStores = useProjectPages();
// subscribing to the projectPageStore
const { projectPageIds } = pageStores;
if (!projectPageIds)
if (!projectPageIds) {
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
@ -19,6 +19,6 @@ export const AllPagesList: FC = observer(() => {
<Loader.Item height="40px" />
</Loader>
);
}
return <PagesListView pageIds={projectPageIds} />;
});

View File

@ -3,14 +3,15 @@ import { observer } from "mobx-react-lite";
// components
import { PagesListView } from "components/pages/pages-list";
// hooks
import { usePage } from "hooks/store";
// ui
import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const ArchivedPagesList: FC = observer(() => {
const { archivedProjectPageIds } = usePage();
const projectPageStore = useProjectPages();
const { archivedPageIds } = projectPageStore;
if (!archivedProjectPageIds)
if (!archivedPageIds)
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
@ -19,5 +20,5 @@ export const ArchivedPagesList: FC = observer(() => {
</Loader>
);
return <PagesListView pageIds={archivedProjectPageIds} />;
return <PagesListView pageIds={archivedPageIds} />;
});

View File

@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
// components
import { PagesListView } from "components/pages/pages-list";
// hooks
import { usePage } from "hooks/store";
// ui
import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const FavoritePagesList: FC = observer(() => {
const { favoriteProjectPageIds } = usePage();
const projectPageStore = useProjectPages();
const { favoriteProjectPageIds } = projectPageStore;
if (!favoriteProjectPageIds)
return (

View File

@ -13,10 +13,6 @@ import {
Star,
Trash2,
} from "lucide-react";
// hooks
import { useMember, usePage, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper";
// ui
@ -25,142 +21,120 @@ import { CustomMenu, Tooltip } from "@plane/ui";
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
// constants
import { EUserProjectRoles } from "constants/project";
import { useRouter } from "next/router";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
import { useMember, usePage, useUser } from "hooks/store";
import { IIssueLabel } from "@plane/types";
export interface IPagesListItem {
workspaceSlug: string;
projectId: string;
pageId: string;
projectId: string;
}
export const PagesListItem: FC<IPagesListItem> = observer((props) => {
const { workspaceSlug, projectId, pageId } = props;
export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId }: IPagesListItem) => {
const projectPageStore = useProjectPages();
// Now, I am observing only the projectPages, out of the projectPageStore.
const { archivePage, restorePage } = projectPageStore;
const pageStore = usePage(pageId);
// states
const router = useRouter();
const { workspaceSlug } = router.query;
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [deletePageModal, setDeletePageModal] = useState(false);
// store hooks
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const {
getArchivedPageById,
getUnArchivedPageById,
archivePage,
removeFromFavorites,
addToFavorites,
makePrivate,
makePublic,
restorePage,
} = usePage();
const {
project: { getProjectMemberDetails },
} = useMember();
// toast alert
const { setToastAlert } = useToast();
// derived values
const pageDetails = getUnArchivedPageById(pageId) ?? getArchivedPageById(pageId);
const handleCopyUrl = (e: any) => {
if (!pageStore) return null;
const {
archived_at,
label_details,
access,
is_favorite,
owned_by,
name,
created_at,
updated_at,
makePublic,
makePrivate,
addToFavorites,
removeFromFavorites,
} = pageStore;
const handleCopyUrl = async (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
await copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageId}`);
};
const handleAddToFavorites = (e: any) => {
const handleAddToFavorites = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
addToFavorites();
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
addToFavorites(workspaceSlug, projectId, pageId)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
removeFromFavorites();
};
const handleRemoveFromFavorites = (e: any) => {
const handleMakePublic = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
removeFromFavorites(workspaceSlug, projectId, pageId)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
makePublic();
};
const handleMakePublic = (e: any) => {
const handleMakePrivate = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
makePublic(workspaceSlug, projectId, pageId);
makePrivate();
};
const handleMakePrivate = (e: any) => {
const handleArchivePage = async (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
makePrivate(workspaceSlug, projectId, pageId);
await archivePage(workspaceSlug as string, projectId as string, pageId as string);
};
const handleArchivePage = (e: any) => {
const handleRestorePage = async (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
archivePage(workspaceSlug, projectId, pageId);
await restorePage(workspaceSlug as string, projectId as string, pageId as string);
};
const handleRestorePage = (e: any) => {
e.preventDefault();
e.stopPropagation();
restorePage(workspaceSlug, projectId, pageId);
};
const handleDeletePage = (e: any) => {
const handleDeletePage = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setDeletePageModal(true);
};
const handleEditPage = (e: any) => {
const handleEditPage = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setCreateUpdatePageModal(true);
};
if (!pageDetails) return null;
const ownerDetails = getProjectMemberDetails(pageDetails.owned_by);
const isCurrentUserOwner = pageDetails.owned_by === currentUser?.id;
const ownerDetails = getProjectMemberDetails(owned_by);
const isCurrentUserOwner = owned_by === currentUser?.id;
const userCanEdit =
isCurrentUserOwner ||
@ -173,22 +147,21 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
return (
<>
<CreateUpdatePageModal
pageStore={pageStore}
isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
data={pageDetails}
projectId={projectId}
/>
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={pageDetails} />
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={pageId} />
<li>
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${pageDetails.id}`}>
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}>
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="h-4 w-4 shrink-0" />
<p className="mr-2 truncate text-sm text-custom-text-100">{pageDetails.name}</p>
{/* FIXME: replace any with proper type */}
{pageDetails.label_details.length > 0 &&
pageDetails.label_details.map((label: any) => (
<p className="mr-2 truncate text-sm text-custom-text-100">{name}</p>
{label_details.length > 0 &&
label_details.map((label: IIssueLabel) => (
<div
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
@ -207,26 +180,26 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
))}
</div>
<div className="flex items-center gap-2.5">
{pageDetails.archived_at ? (
{archived_at ? (
<Tooltip
tooltipContent={`Archived at ${renderFormattedTime(
pageDetails.archived_at
)} on ${renderFormattedDate(pageDetails.archived_at)}`}
tooltipContent={`Archived at ${renderFormattedTime(archived_at)} on ${renderFormattedDate(
archived_at
)}`}
>
<p className="text-sm text-custom-text-200">{renderFormattedTime(pageDetails.archived_at)}</p>
<p className="text-sm text-custom-text-200">{renderFormattedTime(archived_at)}</p>
</Tooltip>
) : (
<Tooltip
tooltipContent={`Last updated at ${renderFormattedTime(
pageDetails.updated_at
)} on ${renderFormattedDate(pageDetails.updated_at)}`}
tooltipContent={`Last updated at ${renderFormattedTime(updated_at)} on ${renderFormattedDate(
updated_at
)}`}
>
<p className="text-sm text-custom-text-200">{renderFormattedTime(pageDetails.updated_at)}</p>
<p className="text-sm text-custom-text-200">{renderFormattedTime(updated_at)}</p>
</Tooltip>
)}
{isEditingAllowed && (
<Tooltip tooltipContent={`${pageDetails.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
{pageDetails.is_favorite ? (
<Tooltip tooltipContent={`${is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
{is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" />
</button>
@ -240,12 +213,10 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
{userCanChangeAccess && (
<Tooltip
tooltipContent={`${
pageDetails.access
? "This page is only visible to you"
: "This page can be viewed by anyone in the project"
access ? "This page is only visible to you" : "This page can be viewed by anyone in the project"
}`}
>
{pageDetails.access ? (
{access ? (
<button type="button" onClick={handleMakePublic}>
<Lock className="h-3.5 w-3.5" />
</button>
@ -259,13 +230,13 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
<Tooltip
position="top-right"
tooltipContent={`Created by ${ownerDetails?.member.display_name} on ${renderFormattedDate(
pageDetails.created_at
created_at
)}`}
>
<AlertCircle className="h-3.5 w-3.5" />
</Tooltip>
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
{pageDetails.archived_at ? (
{archived_at ? (
<>
{userCanArchive && (
<CustomMenu.MenuItem onClick={handleRestorePage}>

View File

@ -1,11 +1,9 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// hooks
import { useApplication, useUser } from "hooks/store";
// components
import { PagesListItem } from "./list-item";
import { NewEmptyState } from "components/common/new-empty-state";
// ui
import { Loader } from "@plane/ui";
@ -13,14 +11,17 @@ import { Loader } from "@plane/ui";
import emptyPage from "public/empty-state/empty_page.png";
// constants
import { EUserProjectRoles } from "constants/project";
import { PagesListItem } from "./list-item";
type IPagesListView = {
pageIds: string[];
};
export const PagesListView: FC<IPagesListView> = observer((props) => {
const { pageIds } = props;
export const PagesListView: FC<IPagesListView> = (props) => {
const { pageIds: projectPageIds } = props;
// store hooks
// trace(true);
const {
commandPalette: { toggleCreatePageModal },
} = useApplication();
@ -31,21 +32,18 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return (
<>
{pageIds && workspaceSlug && projectId ? (
{projectPageIds && workspaceSlug && projectId ? (
<div className="h-full space-y-4 overflow-y-auto">
{pageIds.length > 0 ? (
{projectPageIds.length > 0 ? (
<ul role="list" className="divide-y divide-custom-border-200">
{pageIds.map((pageId) => (
<PagesListItem
key={pageId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
pageId={pageId}
/>
{projectPageIds.map((pageId: string) => (
<PagesListItem key={pageId} pageId={pageId} projectId={projectId.toString()} />
))}
</ul>
) : (
@ -77,4 +75,4 @@ export const PagesListView: FC<IPagesListView> = observer((props) => {
)}
</>
);
});
};

View File

@ -1,14 +1,15 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { usePage } from "hooks/store";
// components
import { PagesListView } from "components/pages/pages-list";
// ui
import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const PrivatePagesList: FC = observer(() => {
const { privateProjectPageIds } = usePage();
const projectPageStore = useProjectPages();
const { privateProjectPageIds } = projectPageStore;
if (!privateProjectPageIds)
return (

View File

@ -2,7 +2,7 @@ import React, { FC } from "react";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// hooks
import { useApplication, usePage, useUser } from "hooks/store";
import { useApplication, useUser } from "hooks/store";
// components
import { PagesListView } from "components/pages/pages-list";
import { NewEmptyState } from "components/common/new-empty-state";
@ -14,6 +14,7 @@ import emptyPage from "public/empty-state/empty_page.png";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// constants
import { EUserProjectRoles } from "constants/project";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const RecentPagesList: FC = observer(() => {
// store hooks
@ -21,7 +22,7 @@ export const RecentPagesList: FC = observer(() => {
const {
membership: { currentProjectRole },
} = useUser();
const { recentProjectPages } = usePage();
const { recentProjectPages } = useProjectPages();
// FIXME: replace any with proper type
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);

View File

@ -3,12 +3,13 @@ import { observer } from "mobx-react-lite";
// components
import { PagesListView } from "components/pages/pages-list";
// hooks
import { usePage } from "hooks/store";
// ui
import { Loader } from "@plane/ui";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
export const SharedPagesList: FC = observer(() => {
const { publicProjectPageIds } = usePage();
const projectPageStore = useProjectPages();
const { publicProjectPageIds } = projectPageStore;
if (!publicProjectPageIds)
return (

View File

@ -1,11 +1,21 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { IPageStore } from "store/page.store";
export const usePage = (): IPageStore => {
export const usePage = (pageId: string) => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
return context.page;
const { projectPageMap, projectArchivedPageMap } = context.projectPages;
const { projectId, workspaceSlug } = context.app.router;
if (!projectId || !workspaceSlug) throw new Error("usePage must be used within ProjectProvider");
if (projectPageMap[projectId] && projectPageMap[projectId][pageId]) {
return projectPageMap[projectId][pageId];
} else if (projectArchivedPageMap[projectId] && projectArchivedPageMap[projectId][pageId]) {
return projectArchivedPageMap[projectId][pageId];
} else {
return;
}
};

View File

@ -1,8 +1,9 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
import { IProjectPageStore } from "store/project-page.store";
export const useProjectPages = () => {
export const useProjectPages = (): IProjectPageStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider");
return context.projectPages;

View File

@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { IProjectPageStore } from "store/project-page.store";
export const useProjectPages = (): IProjectPageStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
return context.projectPages;
};

View File

@ -0,0 +1,48 @@
import { TIssue } from "@plane/types";
import { PROJECT_ISSUES_LIST, STATES_LIST } from "constants/fetch-keys";
import { EIssuesStoreType } from "constants/issue";
import { StoreContext } from "contexts/store-context";
import { autorun, toJS } from "mobx";
import { useContext } from "react";
import { IssueService } from "services/issue";
import useSWR from "swr";
import { useIssueDetail, useIssues, useMember, useProject, useProjectState } from "./store";
const issueService = new IssueService();
export const useIssueEmbeds = () => {
const workspaceSlug = useContext(StoreContext).app.router.workspaceSlug;
const projectId = useContext(StoreContext).app.router.projectId;
const { getProjectById } = useProject();
const { setPeekIssue } = useIssueDetail();
const { getStateById } = useProjectState();
const { getUserDetails } = useMember();
const { data: issuesResponse } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
);
const issues = Object.values(issuesResponse ?? {});
const issuesWithStateAndProject = issues.map((issue) => ({
...issue,
state_detail: toJS(getStateById(issue.state_id)),
project_detail: toJS(getProjectById(issue.project_id)),
assignee_details: issue.assignee_ids.map((assigneeid) => toJS(getUserDetails(assigneeid))),
}));
const fetchIssue = async (issueId: string) => issuesWithStateAndProject.find((issue) => issue.id === issueId);
const issueWidgetClickAction = (issueId: string) => {
if (!workspaceSlug || !projectId) return;
setPeekIssue({ workspaceSlug, projectId: projectId, issueId });
};
return {
issues: issuesWithStateAndProject,
fetchIssue,
issueWidgetClickAction,
};
};

View File

@ -26,8 +26,8 @@
"@plane/document-editor": "*",
"@plane/lite-text-editor": "*",
"@plane/rich-text-editor": "*",
"@plane/ui": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@popperjs/core": "^2.11.8",
"@sentry/nextjs": "^7.85.0",
"axios": "^1.1.3",
@ -39,7 +39,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.294.0",
"mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3",
"mobx-react": "^9.1.0",
"next": "^14.0.3",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",

View File

@ -1,52 +1,50 @@
import React, { useEffect, useRef, useState, ReactElement, useCallback } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR, { MutatorOptions } from "swr";
import { Controller, useForm } from "react-hook-form";
import { Sparkle } from "lucide-react";
import debounce from "lodash/debounce";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { useRouter } from "next/router";
import { ReactElement, useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import { useApplication, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
import { useApplication, useIssues, usePage, useUser } from "hooks/store";
import useReloadConfirmations from "hooks/use-reload-confirmation";
import useToast from "hooks/use-toast";
// services
import { PageService } from "services/page.service";
import { FileService } from "services/file.service";
import { IssueService } from "services/issue";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { GptAssistantPopover } from "components/core";
import { PageDetailsHeader } from "components/headers/page-details";
import { EmptyState } from "components/common";
// ui
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
import { Spinner } from "@plane/ui";
// assets
import emptyPage from "public/empty-state/page.svg";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IPage } from "@plane/types";
import { NextPageWithLayout } from "lib/types";
import { IPage, TIssue } from "@plane/types";
// fetch-keys
import { PAGE_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// constants
import { EUserProjectRoles } from "constants/project";
import { useProjectPages } from "hooks/store/use-project-specific-pages";
import { useIssueEmbeds } from "hooks/use-issue-embeds";
import { IssuePeekOverview } from "components/issues";
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { IssueService } from "services/issue";
import { EIssuesStoreType } from "constants/issue";
// services
const fileService = new FileService();
const pageService = new PageService();
const issueService = new IssueService();
const PageDetailsPage: NextPageWithLayout = observer(() => {
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [gptModalOpen, setGptModal] = useState(false);
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
// store hooks
const {
@ -59,18 +57,82 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
// toast alert
const { setToastAlert } = useToast();
//TODO:fix reload confirmations, with mobx
const { setShowAlert } = useReloadConfirmations();
const { handleSubmit, setValue, watch, getValues, control, reset } = useForm<IPage>({
defaultValues: { name: "", description_html: "" },
});
const { data: issuesResponse } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
const {
archivePage: archivePageAction,
restorePage: restorePageAction,
createPage: createPageAction,
projectPageMap,
projectArchivedPageMap,
fetchProjectPages,
fetchArchivedProjectPages,
} = useProjectPages();
useSWR(
workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null,
workspaceSlug && projectId && !projectPageMap[projectId as string] && !projectArchivedPageMap[projectId as string]
? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString())
: null
);
// fetching archived pages from API
useSWR(
workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null,
workspaceSlug && projectId && !projectArchivedPageMap[projectId as string] && !projectPageMap[projectId as string]
? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString())
: null
);
const issues = Object.values(issuesResponse ?? {});
const { issues, fetchIssue, issueWidgetClickAction } = useIssueEmbeds();
const pageStore = usePage(pageId as string);
useEffect(
() => () => {
if (pageStore) {
pageStore.cleanup();
}
},
[pageStore]
);
if (!pageStore) {
return (
<div className="grid h-full w-full place-items-center">
<Spinner />
</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 pageDescription = pageStore?.description_html;
const {
lockPage: lockPageAction,
unlockPage: unlockPageAction,
updateName: updateNameAction,
updateDescription: updateDescriptionAction,
id: pageIdMobx,
isSubmitting,
setIsSubmitting,
owned_by,
is_locked,
archived_at,
created_at,
created_by,
updated_at,
updated_by,
} = pageStore;
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return;
await updateDescriptionAction(formData.description_html);
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId || !pageId) return;
@ -78,47 +140,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const newDescription = `${watch("description_html")}<p>${response}</p>`;
setValue("description_html", newDescription);
editorRef.current?.setEditorValue(newDescription);
pageService
.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), {
description_html: newDescription,
})
.then(() => {
mutatePageDetails((prevData) => ({ ...prevData, description_html: newDescription } as IPage), false);
});
};
// =================== Fetching Page Details ======================
const {
data: pageDetails,
mutate: mutatePageDetails,
error,
} = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
workspaceSlug && projectId && pageId
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null,
{
revalidateOnFocus: false,
}
);
const fetchIssue = async (issueId: string) => {
const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string);
return issue as TIssue;
};
const issueWidgetClickAction = (issueId: string) => {
const url = new URL(router.asPath, window.location.origin);
const params = new URLSearchParams(url.search);
if (params.has("peekIssueId")) {
params.set("peekIssueId", issueId);
} else {
params.append("peekIssueId", issueId);
}
// Replace the current URL with the new one
router.replace(`${url.pathname}?${params.toString()}`, undefined, { shallow: true });
updateDescriptionAction(newDescription);
};
const actionCompleteAlert = ({
@ -137,122 +159,14 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
});
};
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert]);
// adding pageDetails.description_html to dependency array causes
// editor rerendering on every save
useEffect(() => {
if (pageDetails?.description_html) {
setLocalIssueDescription({ id: pageId as string, description_html: pageDetails.description_html });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageDetails?.description_html]); // TODO: Verify the exhaustive-deps warning
function createObjectFromArray(keys: string[], options: any): any {
return keys.reduce((obj, key) => {
if (options[key] !== undefined) {
obj[key] = options[key];
}
return obj;
}, {} as { [key: string]: any });
}
const mutatePageDetailsHelper = (
serverMutatorFn: Promise<any>,
dataToMutate: Partial<IPage>,
formDataValues: Array<keyof IPage>,
onErrorAction: () => void
) => {
const commonSwrOptions: MutatorOptions = {
revalidate: false,
populateCache: false,
rollbackOnError: () => {
onErrorAction();
return true;
},
};
const formData = getValues();
const formDataMutationObject = createObjectFromArray(formDataValues, formData);
mutatePageDetails(async () => serverMutatorFn, {
optimisticData: (prevData) => {
if (!prevData) return;
return {
...prevData,
description_html: formData["description_html"],
...formDataMutationObject,
...dataToMutate,
};
},
...commonSwrOptions,
});
};
useEffect(() => {
mutatePageDetails(undefined, {
revalidate: true,
populateCache: true,
rollbackOnError: () => {
actionCompleteAlert({
title: `Page could not be updated`,
message: `Sorry, page could not be updated, please try again later`,
type: "error",
});
return true;
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updatePage = async (formData: IPage) => {
const updatePageTitle = (title: string) => {
if (!workspaceSlug || !projectId || !pageId) return;
formData.name = pageDetails?.name as string;
if (!formData?.name || formData?.name.length === 0) return;
try {
await pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData);
} catch (error) {
actionCompleteAlert({
title: `Page could not be updated`,
message: `Sorry, page could not be updated, please try again later`,
type: "error",
});
}
};
const updatePageTitle = async (title: string) => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), { name: title }),
{
name: title,
},
[],
() =>
actionCompleteAlert({
title: `Page Title could not be updated`,
message: `Sorry, page title could not be updated, please try again later`,
type: "error",
})
);
updateNameAction(title);
};
const createPage = async (payload: Partial<IPage>) => {
if (!workspaceSlug || !projectId) return;
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
await createPageAction(workspaceSlug as string, projectId as string, payload);
};
// ================ Page Menu Actions ==================
@ -260,121 +174,84 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const currentPageValues = getValues();
if (!currentPageValues?.description_html) {
currentPageValues.description_html = pageDetails?.description_html as string;
// TODO: We need to get latest data the above variable will give us stale data
currentPageValues.description_html = pageDescription as string;
}
const formData: Partial<IPage> = {
name: "Copy of " + pageDetails?.name,
name: "Copy of " + pageTitle,
description_html: currentPageValues.description_html,
};
await createPage(formData);
try {
await createPage(formData);
} catch (error) {
actionCompleteAlert({
title: `Page could not be duplicated`,
message: `Sorry, page could not be duplicated, please try again later`,
type: "error",
});
}
};
const archivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
archived_at: renderFormattedPayloadDate(new Date()),
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page could not be Archived`,
message: `Sorry, page could not be Archived, please try again later`,
type: "error",
})
);
try {
await archivePageAction(workspaceSlug as string, projectId as string, pageId as string);
} catch (error) {
actionCompleteAlert({
title: `Page could not be archived`,
message: `Sorry, page could not be archived, please try again later`,
type: "error",
});
}
};
const unArchivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
archived_at: null,
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page could not be Restored`,
message: `Sorry, page could not be Restored, please try again later`,
type: "error",
})
);
try {
await restorePageAction(workspaceSlug as string, projectId as string, pageId as string);
} catch (error) {
actionCompleteAlert({
title: `Page could not be restored`,
message: `Sorry, page could not be restored, please try again later`,
type: "error",
});
}
};
// ========================= Page Lock ==========================
const lockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
is_locked: true,
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page cannot be Locked`,
message: `Sorry, page cannot be Locked, please try again later`,
type: "error",
})
);
try {
await lockPageAction();
} catch (error) {
actionCompleteAlert({
title: `Page could not be locked`,
message: `Sorry, page could not be locked, please try again later`,
type: "error",
});
}
};
const unlockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutatePageDetailsHelper(
pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()),
{
is_locked: false,
},
["description_html"],
() =>
actionCompleteAlert({
title: `Page could not be Unlocked`,
message: `Sorry, page could not be Unlocked, please try again later`,
type: "error",
})
);
try {
await unlockPageAction();
} catch (error) {
actionCompleteAlert({
title: `Page could not be unlocked`,
message: `Sorry, page could not be unlocked, please try again later`,
type: "error",
});
}
};
const [localPageDescription, setLocalIssueDescription] = useState({
id: pageId as string,
description_html: "",
});
// ADDING updatePage TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
}, 1500),
[handleSubmit, pageDetails]
);
if (error)
return (
<EmptyState
image={emptyPage}
title="Page does not exist"
description="The page you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other pages",
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/pages`),
}}
/>
);
const isPageReadOnly =
pageDetails?.is_locked ||
pageDetails?.archived_at ||
is_locked ||
archived_at ||
(currentProjectRole && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(currentProjectRole));
const isCurrentUserOwner = pageDetails?.owned_by === currentUser?.id;
const isCurrentUserOwner = owned_by === currentUser?.id;
const userCanDuplicate =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
@ -382,144 +259,132 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const userCanLock =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return (
<>
{pageDetails && issuesResponse ? (
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full overflow-hidden">
{isPageReadOnly ? (
<DocumentReadOnlyEditorWithRef
onActionCompleteHandler={actionCompleteAlert}
ref={editorRef}
value={localPageDescription.description_html}
rerenderOnPropsChange={localPageDescription}
customClassName={"tracking-tight w-full px-0"}
borderOnFocus={false}
noBorder
documentDetails={{
title: pageDetails.name,
created_by: pageDetails.created_by,
created_on: pageDetails.created_at,
last_updated_at: pageDetails.updated_at,
last_updated_by: pageDetails.updated_by,
}}
pageLockConfig={
userCanLock && !pageDetails.archived_at
? { action: unlockPage, is_locked: pageDetails.is_locked }
: undefined
}
pageDuplicationConfig={
userCanDuplicate && !pageDetails.archived_at ? { action: duplicate_page } : undefined
}
pageArchiveConfig={
userCanArchive
? {
action: pageDetails.archived_at ? unArchivePage : archivePage,
is_archived: pageDetails.archived_at ? true : false,
archived_at: pageDetails.archived_at ? new Date(pageDetails.archived_at) : undefined,
}
: undefined
}
embedConfig={{
issueEmbedConfig: {
issues: issues,
fetchIssue: fetchIssue,
clickAction: issueWidgetClickAction,
},
}}
/>
) : (
<div className="relative h-full w-full overflow-hidden">
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<DocumentEditorWithRef
isSubmitting={isSubmitting}
documentDetails={{
title: pageDetails.name,
created_by: pageDetails.created_by,
created_on: pageDetails.created_at,
last_updated_at: pageDetails.updated_at,
last_updated_by: pageDetails.updated_by,
}}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
setShouldShowAlert={setShowAlert}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
cancelUploadImage={fileService.cancelUpload}
ref={editorRef}
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
updatePageTitle={updatePageTitle}
value={localPageDescription.description_html}
rerenderOnPropsChange={localPageDescription}
onActionCompleteHandler={actionCompleteAlert}
customClassName="tracking-tight self-center px-0 h-full w-full"
onChange={(_description_json: Object, description_html: string) => {
setShowAlert(true);
onChange(description_html);
setIsSubmitting("submitting");
debouncedFormSave();
}}
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
pageArchiveConfig={
userCanArchive
? {
is_archived: pageDetails.archived_at ? true : false,
action: pageDetails.archived_at ? unArchivePage : archivePage,
}
: undefined
}
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
embedConfig={{
issueEmbedConfig: {
issues: issues,
fetchIssue: fetchIssue,
clickAction: issueWidgetClickAction,
},
}}
/>
)}
return pageIdMobx && issues ? (
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full overflow-hidden">
{isPageReadOnly ? (
<DocumentReadOnlyEditorWithRef
onActionCompleteHandler={actionCompleteAlert}
ref={editorRef}
value={pageDescription}
customClassName={"tracking-tight w-full px-0"}
borderOnFocus={false}
noBorder
documentDetails={{
title: pageTitle,
created_by: created_by,
created_on: created_at,
last_updated_at: updated_at,
last_updated_by: updated_by,
}}
pageLockConfig={userCanLock && !archived_at ? { action: unlockPage, is_locked: is_locked } : undefined}
pageDuplicationConfig={userCanDuplicate && !archived_at ? { action: duplicate_page } : undefined}
pageArchiveConfig={
userCanArchive
? {
action: archived_at ? unArchivePage : archivePage,
is_archived: archived_at ? true : false,
archived_at: archived_at ? new Date(archived_at) : undefined,
}
: undefined
}
embedConfig={{
issueEmbedConfig: {
issues: issues,
fetchIssue: fetchIssue,
clickAction: issueWidgetClickAction,
},
}}
/>
) : (
<div className="relative h-full w-full overflow-hidden">
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<DocumentEditorWithRef
isSubmitting={isSubmitting}
documentDetails={{
title: pageTitle,
created_by: created_by,
created_on: created_at,
last_updated_at: updated_at,
last_updated_by: updated_by,
}}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
value={pageDescription}
setShouldShowAlert={setShowAlert}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
cancelUploadImage={fileService.cancelUpload}
ref={editorRef}
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
updatePageTitle={updatePageTitle}
onActionCompleteHandler={actionCompleteAlert}
customClassName="tracking-tight self-center px-0 h-full w-full"
onChange={(_description_json: Object, description_html: string) => {
setShowAlert(true);
onChange(description_html);
handleSubmit(updatePage)();
}}
duplicationConfig={userCanDuplicate ? { action: duplicate_page } : undefined}
pageArchiveConfig={
userCanArchive
? {
is_archived: archived_at ? true : false,
action: archived_at ? unArchivePage : archivePage,
}
: undefined
}
pageLockConfig={userCanLock ? { is_locked: false, action: lockPage } : undefined}
embedConfig={{
issueEmbedConfig: {
issues: issues,
fetchIssue: fetchIssue,
clickAction: issueWidgetClickAction,
},
}}
/>
)}
/>
{projectId && envConfig?.has_openai_configured && (
<div className="absolute right-[68px] top-2.5">
<GptAssistantPopover
isOpen={gptModalOpen}
projectId={projectId.toString()}
handleClose={() => {
setGptModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
className="!min-w-[38rem]"
/>
{projectId && envConfig?.has_openai_configured && (
<div className="absolute right-[68px] top-2.5">
<GptAssistantPopover
isOpen={gptModalOpen}
projectId={projectId.toString()}
handleClose={() => {
setGptModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
placement="top-end"
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
className="!min-w-[38rem]"
/>
</div>
)}
</div>
)}
</div>
</div>
) : (
<div className="grid h-full w-full place-items-center">
<Spinner />
</div>
)}
</>
)}
<IssuePeekOverview />
</div>
</div>
) : (
<div className="grid h-full w-full place-items-center">
<Spinner />
</div>
);
});

View File

@ -5,7 +5,7 @@ import { Tab } from "@headlessui/react";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// hooks
import { usePage, useUser } from "hooks/store";
import { useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
import useUserAuth from "hooks/use-user-auth";
// layouts
@ -17,6 +17,7 @@ import { PagesHeader } from "components/headers";
import { NextPageWithLayout } from "lib/types";
// constants
import { PAGE_TABS_LIST } from "constants/page";
import { useProjectPages } from "hooks/store/use-project-page";
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
ssr: false,
@ -45,8 +46,9 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
// states
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
// store
const { fetchProjectPages, fetchArchivedProjectPages } = usePage();
const { currentUser, currentUserLoader } = useUser();
const { fetchProjectPages, fetchArchivedProjectPages } = useProjectPages();
// hooks
const {} = useUserAuth({ user: currentUser, isLoading: currentUserLoader });
// local storage

View File

@ -1,374 +1,277 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import set from "lodash/set";
import omit from "lodash/omit";
import isToday from "date-fns/isToday";
import isThisWeek from "date-fns/isThisWeek";
import isYesterday from "date-fns/isYesterday";
// services
import { action, makeObservable, observable, reaction, runInAction } from "mobx";
import { IIssueLabel, IPage } from "@plane/types";
import { PageService } from "services/page.service";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IPage, IRecentPages } from "@plane/types";
// store
import { RootStore } from "./root.store";
export interface IPageStore {
pages: Record<string, IPage>;
archivedPages: Record<string, IPage>;
// project computed
projectPageIds: string[] | null;
favoriteProjectPageIds: string[] | null;
privateProjectPageIds: string[] | null;
publicProjectPageIds: string[] | null;
archivedProjectPageIds: string[] | null;
recentProjectPages: IRecentPages | null;
// fetch page information actions
getUnArchivedPageById: (pageId: string) => IPage | null;
getArchivedPageById: (pageId: string) => IPage | null;
// fetch actions
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
// favorites actions
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// crud
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// access control actions
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// archive actions
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
// Page Properties
access: number;
archived_at: string | null;
color: string;
created_at: Date;
created_by: string;
description: string;
description_html: string;
description_stripped: string | null;
id: string;
is_favorite: boolean;
label_details: IIssueLabel[];
is_locked: boolean;
labels: string[];
name: string;
owned_by: string;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;
// Actions
makePublic: () => Promise<void>;
makePrivate: () => Promise<void>;
lockPage: () => Promise<void>;
unlockPage: () => Promise<void>;
addToFavorites: () => Promise<void>;
removeFromFavorites: () => Promise<void>;
updateName: (name: string) => Promise<void>;
updateDescription: (description: string) => Promise<void>;
// Reactions
disposers: Array<() => void>;
// Helpers
oldName: string;
cleanup: () => void;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void;
}
export class PageStore implements IPageStore {
pages: Record<string, IPage> = {};
archivedPages: Record<string, IPage> = {};
// services
access = 0;
isSubmitting: "submitting" | "submitted" | "saved" = "saved";
archived_at: string | null;
color: string;
created_at: Date;
created_by: string;
description: string;
description_html = "";
description_stripped: string | null;
id: string;
is_favorite = false;
is_locked = true;
labels: string[];
name = "";
owned_by: string;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;
oldName = "";
label_details: IIssueLabel[] = [];
disposers: Array<() => void> = [];
pageService;
// stores
// root store
rootStore;
constructor(rootStore: RootStore) {
constructor(page: IPage, _rootStore: RootStore) {
makeObservable(this, {
pages: observable,
archivedPages: observable,
// computed
projectPageIds: computed,
favoriteProjectPageIds: computed,
publicProjectPageIds: computed,
privateProjectPageIds: computed,
archivedProjectPageIds: computed,
recentProjectPages: computed,
// computed actions
getUnArchivedPageById: action,
getArchivedPageById: action,
// fetch actions
fetchProjectPages: action,
fetchArchivedProjectPages: action,
// favorites actions
addToFavorites: action,
removeFromFavorites: action,
// crud
createPage: action,
updatePage: action,
deletePage: action,
// access control actions
name: observable.ref,
description_html: observable.ref,
is_favorite: observable.ref,
is_locked: observable.ref,
isSubmitting: observable.ref,
access: observable.ref,
makePublic: action,
makePrivate: action,
// archive actions
archivePage: action,
restorePage: action,
addToFavorites: action,
removeFromFavorites: action,
updateName: action,
updateDescription: action,
setIsSubmitting: action,
cleanup: action,
});
// stores
this.rootStore = rootStore;
// services
this.created_by = page?.created_by || "";
this.created_at = page?.created_at || new Date();
this.color = page?.color || "";
this.archived_at = page?.archived_at || null;
this.name = page?.name || "";
this.description = page?.description || "";
this.description_stripped = page?.description_stripped || "";
this.description_html = page?.description_html || "";
this.access = page?.access || 0;
this.workspace = page?.workspace || "";
this.updated_by = page?.updated_by || "";
this.updated_at = page?.updated_at || new Date();
this.project = page?.project || "";
this.owned_by = page?.owned_by || "";
this.labels = page?.labels || [];
this.label_details = page?.label_details || [];
this.is_locked = page?.is_locked || false;
this.id = page?.id || "";
this.is_favorite = page?.is_favorite || false;
this.oldName = page?.name || "";
this.rootStore = _rootStore;
this.pageService = new PageService();
}
/**
* retrieves all pages for a projectId that is available in the url.
*/
get projectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return null;
const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId);
return projectPageIds ?? null;
}
/**
* retrieves all favorite pages for a projectId that is available in the url.
*/
get favoriteProjectPageIds() {
if (!this.projectPageIds) return null;
const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite);
return favoritePagesIds ?? null;
}
/**
* retrieves all private pages for a projectId that is available in the url.
*/
get privateProjectPageIds() {
if (!this.projectPageIds) return null;
const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1);
return privatePagesIds ?? null;
}
/**
* retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url.
*/
get publicProjectPageIds() {
if (!this.projectPageIds) return null;
const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0);
return publicPagesIds ?? null;
}
/**
* retrieves all recent pages for a projectId that is available in the url.
* In format where today, yesterday, this_week, older are keys.
*/
get recentProjectPages() {
if (!this.projectPageIds) return null;
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.updated_at))) || [];
data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.updated_at))) || [];
data.this_week =
this.projectPageIds.filter((p) => {
const pageUpdatedAt = this.pages?.[p]?.updated_at;
return (
isThisWeek(new Date(pageUpdatedAt)) &&
!isToday(new Date(pageUpdatedAt)) &&
!isYesterday(new Date(pageUpdatedAt))
);
}) || [];
data.older =
this.projectPageIds.filter((p) => {
const pageUpdatedAt = this.pages?.[p]?.updated_at;
return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt));
}) || [];
return data;
}
/**
* retrieves all archived pages for a projectId that is available in the url.
*/
get archivedProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return null;
const archivedProjectPageIds = Object.keys(this.archivedPages).filter(
(pageId) => this.archivedPages?.[pageId]?.project === projectId
);
return archivedProjectPageIds ?? null;
}
/**
* retrieves a page from pages by id.
* @param pageId
* @returns IPage | null
*/
getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null;
/**
* retrieves a page from archived pages by id.
* @param pageId
* @returns IPage | null
*/
getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null;
/**
* fetches all pages for a project.
* @param workspaceSlug
* @param projectId
* @returns Promise<IPage[]>
*/
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
try {
return await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
console.log("Response from backend 1", response);
runInAction(() => {
response.forEach((page) => {
set(this.pages, [page.id], page);
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 { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.isSubmitting = "submitting";
this.pageService.patchPage(workspaceSlug, projectId, this.id, { description_html }).finally(() => {
runInAction(() => {
this.isSubmitting = "submitted";
});
});
return response;
});
} catch (error) {
throw error;
}
};
},
{ delay: 3000 }
);
/**
* fetches all archived pages for a project.
* @param workspaceSlug
* @param projectId
* @returns Promise<IPage[]>
*/
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
response.forEach((page) => {
set(this.archivedPages, [page.id], page);
});
});
return response;
const pageTitleDisposer = reaction(
() => this.name,
(name) => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.isSubmitting = "submitting";
this.pageService
.patchPage(workspaceSlug, projectId, this.id, { name })
.catch(() => {
runInAction(() => {
this.name = this.oldName;
});
})
.finally(() => {
runInAction(() => {
this.isSubmitting = "submitted";
});
});
},
{ delay: 2000 }
);
this.disposers.push(descriptionDisposer, pageTitleDisposer);
}
updateName = action("updateName", async (name: string) => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.oldName = this.name;
this.name = name;
});
updateDescription = action("updateDescription", async (description_html: string) => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.description_html = description_html;
});
cleanup = action("cleanup", () => {
this.disposers.forEach((disposer) => {
disposer();
});
});
setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => {
this.isSubmitting = isSubmitting;
});
lockPage = action("lockPage", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.is_locked = true;
await this.pageService.lockPage(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => {
this.is_locked = false;
});
});
});
unlockPage = action("unlockPage", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.is_locked = false;
await this.pageService.unlockPage(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => {
this.is_locked = true;
});
});
});
/**
* Add Page to users favorites list
* @param workspaceSlug
* @param projectId
* @param pageId
*/
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
addToFavorites = action("addToFavorites", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.is_favorite = true;
await this.pageService.addPageToFavorites(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], true);
this.is_favorite = false;
});
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
throw error;
}
};
});
});
/**
* Remove page from the users favorites list
* @param workspaceSlug
* @param projectId
* @param pageId
*/
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], false);
});
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
runInAction(() => {
set(this.pages, [pageId, "is_favorite"], true);
});
throw error;
}
};
/**
* Creates a new page using the api and updated the local state in store
* @param workspaceSlug
* @param projectId
* @param data
*/
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) =>
await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
set(this.pages, [response.id], response);
});
return response;
});
removeFromFavorites = action("removeFromFavorites", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
/**
* updates the page using the api and updates the local state in store
* @param workspaceSlug
* @param projectId
* @param pageId
* @param data
* @returns
*/
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) =>
await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => {
const originalPage = this.getUnArchivedPageById(pageId);
runInAction(() => {
set(this.pages, [pageId], { ...originalPage, ...data });
});
return response;
});
this.is_favorite = false;
/**
* delete a page using the api and updates the local state in store
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns
*/
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => {
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, this.id).catch(() => {
runInAction(() => {
omit(this.archivedPages, [pageId]);
this.is_favorite = true;
});
return response;
});
});
/**
* make a page public
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns
*/
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
makePublic = action("makePublic", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
this.access = 0;
this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 0 }).catch(() => {
runInAction(() => {
set(this.pages, [pageId, "access"], 0);
this.access = 1;
});
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
} catch (error) {
runInAction(() => {
set(this.pages, [pageId, "access"], 1);
});
throw error;
}
};
});
});
/**
* Make a page private
* @param workspaceSlug
* @param projectId
* @param pageId
* @returns
*/
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
set(this.pages, [pageId, "access"], 1);
});
await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
} catch (error) {
runInAction(() => {
set(this.pages, [pageId, "access"], 0);
});
throw error;
}
};
makePrivate = action("makePrivate", async () => {
const { projectId, workspaceSlug } = this.rootStore.app.router;
if (!projectId || !workspaceSlug) return;
/**
* Mark a page archived
* @param workspaceSlug
* @param projectId
* @param pageId
*/
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => {
this.access = 1;
this.pageService.patchPage(workspaceSlug, projectId, this.id, { access: 1 }).catch(() => {
runInAction(() => {
set(this.archivedPages, [pageId], this.pages[pageId]);
set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date()));
omit(this.pages, [pageId]);
});
});
/**
* Restore a page from archived pages to pages
* @param workspaceSlug
* @param projectId
* @param pageId
*/
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
runInAction(() => {
set(this.pages, [pageId], this.archivedPages[pageId]);
omit(this.archivedPages, [pageId]);
this.access = 0;
});
});
});
}

View File

@ -1,33 +1,54 @@
import { makeObservable, observable, runInAction, action } from "mobx";
import { makeObservable, observable, runInAction, action, computed } from "mobx";
import { set } from "lodash";
// services
import { PageService } from "services/page.service";
// store
import { PageStore, IPageStore } from "store/page.store";
// types
import { IPage } from "@plane/types";
import { IPage, IRecentPages } from "@plane/types";
import { RootStore } from "./root.store";
import { isThisWeek, isToday, isYesterday } from "date-fns";
export interface IProjectPageStore {
projectPages: Record<string, IPageStore[]>;
projectArchivedPages: Record<string, IPageStore[]>;
projectPageMap: Record<string, Record<string, IPageStore>>;
projectArchivedPageMap: Record<string, Record<string, IPageStore>>;
projectPageIds: string[] | undefined;
archivedPageIds: string[] | undefined;
favoriteProjectPageIds: string[] | undefined;
privateProjectPageIds: string[] | undefined;
publicProjectPageIds: string[] | undefined;
recentProjectPages: IRecentPages | undefined;
// fetch actions
fetchProjectPages: (workspaceSlug: string, projectId: string) => void;
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => void;
fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise<void>;
// crud actions
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => void;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void;
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
}
export class ProjectPageStore implements IProjectPageStore {
projectPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] }
projectArchivedPages: Record<string, IPageStore[]> = {}; // { projectId: [page1, page2] }
projectPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
projectArchivedPageMap: Record<string, Record<string, IPageStore>> = {}; // { projectId: [page1, page2] }
// root store
rootStore;
pageService;
constructor() {
constructor(_rootStore: RootStore) {
makeObservable(this, {
projectPages: observable,
projectArchivedPages: observable,
projectPageMap: observable,
projectArchivedPageMap: observable,
projectPageIds: computed,
archivedPageIds: computed,
favoriteProjectPageIds: computed,
privateProjectPageIds: computed,
publicProjectPageIds: computed,
recentProjectPages: computed,
// fetch actions
fetchProjectPages: action,
fetchArchivedProjectPages: action,
@ -35,19 +56,113 @@ export class ProjectPageStore implements IProjectPageStore {
createPage: action,
deletePage: action,
});
this.rootStore = _rootStore;
this.pageService = new PageService();
}
get projectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.projectPageMap?.[projectId]) return [];
const allProjectIds = Object.keys(this.projectPageMap[projectId]);
return allProjectIds.sort((a, b) => {
const dateA = new Date(this.projectPageMap[projectId][a].created_at).getTime();
const dateB = new Date(this.projectPageMap[projectId][b].created_at).getTime();
return dateB - dateA;
});
}
get archivedPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId || !this.projectArchivedPageMap[projectId]) return [];
const archivedPages = Object.keys(this.projectArchivedPageMap[projectId]);
return archivedPages.sort((a, b) => {
const dateA = new Date(this.projectArchivedPageMap[projectId][a].created_at).getTime();
const dateB = new Date(this.projectArchivedPageMap[projectId][b].created_at).getTime();
return dateB - dateA;
});
}
get favoriteProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!this.projectPageIds || !projectId) return [];
const favouritePages: string[] = this.projectPageIds.filter(
(page) => this.projectPageMap[projectId][page].is_favorite
);
return favouritePages;
}
get privateProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
if (!this.projectPageIds || !projectId) return [];
const privatePages: string[] = this.projectPageIds.filter(
(page) => this.projectPageMap[projectId][page].access === 1
);
return privatePages;
}
get publicProjectPageIds() {
const projectId = this.rootStore.app.router.projectId;
const userId = this.rootStore.user.currentUser?.id;
if (!this.projectPageIds || !projectId || !userId) return [];
const publicPages: string[] = this.projectPageIds.filter(
(page) =>
this.projectPageMap[projectId][page].access === 0 && this.projectPageMap[projectId][page].owned_by === userId
);
return publicPages;
}
get recentProjectPages() {
const projectId = this.rootStore.app.router.projectId;
if (!this.projectPageIds || !projectId) return;
const today: string[] = this.projectPageIds.filter((page) =>
isToday(new Date(this.projectPageMap[projectId][page].updated_at))
);
const yesterday: string[] = this.projectPageIds.filter((page) =>
isYesterday(new Date(this.projectPageMap[projectId][page].updated_at))
);
const this_week: string[] = this.projectPageIds.filter((page) => {
const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at;
return (
isThisWeek(new Date(pageUpdatedAt)) &&
!isToday(new Date(pageUpdatedAt)) &&
!isYesterday(new Date(pageUpdatedAt))
);
});
const older: string[] = this.projectPageIds.filter((page) => {
const pageUpdatedAt = this.projectPageMap[projectId][page].updated_at;
return !isThisWeek(new Date(pageUpdatedAt)) && !isYesterday(new Date(pageUpdatedAt));
});
return { today, yesterday, this_week, older };
}
/**
* Fetching all the pages for a specific project
* @param workspaceSlug
* @param projectId
*/
fetchProjectPages = async (workspaceSlug: string, projectId: string) => {
const response = await this.pageService.getProjectPages(workspaceSlug, projectId);
runInAction(() => {
this.projectPages[projectId] = response?.map((page) => new PageStore(page as any));
});
try {
await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
for (const page of response) {
set(this.projectPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
}
});
return response;
});
} catch (e) {
throw e;
}
};
/**
@ -56,13 +171,20 @@ export class ProjectPageStore implements IProjectPageStore {
* @param projectId
* @returns Promise<IPage[]>
*/
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) =>
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page as any));
fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => {
try {
await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => {
runInAction(() => {
for (const page of response) {
set(this.projectArchivedPageMap, [projectId, page.id], new PageStore(page, this.rootStore));
}
});
return response;
});
return response;
});
} catch (e) {
throw e;
}
};
/**
* Creates a new page using the api and updated the local state in store
@ -73,7 +195,7 @@ export class ProjectPageStore implements IProjectPageStore {
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
runInAction(() => {
this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response as any)];
set(this.projectPageMap, [projectId, response.id], new PageStore(response, this.rootStore));
});
return response;
};
@ -88,11 +210,7 @@ export class ProjectPageStore implements IProjectPageStore {
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
runInAction(() => {
this.projectPages = set(
this.projectPages,
[projectId],
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
);
delete this.projectArchivedPageMap[projectId][pageId];
});
return response;
};
@ -104,14 +222,17 @@ export class ProjectPageStore implements IProjectPageStore {
* @param pageId
*/
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId);
runInAction(() => {
set(
this.projectPages,
[projectId],
this.projectPages[projectId].filter((page: any) => page.id !== pageId)
);
this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]);
set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
set(this.projectArchivedPageMap[projectId][pageId], "archived_at", new Date().toISOString());
delete this.projectPageMap[projectId][pageId];
});
const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId).catch(() => {
runInAction(() => {
set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]);
set(this.projectPageMap[projectId][pageId], "archived_at", null);
delete this.projectArchivedPageMap[projectId][pageId];
});
});
return response;
};
@ -122,15 +243,19 @@ export class ProjectPageStore implements IProjectPageStore {
* @param projectId
* @param pageId
*/
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) =>
await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => {
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
const pageArchivedAt = this.projectArchivedPageMap[projectId][pageId].archived_at;
runInAction(() => {
set(this.projectPageMap, [projectId, pageId], this.projectArchivedPageMap[projectId][pageId]);
set(this.projectPageMap[projectId][pageId], "archived_at", null);
delete this.projectArchivedPageMap[projectId][pageId];
});
await this.pageService.restorePage(workspaceSlug, projectId, pageId).catch(() => {
runInAction(() => {
set(
this.projectArchivedPages,
[projectId],
this.projectArchivedPages[projectId].filter((page: any) => page.id !== pageId)
);
set(this.projectPages, [projectId], [...this.projectPages[projectId]]);
set(this.projectArchivedPageMap, [projectId, pageId], this.projectPageMap[projectId][pageId]);
set(this.projectArchivedPageMap[projectId][pageId], "archived_at", pageArchivedAt);
delete this.projectPageMap[projectId][pageId];
});
});
};
}

View File

@ -9,7 +9,6 @@ import { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IssueRootStore, IIssueRootStore } from "./issue/root.store";
import { IStateStore, StateStore } from "./state.store";
import { IPageStore, PageStore } from "./page.store";
import { ILabelRootStore, LabelRootStore } from "./label";
import { IMemberRootStore, MemberRootStore } from "./member";
import { IInboxRootStore, InboxRootStore } from "./inbox";
@ -33,7 +32,6 @@ export class RootStore {
module: IModuleStore;
projectView: IProjectViewStore;
globalView: IGlobalViewStore;
page: IPageStore;
issue: IIssueRootStore;
state: IStateStore;
estimate: IEstimateStore;
@ -58,8 +56,7 @@ export class RootStore {
this.state = new StateStore(this);
this.estimate = new EstimateStore(this);
this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(this);
this.dashboard = new DashboardStore(this);
this.projectPages = new ProjectPageStore();
this.page = new PageStore(this);
}
}

View File

@ -6617,13 +6617,35 @@ mkdirp@^0.5.5:
dependencies:
minimist "^1.2.6"
mobx-react-lite@^4.0.3:
mobx-devtools-mst@^0.9.30:
version "0.9.30"
resolved "https://registry.yarnpkg.com/mobx-devtools-mst/-/mobx-devtools-mst-0.9.30.tgz#0d1cad8b3d97e1f3f94bb9afb701cd9c8b5b164d"
integrity sha512-6fIYeFG4xT4syIeKddmK55zQbc3ZZZr/272/cCbfaAAM5YiuFdteGZGUgdsz8wxf/mGxWZbFOM3WmASAnpwrbw==
mobx-react-devtools@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-6.1.1.tgz#a462b944085cf11ff96fc937d12bf31dab4c8984"
integrity sha512-nc5IXLdEUFLn3wZal65KF3/JFEFd+mbH4KTz/IG5BOPyw7jo8z29w/8qm7+wiCyqVfUIgJ1gL4+HVKmcXIOgqA==
mobx-react-lite@^4.0.3, mobx-react-lite@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.5.tgz#e2cb98f813e118917bcc463638f5bf6ea053a67b"
integrity sha512-StfB2wxE8imKj1f6T8WWPf4lVMx3cYH9Iy60bbKXEs21+HQ4tvvfIBZfSmMXgQAefi8xYEwQIz4GN9s0d2h7dg==
dependencies:
use-sync-external-store "^1.2.0"
mobx-react@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-9.1.0.tgz#5e54919ca27ffad5f2c0d835148a1f681cebdbc1"
integrity sha512-DeDRTYw4AlgHw8xEXtiZdKKEnp+c5/jeUgTbTQXEqnAzfkrgYRWP3p3Nv3Whc2CEcM/mDycbDWGjxKokQdlffg==
dependencies:
mobx-react-lite "^4.0.4"
mobx-state-tree@^5.4.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/mobx-state-tree/-/mobx-state-tree-5.4.0.tgz#d41b7fd90b8d4b063bc32526758417f1100751df"
integrity sha512-2VuUhAqFklxgGqFNqaZUXYYSQINo8C2SUEP9YfCQrwatHWHqJLlEC7Xb+5WChkev7fubzn3aVuby26Q6h+JeBg==
mobx@^6.10.0:
version "6.12.0"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.12.0.tgz#72b2685ca5af031aaa49e77a4d76ed67fcbf9135"