import React, { useEffect, useRef, useState, ReactElement } from "react"; import { useRouter } from "next/router"; import useSWR, { MutatorOptions } from "swr"; import { Controller, useForm } from "react-hook-form"; // services import { PageService } from "services/page.service"; import { FileService } from "services/file.service"; // hooks import useUser from "hooks/use-user"; import debounce from "lodash/debounce"; import { useMobxStore } from "lib/mobx/store-provider"; // layouts import { AppLayout } from "layouts/app-layout"; // components 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 { renderDateFormat } from "helpers/date-time.helper"; // types import { NextPageWithLayout } from "types/app"; import { IPage, IIssue } from "types"; // fetch-keys import { PAGE_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { IssuePeekOverview } from "components/issues/issue-peek-overview"; import { IssueService } from "services/issue"; import useToast from "hooks/use-toast"; import useReloadConfirmations from "hooks/use-reload-confirmation"; import { EUserWorkspaceRoles } from "constants/workspace"; import { GptAssistantModal } from "components/core"; import { Sparkle } from "lucide-react"; import { observer } from "mobx-react-lite"; // services const fileService = new FileService(); const pageService = new PageService(); const issueService = new IssueService(); const PageDetailsPage: NextPageWithLayout = observer(() => { const { projectIssues: { updateIssue }, appConfig: { envConfig }, user: { currentProjectRole }, } = useMobxStore(); const editorRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [gptModalOpen, setGptModal] = useState(false); const { setShowAlert } = useReloadConfirmations(); const router = useRouter(); const { workspaceSlug, projectId, pageId, peekIssueId } = router.query; const { setToastAlert } = useToast(); const { user } = useUser(); const { handleSubmit, reset, setValue, watch, getValues, control } = 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 issues = Object.values(issuesResponse ?? {}); const handleAiAssistance = async (response: string) => { if (!workspaceSlug || !projectId || !pageId) return; 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 handleUpdateIssue = (issueId: string, data: Partial) => { if (!workspaceSlug || !projectId || !user) return; updateIssue(workspaceSlug.toString(), projectId.toString(), issueId, data); }; const fetchIssue = async (issueId: string) => { const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string); return issue as IIssue; }; const issueWidgetClickAction = (issueId: string, issueTitle: 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 }); }; const actionCompleteAlert = ({ title, message, type, }: { title: string; message: string; type: "success" | "error" | "warning" | "info"; }) => { setToastAlert({ title, message, type, }); }; useEffect(() => { if (isSubmitting === "submitted") { setShowAlert(false); setTimeout(async () => { setIsSubmitting("saved"); }, 2000); } else if (isSubmitting === "submitting") { setShowAlert(true); } }, [isSubmitting, setShowAlert]); useEffect(() => { if (pageDetails?.description_html) { setLocalIssueDescription({ id: pageId as string, description_html: pageDetails.description_html }); } }, [pageDetails?.description_html]); 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: true, populateCache: false, rollbackOnError: (e) => { 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, }); }; const updatePage = async (formData: IPage) => { 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", }) ); }; const createPage = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload); }; // ================ Page Menu Actions ================== const duplicate_page = async () => { const currentPageValues = getValues(); const formData: Partial = { name: "Copy of " + pageDetails?.name, description_html: currentPageValues.description_html, }; await createPage(formData); }; const archivePage = async () => { if (!workspaceSlug || !projectId || !pageId) return; mutatePageDetailsHelper( pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()), { archived_at: renderDateFormat(new Date()), }, ["description_html"], () => 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", }) ); }; // ========================= 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", }) ); }; 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", }) ); }; const [localPageDescription, setLocalIssueDescription] = useState({ id: pageId as string, description_html: "", }); const debouncedFormSave = debounce(async () => { handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted")); }, 1500); if (error) return ( router.push(`/${workspaceSlug}/projects/${projectId}/pages`), }} /> ); const isPageReadOnly = pageDetails?.is_locked || pageDetails?.archived_at || (currentProjectRole && [EUserWorkspaceRoles.VIEWER, EUserWorkspaceRoles.GUEST].includes(currentProjectRole)); const isCurrentUserOwner = pageDetails?.owned_by === user?.id; const userCanDuplicate = currentProjectRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentProjectRole); const userCanArchive = isCurrentUserOwner || currentProjectRole === EUserWorkspaceRoles.ADMIN; const userCanLock = currentProjectRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.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, }, }} /> )} /> {projectId && envConfig?.has_openai_configured && ( <> { setGptModal(false); }} inset="top-9 right-[68px] !w-1/2 !max-h-[50%]" content="" onResponse={(response) => { handleAiAssistance(response); }} projectId={projectId.toString()} /> )}
)} { if (peekIssueId && typeof peekIssueId === "string") { handleUpdateIssue(peekIssueId, issueToUpdate); } }} />
) : (
)} ); }); PageDetailsPage.getLayout = function getLayout(page: ReactElement) { return ( } withProjectWrapper> {page} ); }; export default PageDetailsPage;