From ce2c87aff21512e15c8dae4eb2a0407d66ce02c4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 23 Dec 2024 19:06:48 +0530 Subject: [PATCH 1/8] refactor: created a generic base page instance --- packages/types/src/pages.d.ts | 13 +- .../dropdowns/edit-information-popover.tsx | 4 +- .../pages/dropdowns/quick-actions.tsx | 6 +- .../components/pages/editor/editor-body.tsx | 4 +- .../pages/editor/header/extra-options.tsx | 4 +- .../pages/editor/header/mobile-root.tsx | 6 +- .../pages/editor/header/options-dropdown.tsx | 4 +- .../components/pages/editor/header/root.tsx | 4 +- .../components/pages/editor/page-root.tsx | 4 +- web/core/hooks/store/pages/use-page.ts | 8 +- .../hooks/use-collaborative-page-actions.tsx | 10 +- .../services/page/project-page.service.ts | 9 +- .../store/pages/{page.ts => base-page.ts} | 248 +++++------------- web/core/store/pages/project-page.store.ts | 18 +- web/core/store/pages/project-page.ts | 163 ++++++++++++ web/core/store/root.store.ts | 4 +- 16 files changed, 280 insertions(+), 229 deletions(-) rename web/core/store/pages/{page.ts => base-page.ts} (58%) create mode 100644 web/core/store/pages/project-page.ts diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 011f92d69ba..ceecb1b63e6 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -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,10 @@ export type TPageVersion = { updated_at: string; updated_by: string; workspace: string; -} +}; export type TDocumentPayload = { description_binary: string; description_html: string; description: object; -} \ No newline at end of file +}; diff --git a/web/core/components/pages/dropdowns/edit-information-popover.tsx b/web/core/components/pages/dropdowns/edit-information-popover.tsx index 92e6d8fab5e..a349ef0daff 100644 --- a/web/core/components/pages/dropdowns/edit-information-popover.tsx +++ b/web/core/components/pages/dropdowns/edit-information-popover.tsx @@ -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 = observer((props) => { diff --git a/web/core/components/pages/dropdowns/quick-actions.tsx b/web/core/components/pages/dropdowns/quick-actions.tsx index 6bed6be2c65..804999650e6 100644 --- a/web/core/components/pages/dropdowns/quick-actions.tsx +++ b/web/core/components/pages/dropdowns/quick-actions.tsx @@ -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; }; @@ -60,7 +60,7 @@ export const PageQuickActions: React.FC = 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!", diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index ddd45f62286..25d0ea91c19 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -34,7 +34,7 @@ import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; import { FileService } from "@/services/file.service"; import { ProjectService } from "@/services/project"; // store -import { IPage } from "@/store/pages/page"; +import { TPageInstance } from "@/store/pages/base-page"; // services init const fileService = new FileService(); const projectService = new ProjectService(); @@ -44,7 +44,7 @@ type Props = { editorReady: boolean; handleConnectionStatus: Dispatch>; handleEditorReady: Dispatch>; - page: IPage; + page: TPageInstance; sidePeekVisible: boolean; }; diff --git a/web/core/components/pages/editor/header/extra-options.tsx b/web/core/components/pages/editor/header/extra-options.tsx index 924e28d05e4..5f7153c5105 100644 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ b/web/core/components/pages/editor/header/extra-options.tsx @@ -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; handleDuplicatePage: () => void; - page: IPage; + page: TPageInstance; }; export const PageExtraOptions: React.FC = observer((props) => { diff --git a/web/core/components/pages/editor/header/mobile-root.tsx b/web/core/components/pages/editor/header/mobile-root.tsx index df93a70e922..901f2606984 100644 --- a/web/core/components/pages/editor/header/mobile-root.tsx +++ b/web/core/components/pages/editor/header/mobile-root.tsx @@ -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; handleDuplicatePage: () => void; - page: IPage; + page: TPageInstance; setSidePeekVisible: (sidePeekState: boolean) => void; sidePeekVisible: boolean; }; diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index 928b84bf984..557e5381a5d 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -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 = observer((props) => { diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index 41933feb725..0a5827774ec 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -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; handleDuplicatePage: () => void; - page: IPage; + page: TPageInstance; setSidePeekVisible: (sidePeekState: boolean) => void; sidePeekVisible: boolean; }; diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index 6f4418fe4d1..4ddb2c58206 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -19,10 +19,10 @@ 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"; type TPageRootProps = { - page: IPage; + page: TPageInstance; projectId: string; workspaceSlug: string; }; diff --git a/web/core/hooks/store/pages/use-page.ts b/web/core/hooks/store/pages/use-page.ts index 97e0ef98a3f..53789fc1ea5 100644 --- a/web/core/hooks/store/pages/use-page.ts +++ b/web/core/hooks/store/pages/use-page.ts @@ -1,14 +1,14 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "@/lib/store-context"; -// mobx store -import { IPage } from "@/store/pages/page"; +// store +import { TProjectPage } from "@/store/pages/project-page"; -export const usePage = (pageId: string | undefined): IPage => { +export const usePage = (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] ?? {}; }; diff --git a/web/core/hooks/use-collaborative-page-actions.tsx b/web/core/hooks/use-collaborative-page-actions.tsx index 6196929b6a4..b9dfe9a5f86 100644 --- a/web/core/hooks/use-collaborative-page-actions.tsx +++ b/web/core/hooks/use-collaborative-page-actions.tsx @@ -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(null); diff --git a/web/core/services/page/project-page.service.ts b/web/core/services/page/project-page.service.ts index 00d9401a69a..18ef2ed2f20 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -47,7 +47,12 @@ export class ProjectPageService extends APIService { }); } - async updateAccess(workspaceSlug: string, projectId: string, pageId: string, data: Partial): Promise { + async updateAccess( + workspaceSlug: string, + projectId: string, + pageId: string, + data: Pick + ): Promise { 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, diff --git a/web/core/store/pages/page.ts b/web/core/store/pages/base-page.ts similarity index 58% rename from web/core/store/pages/page.ts rename to web/core/store/pages/base-page.ts index d609cab6498..b4d103e1bf5 100644 --- a/web/core/store/pages/page.ts +++ b/web/core/store/pages/base-page.ts @@ -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) => Promise; + update: (pageData: Partial) => Promise | undefined>; updateTitle: (title: string) => void; updateDescription: (document: TDocumentPayload) => Promise; makePublic: () => Promise; @@ -41,9 +30,37 @@ export interface IPage extends TPage { updatePageLogo: (logo_props: TLogoProps) => Promise; addToFavorites: () => Promise; removePageFromFavorites: () => Promise; -} +}; -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) => Promise>; + updateDescription: (document: TDocumentPayload) => Promise; + updateAccess: (payload: Pick) => Promise; + lock: () => Promise; + unlock: () => Promise; + archive: () => Promise<{ + archived_at: string; + }>; + restore: () => Promise; +}; + +export type TPageInstance = TBasePage & + TBasePagePermissions & { + getRedirectionLink: () => string; + }; + +export class BasePage implements TBasePage { // loaders isSubmitting: TNameDescriptionLoader = "saved"; // page properties @@ -60,22 +77,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 +110,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 +146,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 +161,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 +185,6 @@ export class Page implements IPage { }, { delay: 2000 } ); - this.disposers.push(titleDisposer); } @@ -195,6 +205,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 +219,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 +240,6 @@ export class Page implements IPage { * @param {Partial} pageData */ update = async (pageData: Partial) => { - const { workspaceSlug, projectId } = this.store.router; - if (!workspaceSlug || !projectId || !this.id) return undefined; - const currentPage = this.asJSON; try { runInAction(() => { @@ -351,7 +249,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 +275,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 +294,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 +313,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 +332,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 +349,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 +366,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 +376,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 +393,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 +401,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 +412,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,7 +424,8 @@ export class Page implements IPage { * @description add the page to favorites */ addToFavorites = async () => { - const { workspaceSlug, projectId } = this.store.router; + const { workspaceSlug } = this.store.router; + const projectId = this.project_ids?.[0]; if (!workspaceSlug || !projectId || !this.id) return undefined; const pageIsFavorite = this.is_favorite; @@ -573,8 +451,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(() => { diff --git a/web/core/store/pages/project-page.store.ts b/web/core/store/pages/project-page.store.ts index 7cb8f0014cc..230fb89e868 100644 --- a/web/core/store/pages/project-page.store.ts +++ b/web/core/store/pages/project-page.store.ts @@ -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; // pageId => Page + data: Record; // 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: (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 = {}; // pageId => Page + data: Record = {}; // 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; }); diff --git a/web/core/store/pages/project-page.ts b/web/core/store/pages/project-page.ts new file mode 100644 index 00000000000..b03e391e184 --- /dev/null +++ b/web/core/store/pages/project-page.ts @@ -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}`; + }); +} diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index ebd118da87c..2d6d2f0cadd 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -84,7 +84,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); @@ -115,7 +115,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); From 3aebe4990fa8a7e99438b1a63d764c7e3a4ae881 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 23 Dec 2024 19:37:19 +0530 Subject: [PATCH 2/8] refactor: project store hooks --- .../pages/(detail)/[pageId]/page.tsx | 4 ++-- .../[projectId]/pages/(detail)/header.tsx | 4 ++-- .../(detail)/[projectId]/pages/(list)/page.tsx | 6 +----- .../pages/dropdowns/quick-actions.tsx | 2 +- .../components/pages/list/block-item-action.tsx | 17 ++++++----------- web/core/components/pages/list/block.tsx | 17 ++++++++--------- web/core/components/pages/list/root.tsx | 8 +++----- .../pages/modals/delete-page-modal.tsx | 15 +++++++-------- web/core/hooks/store/pages/use-page.ts | 3 ++- web/core/hooks/use-favorite-item-details.tsx | 4 ++-- web/core/store/pages/base-page.ts | 2 ++ 11 files changed, 36 insertions(+), 46 deletions(-) diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 4d3f395ea00..ee6dc214877 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -14,14 +14,14 @@ import { PageRoot } from "@/components/pages"; // helpers import { cn } from "@/helpers/common.helper"; // hooks -import { usePage, useProjectPages } from "@/hooks/store"; +import { useProjectPage, useProjectPages } from "@/hooks/store"; const PageDetailsPage = observer(() => { const { workspaceSlug, projectId, pageId } = useParams(); // store hooks const { getPageById } = useProjectPages(); - const page = usePage(pageId?.toString() ?? ""); + const page = useProjectPage(pageId?.toString() ?? ""); const { id, name } = page; // fetch page details diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 1c3d96b5718..5044d5203bf 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -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(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 4171e1f332d..93f37ea83b0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -51,11 +51,7 @@ const ProjectPagesPage = observer(() => { projectId={projectId.toString()} pageType={currentPageType()} > - + ); diff --git a/web/core/components/pages/dropdowns/quick-actions.tsx b/web/core/components/pages/dropdowns/quick-actions.tsx index 804999650e6..71344fd48b6 100644 --- a/web/core/components/pages/dropdowns/quick-actions.tsx +++ b/web/core/components/pages/dropdowns/quick-actions.tsx @@ -104,7 +104,7 @@ export const PageQuickActions: React.FC = observer((props) => { return ( <> - setDeletePageModal(false)} pageId={page.id ?? ""} /> + setDeletePageModal(false)} page={page} /> {MENU_ITEMS.map((item) => { diff --git a/web/core/components/pages/list/block-item-action.tsx b/web/core/components/pages/list/block-item-action.tsx index 740b44c814b..9f315053b31 100644 --- a/web/core/components/pages/list/block-item-action.tsx +++ b/web/core/components/pages/list/block-item-action.tsx @@ -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; }; export const BlockItemAction: FC = 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 = observer((props) => { canCurrentUserFavoritePage, addToFavorites, removePageFromFavorites, + getRedirectionLink, } = page; const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined; @@ -94,11 +93,7 @@ export const BlockItemAction: FC = observer((props) => { )} {/* quick actions dropdown */} - + ); }); diff --git a/web/core/components/pages/list/block.tsx b/web/core/components/pages/list/block.tsx index abb373a6495..21e65f6bc73 100644 --- a/web/core/components/pages/list/block.tsx +++ b/web/core/components/pages/list/block.tsx @@ -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 = 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 ( = observer((props) => { } title={getPageName(name)} - itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`} - actionableItems={ - - } + itemLink={getRedirectionLink()} + actionableItems={} isMobile={isMobile} parentRef={parentRef} /> diff --git a/web/core/components/pages/list/root.tsx b/web/core/components/pages/list/root.tsx index 049f37e943d..75109d1e2b3 100644 --- a/web/core/components/pages/list/root.tsx +++ b/web/core/components/pages/list/root.tsx @@ -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 = 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 = observer((props) => { return ( {filteredPageIds.map((pageId) => ( - + ))} ); diff --git a/web/core/components/pages/modals/delete-page-modal.tsx b/web/core/components/pages/modals/delete-page-modal.tsx index 61d3fb85e98..35830fb7cce 100644 --- a/web/core/components/pages/modals/delete-page-modal.tsx +++ b/web/core/components/pages/modals/delete-page-modal.tsx @@ -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 = 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); diff --git a/web/core/hooks/store/pages/use-page.ts b/web/core/hooks/store/pages/use-page.ts index 53789fc1ea5..59f974a3ce0 100644 --- a/web/core/hooks/store/pages/use-page.ts +++ b/web/core/hooks/store/pages/use-page.ts @@ -1,10 +1,11 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "@/lib/store-context"; +import { TUsePage } from "@/store/pages/base-page"; // store import { TProjectPage } from "@/store/pages/project-page"; -export const usePage = (pageId: string | undefined): TProjectPage => { +export const useProjectPage: TUsePage = (pageId: string | undefined): TProjectPage => { const context = useContext(StoreContext); if (context === undefined) throw new Error("usePage must be used within StoreProvider"); diff --git a/web/core/hooks/use-favorite-item-details.tsx b/web/core/hooks/use-favorite-item-details.tsx index 0d561b499f7..e23228ea76d 100644 --- a/web/core/hooks/use-favorite-item-details.tsx +++ b/web/core/hooks/use-favorite-item-details.tsx @@ -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 ?? ""); diff --git a/web/core/store/pages/base-page.ts b/web/core/store/pages/base-page.ts index b4d103e1bf5..2bc03a3e614 100644 --- a/web/core/store/pages/base-page.ts +++ b/web/core/store/pages/base-page.ts @@ -60,6 +60,8 @@ export type TPageInstance = TBasePage & getRedirectionLink: () => string; }; +export type TUsePage = (pageId: string | undefined) => TPageInstance; + export class BasePage implements TBasePage { // loaders isSubmitting: TNameDescriptionLoader = "saved"; From b8772fcb786d6bbc7e5152988c841d2560f27b1e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 23 Dec 2024 20:22:17 +0530 Subject: [PATCH 3/8] chore: add missing prop declaration --- .../(detail)/[projectId]/pages/(detail)/header.tsx | 2 +- web/ce/components/pages/extra-actions.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 5044d5203bf..a6b2b83a894 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -169,7 +169,7 @@ export const PageDetailsHeader = observer(() => { - + ); diff --git a/web/ce/components/pages/extra-actions.tsx b/web/ce/components/pages/extra-actions.tsx index d60d2bc666a..dd15dab7e62 100644 --- a/web/ce/components/pages/extra-actions.tsx +++ b/web/ce/components/pages/extra-actions.tsx @@ -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 = () => null; From 910e4a77cbcf55f8f0bea9117bd31bbd96eeccaa Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 24 Dec 2024 13:57:44 +0530 Subject: [PATCH 4/8] refactor: editor page root and body --- packages/editor/src/core/types/editor.ts | 9 +- packages/types/src/pages.d.ts | 7 ++ packages/utils/src/editor.ts | 103 ---------------- packages/utils/src/index.ts | 1 - .../pages/(detail)/[pageId]/page.tsx | 107 +++++++++++++++-- .../pages/editor/ai/ask-pi-menu.tsx | 4 +- web/ce/components/pages/editor/ai/menu.tsx | 5 +- .../components/pages/editor/editor-body.tsx | 113 ++++++++---------- .../components/pages/editor/page-root.tsx | 72 ++++++----- web/core/components/pages/editor/title.tsx | 19 ++- 10 files changed, 208 insertions(+), 232 deletions(-) delete mode 100644 packages/utils/src/editor.ts diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 27a719f0476..cdb469f8b52 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -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; }; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index ceecb1b63e6..183d015bf69 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -67,3 +67,10 @@ export type TDocumentPayload = { description_html: string; description: object; }; + +export type TWebhookConnectionQueryParams = { + documentType: "project_page" | "team_page" | "workspace_page"; + projectId?: string; + teamId?: string; + workspaceSlug: string; +}; diff --git a/packages/utils/src/editor.ts b/packages/utils/src/editor.ts deleted file mode 100644 index 809c1dd3d2a..00000000000 --- a/packages/utils/src/editor.ts +++ /dev/null @@ -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; -type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; -type UploadImage = (file: File) => Promise; - -// Define the FileService interface based on usage -interface IFileService { - deleteOldEditorAsset: (workspaceId: string, src: string) => Promise; - deleteNewAsset: (url: string) => Promise; - restoreOldEditorAsset: (workspaceId: string, src: string) => Promise; - restoreNewAsset: (anchor: string, src: string) => Promise; - cancelUpload: () => void; -} - -// Define TFileHandler locally since we can't import from @plane/editor -interface TFileHandler { - getAssetSrc: (path: string) => Promise; - 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; - 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 -): { 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) ?? ""; - } - }, - }; -}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 597fb5db950..16c70502627 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,7 +2,6 @@ export * from "./auth"; export * from "./color"; export * from "./common"; export * from "./datetime"; -export * from "./editor"; export * from "./emoji"; export * from "./file"; export * from "./issue"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index ee6dc214877..b99ec1f90b3 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,29 +1,56 @@ "use client"; +import { 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 { 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 { useProjectPage, useProjectPages } from "@/hooks/store"; +import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store"; +import { useEditorMention } from "@/hooks/use-editor-mention"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; +// services +import { FileService } from "@/services/file.service"; +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +import { ProjectService } from "@/services/project"; +const fileService = new FileService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); +const projectService = new ProjectService(); const PageDetailsPage = observer(() => { const { workspaceSlug, projectId, pageId } = useParams(); - // store hooks - const { getPageById } = useProjectPages(); + const { createPage, getPageById } = useProjectPages(); const page = useProjectPage(pageId?.toString() ?? ""); - const { id, name } = page; - + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const { id, name, updateDescription } = page; + // issue-embed + const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? ""); + // use editor mention + const { fetchMentions } = useEditorMention({ + searchEntity: async (payload) => + await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload), + }); + // file size + const { maxFileSize } = useFileSize(); // fetch page details const { error: pageDetailsError } = useSWR( workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, @@ -36,6 +63,63 @@ 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); + }, + fetchMentions, + 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, fetchMentions, 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() ?? "", + }), + issueEmbedConfig: issueEmbedProps, + webhookConnectionParams: { + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }, + }), + [id, issueEmbedProps, maxFileSize, projectId, workspaceId, workspaceSlug] + ); if ((!page || !id) && !pageDetailsError) return ( @@ -65,7 +149,12 @@ const PageDetailsPage = observer(() => {
- +
diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index 211155d37d7..b9d6c85ef57 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -11,13 +11,12 @@ type Props = { handleInsertText: (insertOnNextLine: boolean) => void; handleRegenerate: () => Promise; isRegenerating: boolean; - projectId: string; response: string | undefined; workspaceSlug: string; }; export const AskPiMenu: React.FC = (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) => { containerClassName="!p-0 border-none" editorClassName="!pl-0" workspaceSlug={workspaceSlug} - projectId={projectId} />