[PE-182] refactor: pages' components and store for scalability (#6283)

* refactor: created a generic base page instance

* refactor: project store hooks

* chore: add missing prop declaration

* refactor: editor page root and body

* refactor: issue embed hook

* chore: update search entity types

* fix: version editor component

* fix: add page to favorites action

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-12-27 20:41:38 +05:30 committed by GitHub
parent 211d5e1cd0
commit 8d7425a3b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 553 additions and 521 deletions

View file

@ -1,5 +1,9 @@
import { Extensions, JSONContent } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
// plane types
import { TWebhookConnectionQueryParams } from "@plane/types";
// extension types
import { TTextAlign } from "@/extensions";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// types
@ -15,7 +19,6 @@ import {
TReadOnlyMentionHandler,
TServerHandler,
} from "@/types";
import { TTextAlign } from "@/extensions";
export type TEditorCommands =
| "text"
@ -185,7 +188,5 @@ export type TUserDetails = {
export type TRealtimeConfig = {
url: string;
queryParams: {
[key: string]: string;
};
queryParams: TWebhookConnectionQueryParams;
};

View file

@ -15,7 +15,8 @@ export type TPage = {
label_ids: string[] | undefined;
name: string | undefined;
owned_by: string | undefined;
project_ids: string[] | undefined;
project_ids?: string[] | undefined;
team: string | null | undefined;
updated_at: Date | undefined;
updated_by: string | undefined;
workspace: string | undefined;
@ -25,11 +26,7 @@ export type TPage = {
// page filters
export type TPageNavigationTabs = "public" | "private" | "archived";
export type TPageFiltersSortKey =
| "name"
| "created_at"
| "updated_at"
| "opened_at";
export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at";
export type TPageFiltersSortBy = "asc" | "desc";
@ -63,10 +60,17 @@ export type TPageVersion = {
updated_at: string;
updated_by: string;
workspace: string;
}
};
export type TDocumentPayload = {
description_binary: string;
description_html: string;
description: object;
}
};
export type TWebhookConnectionQueryParams = {
documentType: "project_page" | "team_page" | "workspace_page";
projectId?: string;
teamId?: string;
workspaceSlug: string;
};

View file

@ -6,13 +6,7 @@ import { IProject } from "./project";
import { IUser } from "./users";
import { IWorkspace } from "./workspace";
export type TSearchEntities =
| "user_mention"
| "issue_mention"
| "project_mention"
| "cycle_mention"
| "module_mention"
| "page_mention";
export type TSearchEntities = "user_mention" | "issue" | "project" | "cycle" | "module" | "page";
export type TUserSearchResponse = {
member__avatar_url: IUser["avatar_url"];
@ -66,11 +60,11 @@ export type TPageSearchResponse = {
};
export type TSearchResponse = {
cycle_mention?: TCycleSearchResponse[];
issue_mention?: TIssueSearchResponse[];
module_mention?: TModuleSearchResponse[];
page_mention?: TPageSearchResponse[];
project_mention?: TProjectSearchResponse[];
cycle?: TCycleSearchResponse[];
issue?: TIssueSearchResponse[];
module?: TModuleSearchResponse[];
page?: TPageSearchResponse[];
project?: TProjectSearchResponse[];
user_mention?: TUserSearchResponse[];
};

View file

@ -1,103 +0,0 @@
import { MAX_FILE_SIZE } from "@plane/constants";
import { getFileURL } from "./file";
// Define image-related types locally
type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
type UploadImage = (file: File) => Promise<string>;
// Define the FileService interface based on usage
interface IFileService {
deleteOldEditorAsset: (workspaceId: string, src: string) => Promise<void>;
deleteNewAsset: (url: string) => Promise<void>;
restoreOldEditorAsset: (workspaceId: string, src: string) => Promise<void>;
restoreNewAsset: (anchor: string, src: string) => Promise<void>;
cancelUpload: () => void;
}
// Define TFileHandler locally since we can't import from @plane/editor
interface TFileHandler {
getAssetSrc: (path: string) => Promise<string>;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
validation: {
maxFileSize: number;
};
}
/**
* @description generate the file source using assetId
* @param {string} anchor
* @param {string} assetId
*/
export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => {
const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`);
return url;
};
type TArgs = {
anchor: string;
uploadFile: (file: File) => Promise<string>;
workspaceId: string;
fileService: IFileService;
};
/**
* @description this function returns the file handler required by the editors
* @param {TArgs} args
*/
export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
const { anchor, uploadFile, workspaceId, fileService } = args;
return {
getAssetSrc: async (path: string) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";
}
},
upload: uploadFile,
delete: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.deleteOldEditorAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? "");
}
},
restore: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.restoreOldEditorAsset(workspaceId, src);
} else {
await fileService.restoreNewAsset(anchor, src);
}
},
cancel: fileService.cancelUpload,
validation: {
maxFileSize: MAX_FILE_SIZE,
},
};
};
/**
* @description this function returns the file handler required by the read-only editors
*/
export const getReadOnlyEditorFileHandlers = (
args: Pick<TArgs, "anchor">
): { getAssetSrc: TFileHandler["getAssetSrc"] } => {
const { anchor } = args;
return {
getAssetSrc: async (path: string) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";
}
},
};
};

View file

@ -4,7 +4,6 @@ export * from "./datetime";
export * from "./color";
export * from "./common";
export * from "./datetime";
export * from "./editor";
export * from "./emoji";
export * from "./file";
export * from "./issue";

View file

@ -1,29 +1,58 @@
"use client";
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
// plane types
import { TSearchEntityRequestPayload } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
// plane ui
import { getButtonStyling } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { IssuePeekOverview } from "@/components/issues";
import { PageRoot } from "@/components/pages";
import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { usePage, useProjectPages } from "@/hooks/store";
import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
const workspaceService = new WorkspaceService();
const fileService = new FileService();
const projectPageService = new ProjectPageService();
const projectPageVersionService = new ProjectPageVersionService();
const PageDetailsPage = observer(() => {
const { workspaceSlug, projectId, pageId } = useParams();
// store hooks
const { getPageById } = useProjectPages();
const page = usePage(pageId?.toString() ?? "");
const { id, name } = page;
const { createPage, getPageById } = useProjectPages();
const page = useProjectPage(pageId?.toString() ?? "");
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const { id, name, updateDescription } = page;
// entity search handler
const fetchEntityCallback = useCallback(
async (payload: TSearchEntityRequestPayload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
}),
[projectId, workspaceSlug]
);
// file size
const { maxFileSize } = useFileSize();
// fetch page details
const { error: pageDetailsError } = useSWR(
workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null,
@ -36,6 +65,62 @@ const PageDetailsPage = observer(() => {
revalidateOnReconnect: true,
}
);
// page root handlers
const pageRootHandlers: TPageRootHandlers = useMemo(
() => ({
create: createPage,
fetchAllVersions: async (pageId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId);
},
fetchDescriptionBinary: async () => {
if (!workspaceSlug || !projectId || !page.id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), page.id);
},
fetchEntity: fetchEntityCallback,
fetchVersionDetails: async (pageId, versionId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchVersionById(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
},
getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`,
updateDescription,
}),
[createPage, fetchEntityCallback, page.id, projectId, updateDescription, workspaceSlug]
);
// page root config
const pageRootConfig: TPageRootConfig = useMemo(
() => ({
fileHandler: getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
entity_identifier: id ?? "",
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
}),
webhookConnectionParams: {
documentType: "project_page",
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
},
}),
[id, maxFileSize, projectId, workspaceId, workspaceSlug]
);
if ((!page || !id) && !pageDetailsError)
return (
@ -65,7 +150,12 @@ const PageDetailsPage = observer(() => {
<PageHead title={name} />
<div className="flex h-full flex-col justify-between">
<div className="relative h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<PageRoot page={page} projectId={projectId.toString()} workspaceSlug={workspaceSlug.toString()} />
<PageRoot
config={pageRootConfig}
handlers={pageRootHandlers}
page={page}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<IssuePeekOverview />
</div>
</div>

View file

@ -15,7 +15,7 @@ import { PageEditInformationPopover } from "@/components/pages";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useProjectPage, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
@ -32,7 +32,7 @@ export const PageDetailsHeader = observer(() => {
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { currentProjectDetails, loader } = useProject();
const page = usePage(pageId?.toString() ?? "");
const page = useProjectPage(pageId?.toString() ?? "");
const { name, logo_props, updatePageLogo, owned_by } = page;
const { allowPermissions } = useUserPermissions();
const { data: currentUser } = useUser();
@ -169,7 +169,7 @@ export const PageDetailsHeader = observer(() => {
</Header.LeftItem>
<Header.RightItem>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions />
<PageDetailsHeaderExtraActions page={page} />
</Header.RightItem>
</Header>
);

View file

@ -51,11 +51,7 @@ const ProjectPagesPage = observer(() => {
projectId={projectId.toString()}
pageType={currentPageType()}
>
<PagesListRoot
pageType={currentPageType()}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<PagesListRoot pageType={currentPageType()} />
</PagesListView>
</>
);

View file

@ -11,13 +11,12 @@ type Props = {
handleInsertText: (insertOnNextLine: boolean) => void;
handleRegenerate: () => Promise<void>;
isRegenerating: boolean;
projectId: string;
response: string | undefined;
workspaceSlug: string;
};
export const AskPiMenu: React.FC<Props> = (props) => {
const { handleInsertText, handleRegenerate, isRegenerating, projectId, response, workspaceSlug } = props;
const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props;
// states
const [query, setQuery] = useState("");
@ -42,7 +41,6 @@ export const AskPiMenu: React.FC<Props> = (props) => {
containerClassName="!p-0 border-none"
editorClassName="!pl-0"
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<div className="mt-3 flex items-center gap-4">
<button

View file

@ -21,7 +21,6 @@ type Props = {
editorRef: RefObject<EditorRefApi>;
isOpen: boolean;
onClose: () => void;
projectId: string;
workspaceSlug: string;
};
@ -59,7 +58,7 @@ const TONES_LIST = [
];
export const EditorAIMenu: React.FC<Props> = (props) => {
const { editorRef, isOpen, onClose, projectId, workspaceSlug } = props;
const { editorRef, isOpen, onClose, workspaceSlug } = props;
// states
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
const [response, setResponse] = useState<string | undefined>(undefined);
@ -193,7 +192,6 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
handleInsertText={handleInsertText}
handleRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
projectId={projectId}
response={response}
workspaceSlug={workspaceSlug}
/>
@ -218,7 +216,6 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
containerClassName="!p-0 border-none"
editorClassName="!pl-0"
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<div className="mt-3 flex items-center gap-4">
<button

View file

@ -1 +1,8 @@
export const PageDetailsHeaderExtraActions = () => null;
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageHeaderExtraActionsProps = {
page: TPageInstance;
};
export const PageDetailsHeaderExtraActions: React.FC<TPageHeaderExtraActionsProps> = () => null;

View file

@ -1,12 +1,18 @@
// editor
import { TEmbedConfig } from "@plane/editor";
// types
import { TPageEmbedType } from "@plane/types";
// plane types
import { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
// plane web components
import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages";
export type TIssueEmbedHookProps = {
fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
projectId?: string;
workspaceSlug?: string;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => {
export const useIssueEmbed = (props: TIssueEmbedHookProps) => {
const widgetCallback = () => <IssueEmbedUpgradeCard />;
const issueEmbedProps: TEmbedConfig["issue"] = {

View file

@ -9,10 +9,10 @@ import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember } from "@/hooks/store";
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: IPage;
page: TPageInstance;
};
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {

View file

@ -9,10 +9,10 @@ import { DeletePageModal } from "@/components/pages";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: IPage;
page: TPageInstance;
pageLink: string;
parentRef: React.RefObject<HTMLElement>;
};
@ -60,7 +60,7 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
title: "Success!",
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
});
} catch (err) {
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
@ -104,7 +104,7 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
return (
<>
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={page.id ?? ""} />
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} page={page} />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu placement="bottom-end" ellipsis closeOnSelect>
{MENU_ITEMS.map((item) => {

View file

@ -1,87 +1,94 @@
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// document-editor
import {
CollaborativeDocumentEditorWithRef,
EditorRefApi,
TAIMenuProps,
TDisplayConfig,
TFileHandler,
TRealtimeConfig,
TServerHandler,
} from "@plane/editor";
// types
import { EFileAssetType } from "@plane/types/src/enums";
// components
// plane types
import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types";
// plane ui
import { Row } from "@plane/ui";
// components
import { EditorMentionsRoot } from "@/components/editor";
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { generateRandomColor } from "@/helpers/string.helper";
// hooks
import { useUser, useWorkspace } from "@/hooks/store";
import { useUser } from "@/hooks/store";
import { useEditorMention } from "@/hooks/use-editor-mention";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web components
import { EditorAIMenu } from "@/plane-web/components/pages";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
// store
import { IPage } from "@/store/pages/page";
// services init
const workspaceService = new WorkspaceService();
const fileService = new FileService();
import { TPageInstance } from "@/store/pages/base-page";
export type TEditorBodyConfig = {
fileHandler: TFileHandler;
webhookConnectionParams: TWebhookConnectionQueryParams;
};
export type TEditorBodyHandlers = {
fetchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
};
type Props = {
config: TEditorBodyConfig;
editorRef: React.RefObject<EditorRefApi>;
editorReady: boolean;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: Dispatch<SetStateAction<boolean>>;
page: IPage;
handlers: TEditorBodyHandlers;
page: TPageInstance;
sidePeekVisible: boolean;
workspaceSlug: string;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
const { editorRef, handleConnectionStatus, handleEditorReady, page, sidePeekVisible } = props;
// router
const { workspaceSlug, projectId } = useParams();
const {
config,
editorRef,
handleConnectionStatus,
handleEditorReady,
handlers,
page,
sidePeekVisible,
workspaceSlug,
} = props;
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const pageId = page?.id;
const pageTitle = page?.name ?? "";
const { isContentEditable, updateTitle } = page;
const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page;
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
fetchEmbedSuggestions: handlers.fetchEntity,
workspaceSlug,
});
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
}),
searchEntity: handlers.fetchEntity,
});
// editor flaggings
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug);
// page filters
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
// issue-embed
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
// file size
const { maxFileSize } = useFileSize();
const displayConfig: TDisplayConfig = {
fontSize,
fontStyle,
};
// derived values
const displayConfig: TDisplayConfig = useMemo(
() => ({
fontSize,
fontStyle,
}),
[fontSize, fontStyle]
);
const getAIMenu = useCallback(
({ isOpen, onClose }: TAIMenuProps) => (
@ -89,11 +96,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
editorRef={editorRef}
isOpen={isOpen}
onClose={onClose}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
[editorRef, projectId, workspaceSlug]
[editorRef, workspaceSlug]
);
const handleServerConnect = useCallback(() => {
@ -123,17 +129,13 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
// Construct realtime config
return {
url: WS_LIVE_URL.toString(),
queryParams: {
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId?.toString(),
documentType: "project_page",
},
queryParams: config.webhookConnectionParams,
};
} catch (error) {
console.error("Error creating realtime config", error);
return undefined;
}
}, [projectId, workspaceSlug]);
}, [config.webhookConnectionParams]);
const userConfig = useMemo(
() => ({
@ -173,24 +175,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
<CollaborativeDocumentEditorWithRef
editable={isContentEditable}
id={pageId}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
entity_identifier: pageId,
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady}
ref={editorRef}
containerClassName="h-full p-0 pb-64"

View file

@ -13,12 +13,12 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import useOnlineStatus from "@/hooks/use-online-status";
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
page: TPageInstance;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {

View file

@ -1,18 +1,18 @@
import { observer } from "mobx-react";
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
page: TPageInstance;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};

View file

@ -28,12 +28,12 @@ import { usePageFilters } from "@/hooks/use-page-filters";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
import { useQueryParams } from "@/hooks/use-query-params";
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorRef: EditorRefApi | null;
handleDuplicatePage: () => void;
page: IPage;
page: TPageInstance;
};
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {

View file

@ -8,13 +8,13 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
page: TPageInstance;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};

View file

@ -4,31 +4,45 @@ import { useSearchParams } from "next/navigation";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import { TPage } from "@plane/types";
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor } from "@/components/pages";
import {
PageEditorHeaderRoot,
PageEditorBody,
PageVersionsOverlay,
PagesVersionEditor,
TEditorBodyHandlers,
TEditorBodyConfig,
} from "@/components/pages";
// hooks
import { useProjectPages } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
import { useQueryParams } from "@/hooks/use-query-params";
// services
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
const projectPageService = new ProjectPageService();
const projectPageVersionService = new ProjectPageVersionService();
// store
import { IPage } from "@/store/pages/page";
import { TPageInstance } from "@/store/pages/base-page";
export type TPageRootHandlers = {
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchDescriptionBinary: () => Promise<any>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
getRedirectionLink: (pageId: string) => string;
updateDescription: (document: TDocumentPayload) => Promise<void>;
} & TEditorBodyHandlers;
export type TPageRootConfig = TEditorBodyConfig;
type TPageRootProps = {
page: IPage;
projectId: string;
config: TPageRootConfig;
handlers: TPageRootHandlers;
page: TPageInstance;
workspaceSlug: string;
};
export const PageRoot = observer((props: TPageRootProps) => {
const { projectId, workspaceSlug, page } = props;
const { config, handlers, page, workspaceSlug } = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
@ -40,25 +54,18 @@ export const PageRoot = observer((props: TPageRootProps) => {
const router = useAppRouter();
// search params
const searchParams = useSearchParams();
// store hooks
const { createPage } = useProjectPages();
// derived values
const { access, description_html, name, isContentEditable, updateDescription } = page;
const { access, description_html, name, isContentEditable } = page;
// page fallback
usePageFallback({
editorRef,
fetchPageDescription: async () => {
if (!page.id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id);
},
fetchPageDescription: handlers.fetchDescriptionBinary,
hasConnectionFailed,
updatePageDescription: async (data) => await updateDescription(data),
updatePageDescription: handlers.updateDescription,
});
// update query params
const { updateQueryParams } = useQueryParams();
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
const handleDuplicatePage = async () => {
const formData: Partial<TPage> = {
name: "Copy of " + name,
@ -66,8 +73,9 @@ export const PageRoot = observer((props: TPageRootProps) => {
access,
};
await handleCreatePage(formData)
.then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res?.id}`))
await handlers
.create(formData)
.then((res) => router.push(handlers.getRedirectionLink(res?.id ?? "")))
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
@ -105,23 +113,8 @@ export const PageRoot = observer((props: TPageRootProps) => {
activeVersion={version}
currentVersionDescription={currentVersionDescription ?? null}
editorComponent={PagesVersionEditor}
fetchAllVersions={async (pageId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchAllVersions(
workspaceSlug.toString(),
projectId.toString(),
pageId
);
}}
fetchVersionDetails={async (pageId, versionId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchVersionById(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
}}
fetchAllVersions={handlers.fetchAllVersions}
fetchVersionDetails={handlers.fetchVersionDetails}
handleRestore={handleRestoreVersion}
isOpen={isVersionsOverlayOpen}
onClose={handleCloseVersionsOverlay}
@ -137,12 +130,15 @@ export const PageRoot = observer((props: TPageRootProps) => {
sidePeekVisible={sidePeekVisible}
/>
<PageEditorBody
config={config}
editorReady={editorReady}
editorRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={setEditorReady}
handlers={handlers}
page={page}
sidePeekVisible={sidePeekVisible}
workspaceSlug={workspaceSlug}
/>
</>
);

View file

@ -8,13 +8,14 @@ import { EditorRefApi } from "@plane/editor";
import { TextArea } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
type Props = {
editorRef: React.RefObject<EditorRefApi>;
readOnly: boolean;
title: string;
title: string | undefined;
updateTitle: (title: string) => void;
};
@ -33,7 +34,17 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
return (
<div className="relative w-full flex-shrink-0 md:pl-5 px-4">
{readOnly ? (
<h6 className={cn(titleClassName, "break-words pb-1.5")}>{title}</h6>
<h6
className={cn(
titleClassName,
{
"text-custom-text-400": !title,
},
"break-words pb-1.5"
)}
>
{getPageName(title)}
</h6>
) : (
<>
<TextArea
@ -62,10 +73,10 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
>
<span
className={cn({
"text-red-500": title.length > 255,
"text-red-500": title && title.length > 255,
})}
>
{title.length}
{title?.length}
</span>
/255
</div>

View file

@ -11,19 +11,17 @@ import { PageQuickActions } from "@/components/pages/dropdowns";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember, usePage } from "@/hooks/store";
import { useMember } from "@/hooks/store";
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
workspaceSlug: string;
projectId: string;
pageId: string;
page: TPageInstance;
parentRef: React.RefObject<HTMLElement>;
};
export const BlockItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, pageId, parentRef } = props;
const { page, parentRef } = props;
// store hooks
const page = usePage(pageId);
const { getUserDetails } = useMember();
// derived values
const {
@ -34,6 +32,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
canCurrentUserFavoritePage,
addToFavorites,
removePageFromFavorites,
getRedirectionLink,
} = page;
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
@ -94,11 +93,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
)}
{/* quick actions dropdown */}
<PageQuickActions
parentRef={parentRef}
page={page}
pageLink={`${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
/>
<PageQuickActions parentRef={parentRef} page={page} pageLink={getRedirectionLink()} />
</>
);
});

View file

@ -10,22 +10,23 @@ import { BlockItemAction } from "@/components/pages/list";
// helpers
import { getPageName } from "@/helpers/page.helper";
// hooks
import { usePage } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { TUsePage } from "@/store/pages/base-page";
type TPageListBlock = {
workspaceSlug: string;
projectId: string;
pageId: string;
usePage: TUsePage;
};
export const PageListBlock: FC<TPageListBlock> = observer((props) => {
const { workspaceSlug, projectId, pageId } = props;
const { pageId, usePage } = props;
// refs
const parentRef = useRef(null);
// hooks
const { name, logo_props } = usePage(pageId);
const page = usePage(pageId);
const { isMobile } = usePlatformOS();
// derived values
const { name, logo_props, getRedirectionLink } = page;
return (
<ListItem
@ -39,10 +40,8 @@ export const PageListBlock: FC<TPageListBlock> = observer((props) => {
</>
}
title={getPageName(name)}
itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
actionableItems={
<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} />
}
itemLink={getRedirectionLink()}
actionableItems={<BlockItemAction page={page} parentRef={parentRef} />}
isMobile={isMobile}
parentRef={parentRef}
/>

View file

@ -5,18 +5,16 @@ import { TPageNavigationTabs } from "@plane/types";
// components
import { ListLayout } from "@/components/core/list";
// hooks
import { useProjectPages } from "@/hooks/store";
import { useProjectPage, useProjectPages } from "@/hooks/store";
// components
import { PageListBlock } from "./";
type TPagesListRoot = {
pageType: TPageNavigationTabs;
projectId: string;
workspaceSlug: string;
};
export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
const { pageType, projectId, workspaceSlug } = props;
const { pageType } = props;
// store hooks
const { getCurrentProjectFilteredPageIds } = useProjectPages();
// derived values
@ -26,7 +24,7 @@ export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
return (
<ListLayout>
{filteredPageIds.map((pageId) => (
<PageListBlock key={pageId} workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} />
<PageListBlock key={pageId} pageId={pageId} usePage={useProjectPage} />
))}
</ListLayout>
);

View file

@ -7,26 +7,25 @@ import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { PAGE_DELETED } from "@/constants/event-tracker";
// hooks
import { useEventTracker, usePage, useProjectPages } from "@/hooks/store";
import { useEventTracker, useProjectPages } from "@/hooks/store";
import { TPageInstance } from "@/store/pages/base-page";
type TConfirmPageDeletionProps = {
page: TPageInstance;
isOpen: boolean;
onClose: () => void;
pageId: string;
};
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
const { pageId, isOpen, onClose } = props;
const { page, isOpen, onClose } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { removePage } = useProjectPages();
const { capturePageEvent } = useEventTracker();
const page = usePage(pageId);
if (!page) return null;
const { name } = page;
if (!page || !page.id) return null;
// derived values
const { id: pageId, name } = page;
const handleClose = () => {
setIsDeleting(false);

View file

@ -30,7 +30,10 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
// editor flaggings
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
// issue-embed
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
const { issueEmbedProps } = useIssueEmbed({
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
});
// page filters
const { fontSize, fontStyle } = usePageFilters();

View file

@ -1,14 +1,15 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import { IPage } from "@/store/pages/page";
import { TUsePage } from "@/store/pages/base-page";
// store
import { TProjectPage } from "@/store/pages/project-page";
export const usePage = (pageId: string | undefined): IPage => {
export const useProjectPage: TUsePage = (pageId: string | undefined): TProjectPage => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
if (!pageId) return {} as IPage;
if (!pageId) return {} as TProjectPage;
return context.projectPages.data?.[pageId] ?? {};
};

View file

@ -1,8 +1,11 @@
import { useState, useEffect, useCallback, useMemo } from "react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor";
import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
// plane ui
import { TOAST_TYPE, setToast } from "@plane/ui";
import { IPage } from "@/store/pages/page";
// store
import { TPageInstance } from "@/store/pages/base-page";
// Better type naming and structure
type CollaborativeAction = {
@ -14,7 +17,10 @@ type CollaborativeActionEvent =
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorReadOnlyRefApi | null, page: IPage) => {
export const useCollaborativePageActions = (
editorRef: EditorRefApi | EditorReadOnlyRefApi | null,
page: TPageInstance
) => {
// currentUserAction local state to track if the current action is being processed, a
// local action is basically the action performed by the current user to avoid double operations
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);

View file

@ -8,7 +8,7 @@ import {
// helpers
import { getPageName } from "@/helpers/page.helper";
// hooks
import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hooks/store";
import { useProject, useProjectPage, useProjectView, useCycle, useModule } from "@/hooks/store";
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
const favoriteItemId = favorite?.entity_data?.id;
@ -23,7 +23,7 @@ export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorit
const { getModuleById } = useModule();
// derived values
const pageDetail = usePage(favoriteItemId ?? "");
const pageDetail = useProjectPage(favoriteItemId ?? "");
const viewDetails = getViewById(favoriteItemId ?? "");
const cycleDetail = getCycleById(favoriteItemId ?? "");
const moduleDetail = getModuleById(favoriteItemId ?? "");

View file

@ -47,7 +47,12 @@ export class ProjectPageService extends APIService {
});
}
async updateAccess(workspaceSlug: string, projectId: string, pageId: string, data: Partial<TPage>): Promise<void> {
async updateAccess(
workspaceSlug: string,
projectId: string,
pageId: string,
data: Pick<TPage, "access">
): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/access/`, data)
.then((response) => response?.data)
.catch((error) => {
@ -146,7 +151,7 @@ export class ProjectPageService extends APIService {
});
}
async updateDescriptionYJS(
async updateDescription(
workspaceSlug: string,
projectId: string,
pageId: string,

View file

@ -4,32 +4,21 @@ import { action, computed, makeObservable, observable, reaction, runInAction } f
import { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types";
// constants
import { EPageAccess } from "@/constants/page";
import { EUserPermissions } from "@/plane-web/constants/user-permissions";
// services
import { ProjectPageService } from "@/services/page";
// store
import { CoreRootStore } from "../root.store";
// plane web store
import { RootStore } from "@/plane-web/store/root.store";
export interface IPage extends TPage {
export type TBasePage = TPage & {
// observables
isSubmitting: TNameDescriptionLoader;
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean; // it will give the user is the owner of the page or not
canCurrentUserEditPage: boolean; // it will give the user permission to read the page or write the page
canCurrentUserDuplicatePage: boolean;
canCurrentUserLockPage: boolean;
canCurrentUserChangeAccess: boolean;
canCurrentUserArchivePage: boolean;
canCurrentUserDeletePage: boolean;
canCurrentUserFavoritePage: boolean;
isContentEditable: boolean;
isCurrentUserOwner: boolean;
// helpers
oldName: string;
setIsSubmitting: (value: TNameDescriptionLoader) => void;
cleanup: () => void;
// actions
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
updateTitle: (title: string) => void;
updateDescription: (document: TDocumentPayload) => Promise<void>;
makePublic: () => Promise<void>;
@ -41,9 +30,39 @@ export interface IPage extends TPage {
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
}
};
export class Page implements IPage {
export type TBasePagePermissions = {
canCurrentUserEditPage: boolean;
canCurrentUserDuplicatePage: boolean;
canCurrentUserLockPage: boolean;
canCurrentUserChangeAccess: boolean;
canCurrentUserArchivePage: boolean;
canCurrentUserDeletePage: boolean;
canCurrentUserFavoritePage: boolean;
isContentEditable: boolean;
};
export type TBasePageServices = {
update: (payload: Partial<TPage>) => Promise<Partial<TPage>>;
updateDescription: (document: TDocumentPayload) => Promise<void>;
updateAccess: (payload: Pick<TPage, "access">) => Promise<void>;
lock: () => Promise<void>;
unlock: () => Promise<void>;
archive: () => Promise<{
archived_at: string;
}>;
restore: () => Promise<void>;
};
export type TPageInstance = TBasePage &
TBasePagePermissions & {
getRedirectionLink: () => string;
};
export type TUsePage = (pageId: string | undefined) => TPageInstance;
export class BasePage implements TBasePage {
// loaders
isSubmitting: TNameDescriptionLoader = "saved";
// page properties
@ -60,22 +79,24 @@ export class Page implements IPage {
is_locked: boolean;
archived_at: string | null | undefined;
workspace: string | undefined;
project_ids: string[] | undefined;
project_ids?: string[] | undefined;
team: string | null | undefined;
created_by: string | undefined;
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
// helpers
oldName: string = "";
// services
services: TBasePageServices;
// reactions
disposers: Array<() => void> = [];
// services
pageService: ProjectPageService;
// root store
rootStore: CoreRootStore;
rootStore: RootStore;
constructor(
private store: CoreRootStore,
page: TPage
private store: RootStore,
page: TPage,
services: TBasePageServices
) {
this.id = page?.id || undefined;
this.name = page?.name;
@ -91,6 +112,7 @@ export class Page implements IPage {
this.archived_at = page?.archived_at || undefined;
this.workspace = page?.workspace || undefined;
this.project_ids = page?.project_ids || undefined;
this.team = page?.team || undefined;
this.created_by = page?.created_by || undefined;
this.updated_by = page?.updated_by || undefined;
this.created_at = page?.created_at || undefined;
@ -126,14 +148,6 @@ export class Page implements IPage {
// computed
asJSON: computed,
isCurrentUserOwner: computed,
canCurrentUserEditPage: computed,
canCurrentUserDuplicatePage: computed,
canCurrentUserLockPage: computed,
canCurrentUserChangeAccess: computed,
canCurrentUserArchivePage: computed,
canCurrentUserDeletePage: computed,
canCurrentUserFavoritePage: computed,
isContentEditable: computed,
// actions
update: action,
updateTitle: action,
@ -149,16 +163,15 @@ export class Page implements IPage {
removePageFromFavorites: action,
});
this.pageService = new ProjectPageService();
this.rootStore = store;
this.services = services;
const titleDisposer = reaction(
() => this.name,
(name) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return;
this.isSubmitting = "submitting";
this.pageService
.update(workspaceSlug, projectId, this.id, {
this.services
.update({
name,
})
.catch(() =>
@ -174,7 +187,6 @@ export class Page implements IPage {
},
{ delay: 2000 }
);
this.disposers.push(titleDisposer);
}
@ -195,6 +207,7 @@ export class Page implements IPage {
archived_at: this.archived_at,
workspace: this.workspace,
project_ids: this.project_ids,
team: this.team,
created_by: this.created_by,
updated_by: this.updated_by,
created_at: this.created_at,
@ -208,116 +221,6 @@ export class Page implements IPage {
return this.owned_by === currentUserId;
}
/**
* @description returns true if the current logged in user can edit the page
*/
get canCurrentUserEditPage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
const isPagePublic = this.access === EPageAccess.PUBLIC;
return (
(isPagePublic && !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER) ||
(!isPagePublic && this.isCurrentUserOwner)
);
}
/**
* @description returns true if the current logged in user can create a duplicate the page
*/
get canCurrentUserDuplicatePage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
}
/**
* @description returns true if the current logged in user can lock the page
*/
get canCurrentUserLockPage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return this.isCurrentUserOwner || currentUserProjectRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can change the access of the page
*/
get canCurrentUserChangeAccess() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return this.isCurrentUserOwner || currentUserProjectRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can archive the page
*/
get canCurrentUserArchivePage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return this.isCurrentUserOwner || currentUserProjectRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can delete the page
*/
get canCurrentUserDeletePage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return this.isCurrentUserOwner || currentUserProjectRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can favorite the page
*/
get canCurrentUserFavoritePage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
}
/**
* @description returns true if the page can be edited
*/
get isContentEditable() {
const { workspaceSlug, projectId } = this.store.router;
const isOwner = this.isCurrentUserOwner;
const currentUserRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
const isPublic = this.access === EPageAccess.PUBLIC;
const isArchived = this.archived_at;
const isLocked = this.is_locked;
return (
!isArchived &&
!isLocked &&
(isOwner || (isPublic && !!currentUserRole && currentUserRole >= EUserPermissions.MEMBER))
);
}
/**
* @description update the submitting state
* @param value
@ -339,9 +242,6 @@ export class Page implements IPage {
* @param {Partial<TPage>} pageData
*/
update = async (pageData: Partial<TPage>) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const currentPage = this.asJSON;
try {
runInAction(() => {
@ -351,7 +251,7 @@ export class Page implements IPage {
});
});
await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
return await this.services.update(currentPage);
} catch (error) {
runInAction(() => {
Object.keys(pageData).forEach((key) => {
@ -377,16 +277,13 @@ export class Page implements IPage {
* @param {TDocumentPayload} document
*/
updateDescription = async (document: TDocumentPayload) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const currentDescription = this.description_html;
runInAction(() => {
this.description_html = document.description_html;
});
try {
await this.pageService.updateDescriptionYJS(workspaceSlug, projectId, this.id, document);
await this.services.updateDescription(document);
} catch (error) {
runInAction(() => {
this.description_html = currentDescription;
@ -399,14 +296,11 @@ export class Page implements IPage {
* @description make the page public
*/
makePublic = async () => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageAccess = this.access;
runInAction(() => (this.access = EPageAccess.PUBLIC));
try {
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
await this.services.updateAccess({
access: EPageAccess.PUBLIC,
});
} catch (error) {
@ -421,14 +315,11 @@ export class Page implements IPage {
* @description make the page private
*/
makePrivate = async () => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageAccess = this.access;
runInAction(() => (this.access = EPageAccess.PRIVATE));
try {
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
await this.services.updateAccess({
access: EPageAccess.PRIVATE,
});
} catch (error) {
@ -443,14 +334,11 @@ export class Page implements IPage {
* @description lock the page
*/
lock = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageIsLocked = this.is_locked;
runInAction(() => (this.is_locked = true));
if (shouldSync) {
await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => {
await this.services.lock().catch((error) => {
runInAction(() => {
this.is_locked = pageIsLocked;
});
@ -463,14 +351,11 @@ export class Page implements IPage {
* @description unlock the page
*/
unlock = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageIsLocked = this.is_locked;
runInAction(() => (this.is_locked = false));
if (shouldSync) {
await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => {
await this.services.unlock().catch((error) => {
runInAction(() => {
this.is_locked = pageIsLocked;
});
@ -483,8 +368,7 @@ export class Page implements IPage {
* @description archive the page
*/
archive = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
if (!this.id) return undefined;
try {
runInAction(() => {
@ -494,7 +378,7 @@ export class Page implements IPage {
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
if (shouldSync) {
const response = await this.pageService.archive(workspaceSlug, projectId, this.id);
const response = await this.services.archive();
runInAction(() => {
this.archived_at = response.archived_at;
});
@ -511,9 +395,6 @@ export class Page implements IPage {
* @description restore the page
*/
restore = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const archivedAtBeforeRestore = this.archived_at;
try {
@ -522,7 +403,7 @@ export class Page implements IPage {
});
if (shouldSync) {
await this.pageService.restore(workspaceSlug, projectId, this.id);
await this.services.restore();
}
} catch (error) {
console.error(error);
@ -533,9 +414,7 @@ export class Page implements IPage {
};
updatePageLogo = async (logo_props: TLogoProps) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
await this.pageService.update(workspaceSlug, projectId, this.id, {
await this.services.update({
logo_props,
});
runInAction(() => {
@ -547,8 +426,9 @@ export class Page implements IPage {
* @description add the page to favorites
*/
addToFavorites = async () => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const { workspaceSlug } = this.store.router;
const projectId = this.project_ids?.[0] ?? null;
if (!workspaceSlug || !this.id) return undefined;
const pageIsFavorite = this.is_favorite;
runInAction(() => {
@ -573,8 +453,8 @@ export class Page implements IPage {
* @description remove the page from favorites
*/
removePageFromFavorites = async () => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const { workspaceSlug } = this.store.router;
if (!workspaceSlug || !this.id) return undefined;
const pageIsFavorite = this.is_favorite;
runInAction(() => {

View file

@ -8,11 +8,13 @@ import { TPage, TPageFilters, TPageNavigationTabs } from "@plane/types";
import { filterPagesByPageType, getPageName, orderPages, shouldFilterPage } from "@/helpers/page.helper";
// plane web constants
import { EUserPermissions } from "@/plane-web/constants";
// plane web store
import { RootStore } from "@/plane-web/store/root.store";
// services
import { ProjectPageService } from "@/services/page";
// store
import { IPage, Page } from "@/store/pages/page";
import { CoreRootStore } from "../root.store";
import { ProjectPage, TProjectPage } from "./project-page";
type TLoader = "init-loader" | "mutation-loader" | undefined;
@ -21,7 +23,7 @@ type TError = { title: string; description: string };
export interface IProjectPageStore {
// observables
loader: TLoader;
data: Record<string, IPage>; // pageId => Page
data: Record<string, TProjectPage>; // pageId => Page
error: TError | undefined;
filters: TPageFilters;
// computed
@ -30,7 +32,7 @@ export interface IProjectPageStore {
// helper actions
getCurrentProjectPageIds: (pageType: TPageNavigationTabs) => string[] | undefined;
getCurrentProjectFilteredPageIds: (pageType: TPageNavigationTabs) => string[] | undefined;
pageById: (pageId: string) => IPage | undefined;
pageById: (pageId: string) => TProjectPage | undefined;
updateFilters: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void;
clearAllFilters: () => void;
// actions
@ -47,7 +49,7 @@ export interface IProjectPageStore {
export class ProjectPageStore implements IProjectPageStore {
// observables
loader: TLoader = "init-loader";
data: Record<string, IPage> = {}; // pageId => Page
data: Record<string, TProjectPage> = {}; // pageId => Page
error: TError | undefined = undefined;
filters: TPageFilters = {
searchQuery: "",
@ -58,7 +60,7 @@ export class ProjectPageStore implements IProjectPageStore {
service: ProjectPageService;
rootStore: CoreRootStore;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
loader: observable.ref,
@ -184,7 +186,7 @@ export class ProjectPageStore implements IProjectPageStore {
const pages = await this.service.fetchAll(workspaceSlug, projectId);
runInAction(() => {
for (const page of pages) if (page?.id) set(this.data, [page.id], new Page(this.store, page));
for (const page of pages) if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
this.loader = undefined;
});
@ -217,7 +219,7 @@ export class ProjectPageStore implements IProjectPageStore {
const page = await this.service.fetchById(workspaceSlug, projectId, pageId);
runInAction(() => {
if (page?.id) set(this.data, [page.id], new Page(this.store, page));
if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
this.loader = undefined;
});
@ -250,7 +252,7 @@ export class ProjectPageStore implements IProjectPageStore {
const page = await this.service.create(workspaceSlug, projectId, pageData);
runInAction(() => {
if (page?.id) set(this.data, [page.id], new Page(this.store, page));
if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
this.loader = undefined;
});

View file

@ -0,0 +1,163 @@
import { computed, makeObservable } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { TPage } from "@plane/types";
// constants
import { EPageAccess } from "@/constants/page";
import { EUserPermissions } from "@/plane-web/constants/user-permissions";
// plane web store
import { RootStore } from "@/plane-web/store/root.store";
// services
import { ProjectPageService } from "@/services/page";
const projectPageService = new ProjectPageService();
// store
import { BasePage, TPageInstance } from "./base-page";
export type TProjectPage = TPageInstance;
export class ProjectPage extends BasePage implements TProjectPage {
constructor(store: RootStore, page: TPage) {
// required fields for API calls
const { workspaceSlug } = store.router;
const projectId = page.project_ids?.[0];
// initialize base instance
super(store, page, {
update: async (payload) => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
return await projectPageService.update(workspaceSlug, projectId, page.id, payload);
},
updateDescription: async (document) => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
await projectPageService.updateDescription(workspaceSlug, projectId, page.id, document);
},
updateAccess: async (payload) => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
await projectPageService.updateAccess(workspaceSlug, projectId, page.id, payload);
},
lock: async () => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
await projectPageService.lock(workspaceSlug, projectId, page.id);
},
unlock: async () => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
await projectPageService.unlock(workspaceSlug, projectId, page.id);
},
archive: async () => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
return await projectPageService.archive(workspaceSlug, projectId, page.id);
},
restore: async () => {
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
await projectPageService.restore(workspaceSlug, projectId, page.id);
},
});
makeObservable(this, {
// computed
canCurrentUserEditPage: computed,
canCurrentUserDuplicatePage: computed,
canCurrentUserLockPage: computed,
canCurrentUserChangeAccess: computed,
canCurrentUserArchivePage: computed,
canCurrentUserDeletePage: computed,
canCurrentUserFavoritePage: computed,
isContentEditable: computed,
});
}
private getHighestRoleAcrossProjects = computedFn((): EUserPermissions | undefined => {
const { workspaceSlug } = this.rootStore.router;
if (!workspaceSlug || !this.project_ids?.length) return;
let highestRole: EUserPermissions | undefined = undefined;
this.project_ids.map((projectId) => {
const currentUserProjectRole = this.rootStore.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
if (currentUserProjectRole) {
if (!highestRole) highestRole = currentUserProjectRole;
else if (currentUserProjectRole > highestRole) highestRole = currentUserProjectRole;
}
});
return highestRole;
});
/**
* @description returns true if the current logged in user can edit the page
*/
get canCurrentUserEditPage() {
const highestRole = this.getHighestRoleAcrossProjects();
const isPagePublic = this.access === EPageAccess.PUBLIC;
return (
(isPagePublic && !!highestRole && highestRole >= EUserPermissions.MEMBER) ||
(!isPagePublic && this.isCurrentUserOwner)
);
}
/**
* @description returns true if the current logged in user can create a duplicate the page
*/
get canCurrentUserDuplicatePage() {
const highestRole = this.getHighestRoleAcrossProjects();
return !!highestRole && highestRole >= EUserPermissions.MEMBER;
}
/**
* @description returns true if the current logged in user can lock the page
*/
get canCurrentUserLockPage() {
const highestRole = this.getHighestRoleAcrossProjects();
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can change the access of the page
*/
get canCurrentUserChangeAccess() {
const highestRole = this.getHighestRoleAcrossProjects();
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can archive the page
*/
get canCurrentUserArchivePage() {
const highestRole = this.getHighestRoleAcrossProjects();
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can delete the page
*/
get canCurrentUserDeletePage() {
const highestRole = this.getHighestRoleAcrossProjects();
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
}
/**
* @description returns true if the current logged in user can favorite the page
*/
get canCurrentUserFavoritePage() {
const highestRole = this.getHighestRoleAcrossProjects();
return !!highestRole && highestRole >= EUserPermissions.MEMBER;
}
/**
* @description returns true if the page can be edited
*/
get isContentEditable() {
const highestRole = this.getHighestRoleAcrossProjects();
const isOwner = this.isCurrentUserOwner;
const isPublic = this.access === EPageAccess.PUBLIC;
const isArchived = this.archived_at;
const isLocked = this.is_locked;
return (
!isArchived && !isLocked && (isOwner || (isPublic && !!highestRole && highestRole >= EUserPermissions.MEMBER))
);
}
getRedirectionLink = computedFn(() => {
const { workspaceSlug } = this.rootStore.router;
return `/${workspaceSlug}/projects/${this.project_ids?.[0]}/pages/${this.id}`;
});
}

View file

@ -82,7 +82,7 @@ export class CoreRootStore {
this.eventTracker = new EventTrackerStore(this);
this.multipleSelect = new MultipleSelectStore();
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);
this.favorite = new FavoriteStore(this);
@ -112,7 +112,7 @@ export class CoreRootStore {
this.dashboard = new DashboardStore(this);
this.eventTracker = new EventTrackerStore(this);
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.multipleSelect = new MultipleSelectStore();
this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);