Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
538
apps/web/core/store/pages/base-page.ts
Normal file
538
apps/web/core/store/pages/base-page.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
import type { TChangeHandlerProps } from "@plane/propel/emoji-icon-picker";
|
||||
import type { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types";
|
||||
// plane web store
|
||||
import { ExtendedBasePage } from "@/plane-web/store/pages/extended-base-page";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// local imports
|
||||
import { PageEditorInstance } from "./page-editor-info";
|
||||
|
||||
export type TBasePage = TPage & {
|
||||
// observables
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
// computed
|
||||
asJSON: TPage | undefined;
|
||||
isCurrentUserOwner: boolean;
|
||||
// helpers
|
||||
oldName: string;
|
||||
setIsSubmitting: (value: TNameDescriptionLoader) => void;
|
||||
cleanup: () => void;
|
||||
// actions
|
||||
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
updateTitle: (title: string) => void;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
makePublic: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
makePrivate: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
lock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
|
||||
unlock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
|
||||
archive: (params: { shouldSync?: boolean; archived_at?: string | null }) => Promise<void>;
|
||||
restore: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
updatePageLogo: (value: TChangeHandlerProps) => Promise<void>;
|
||||
addToFavorites: () => Promise<void>;
|
||||
removePageFromFavorites: () => Promise<void>;
|
||||
duplicate: () => Promise<TPage | undefined>;
|
||||
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
|
||||
// sub-store
|
||||
editor: PageEditorInstance;
|
||||
};
|
||||
|
||||
export type TBasePagePermissions = {
|
||||
canCurrentUserAccessPage: boolean;
|
||||
canCurrentUserEditPage: boolean;
|
||||
canCurrentUserDuplicatePage: boolean;
|
||||
canCurrentUserLockPage: boolean;
|
||||
canCurrentUserChangeAccess: boolean;
|
||||
canCurrentUserArchivePage: boolean;
|
||||
canCurrentUserDeletePage: boolean;
|
||||
canCurrentUserFavoritePage: boolean;
|
||||
canCurrentUserMovePage: 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>;
|
||||
duplicate: () => Promise<TPage>;
|
||||
};
|
||||
|
||||
export type TPageInstance = TBasePage &
|
||||
TBasePagePermissions & {
|
||||
getRedirectionLink: () => string;
|
||||
};
|
||||
|
||||
export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
// loaders
|
||||
isSubmitting: TNameDescriptionLoader = "saved";
|
||||
// page properties
|
||||
id: string | undefined;
|
||||
name: string | undefined;
|
||||
logo_props: TLogoProps | undefined;
|
||||
description: object | undefined;
|
||||
description_html: string | undefined;
|
||||
color: string | undefined;
|
||||
label_ids: string[] | undefined;
|
||||
owned_by: string | undefined;
|
||||
access: EPageAccess | undefined;
|
||||
is_favorite: boolean;
|
||||
is_locked: boolean;
|
||||
archived_at: string | null | undefined;
|
||||
workspace: string | undefined;
|
||||
project_ids?: string[] | undefined;
|
||||
created_by: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
updated_at: Date | undefined;
|
||||
deleted_at: Date | undefined;
|
||||
// helpers
|
||||
oldName: string = "";
|
||||
// services
|
||||
services: TBasePageServices;
|
||||
// reactions
|
||||
disposers: Array<() => void> = [];
|
||||
// root store
|
||||
rootStore: RootStore;
|
||||
// sub-store
|
||||
editor: PageEditorInstance;
|
||||
|
||||
constructor(
|
||||
private store: RootStore,
|
||||
page: TPage,
|
||||
services: TBasePageServices
|
||||
) {
|
||||
super(store, page, services);
|
||||
|
||||
this.id = page?.id || undefined;
|
||||
this.name = page?.name;
|
||||
this.logo_props = page?.logo_props || undefined;
|
||||
this.description = page?.description || undefined;
|
||||
this.description_html = page?.description_html || undefined;
|
||||
this.color = page?.color || undefined;
|
||||
this.label_ids = page?.label_ids || undefined;
|
||||
this.owned_by = page?.owned_by || undefined;
|
||||
this.access = page?.access || EPageAccess.PUBLIC;
|
||||
this.is_favorite = page?.is_favorite || false;
|
||||
this.is_locked = page?.is_locked || false;
|
||||
this.archived_at = page?.archived_at || undefined;
|
||||
this.workspace = page?.workspace || undefined;
|
||||
this.project_ids = page?.project_ids || undefined;
|
||||
this.created_by = page?.created_by || undefined;
|
||||
this.updated_by = page?.updated_by || undefined;
|
||||
this.created_at = page?.created_at || undefined;
|
||||
this.updated_at = page?.updated_at || undefined;
|
||||
this.oldName = page?.name || "";
|
||||
this.deleted_at = page?.deleted_at || undefined;
|
||||
|
||||
makeObservable(this, {
|
||||
// loaders
|
||||
isSubmitting: observable.ref,
|
||||
// page properties
|
||||
id: observable.ref,
|
||||
name: observable.ref,
|
||||
logo_props: observable.ref,
|
||||
description: observable,
|
||||
description_html: observable.ref,
|
||||
color: observable.ref,
|
||||
label_ids: observable,
|
||||
owned_by: observable.ref,
|
||||
access: observable.ref,
|
||||
is_favorite: observable.ref,
|
||||
is_locked: observable.ref,
|
||||
archived_at: observable.ref,
|
||||
workspace: observable.ref,
|
||||
project_ids: observable,
|
||||
created_by: observable.ref,
|
||||
updated_by: observable.ref,
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
deleted_at: observable.ref,
|
||||
// helpers
|
||||
oldName: observable.ref,
|
||||
setIsSubmitting: action,
|
||||
cleanup: action,
|
||||
// computed
|
||||
asJSON: computed,
|
||||
isCurrentUserOwner: computed,
|
||||
// actions
|
||||
update: action,
|
||||
updateTitle: action,
|
||||
updateDescription: action,
|
||||
makePublic: action,
|
||||
makePrivate: action,
|
||||
lock: action,
|
||||
unlock: action,
|
||||
archive: action,
|
||||
restore: action,
|
||||
updatePageLogo: action,
|
||||
addToFavorites: action,
|
||||
removePageFromFavorites: action,
|
||||
duplicate: action,
|
||||
mutateProperties: action,
|
||||
});
|
||||
|
||||
// init
|
||||
this.services = services;
|
||||
this.rootStore = store;
|
||||
this.editor = new PageEditorInstance();
|
||||
|
||||
const titleDisposer = reaction(
|
||||
() => this.name,
|
||||
(name) => {
|
||||
this.isSubmitting = "submitting";
|
||||
this.services
|
||||
.update({
|
||||
name,
|
||||
})
|
||||
.catch(() =>
|
||||
runInAction(() => {
|
||||
this.name = this.oldName;
|
||||
})
|
||||
)
|
||||
.finally(() =>
|
||||
runInAction(() => {
|
||||
this.isSubmitting = "submitted";
|
||||
})
|
||||
);
|
||||
},
|
||||
{ delay: 2000 }
|
||||
);
|
||||
this.disposers.push(titleDisposer);
|
||||
}
|
||||
|
||||
// computed
|
||||
get asJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
description_html: this.description_html,
|
||||
color: this.color,
|
||||
label_ids: this.label_ids,
|
||||
owned_by: this.owned_by,
|
||||
access: this.access,
|
||||
logo_props: this.logo_props,
|
||||
is_favorite: this.is_favorite,
|
||||
is_locked: this.is_locked,
|
||||
archived_at: this.archived_at,
|
||||
workspace: this.workspace,
|
||||
project_ids: this.project_ids,
|
||||
created_by: this.created_by,
|
||||
updated_by: this.updated_by,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
deleted_at: this.deleted_at,
|
||||
...this.asJSONExtended,
|
||||
};
|
||||
}
|
||||
|
||||
get isCurrentUserOwner() {
|
||||
const currentUserId = this.store.user.data?.id;
|
||||
if (!currentUserId) return false;
|
||||
return this.owned_by === currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description update the submitting state
|
||||
* @param value
|
||||
*/
|
||||
setIsSubmitting = (value: TNameDescriptionLoader) => {
|
||||
runInAction(() => {
|
||||
this.isSubmitting = value;
|
||||
});
|
||||
};
|
||||
|
||||
cleanup = () => {
|
||||
this.disposers.forEach((disposer) => {
|
||||
disposer();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the page
|
||||
* @param {Partial<TPage>} pageData
|
||||
*/
|
||||
update = async (pageData: Partial<TPage>) => {
|
||||
const currentPage = this.asJSON;
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(pageData).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(this, key, pageData[currentPageKey] || undefined);
|
||||
});
|
||||
});
|
||||
|
||||
return await this.services.update(currentPage);
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
Object.keys(pageData).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(this, key, currentPage?.[currentPageKey] || undefined);
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the page title
|
||||
* @param title
|
||||
*/
|
||||
updateTitle = (title: string) => {
|
||||
this.oldName = this.name ?? "";
|
||||
this.name = title;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the page description
|
||||
* @param {TDocumentPayload} document
|
||||
*/
|
||||
updateDescription = async (document: TDocumentPayload) => {
|
||||
const currentDescription = this.description_html;
|
||||
runInAction(() => {
|
||||
this.description_html = document.description_html;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.services.updateDescription(document);
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.description_html = currentDescription;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description make the page public
|
||||
*/
|
||||
makePublic = async ({ shouldSync = true }) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PUBLIC;
|
||||
});
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.services.updateAccess({
|
||||
access: EPageAccess.PUBLIC,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description make the page private
|
||||
*/
|
||||
makePrivate = async ({ shouldSync = true }) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PRIVATE;
|
||||
});
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.services.updateAccess({
|
||||
access: EPageAccess.PRIVATE,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description lock the page
|
||||
*/
|
||||
lock = async ({ shouldSync = true }) => {
|
||||
const pageIsLocked = this.is_locked;
|
||||
runInAction(() => (this.is_locked = true));
|
||||
|
||||
if (shouldSync) {
|
||||
await this.services.lock().catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_locked = pageIsLocked;
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description unlock the page
|
||||
*/
|
||||
unlock = async ({ shouldSync = true }) => {
|
||||
const pageIsLocked = this.is_locked;
|
||||
runInAction(() => (this.is_locked = false));
|
||||
|
||||
if (shouldSync) {
|
||||
await this.services.unlock().catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_locked = pageIsLocked;
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description archive the page
|
||||
*/
|
||||
archive = async ({ shouldSync = true, archived_at }: { shouldSync?: boolean; archived_at?: string | null }) => {
|
||||
if (!this.id) return undefined;
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.archived_at = archived_at ?? new Date().toISOString();
|
||||
});
|
||||
|
||||
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
|
||||
|
||||
if (shouldSync) {
|
||||
const response = await this.services.archive();
|
||||
runInAction(() => {
|
||||
this.archived_at = response.archived_at;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
runInAction(() => {
|
||||
this.archived_at = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description restore the page
|
||||
*/
|
||||
restore = async ({ shouldSync = true }: { shouldSync?: boolean }) => {
|
||||
const archivedAtBeforeRestore = this.archived_at;
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.archived_at = null;
|
||||
});
|
||||
|
||||
if (shouldSync) {
|
||||
await this.services.restore();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
runInAction(() => {
|
||||
this.archived_at = archivedAtBeforeRestore;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updatePageLogo = async (value: TChangeHandlerProps) => {
|
||||
const originalLogoProps = { ...this.logo_props };
|
||||
try {
|
||||
let logoValue = {};
|
||||
if (value?.type === "emoji")
|
||||
logoValue = {
|
||||
value: value.value,
|
||||
url: undefined,
|
||||
};
|
||||
else if (value?.type === "icon") logoValue = value.value;
|
||||
|
||||
const logoProps: TLogoProps = {
|
||||
in_use: value?.type,
|
||||
[value?.type]: logoValue,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.logo_props = logoProps;
|
||||
});
|
||||
await this.services.update({
|
||||
logo_props: logoProps,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in updating page logo", error);
|
||||
runInAction(() => {
|
||||
this.logo_props = originalLogoProps as TLogoProps;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description add the page to favorites
|
||||
*/
|
||||
addToFavorites = async () => {
|
||||
const { workspaceSlug } = this.store.router;
|
||||
const projectId = this.project_ids?.[0] ?? null;
|
||||
if (!workspaceSlug || !this.id) return undefined;
|
||||
|
||||
const pageIsFavorite = this.is_favorite;
|
||||
runInAction(() => {
|
||||
this.is_favorite = true;
|
||||
});
|
||||
await this.rootStore.favorite
|
||||
.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "page",
|
||||
entity_identifier: this.id,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.name || "" },
|
||||
})
|
||||
.catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_favorite = pageIsFavorite;
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description remove the page from favorites
|
||||
*/
|
||||
removePageFromFavorites = async () => {
|
||||
const { workspaceSlug } = this.store.router;
|
||||
if (!workspaceSlug || !this.id) return undefined;
|
||||
|
||||
const pageIsFavorite = this.is_favorite;
|
||||
runInAction(() => {
|
||||
this.is_favorite = false;
|
||||
});
|
||||
|
||||
await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, this.id).catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_favorite = pageIsFavorite;
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description duplicate the page
|
||||
*/
|
||||
duplicate = async () => await this.services.duplicate();
|
||||
|
||||
/**
|
||||
* @description mutate multiple properties at once
|
||||
* @param data Partial<TPage>
|
||||
*/
|
||||
mutateProperties = (data: Partial<TPage>, shouldUpdateName: boolean = true) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key as keyof TPage];
|
||||
if (key === "name" && !shouldUpdateName) return;
|
||||
set(this, key, value);
|
||||
});
|
||||
};
|
||||
}
|
||||
41
apps/web/core/store/pages/page-editor-info.ts
Normal file
41
apps/web/core/store/pages/page-editor-info.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import type { EditorRefApi, TEditorAsset } from "@plane/editor";
|
||||
|
||||
export type TPageEditorInstance = {
|
||||
// observables
|
||||
assetsList: TEditorAsset[];
|
||||
editorRef: EditorRefApi | null;
|
||||
// actions
|
||||
setEditorRef: (editorRef: EditorRefApi | null) => void;
|
||||
updateAssetsList: (assets: TEditorAsset[]) => void;
|
||||
};
|
||||
|
||||
export class PageEditorInstance implements TPageEditorInstance {
|
||||
// observables
|
||||
editorRef: EditorRefApi | null = null;
|
||||
assetsList: TEditorAsset[] = [];
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
editorRef: observable.ref,
|
||||
assetsList: observable,
|
||||
// actions
|
||||
setEditorRef: action,
|
||||
updateAssetsList: action,
|
||||
});
|
||||
}
|
||||
|
||||
setEditorRef: TPageEditorInstance["setEditorRef"] = (editorRef) => {
|
||||
runInAction(() => {
|
||||
this.editorRef = editorRef;
|
||||
});
|
||||
};
|
||||
|
||||
updateAssetsList: TPageEditorInstance["updateAssetsList"] = (assets) => {
|
||||
runInAction(() => {
|
||||
this.assetsList = assets;
|
||||
});
|
||||
};
|
||||
}
|
||||
364
apps/web/core/store/pages/project-page.store.ts
Normal file
364
apps/web/core/store/pages/project-page.store.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { unset, set } from "lodash-es";
|
||||
import { makeObservable, observable, runInAction, action, reaction, computed } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import type { TPage, TPageFilters, TPageNavigationTabs } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// helpers
|
||||
import { filterPagesByPageType, getPageName, orderPages, shouldFilterPage } from "@plane/utils";
|
||||
// plane web constants
|
||||
// plane web store
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// services
|
||||
import { ProjectPageService } from "@/services/page";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
import type { TProjectPage } from "./project-page";
|
||||
import { ProjectPage } from "./project-page";
|
||||
|
||||
type TLoader = "init-loader" | "mutation-loader" | undefined;
|
||||
|
||||
type TError = { title: string; description: string };
|
||||
|
||||
export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [
|
||||
EUserPermissions.ADMIN,
|
||||
EUserPermissions.MEMBER,
|
||||
EUserProjectRoles.ADMIN,
|
||||
EUserProjectRoles.MEMBER,
|
||||
];
|
||||
|
||||
export interface IProjectPageStore {
|
||||
// observables
|
||||
loader: TLoader;
|
||||
data: Record<string, TProjectPage>; // pageId => Page
|
||||
error: TError | undefined;
|
||||
filters: TPageFilters;
|
||||
// computed
|
||||
isAnyPageAvailable: boolean;
|
||||
canCurrentUserCreatePage: boolean;
|
||||
// helper actions
|
||||
getCurrentProjectPageIdsByTab: (pageType: TPageNavigationTabs) => string[] | undefined;
|
||||
getCurrentProjectPageIds: (projectId: string) => string[];
|
||||
getCurrentProjectFilteredPageIdsByTab: (pageType: TPageNavigationTabs) => string[] | undefined;
|
||||
getPageById: (pageId: string) => TProjectPage | undefined;
|
||||
updateFilters: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void;
|
||||
clearAllFilters: () => void;
|
||||
// actions
|
||||
fetchPagesList: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageType?: TPageNavigationTabs
|
||||
) => Promise<TPage[] | undefined>;
|
||||
fetchPageDetails: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
options?: { trackVisit?: boolean }
|
||||
) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
|
||||
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectPageStore implements IProjectPageStore {
|
||||
// observables
|
||||
loader: TLoader = "init-loader";
|
||||
data: Record<string, TProjectPage> = {}; // pageId => Page
|
||||
error: TError | undefined = undefined;
|
||||
filters: TPageFilters = {
|
||||
searchQuery: "",
|
||||
sortKey: "updated_at",
|
||||
sortBy: "desc",
|
||||
};
|
||||
// service
|
||||
service: ProjectPageService;
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
data: observable,
|
||||
error: observable,
|
||||
filters: observable,
|
||||
// computed
|
||||
isAnyPageAvailable: computed,
|
||||
canCurrentUserCreatePage: computed,
|
||||
// helper actions
|
||||
updateFilters: action,
|
||||
clearAllFilters: action,
|
||||
// actions
|
||||
fetchPagesList: action,
|
||||
fetchPageDetails: action,
|
||||
createPage: action,
|
||||
removePage: action,
|
||||
movePage: action,
|
||||
});
|
||||
this.rootStore = store;
|
||||
// service
|
||||
this.service = new ProjectPageService();
|
||||
// initialize display filters of the current project
|
||||
reaction(
|
||||
() => this.store.router.projectId,
|
||||
(projectId) => {
|
||||
if (!projectId) return;
|
||||
this.filters.searchQuery = "";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description check if any page is available
|
||||
*/
|
||||
get isAnyPageAvailable() {
|
||||
if (this.loader) return true;
|
||||
return Object.keys(this.data).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if the current logged in user can create a page
|
||||
*/
|
||||
get canCurrentUserCreatePage() {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
const currentUserProjectRole = this.store.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(
|
||||
workspaceSlug?.toString() || "",
|
||||
projectId?.toString() || ""
|
||||
);
|
||||
return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get the current project page ids based on the pageType
|
||||
* @param {TPageNavigationTabs} pageType
|
||||
*/
|
||||
getCurrentProjectPageIdsByTab = computedFn((pageType: TPageNavigationTabs) => {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
// helps to filter pages based on the pageType
|
||||
let pagesByType = filterPagesByPageType(pageType, Object.values(this?.data || {}));
|
||||
pagesByType = pagesByType.filter((p) => p.project_ids?.includes(projectId));
|
||||
|
||||
const pages = (pagesByType.map((page) => page.id) as string[]) || undefined;
|
||||
|
||||
return pages ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the current project page ids
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getCurrentProjectPageIds = computedFn((projectId: string) => {
|
||||
if (!projectId) return [];
|
||||
const pages = Object.values(this?.data || {}).filter((page) => page.project_ids?.includes(projectId));
|
||||
return pages.map((page) => page.id) as string[];
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the current project filtered page ids based on the pageType
|
||||
* @param {TPageNavigationTabs} pageType
|
||||
*/
|
||||
getCurrentProjectFilteredPageIdsByTab = computedFn((pageType: TPageNavigationTabs) => {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
|
||||
// helps to filter pages based on the pageType
|
||||
const pagesByType = filterPagesByPageType(pageType, Object.values(this?.data || {}));
|
||||
let filteredPages = pagesByType.filter(
|
||||
(p) =>
|
||||
p.project_ids?.includes(projectId) &&
|
||||
getPageName(p.name).toLowerCase().includes(this.filters.searchQuery.toLowerCase()) &&
|
||||
shouldFilterPage(p, this.filters.filters)
|
||||
);
|
||||
filteredPages = orderPages(filteredPages, this.filters.sortKey, this.filters.sortBy);
|
||||
|
||||
const pages = (filteredPages.map((page) => page.id) as string[]) || undefined;
|
||||
|
||||
return pages ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the page store by id
|
||||
* @param {string} pageId
|
||||
*/
|
||||
getPageById = computedFn((pageId: string) => this.data?.[pageId] || undefined);
|
||||
|
||||
updateFilters = <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => {
|
||||
runInAction(() => {
|
||||
set(this.filters, [filterKey], filterValue);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description clear all the filters
|
||||
*/
|
||||
clearAllFilters = () =>
|
||||
runInAction(() => {
|
||||
set(this.filters, ["filters"], {});
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetch all the pages
|
||||
*/
|
||||
fetchPagesList = async (workspaceSlug: string, projectId: string, pageType?: TPageNavigationTabs) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId) return undefined;
|
||||
|
||||
const currentPageIds = pageType ? this.getCurrentProjectPageIdsByTab(pageType) : undefined;
|
||||
runInAction(() => {
|
||||
this.loader = currentPageIds && currentPageIds.length > 0 ? `mutation-loader` : `init-loader`;
|
||||
this.error = undefined;
|
||||
});
|
||||
|
||||
const pages = await this.service.fetchAll(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
for (const page of pages) {
|
||||
if (page?.id) {
|
||||
const existingPage = this.getPageById(page.id);
|
||||
if (existingPage) {
|
||||
// If page already exists, update all fields except name
|
||||
const { name, ...otherFields } = page;
|
||||
existingPage.mutateProperties(otherFields, false);
|
||||
} else {
|
||||
// If new page, create a new instance with all data
|
||||
set(this.data, [page.id], new ProjectPage(this.store, page));
|
||||
}
|
||||
}
|
||||
}
|
||||
this.loader = undefined;
|
||||
});
|
||||
|
||||
return pages;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
title: "Failed",
|
||||
description: "Failed to fetch the pages, Please try again later.",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch the details of a page
|
||||
* @param {string} pageId
|
||||
*/
|
||||
fetchPageDetails = async (...args: Parameters<IProjectPageStore["fetchPageDetails"]>) => {
|
||||
const [workspaceSlug, projectId, pageId, options] = args;
|
||||
const { trackVisit } = options || {};
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !pageId) return undefined;
|
||||
|
||||
const currentPageId = this.getPageById(pageId);
|
||||
runInAction(() => {
|
||||
this.loader = currentPageId ? `mutation-loader` : `init-loader`;
|
||||
this.error = undefined;
|
||||
});
|
||||
|
||||
const page = await this.service.fetchById(workspaceSlug, projectId, pageId, trackVisit ?? true);
|
||||
|
||||
runInAction(() => {
|
||||
if (page?.id) {
|
||||
const pageInstance = this.getPageById(page.id);
|
||||
if (pageInstance) {
|
||||
pageInstance.mutateProperties(page, false);
|
||||
} else {
|
||||
set(this.data, [page.id], new ProjectPage(this.store, page));
|
||||
}
|
||||
}
|
||||
this.loader = undefined;
|
||||
});
|
||||
|
||||
return page;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
title: "Failed",
|
||||
description: "Failed to fetch the page, Please try again later.",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description create a page
|
||||
* @param {Partial<TPage>} pageData
|
||||
*/
|
||||
createPage = async (pageData: Partial<TPage>) => {
|
||||
try {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId) return undefined;
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = "mutation-loader";
|
||||
this.error = undefined;
|
||||
});
|
||||
|
||||
const page = await this.service.create(workspaceSlug, projectId, pageData);
|
||||
runInAction(() => {
|
||||
if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page));
|
||||
this.loader = undefined;
|
||||
});
|
||||
|
||||
return page;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
title: "Failed",
|
||||
description: "Failed to create a page, Please try again later.",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description delete a page
|
||||
* @param {string} pageId
|
||||
*/
|
||||
removePage = async ({ pageId, shouldSync = true }: { pageId: string; shouldSync?: boolean }) => {
|
||||
try {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !pageId) return undefined;
|
||||
|
||||
await this.service.remove(workspaceSlug, projectId, pageId);
|
||||
runInAction(() => {
|
||||
unset(this.data, [pageId]);
|
||||
if (this.rootStore.favorite.entityMap[pageId]) this.rootStore.favorite.removeFavoriteFromStore(pageId);
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
title: "Failed",
|
||||
description: "Failed to delete a page, Please try again later.",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page to a new project
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} projectId
|
||||
* @param {string} pageId
|
||||
* @param {string} newProjectId
|
||||
*/
|
||||
movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {
|
||||
try {
|
||||
await this.service.move(workspaceSlug, projectId, pageId, newProjectId);
|
||||
runInAction(() => {
|
||||
unset(this.data, [pageId]);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Unable to move page", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
184
apps/web/core/store/pages/project-page.ts
Normal file
184
apps/web/core/store/pages/project-page.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { computed, makeObservable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// constants
|
||||
import { EPageAccess, EUserPermissions } from "@plane/constants";
|
||||
import type { TPage } from "@plane/types";
|
||||
// plane web store
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// services
|
||||
import { ProjectPageService } from "@/services/page";
|
||||
const projectPageService = new ProjectPageService();
|
||||
// store
|
||||
import { BasePage } from "./base-page";
|
||||
import type { 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);
|
||||
},
|
||||
duplicate: async () => {
|
||||
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
|
||||
return await projectPageService.duplicate(workspaceSlug, projectId, page.id);
|
||||
},
|
||||
});
|
||||
makeObservable(this, {
|
||||
// computed
|
||||
canCurrentUserAccessPage: computed,
|
||||
canCurrentUserEditPage: computed,
|
||||
canCurrentUserDuplicatePage: computed,
|
||||
canCurrentUserLockPage: computed,
|
||||
canCurrentUserChangeAccess: computed,
|
||||
canCurrentUserArchivePage: computed,
|
||||
canCurrentUserDeletePage: computed,
|
||||
canCurrentUserFavoritePage: computed,
|
||||
canCurrentUserMovePage: 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.getProjectRoleByWorkspaceSlugAndProjectId(
|
||||
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 access the page
|
||||
*/
|
||||
get canCurrentUserAccessPage() {
|
||||
const isPagePublic = this.access === EPageAccess.PUBLIC;
|
||||
return isPagePublic || this.isCurrentUserOwner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 current logged in user can move the page
|
||||
*/
|
||||
get canCurrentUserMovePage() {
|
||||
const highestRole = this.getHighestRoleAcrossProjects();
|
||||
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user