From 06a7bdffd787a0ff47ad927bbfda7c95ffcb60b9 Mon Sep 17 00:00:00 2001 From: Henit Chobisa Date: Fri, 19 Jan 2024 15:18:47 +0530 Subject: [PATCH] =?UTF-8?q?Improvement:=20High=20Performance=20MobX=20Inte?= =?UTF-8?q?gration=20for=20Pages=20=E2=9C=88=EF=B8=8E=20(#3397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apiserver/plane/app/views/page.py | 31 +- packages/editor/document-editor/package.json | 1 + .../src/ui/components/page-renderer.tsx | 18 +- .../src/ui/extensions/index.tsx | 2 +- .../issue-embed-suggestion-list/index.tsx | 2 +- .../issue-suggestion-extension.tsx | 5 +- .../issue-suggestion-renderer.tsx | 20 +- .../issue-embed-widget/issue-widget-card.tsx | 7 +- .../editor/document-editor/src/ui/index.tsx | 4 +- web/components/headers/page-details.tsx | 15 +- .../pages/create-update-page-modal.tsx | 126 +--- web/components/pages/delete-page-modal.tsx | 24 +- web/components/pages/page-form.tsx | 17 +- .../pages/pages-list/all-pages-list.tsx | 12 +- .../pages/pages-list/archived-pages-list.tsx | 9 +- .../pages/pages-list/favorite-pages-list.tsx | 5 +- web/components/pages/pages-list/list-item.tsx | 183 ++--- web/components/pages/pages-list/list-view.tsx | 26 +- .../pages/pages-list/private-page-list.tsx | 5 +- .../pages/pages-list/recent-pages-list.tsx | 5 +- .../pages/pages-list/shared-pages-list.tsx | 5 +- web/hooks/store/use-page.ts | 18 +- web/hooks/store/use-project-page.ts | 3 +- web/hooks/store/use-project-specific-pages.ts | 11 + web/hooks/use-issue-embeds.tsx | 48 ++ web/package.json | 4 +- .../projects/[projectId]/pages/[pageId].tsx | 657 +++++++----------- .../projects/[projectId]/pages/index.tsx | 6 +- web/store/page.store.ts | 547 ++++++--------- web/store/project-page.store.ts | 215 ++++-- web/store/root.store.ts | 5 +- yarn.lock | 24 +- 32 files changed, 960 insertions(+), 1100 deletions(-) create mode 100644 web/hooks/store/use-project-specific-pages.ts create mode 100644 web/hooks/use-issue-embeds.tsx diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1054b6af3..d7bff43d6 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -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 ): diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 21d610751..bbce330ab 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -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", diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index 1bda353b8..72c4c8b46 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -18,7 +18,7 @@ import { type IPageRenderer = { documentDetails: DocumentDetails; - updatePageTitle: (title: string) => Promise; + 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(() => () => {}); diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index 155245f9e..ca023f4a7 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -26,7 +26,7 @@ export const DocumentEditorExtensions = ( .focus() .insertContentAt( range, - "

#issue_

" + "

#issue_

\n" ) .run(); }, diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx index acc6213c2..35a09bcc2 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx @@ -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() diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx index 75d977e49..96a5c1325 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx @@ -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, }), ]; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx index 0a166c3e3..637afe29c 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx @@ -53,7 +53,7 @@ const IssueSuggestionList = ({ const commandListContainer = useRef(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)} >
{item.identifier}
@@ -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 && diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx index 78554c26d..caca2ded7 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx @@ -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 ? (
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id} diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 34aa54c50..8d12f253b 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -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; + updatePageTitle: (title: string) => void; debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index 9a7d08f96..aedc13843 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -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 = observer((props) => { const { showButton = false } = props; @@ -28,12 +22,7 @@ export const PageDetailsHeader: FC = 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 (
diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index 21142cab9..e6763acc6 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -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) => { - 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) => { leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
diff --git a/web/components/pages/delete-page-modal.tsx b/web/components/pages/delete-page-modal.tsx index fbb4667dc..736f21359 100644 --- a/web/components/pages/delete-page-modal.tsx +++ b/web/components/pages/delete-page-modal.tsx @@ -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 = 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 = observer((pr

Are you sure you want to delete page-{" "} - {data?.name}? The Page - will be deleted permanently. This action cannot be undone. + {name}? The Page will be + deleted permanently. This action cannot be undone.

diff --git a/web/components/pages/page-form.tsx b/web/components/pages/page-form.tsx index 84ef66b59..79d378c59 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/page-form.tsx @@ -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; handleClose: () => void; - data?: IPage | null; + pageStore?: IPageStore; }; const defaultValues = { @@ -19,24 +20,24 @@ const defaultValues = { }; export const PageForm: React.FC = (props) => { - const { handleFormSubmit, handleClose, data } = props; + const { handleFormSubmit, handleClose, pageStore } = props; const { formState: { errors, isSubmitting }, handleSubmit, control, } = useForm({ - 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 (
-

{data ? "Update" : "Create"} Page

+

{pageStore ? "Update" : "Create"} Page

= (props) => { Cancel
diff --git a/web/components/pages/pages-list/all-pages-list.tsx b/web/components/pages/pages-list/all-pages-list.tsx index 0f02efb55..4ed759a0f 100644 --- a/web/components/pages/pages-list/all-pages-list.tsx +++ b/web/components/pages/pages-list/all-pages-list.tsx @@ -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 ( @@ -19,6 +19,6 @@ export const AllPagesList: FC = observer(() => { ); - + } return ; }); diff --git a/web/components/pages/pages-list/archived-pages-list.tsx b/web/components/pages/pages-list/archived-pages-list.tsx index b0de19241..4e679fb6d 100644 --- a/web/components/pages/pages-list/archived-pages-list.tsx +++ b/web/components/pages/pages-list/archived-pages-list.tsx @@ -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 ( @@ -19,5 +20,5 @@ export const ArchivedPagesList: FC = observer(() => { ); - return ; + return ; }); diff --git a/web/components/pages/pages-list/favorite-pages-list.tsx b/web/components/pages/pages-list/favorite-pages-list.tsx index fc2b55cad..4ce301a68 100644 --- a/web/components/pages/pages-list/favorite-pages-list.tsx +++ b/web/components/pages/pages-list/favorite-pages-list.tsx @@ -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 ( diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx index 7a92f3296..99b50e3c0 100644 --- a/web/components/pages/pages-list/list-item.tsx +++ b/web/components/pages/pages-list/list-item.tsx @@ -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 = observer((props) => { - const { workspaceSlug, projectId, pageId } = props; +export const PagesListItem: FC = 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) => { 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) => { + e.preventDefault(); + e.stopPropagation(); + addToFavorites(); + }; + + const handleRemoveFromFavorites = (e: React.MouseEvent) => { 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) => { 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) => { e.preventDefault(); e.stopPropagation(); - makePublic(workspaceSlug, projectId, pageId); + makePrivate(); }; - const handleMakePrivate = (e: any) => { + const handleArchivePage = async (e: React.MouseEvent) => { 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) => { 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) => { e.preventDefault(); e.stopPropagation(); setDeletePageModal(true); }; - const handleEditPage = (e: any) => { + const handleEditPage = (e: React.MouseEvent) => { 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 = observer((props) => { return ( <> setCreateUpdatePageModal(false)} - data={pageDetails} projectId={projectId} /> - setDeletePageModal(false)} data={pageDetails} /> + setDeletePageModal(false)} pageId={pageId} />
  • - +
    -

    {pageDetails.name}

    - {/* FIXME: replace any with proper type */} - {pageDetails.label_details.length > 0 && - pageDetails.label_details.map((label: any) => ( +

    {name}

    + {label_details.length > 0 && + label_details.map((label: IIssueLabel) => (
    = observer((props) => { ))}
    - {pageDetails.archived_at ? ( + {archived_at ? ( -

    {renderFormattedTime(pageDetails.archived_at)}

    +

    {renderFormattedTime(archived_at)}

    ) : ( -

    {renderFormattedTime(pageDetails.updated_at)}

    +

    {renderFormattedTime(updated_at)}

    )} {isEditingAllowed && ( - - {pageDetails.is_favorite ? ( + + {is_favorite ? ( @@ -240,12 +213,10 @@ export const PagesListItem: FC = observer((props) => { {userCanChangeAccess && ( - {pageDetails.access ? ( + {access ? ( @@ -259,13 +230,13 @@ export const PagesListItem: FC = observer((props) => { - {pageDetails.archived_at ? ( + {archived_at ? ( <> {userCanArchive && ( diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx index 059a6136f..d00a641f4 100644 --- a/web/components/pages/pages-list/list-view.tsx +++ b/web/components/pages/pages-list/list-view.tsx @@ -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 = observer((props) => { - const { pageIds } = props; +export const PagesListView: FC = (props) => { + const { pageIds: projectPageIds } = props; // store hooks + // trace(true); + const { commandPalette: { toggleCreatePageModal }, } = useApplication(); @@ -31,21 +32,18 @@ export const PagesListView: FC = 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 ? (
    - {pageIds.length > 0 ? ( + {projectPageIds.length > 0 ? (
      - {pageIds.map((pageId) => ( - + {projectPageIds.map((pageId: string) => ( + ))}
    ) : ( @@ -77,4 +75,4 @@ export const PagesListView: FC = observer((props) => { )} ); -}); +}; diff --git a/web/components/pages/pages-list/private-page-list.tsx b/web/components/pages/pages-list/private-page-list.tsx index b19f80fdd..15a577d80 100644 --- a/web/components/pages/pages-list/private-page-list.tsx +++ b/web/components/pages/pages-list/private-page-list.tsx @@ -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 ( diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx index 2b14d22c7..77d313612 100644 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ b/web/components/pages/pages-list/recent-pages-list.tsx @@ -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); diff --git a/web/components/pages/pages-list/shared-pages-list.tsx b/web/components/pages/pages-list/shared-pages-list.tsx index 8b2c56018..d20a1350e 100644 --- a/web/components/pages/pages-list/shared-pages-list.tsx +++ b/web/components/pages/pages-list/shared-pages-list.tsx @@ -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 ( diff --git a/web/hooks/store/use-page.ts b/web/hooks/store/use-page.ts index 8cd13dcdc..8971acd22 100644 --- a/web/hooks/store/use-page.ts +++ b/web/hooks/store/use-page.ts @@ -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; + } }; diff --git a/web/hooks/store/use-project-page.ts b/web/hooks/store/use-project-page.ts index 77d4e7d06..f7c25ea17 100644 --- a/web/hooks/store/use-project-page.ts +++ b/web/hooks/store/use-project-page.ts @@ -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; diff --git a/web/hooks/store/use-project-specific-pages.ts b/web/hooks/store/use-project-specific-pages.ts new file mode 100644 index 000000000..325c2ef16 --- /dev/null +++ b/web/hooks/store/use-project-specific-pages.ts @@ -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; +}; diff --git a/web/hooks/use-issue-embeds.tsx b/web/hooks/use-issue-embeds.tsx new file mode 100644 index 000000000..2c8f7700b --- /dev/null +++ b/web/hooks/use-issue-embeds.tsx @@ -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, + }; +}; diff --git a/web/package.json b/web/package.json index be1c9965d..7336b8dfc 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index de3b5848c..6d82821b7 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -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(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({ 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 ( +
    + +
    + ); + } + + // 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")}

    ${response}

    `; 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, - dataToMutate: Partial, - formDataValues: Array, - 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) => { 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 = { - 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 ( - 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 ? ( -
    -
    - {isPageReadOnly ? ( - - ) : ( -
    - ( - { - 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 ? ( +
    +
    + {isPageReadOnly ? ( + + ) : ( +
    + ( + { + 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 && ( +
    + { + 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={ + + } + className="!min-w-[38rem]" /> - {projectId && envConfig?.has_openai_configured && ( -
    - { - 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={ - - } - className="!min-w-[38rem]" - /> -
    - )}
    )}
    -
    - ) : ( -
    - -
    - )} - + )} + +
    +
    + ) : ( +
    + +
    ); }); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index dd11c4b2f..cd9699b34 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -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(() => 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 diff --git a/web/store/page.store.ts b/web/store/page.store.ts index abb196334..fa5970e49 100644 --- a/web/store/page.store.ts +++ b/web/store/page.store.ts @@ -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; - archivedPages: Record; - // 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; - fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; - // favorites actions - addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // crud - createPage: (workspaceSlug: string, projectId: string, data: Partial) => Promise; - updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial) => Promise; - deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // access control actions - makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // archive actions - archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + // 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; + makePrivate: () => Promise; + lockPage: () => Promise; + unlockPage: () => Promise; + addToFavorites: () => Promise; + removeFromFavorites: () => Promise; + updateName: (name: string) => Promise; + updateDescription: (description: string) => Promise; + + // 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 = {}; - archivedPages: Record = {}; - // 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 - */ - 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 - */ - 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) => - 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) => - 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; }); }); + }); } diff --git a/web/store/project-page.store.ts b/web/store/project-page.store.ts index 4186e65e0..f2e3f9227 100644 --- a/web/store/project-page.store.ts +++ b/web/store/project-page.store.ts @@ -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; - projectArchivedPages: Record; + projectPageMap: Record>; + projectArchivedPageMap: Record>; + + 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; + fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; // crud actions - createPage: (workspaceSlug: string, projectId: string, data: Partial) => void; - deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void; + createPage: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; } export class ProjectPageStore implements IProjectPageStore { - projectPages: Record = {}; // { projectId: [page1, page2] } - projectArchivedPages: Record = {}; // { projectId: [page1, page2] } + projectPageMap: Record> = {}; // { projectId: [page1, page2] } + projectArchivedPageMap: Record> = {}; // { 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 */ - 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) => { 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]; }); }); + }; } diff --git a/web/store/root.store.ts b/web/store/root.store.ts index ebb3779d1..bc208575a 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -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); } } diff --git a/yarn.lock b/yarn.lock index 7ed7e3a9e..d04bdb628 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"