feat: init
This commit is contained in:
108
apps/web/core/store/analytics.store.ts
Normal file
108
apps/web/core/store/analytics.store.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import type { TAnalyticsTabsBase } from "@plane/types";
|
||||
|
||||
type DurationType = (typeof ANALYTICS_DURATION_FILTER_OPTIONS)[number]["value"];
|
||||
|
||||
export interface IBaseAnalyticsStore {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsBase;
|
||||
selectedProjects: string[];
|
||||
selectedDuration: DurationType;
|
||||
selectedCycle: string;
|
||||
selectedModule: string;
|
||||
isPeekView?: boolean;
|
||||
isEpic?: boolean;
|
||||
//computed
|
||||
selectedDurationLabel: DurationType | null;
|
||||
|
||||
//actions
|
||||
updateSelectedProjects: (projects: string[]) => void;
|
||||
updateSelectedDuration: (duration: DurationType) => void;
|
||||
updateSelectedCycle: (cycle: string) => void;
|
||||
updateSelectedModule: (module: string) => void;
|
||||
updateIsPeekView: (isPeekView: boolean) => void;
|
||||
updateIsEpic: (isEpic: boolean) => void;
|
||||
}
|
||||
|
||||
export abstract class BaseAnalyticsStore implements IBaseAnalyticsStore {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsBase = "overview";
|
||||
selectedProjects: string[] = [];
|
||||
selectedDuration: DurationType = "last_30_days";
|
||||
selectedCycle: string = "";
|
||||
selectedModule: string = "";
|
||||
isPeekView: boolean = false;
|
||||
isEpic: boolean = false;
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
currentTab: observable.ref,
|
||||
selectedDuration: observable.ref,
|
||||
selectedProjects: observable,
|
||||
selectedCycle: observable.ref,
|
||||
selectedModule: observable.ref,
|
||||
isPeekView: observable.ref,
|
||||
isEpic: observable.ref,
|
||||
// computed
|
||||
selectedDurationLabel: computed,
|
||||
// actions
|
||||
updateSelectedProjects: action,
|
||||
updateSelectedDuration: action,
|
||||
updateSelectedCycle: action,
|
||||
updateSelectedModule: action,
|
||||
updateIsPeekView: action,
|
||||
updateIsEpic: action,
|
||||
});
|
||||
}
|
||||
|
||||
get selectedDurationLabel() {
|
||||
return ANALYTICS_DURATION_FILTER_OPTIONS.find((item) => item.value === this.selectedDuration)?.name ?? null;
|
||||
}
|
||||
|
||||
updateSelectedProjects = (projects: string[]) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedProjects = projects;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update selected project");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateSelectedDuration = (duration: DurationType) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedDuration = duration;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update selected duration");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateSelectedCycle = (cycle: string) => {
|
||||
runInAction(() => {
|
||||
this.selectedCycle = cycle;
|
||||
});
|
||||
};
|
||||
|
||||
updateSelectedModule = (module: string) => {
|
||||
runInAction(() => {
|
||||
this.selectedModule = module;
|
||||
});
|
||||
};
|
||||
|
||||
updateIsPeekView = (isPeekView: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isPeekView = isPeekView;
|
||||
});
|
||||
};
|
||||
|
||||
updateIsEpic = (isEpic: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isEpic = isEpic;
|
||||
});
|
||||
};
|
||||
}
|
||||
279
apps/web/core/store/base-command-palette.store.ts
Normal file
279
apps/web/core/store/base-command-palette.store.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { observable, action, makeObservable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TCreateModalStoreTypes, TCreatePageModal } from "@plane/constants";
|
||||
import { DEFAULT_CREATE_PAGE_MODAL_DATA, EPageAccess } from "@plane/constants";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
|
||||
export interface ModalData {
|
||||
store: EIssuesStoreType;
|
||||
viewId: string;
|
||||
}
|
||||
|
||||
export interface IBaseCommandPaletteStore {
|
||||
// observables
|
||||
isCommandPaletteOpen: boolean;
|
||||
isShortcutModalOpen: boolean;
|
||||
isCreateProjectModalOpen: boolean;
|
||||
isCreateCycleModalOpen: boolean;
|
||||
isCreateModuleModalOpen: boolean;
|
||||
isCreateViewModalOpen: boolean;
|
||||
createPageModal: TCreatePageModal;
|
||||
isCreateIssueModalOpen: boolean;
|
||||
isDeleteIssueModalOpen: boolean;
|
||||
isBulkDeleteIssueModalOpen: boolean;
|
||||
createIssueStoreType: TCreateModalStoreTypes;
|
||||
createWorkItemAllowedProjectIds: string[] | undefined;
|
||||
allStickiesModal: boolean;
|
||||
projectListOpenMap: Record<string, boolean>;
|
||||
getIsProjectListOpen: (projectId: string) => boolean;
|
||||
// toggle actions
|
||||
toggleCommandPaletteModal: (value?: boolean) => void;
|
||||
toggleShortcutModal: (value?: boolean) => void;
|
||||
toggleCreateProjectModal: (value?: boolean) => void;
|
||||
toggleCreateCycleModal: (value?: boolean) => void;
|
||||
toggleCreateViewModal: (value?: boolean) => void;
|
||||
toggleCreatePageModal: (value?: TCreatePageModal) => void;
|
||||
toggleCreateIssueModal: (value?: boolean, storeType?: TCreateModalStoreTypes, allowedProjectIds?: string[]) => void;
|
||||
toggleCreateModuleModal: (value?: boolean) => void;
|
||||
toggleDeleteIssueModal: (value?: boolean) => void;
|
||||
toggleBulkDeleteIssueModal: (value?: boolean) => void;
|
||||
toggleAllStickiesModal: (value?: boolean) => void;
|
||||
toggleProjectListOpen: (projectId: string, value?: boolean) => void;
|
||||
}
|
||||
|
||||
export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore {
|
||||
// observables
|
||||
isCommandPaletteOpen: boolean = false;
|
||||
isShortcutModalOpen: boolean = false;
|
||||
isCreateProjectModalOpen: boolean = false;
|
||||
isCreateCycleModalOpen: boolean = false;
|
||||
isCreateModuleModalOpen: boolean = false;
|
||||
isCreateViewModalOpen: boolean = false;
|
||||
isCreateIssueModalOpen: boolean = false;
|
||||
isDeleteIssueModalOpen: boolean = false;
|
||||
isBulkDeleteIssueModalOpen: boolean = false;
|
||||
createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA;
|
||||
createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT;
|
||||
createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined;
|
||||
allStickiesModal: boolean = false;
|
||||
projectListOpenMap: Record<string, boolean> = {};
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isCommandPaletteOpen: observable.ref,
|
||||
isShortcutModalOpen: observable.ref,
|
||||
isCreateProjectModalOpen: observable.ref,
|
||||
isCreateCycleModalOpen: observable.ref,
|
||||
isCreateModuleModalOpen: observable.ref,
|
||||
isCreateViewModalOpen: observable.ref,
|
||||
isCreateIssueModalOpen: observable.ref,
|
||||
isDeleteIssueModalOpen: observable.ref,
|
||||
isBulkDeleteIssueModalOpen: observable.ref,
|
||||
createPageModal: observable,
|
||||
createIssueStoreType: observable,
|
||||
createWorkItemAllowedProjectIds: observable,
|
||||
allStickiesModal: observable,
|
||||
projectListOpenMap: observable,
|
||||
// projectPages: computed,
|
||||
// toggle actions
|
||||
toggleCommandPaletteModal: action,
|
||||
toggleShortcutModal: action,
|
||||
toggleCreateProjectModal: action,
|
||||
toggleCreateCycleModal: action,
|
||||
toggleCreateViewModal: action,
|
||||
toggleCreatePageModal: action,
|
||||
toggleCreateIssueModal: action,
|
||||
toggleCreateModuleModal: action,
|
||||
toggleDeleteIssueModal: action,
|
||||
toggleBulkDeleteIssueModal: action,
|
||||
toggleAllStickiesModal: action,
|
||||
toggleProjectListOpen: action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any base modal is open
|
||||
* @protected - allows access from child classes
|
||||
*/
|
||||
protected getCoreModalsState(): boolean {
|
||||
return Boolean(
|
||||
this.isCreateIssueModalOpen ||
|
||||
this.isCreateCycleModalOpen ||
|
||||
this.isCreateProjectModalOpen ||
|
||||
this.isCreateModuleModalOpen ||
|
||||
this.isCreateViewModalOpen ||
|
||||
this.isShortcutModalOpen ||
|
||||
this.isBulkDeleteIssueModalOpen ||
|
||||
this.isDeleteIssueModalOpen ||
|
||||
this.createPageModal.isOpen ||
|
||||
this.allStickiesModal
|
||||
);
|
||||
}
|
||||
// computedFn
|
||||
getIsProjectListOpen = computedFn((projectId: string) => this.projectListOpenMap[projectId]);
|
||||
|
||||
/**
|
||||
* Toggles the project list open state
|
||||
* @param projectId
|
||||
* @param value
|
||||
*/
|
||||
toggleProjectListOpen = (projectId: string, value?: boolean) => {
|
||||
if (value !== undefined) this.projectListOpenMap[projectId] = value;
|
||||
else this.projectListOpenMap[projectId] = !this.projectListOpenMap[projectId];
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the command palette modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleCommandPaletteModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isCommandPaletteOpen = value;
|
||||
} else {
|
||||
this.isCommandPaletteOpen = !this.isCommandPaletteOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the shortcut modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleShortcutModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isShortcutModalOpen = value;
|
||||
} else {
|
||||
this.isShortcutModalOpen = !this.isShortcutModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the create project modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleCreateProjectModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isCreateProjectModalOpen = value;
|
||||
} else {
|
||||
this.isCreateProjectModalOpen = !this.isCreateProjectModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the create cycle modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleCreateCycleModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isCreateCycleModalOpen = value;
|
||||
} else {
|
||||
this.isCreateCycleModalOpen = !this.isCreateCycleModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the create view modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleCreateViewModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isCreateViewModalOpen = value;
|
||||
} else {
|
||||
this.isCreateViewModalOpen = !this.isCreateViewModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the create page modal along with the page access
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleCreatePageModal = (value?: TCreatePageModal) => {
|
||||
if (value) {
|
||||
this.createPageModal = {
|
||||
isOpen: value.isOpen,
|
||||
pageAccess: value.pageAccess || EPageAccess.PUBLIC,
|
||||
};
|
||||
} else {
|
||||
this.createPageModal = {
|
||||
isOpen: !this.createPageModal.isOpen,
|
||||
pageAccess: EPageAccess.PUBLIC,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the create issue modal
|
||||
* @param value
|
||||
* @param storeType
|
||||
* @returns
|
||||
*/
|
||||
toggleCreateIssueModal = (value?: boolean, storeType?: TCreateModalStoreTypes, allowedProjectIds?: string[]) => {
|
||||
if (value !== undefined) {
|
||||
this.isCreateIssueModalOpen = value;
|
||||
this.createIssueStoreType = storeType || EIssuesStoreType.PROJECT;
|
||||
this.createWorkItemAllowedProjectIds = allowedProjectIds ?? undefined;
|
||||
} else {
|
||||
this.isCreateIssueModalOpen = !this.isCreateIssueModalOpen;
|
||||
this.createIssueStoreType = EIssuesStoreType.PROJECT;
|
||||
this.createWorkItemAllowedProjectIds = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the delete issue modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleDeleteIssueModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isDeleteIssueModalOpen = value;
|
||||
} else {
|
||||
this.isDeleteIssueModalOpen = !this.isDeleteIssueModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the create module modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleCreateModuleModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isCreateModuleModalOpen = value;
|
||||
} else {
|
||||
this.isCreateModuleModalOpen = !this.isCreateModuleModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the bulk delete issue modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleBulkDeleteIssueModal = (value?: boolean) => {
|
||||
if (value !== undefined) {
|
||||
this.isBulkDeleteIssueModalOpen = value;
|
||||
} else {
|
||||
this.isBulkDeleteIssueModalOpen = !this.isBulkDeleteIssueModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the all stickies modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleAllStickiesModal = (value?: boolean) => {
|
||||
if (value) {
|
||||
this.allStickiesModal = value;
|
||||
} else {
|
||||
this.allStickiesModal = !this.allStickiesModal;
|
||||
}
|
||||
};
|
||||
}
|
||||
721
apps/web/core/store/cycle.store.ts
Normal file
721
apps/web/core/store/cycle.store.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { isPast, isToday } from "date-fns";
|
||||
import { sortBy, set, isEmpty } from "lodash-es";
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type {
|
||||
ICycle,
|
||||
TCyclePlotType,
|
||||
TProgressSnapshot,
|
||||
TCycleEstimateDistribution,
|
||||
TCycleDistribution,
|
||||
TCycleEstimateType,
|
||||
} from "@plane/types";
|
||||
import type { DistributionUpdates } from "@plane/utils";
|
||||
import { orderCycles, shouldFilterCycle, getDate, updateDistribution } from "@plane/utils";
|
||||
// helpers
|
||||
// services
|
||||
import { syncIssuesWithDeletedCycles } from "@/local-db/utils/load-workspace";
|
||||
import { CycleService } from "@/services/cycle.service";
|
||||
import { CycleArchiveService } from "@/services/cycle_archive.service";
|
||||
import { IssueService } from "@/services/issue";
|
||||
import { ProjectService } from "@/services/project";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface ICycleStore {
|
||||
// loaders
|
||||
loader: boolean;
|
||||
progressLoader: boolean;
|
||||
// observables
|
||||
fetchedMap: Record<string, boolean>;
|
||||
cycleMap: Record<string, ICycle>;
|
||||
plotType: Record<string, TCyclePlotType>;
|
||||
estimatedType: Record<string, TCycleEstimateType>;
|
||||
activeCycleIdMap: Record<string, boolean>;
|
||||
|
||||
// computed
|
||||
currentProjectCycleIds: string[] | null;
|
||||
currentProjectCompletedCycleIds: string[] | null;
|
||||
currentProjectIncompleteCycleIds: string[] | null;
|
||||
currentProjectActiveCycleId: string | null;
|
||||
currentProjectArchivedCycleIds: string[] | null;
|
||||
currentProjectActiveCycle: ICycle | null;
|
||||
|
||||
// computed actions
|
||||
getFilteredCycleIds: (projectId: string, sortByManual: boolean) => string[] | null;
|
||||
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
|
||||
getFilteredArchivedCycleIds: (projectId: string) => string[] | null;
|
||||
getCycleById: (cycleId: string) => ICycle | null;
|
||||
getCycleNameById: (cycleId: string) => string | undefined;
|
||||
getProjectCycleDetails: (projectId: string) => ICycle[] | null;
|
||||
getProjectCycleIds: (projectId: string) => string[] | null;
|
||||
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
|
||||
getEstimateTypeByCycleId: (cycleId: string) => TCycleEstimateType;
|
||||
getIsPointsDataAvailable: (cycleId: string) => boolean;
|
||||
|
||||
// actions
|
||||
updateCycleDistribution: (distributionUpdates: DistributionUpdates, cycleId: string) => void;
|
||||
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
|
||||
setEstimateType: (cycleId: string, estimateType: TCycleEstimateType) => void;
|
||||
// fetch
|
||||
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
|
||||
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
||||
fetchActiveCycle: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
||||
fetchArchivedCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
||||
fetchArchivedCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
|
||||
fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
|
||||
fetchActiveCycleProgress: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<TProgressSnapshot>;
|
||||
fetchActiveCycleProgressPro: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
fetchActiveCycleAnalytics: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
analytic_type: string
|
||||
) => Promise<TCycleDistribution | TCycleEstimateDistribution>;
|
||||
// crud
|
||||
createCycle: (workspaceSlug: string, projectId: string, data: Partial<ICycle>) => Promise<ICycle>;
|
||||
updateCycleDetails: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
data: Partial<ICycle>
|
||||
) => Promise<ICycle>;
|
||||
deleteCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
// favorites
|
||||
addCycleToFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>;
|
||||
removeCycleFromFavorites: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
// archive
|
||||
archiveCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
restoreCycle: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class CycleStore implements ICycleStore {
|
||||
// observables
|
||||
loader: boolean = false;
|
||||
progressLoader: boolean = false;
|
||||
cycleMap: Record<string, ICycle> = {};
|
||||
plotType: Record<string, TCyclePlotType> = {};
|
||||
estimatedType: Record<string, TCycleEstimateType> = {};
|
||||
activeCycleIdMap: Record<string, boolean> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
// root store
|
||||
rootStore;
|
||||
// services
|
||||
projectService;
|
||||
issueService;
|
||||
cycleService;
|
||||
cycleArchiveService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
progressLoader: observable,
|
||||
cycleMap: observable,
|
||||
plotType: observable,
|
||||
estimatedType: observable,
|
||||
activeCycleIdMap: observable,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
currentProjectCycleIds: computed,
|
||||
currentProjectCompletedCycleIds: computed,
|
||||
currentProjectIncompleteCycleIds: computed,
|
||||
currentProjectActiveCycleId: computed,
|
||||
currentProjectArchivedCycleIds: computed,
|
||||
currentProjectActiveCycle: computed,
|
||||
|
||||
// actions
|
||||
setEstimateType: action,
|
||||
fetchWorkspaceCycles: action,
|
||||
fetchAllCycles: action,
|
||||
fetchActiveCycle: action,
|
||||
fetchArchivedCycles: action,
|
||||
fetchArchivedCycleDetails: action,
|
||||
fetchActiveCycleProgress: action,
|
||||
fetchActiveCycleAnalytics: action,
|
||||
fetchCycleDetails: action,
|
||||
updateCycleDetails: action,
|
||||
deleteCycle: action,
|
||||
addCycleToFavorites: action,
|
||||
removeCycleFromFavorites: action,
|
||||
archiveCycle: action,
|
||||
restoreCycle: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
|
||||
// services
|
||||
this.projectService = new ProjectService();
|
||||
this.issueService = new IssueService();
|
||||
this.cycleService = new CycleService();
|
||||
this.cycleArchiveService = new CycleArchiveService();
|
||||
}
|
||||
|
||||
// computed
|
||||
/**
|
||||
* returns all cycle ids for a project
|
||||
*/
|
||||
get currentProjectCycleIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId && !c?.archived_at);
|
||||
allCycles = sortBy(allCycles, [(c) => c.sort_order]);
|
||||
const allCycleIds = allCycles.map((c) => c.id);
|
||||
return allCycleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all completed cycle ids for a project
|
||||
*/
|
||||
get currentProjectCompletedCycleIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let completedCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
|
||||
const endDate = getDate(c.end_date);
|
||||
const hasEndDatePassed = endDate && isPast(endDate);
|
||||
const isEndDateToday = endDate && isToday(endDate);
|
||||
return (
|
||||
c.project_id === projectId && ((hasEndDatePassed && !isEndDateToday) || c.status?.toLowerCase() === "completed")
|
||||
);
|
||||
});
|
||||
completedCycles = sortBy(completedCycles, [(c) => c.sort_order]);
|
||||
const completedCycleIds = completedCycles.map((c) => c.id);
|
||||
return completedCycleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all incomplete cycle ids for a project
|
||||
*/
|
||||
get currentProjectIncompleteCycleIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
|
||||
const endDate = getDate(c.end_date);
|
||||
const hasEndDatePassed = endDate && isPast(endDate);
|
||||
return (
|
||||
c.project_id === projectId && !hasEndDatePassed && !c?.archived_at && c.status?.toLowerCase() !== "completed"
|
||||
);
|
||||
});
|
||||
incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]);
|
||||
const incompleteCycleIds = incompleteCycles.map((c) => c.id);
|
||||
return incompleteCycleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns active cycle id for a project
|
||||
*/
|
||||
get currentProjectActiveCycleId() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return null;
|
||||
const activeCycle = Object.keys(this.cycleMap ?? {}).find(
|
||||
(cycleId) =>
|
||||
this.cycleMap?.[cycleId]?.project_id === projectId &&
|
||||
this.cycleMap?.[cycleId]?.status?.toLowerCase() === "current"
|
||||
);
|
||||
return activeCycle || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all archived cycle ids for a project
|
||||
*/
|
||||
get currentProjectArchivedCycleIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let archivedCycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) => c.project_id === projectId && !!c.archived_at
|
||||
);
|
||||
archivedCycles = sortBy(archivedCycles, [(c) => c.sort_order]);
|
||||
const archivedCycleIds = archivedCycles.map((c) => c.id);
|
||||
return archivedCycleIds;
|
||||
}
|
||||
|
||||
get currentProjectActiveCycle() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId && !this.currentProjectActiveCycleId) return null;
|
||||
return this.cycleMap?.[this.currentProjectActiveCycleId!] ?? null;
|
||||
}
|
||||
|
||||
getIsPointsDataAvailable = computedFn((cycleId: string) => {
|
||||
const cycle = this.getCycleById(cycleId);
|
||||
if (!cycle) return false;
|
||||
if (cycle.version === 2) return cycle.progress?.some((p) => p.total_estimate_points > 0);
|
||||
else if (cycle.version === 1) {
|
||||
const completionChart = cycle.estimate_distribution?.completion_chart || {};
|
||||
return !isEmpty(completionChart) && Object.keys(completionChart).some((p) => completionChart[p]! > 0);
|
||||
} else return false;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns filtered cycle ids based on display filters and filters
|
||||
* @param {TCycleDisplayFilters} displayFilters
|
||||
* @param {TCycleFilters} filters
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredCycleIds = computedFn((projectId: string, sortByManual: boolean) => {
|
||||
const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.cycleFilter.searchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) =>
|
||||
c.project_id === projectId &&
|
||||
!c.archived_at &&
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterCycle(c, filters ?? {})
|
||||
);
|
||||
cycles = orderCycles(cycles, sortByManual);
|
||||
const cycleIds = cycles.map((c) => c.id);
|
||||
return cycleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns filtered cycle ids based on display filters and filters
|
||||
* @param {TCycleDisplayFilters} displayFilters
|
||||
* @param {TCycleFilters} filters
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredCompletedCycleIds = computedFn((projectId: string) => {
|
||||
const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.cycleFilter.searchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) =>
|
||||
c.project_id === projectId &&
|
||||
!c.archived_at &&
|
||||
c.status?.toLowerCase() === "completed" &&
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterCycle(c, filters ?? {})
|
||||
);
|
||||
cycles = sortBy(cycles, [(c) => !c.start_date]);
|
||||
const cycleIds = cycles.map((c) => c.id);
|
||||
return cycleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns filtered archived cycle ids based on display filters and filters
|
||||
* @param {string} projectId
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredArchivedCycleIds = computedFn((projectId: string) => {
|
||||
const filters = this.rootStore.cycleFilter.getArchivedFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.cycleFilter.archivedCyclesSearchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter(
|
||||
(c) =>
|
||||
c.project_id === projectId &&
|
||||
!!c.archived_at &&
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterCycle(c, filters ?? {})
|
||||
);
|
||||
cycles = sortBy(cycles, [(c) => !c.start_date]);
|
||||
const cycleIds = cycles.map((c) => c.id);
|
||||
return cycleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns cycle details by cycle id
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
getCycleById = computedFn((cycleId: string): ICycle | null => this.cycleMap?.[cycleId] ?? null);
|
||||
|
||||
/**
|
||||
* @description returns cycle name by cycle id
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
getCycleNameById = computedFn((cycleId: string): string => this.cycleMap?.[cycleId]?.name);
|
||||
|
||||
/**
|
||||
* @description returns list of cycle details of the project id passed as argument
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectCycleDetails = computedFn((projectId: string): ICycle[] | null => {
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
|
||||
let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId && !c?.archived_at);
|
||||
cycles = sortBy(cycles, [(c) => c.sort_order]);
|
||||
return cycles || null;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns list of cycle ids of the project id passed as argument
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectCycleIds = computedFn((projectId: string): string[] | null => {
|
||||
const cycles = this.getProjectCycleDetails(projectId);
|
||||
if (!cycles) return null;
|
||||
const cycleIds = cycles.map((c) => c.id);
|
||||
return cycleIds || null;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description gets the plot type for the cycle store
|
||||
* @param {TCyclePlotType} plotType
|
||||
*/
|
||||
getPlotTypeByCycleId = computedFn((cycleId: string) => this.plotType[cycleId] || "burndown");
|
||||
|
||||
/**
|
||||
* @description gets the estimate type for the cycle store
|
||||
* @param {TCycleEstimateType} estimateType
|
||||
*/
|
||||
getEstimateTypeByCycleId = computedFn((cycleId: string) => {
|
||||
const { projectId } = this.rootStore.router;
|
||||
|
||||
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
|
||||
? this.estimatedType[cycleId] || "issues"
|
||||
: "issues";
|
||||
});
|
||||
|
||||
/**
|
||||
* @description updates the plot type for the cycle store
|
||||
* @param {TCyclePlotType} plotType
|
||||
*/
|
||||
setPlotType = (cycleId: string, plotType: TCyclePlotType) => {
|
||||
set(this.plotType, [cycleId], plotType);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the estimate type for the cycle store
|
||||
* @param {TCycleEstimateType} estimateType
|
||||
*/
|
||||
setEstimateType = (cycleId: string, estimateType: TCycleEstimateType) => {
|
||||
set(this.estimatedType, [cycleId], estimateType);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all cycles
|
||||
* @param workspaceSlug
|
||||
* @returns ICycle[]
|
||||
*/
|
||||
fetchWorkspaceCycles = async (workspaceSlug: string) =>
|
||||
await this.cycleService.getWorkspaceCycles(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((cycle) => {
|
||||
set(this.cycleMap, [cycle.id], { ...this.cycleMap[cycle.id], ...cycle });
|
||||
set(this.fetchedMap, cycle.project_id, true);
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetches all cycles for a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
fetchAllCycles = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
await this.cycleService.getCyclesWithParams(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((cycle) => {
|
||||
set(this.cycleMap, [cycle.id], cycle);
|
||||
if (cycle.status?.toLowerCase() === "current") {
|
||||
set(this.activeCycleIdMap, [cycle.id], true);
|
||||
}
|
||||
});
|
||||
set(this.fetchedMap, projectId, true);
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
} catch {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetches archived cycles for a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
fetchArchivedCycles = async (workspaceSlug: string, projectId: string) => {
|
||||
this.loader = true;
|
||||
return await this.cycleArchiveService
|
||||
.getArchivedCycles(workspaceSlug, projectId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((cycle) => {
|
||||
set(this.cycleMap, [cycle.id], cycle);
|
||||
});
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetches active cycle for a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
fetchActiveCycle = async (workspaceSlug: string, projectId: string) =>
|
||||
await this.cycleService.getCyclesWithParams(workspaceSlug, projectId, "current").then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((cycle) => {
|
||||
set(this.activeCycleIdMap, [cycle.id], true);
|
||||
set(this.cycleMap, [cycle.id], cycle);
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetches active cycle progress
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
this.progressLoader = true;
|
||||
return await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId], { ...this.cycleMap[cycleId], ...progress });
|
||||
this.progressLoader = false;
|
||||
});
|
||||
return progress;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetches active cycle progress for pro users
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
fetchActiveCycleProgressPro = action(async (workspaceSlug: string, projectId: string, cycleId: string) => {});
|
||||
|
||||
/**
|
||||
* @description fetches active cycle analytics
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchActiveCycleAnalytics = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
analytic_type: string
|
||||
) =>
|
||||
await this.cycleService
|
||||
.workspaceActiveCyclesAnalytics(workspaceSlug, projectId, cycleId, analytic_type)
|
||||
.then((cycle) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId, analytic_type === "points" ? "estimate_distribution" : "distribution"], cycle);
|
||||
});
|
||||
return cycle;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetches cycle details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchArchivedCycleDetails = async (workspaceSlug: string, projectId: string, cycleId: string) =>
|
||||
await this.cycleArchiveService.getArchivedCycleDetails(workspaceSlug, projectId, cycleId).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [response.id], { ...this.cycleMap?.[response.id], ...response });
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetches cycle details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchCycleDetails = async (workspaceSlug: string, projectId: string, cycleId: string) =>
|
||||
await this.cycleService.getCycleDetails(workspaceSlug, projectId, cycleId).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [response.id], { ...this.cycleMap?.[response.id], ...response });
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* This method updates the cycle's stats locally without fetching the updated stats from backend
|
||||
* @param distributionUpdates
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
updateCycleDistribution = (distributionUpdates: DistributionUpdates, cycleId: string) => {
|
||||
const cycle = this.getCycleById(cycleId);
|
||||
if (!cycle) return;
|
||||
|
||||
runInAction(() => {
|
||||
updateDistribution(cycle, distributionUpdates);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description creates a new cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
createCycle = action(
|
||||
async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
|
||||
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [response.id], response);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @description updates cycle details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
updateCycleDetails = async (workspaceSlug: string, projectId: string, cycleId: string, data: Partial<ICycle>) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId], { ...this.cycleMap?.[cycleId], ...data });
|
||||
});
|
||||
const response = await this.cycleService.patchCycle(workspaceSlug, projectId, cycleId, data);
|
||||
this.fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to patch cycle from cycle store");
|
||||
this.fetchAllCycles(workspaceSlug, projectId);
|
||||
this.fetchActiveCycle(workspaceSlug, projectId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description deletes a cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
*/
|
||||
deleteCycle = async (workspaceSlug: string, projectId: string, cycleId: string) =>
|
||||
await this.cycleService.deleteCycle(workspaceSlug, projectId, cycleId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.cycleMap[cycleId];
|
||||
delete this.activeCycleIdMap[cycleId];
|
||||
if (this.rootStore.favorite.entityMap[cycleId]) this.rootStore.favorite.removeFavoriteFromStore(cycleId);
|
||||
syncIssuesWithDeletedCycles([cycleId]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @description adds a cycle to favorites
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
addCycleToFavorites = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const currentCycle = this.getCycleById(cycleId);
|
||||
try {
|
||||
runInAction(() => {
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true);
|
||||
});
|
||||
// updating through api.
|
||||
const response = await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "cycle",
|
||||
entity_identifier: cycleId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: currentCycle?.name || "" },
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description removes a cycle from favorites
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
removeCycleFromFavorites = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const currentCycle = this.getCycleById(cycleId);
|
||||
try {
|
||||
runInAction(() => {
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false);
|
||||
});
|
||||
const response = await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, cycleId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description archives a cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
archiveCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const cycleDetails = this.getCycleById(cycleId);
|
||||
if (cycleDetails?.archived_at) return;
|
||||
await this.cycleArchiveService
|
||||
.archiveCycle(workspaceSlug, projectId, cycleId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId, "archived_at"], response.archived_at);
|
||||
if (this.rootStore.favorite.entityMap[cycleId]) this.rootStore.favorite.removeFavoriteFromStore(cycleId);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to archive cycle in cycle store", error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description restores a cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
restoreCycle = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const cycleDetails = this.getCycleById(cycleId);
|
||||
if (!cycleDetails?.archived_at) return;
|
||||
await this.cycleArchiveService
|
||||
.restoreCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [cycleId, "archived_at"], null);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore cycle in cycle store", error);
|
||||
});
|
||||
};
|
||||
}
|
||||
181
apps/web/core/store/cycle_filter.store.ts
Normal file
181
apps/web/core/store/cycle_filter.store.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { TCycleDisplayFilters, TCycleFilters, TCycleFiltersByState } from "@plane/types";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface ICycleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TCycleDisplayFilters>;
|
||||
filters: Record<string, TCycleFiltersByState>;
|
||||
searchQuery: string;
|
||||
archivedCyclesSearchQuery: string;
|
||||
// computed
|
||||
currentProjectDisplayFilters: TCycleDisplayFilters | undefined;
|
||||
currentProjectFilters: TCycleFilters | undefined;
|
||||
currentProjectArchivedFilters: TCycleFilters | undefined;
|
||||
// computed functions
|
||||
getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined;
|
||||
getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
|
||||
getArchivedFiltersByProjectId: (projectId: string) => TCycleFilters | undefined;
|
||||
// actions
|
||||
updateDisplayFilters: (projectId: string, displayFilters: TCycleDisplayFilters) => void;
|
||||
updateFilters: (projectId: string, filters: TCycleFilters, state?: keyof TCycleFiltersByState) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
updateArchivedCyclesSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (projectId: string, state?: keyof TCycleFiltersByState) => void;
|
||||
}
|
||||
|
||||
export class CycleFilterStore implements ICycleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TCycleDisplayFilters> = {};
|
||||
filters: Record<string, TCycleFiltersByState> = {};
|
||||
searchQuery: string = "";
|
||||
archivedCyclesSearchQuery: string = "";
|
||||
// root store
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
displayFilters: observable,
|
||||
filters: observable,
|
||||
searchQuery: observable.ref,
|
||||
archivedCyclesSearchQuery: observable.ref,
|
||||
// computed
|
||||
currentProjectDisplayFilters: computed,
|
||||
currentProjectFilters: computed,
|
||||
currentProjectArchivedFilters: computed,
|
||||
// actions
|
||||
updateDisplayFilters: action,
|
||||
updateFilters: action,
|
||||
updateSearchQuery: action,
|
||||
updateArchivedCyclesSearchQuery: action,
|
||||
clearAllFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// initialize display filters of the current project
|
||||
reaction(
|
||||
() => this.rootStore.router.projectId,
|
||||
(projectId) => {
|
||||
if (!projectId) return;
|
||||
this.initProjectCycleFilters(projectId);
|
||||
this.searchQuery = "";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get display filters of the current project
|
||||
*/
|
||||
get currentProjectDisplayFilters() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.displayFilters[projectId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get filters of the current project
|
||||
*/
|
||||
get currentProjectFilters() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId]?.default ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get archived filters of the current project
|
||||
*/
|
||||
get currentProjectArchivedFilters() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId].archived;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get display filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]);
|
||||
|
||||
/**
|
||||
* @description get filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]?.default ?? {});
|
||||
|
||||
/**
|
||||
* @description get archived filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getArchivedFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId].archived);
|
||||
|
||||
/**
|
||||
* @description initialize display filters and filters of a project
|
||||
* @param {string} projectId
|
||||
*/
|
||||
initProjectCycleFilters = (projectId: string) => {
|
||||
const displayFilters = this.getDisplayFiltersByProjectId(projectId);
|
||||
runInAction(() => {
|
||||
this.displayFilters[projectId] = {
|
||||
active_tab: displayFilters?.active_tab || "active",
|
||||
layout: displayFilters?.layout || "list",
|
||||
};
|
||||
this.filters[projectId] = this.filters[projectId] ?? {
|
||||
default: {},
|
||||
archived: {},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update display filters of a project
|
||||
* @param {string} projectId
|
||||
* @param {TCycleDisplayFilters} displayFilters
|
||||
*/
|
||||
updateDisplayFilters = (projectId: string, displayFilters: TCycleDisplayFilters) => {
|
||||
runInAction(() => {
|
||||
Object.keys(displayFilters).forEach((key) => {
|
||||
set(this.displayFilters, [projectId, key], displayFilters[key as keyof TCycleDisplayFilters]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update filters of a project
|
||||
* @param {string} projectId
|
||||
* @param {TCycleFilters} filters
|
||||
*/
|
||||
updateFilters = (projectId: string, filters: TCycleFilters, state: keyof TCycleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
Object.keys(filters).forEach((key) => {
|
||||
set(this.filters, [projectId, state, key], filters[key as keyof TCycleFilters]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateSearchQuery = (query: string) => (this.searchQuery = query);
|
||||
|
||||
/**
|
||||
* @description update archived search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateArchivedCyclesSearchQuery = (query: string) => (this.archivedCyclesSearchQuery = query);
|
||||
|
||||
/**
|
||||
* @description clear all filters of a project
|
||||
* @param {string} projectId
|
||||
*/
|
||||
clearAllFilters = (projectId: string, state: keyof TCycleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
this.filters[projectId][state] = {};
|
||||
});
|
||||
};
|
||||
}
|
||||
285
apps/web/core/store/dashboard.store.ts
Normal file
285
apps/web/core/store/dashboard.store.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type {
|
||||
THomeDashboardResponse,
|
||||
TWidget,
|
||||
TWidgetFiltersFormData,
|
||||
TWidgetStatsResponse,
|
||||
TWidgetKeys,
|
||||
TWidgetStatsRequestParams,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { DashboardService } from "@/services/dashboard.service";
|
||||
// plane web store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IDashboardStore {
|
||||
// error states
|
||||
widgetStatsError: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, any | null>> };
|
||||
// observables
|
||||
homeDashboardId: string | null;
|
||||
widgetDetails: { [workspaceSlug: string]: Record<string, TWidget[]> };
|
||||
// {
|
||||
// workspaceSlug: {
|
||||
// dashboardId: TWidget[]
|
||||
// }
|
||||
// }
|
||||
widgetStats: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, TWidgetStatsResponse>> };
|
||||
// {
|
||||
// workspaceSlug: {
|
||||
// dashboardId: {
|
||||
// widgetKey: TWidgetStatsResponse;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// computed
|
||||
homeDashboardWidgets: TWidget[] | undefined;
|
||||
// computed actions
|
||||
getWidgetDetails: (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => TWidget | undefined;
|
||||
getWidgetStats: <T>(workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => T | undefined;
|
||||
getWidgetStatsError: (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => any | null;
|
||||
// actions
|
||||
fetchHomeDashboardWidgets: (workspaceSlug: string) => Promise<THomeDashboardResponse>;
|
||||
fetchWidgetStats: (
|
||||
workspaceSlug: string,
|
||||
dashboardId: string,
|
||||
params: TWidgetStatsRequestParams
|
||||
) => Promise<TWidgetStatsResponse>;
|
||||
updateDashboardWidget: (
|
||||
workspaceSlug: string,
|
||||
dashboardId: string,
|
||||
widgetId: string,
|
||||
data: Partial<TWidget>
|
||||
) => Promise<any>;
|
||||
updateDashboardWidgetFilters: (
|
||||
workspaceSlug: string,
|
||||
dashboardId: string,
|
||||
widgetId: string,
|
||||
data: TWidgetFiltersFormData
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
export class DashboardStore implements IDashboardStore {
|
||||
// error states
|
||||
widgetStatsError: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, any>> } = {};
|
||||
// observables
|
||||
homeDashboardId: string | null = null;
|
||||
widgetDetails: { [workspaceSlug: string]: Record<string, TWidget[]> } = {};
|
||||
widgetStats: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, TWidgetStatsResponse>> } = {};
|
||||
// stores
|
||||
routerStore;
|
||||
issueStore;
|
||||
// services
|
||||
dashboardService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// error states
|
||||
widgetStatsError: observable,
|
||||
// observables
|
||||
homeDashboardId: observable.ref,
|
||||
widgetDetails: observable,
|
||||
widgetStats: observable,
|
||||
// computed
|
||||
homeDashboardWidgets: computed,
|
||||
// fetch actions
|
||||
fetchHomeDashboardWidgets: action,
|
||||
fetchWidgetStats: action,
|
||||
// update actions
|
||||
updateDashboardWidget: action,
|
||||
updateDashboardWidgetFilters: action,
|
||||
});
|
||||
|
||||
// router store
|
||||
this.routerStore = _rootStore.router;
|
||||
this.issueStore = _rootStore.issue.issues;
|
||||
// services
|
||||
this.dashboardService = new DashboardService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get home dashboard widgets
|
||||
* @returns {TWidget[] | undefined}
|
||||
*/
|
||||
get homeDashboardWidgets() {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return undefined;
|
||||
const { homeDashboardId, widgetDetails } = this;
|
||||
return homeDashboardId ? widgetDetails?.[workspaceSlug]?.[homeDashboardId] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get widget details
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} dashboardId
|
||||
* @param {TWidgetKeys} widgetKey
|
||||
* @returns {TWidget | undefined}
|
||||
*/
|
||||
getWidgetDetails = computedFn((workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => {
|
||||
const widgets = this.widgetDetails?.[workspaceSlug]?.[dashboardId];
|
||||
if (!widgets) return undefined;
|
||||
return widgets.find((widget) => widget.key === widgetKey);
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get widget stats
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} dashboardId
|
||||
* @param {TWidgetKeys} widgetKey
|
||||
* @returns {T | undefined}
|
||||
*/
|
||||
getWidgetStats = <T>(workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys): T | undefined =>
|
||||
(this.widgetStats?.[workspaceSlug]?.[dashboardId]?.[widgetKey] as unknown as T) ?? undefined;
|
||||
|
||||
/**
|
||||
* @description get widget stats error
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} dashboardId
|
||||
* @param {TWidgetKeys} widgetKey
|
||||
* @returns {any | null}
|
||||
*/
|
||||
getWidgetStatsError = (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) =>
|
||||
this.widgetStatsError?.[workspaceSlug]?.[dashboardId]?.[widgetKey] ?? null;
|
||||
|
||||
/**
|
||||
* @description fetch home dashboard details and widgets
|
||||
* @param {string} workspaceSlug
|
||||
* @returns {Promise<THomeDashboardResponse>}
|
||||
*/
|
||||
fetchHomeDashboardWidgets = async (workspaceSlug: string): Promise<THomeDashboardResponse> => {
|
||||
try {
|
||||
const response = await this.dashboardService.getHomeDashboardWidgets(workspaceSlug);
|
||||
|
||||
runInAction(() => {
|
||||
this.homeDashboardId = response.dashboard.id;
|
||||
set(this.widgetDetails, [workspaceSlug, response.dashboard.id], response.widgets);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.homeDashboardId = null;
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch widget stats
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} dashboardId
|
||||
* @param {TWidgetStatsRequestParams} widgetKey
|
||||
* @returns widget stats
|
||||
*/
|
||||
fetchWidgetStats = async (workspaceSlug: string, dashboardId: string, params: TWidgetStatsRequestParams) =>
|
||||
this.dashboardService
|
||||
.getWidgetStats(workspaceSlug, dashboardId, params)
|
||||
.then((res: any) => {
|
||||
runInAction(() => {
|
||||
if (res.issues) this.issueStore.addIssue(res.issues);
|
||||
set(this.widgetStats, [workspaceSlug, dashboardId, params.widget_key], res);
|
||||
set(this.widgetStatsError, [workspaceSlug, dashboardId, params.widget_key], null);
|
||||
});
|
||||
return res;
|
||||
})
|
||||
.catch((error) => {
|
||||
runInAction(() => {
|
||||
set(this.widgetStatsError, [workspaceSlug, dashboardId, params.widget_key], error);
|
||||
});
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description update dashboard widget
|
||||
* @param {string} dashboardId
|
||||
* @param {string} widgetId
|
||||
* @param {Partial<TWidget>} data
|
||||
* @returns updated widget
|
||||
*/
|
||||
updateDashboardWidget = async (
|
||||
workspaceSlug: string,
|
||||
dashboardId: string,
|
||||
widgetId: string,
|
||||
data: Partial<TWidget>
|
||||
): Promise<any> => {
|
||||
// find all widgets in dashboard
|
||||
const widgets = this.widgetDetails?.[workspaceSlug]?.[dashboardId];
|
||||
if (!widgets) throw new Error("Dashboard not found");
|
||||
// find widget index
|
||||
const widgetIndex = widgets.findIndex((widget) => widget.id === widgetId);
|
||||
// get original widget
|
||||
const originalWidget = { ...widgets[widgetIndex] };
|
||||
if (widgetIndex === -1) throw new Error("Widget not found");
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.widgetDetails[workspaceSlug][dashboardId][widgetIndex] = {
|
||||
...widgets[widgetIndex],
|
||||
...data,
|
||||
};
|
||||
});
|
||||
const response = await this.dashboardService.updateDashboardWidget(dashboardId, widgetId, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// revert changes
|
||||
runInAction(() => {
|
||||
this.widgetDetails[workspaceSlug][dashboardId][widgetIndex] = originalWidget;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update dashboard widget filters
|
||||
* @param {string} dashboardId
|
||||
* @param {string} widgetId
|
||||
* @param {TWidgetFiltersFormData} data
|
||||
* @returns updated widget
|
||||
*/
|
||||
updateDashboardWidgetFilters = async (
|
||||
workspaceSlug: string,
|
||||
dashboardId: string,
|
||||
widgetId: string,
|
||||
data: TWidgetFiltersFormData
|
||||
): Promise<TWidget> => {
|
||||
const widgetDetails = this.getWidgetDetails(workspaceSlug, dashboardId, data.widgetKey);
|
||||
if (!widgetDetails) throw new Error("Widget not found");
|
||||
try {
|
||||
const updatedWidget = {
|
||||
...widgetDetails,
|
||||
widget_filters: {
|
||||
...widgetDetails.widget_filters,
|
||||
...data.filters,
|
||||
},
|
||||
};
|
||||
// update widget details optimistically
|
||||
runInAction(() => {
|
||||
set(
|
||||
this.widgetDetails,
|
||||
[workspaceSlug, dashboardId],
|
||||
this.widgetDetails?.[workspaceSlug]?.[dashboardId]?.map((w) => (w.id === widgetId ? updatedWidget : w))
|
||||
);
|
||||
});
|
||||
const response = await this.updateDashboardWidget(workspaceSlug, dashboardId, widgetId, {
|
||||
filters: {
|
||||
...widgetDetails.widget_filters,
|
||||
...data.filters,
|
||||
},
|
||||
}).then((res) => res);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// revert changes
|
||||
runInAction(() => {
|
||||
this.widgetDetails[workspaceSlug][dashboardId] = this.widgetDetails?.[workspaceSlug]?.[dashboardId]?.map((w) =>
|
||||
w.id === widgetId ? widgetDetails : w
|
||||
);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
120
apps/web/core/store/editor/asset.store.ts
Normal file
120
apps/web/core/store/editor/asset.store.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { debounce, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane types
|
||||
import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
|
||||
|
||||
export interface IEditorAssetStore {
|
||||
// computed
|
||||
assetsUploadPercentage: Record<string, number>;
|
||||
// helper methods
|
||||
getAssetUploadStatusByEditorBlockId: (blockId: string) => TAttachmentUploadStatus | undefined;
|
||||
// actions
|
||||
uploadEditorAsset: ({
|
||||
blockId,
|
||||
data,
|
||||
file,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
blockId: string;
|
||||
data: TFileEntityInfo;
|
||||
file: File;
|
||||
projectId?: string;
|
||||
workspaceSlug: string;
|
||||
}) => Promise<TFileSignedURLResponse>;
|
||||
}
|
||||
|
||||
export class EditorAssetStore implements IEditorAssetStore {
|
||||
// observables
|
||||
assetsUploadStatus: Record<string, TAttachmentUploadStatus> = {};
|
||||
// services
|
||||
fileService: FileService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
assetsUploadStatus: observable,
|
||||
// computed
|
||||
assetsUploadPercentage: computed,
|
||||
// actions
|
||||
uploadEditorAsset: action,
|
||||
});
|
||||
// services
|
||||
this.fileService = new FileService();
|
||||
}
|
||||
|
||||
get assetsUploadPercentage() {
|
||||
const assetsStatus = this.assetsUploadStatus;
|
||||
const assetsPercentage: Record<string, number> = {};
|
||||
Object.keys(assetsStatus).forEach((blockId) => {
|
||||
const asset = assetsStatus[blockId];
|
||||
if (asset) assetsPercentage[blockId] = asset.progress;
|
||||
});
|
||||
return assetsPercentage;
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getAssetUploadStatusByEditorBlockId: IEditorAssetStore["getAssetUploadStatusByEditorBlockId"] = computedFn(
|
||||
(blockId) => {
|
||||
const blockDetails = this.assetsUploadStatus[blockId];
|
||||
if (!blockDetails) return undefined;
|
||||
return blockDetails;
|
||||
}
|
||||
);
|
||||
|
||||
// actions
|
||||
private debouncedUpdateProgress = debounce((blockId: string, progress: number) => {
|
||||
runInAction(() => {
|
||||
set(this.assetsUploadStatus, [blockId, "progress"], progress);
|
||||
});
|
||||
}, 16);
|
||||
|
||||
uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => {
|
||||
const { blockId, data, file, projectId, workspaceSlug } = args;
|
||||
const tempId = uuidv4();
|
||||
|
||||
try {
|
||||
// update attachment upload status
|
||||
runInAction(() => {
|
||||
set(this.assetsUploadStatus, [blockId], {
|
||||
id: tempId,
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
});
|
||||
if (projectId) {
|
||||
const response = await this.fileService.uploadProjectAsset(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
data,
|
||||
file,
|
||||
(progressEvent) => {
|
||||
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
|
||||
this.debouncedUpdateProgress(blockId, progressPercentage);
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} else {
|
||||
const response = await this.fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => {
|
||||
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
|
||||
this.debouncedUpdateProgress(blockId, progressPercentage);
|
||||
});
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in uploading page asset:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
delete this.assetsUploadStatus[blockId];
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
151
apps/web/core/store/estimates/estimate-point.ts
Normal file
151
apps/web/core/store/estimates/estimate-point.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/* eslint-disable no-useless-catch */
|
||||
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// types
|
||||
import type { IEstimate, IEstimatePoint as IEstimatePointType } from "@plane/types";
|
||||
// plane web services
|
||||
import estimateService from "@/plane-web/services/project/estimate.service";
|
||||
// store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
type TErrorCodes = {
|
||||
status: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface IEstimatePoint extends IEstimatePointType {
|
||||
// observables
|
||||
error: TErrorCodes | undefined;
|
||||
// computed
|
||||
asJson: IEstimatePointType;
|
||||
// helper actions
|
||||
updateEstimatePointObject: (estimatePoint: Partial<IEstimatePointType>) => void;
|
||||
// actions
|
||||
updateEstimatePoint: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: Partial<IEstimatePointType>
|
||||
) => Promise<IEstimatePointType | undefined>;
|
||||
}
|
||||
|
||||
export class EstimatePoint implements IEstimatePoint {
|
||||
// data model observables
|
||||
id: string | undefined = undefined;
|
||||
key: number | undefined = undefined;
|
||||
value: string | undefined = undefined;
|
||||
description: string | undefined = undefined;
|
||||
workspace: string | undefined = undefined;
|
||||
project: string | undefined = undefined;
|
||||
estimate: string | undefined = undefined;
|
||||
created_at: Date | undefined = undefined;
|
||||
updated_at: Date | undefined = undefined;
|
||||
created_by: string | undefined = undefined;
|
||||
updated_by: string | undefined = undefined;
|
||||
// observables
|
||||
error: TErrorCodes | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
private store: CoreRootStore,
|
||||
private projectEstimate: IEstimate,
|
||||
private data: IEstimatePointType
|
||||
) {
|
||||
makeObservable(this, {
|
||||
// data model observables
|
||||
id: observable.ref,
|
||||
key: observable.ref,
|
||||
value: observable.ref,
|
||||
description: observable.ref,
|
||||
workspace: observable.ref,
|
||||
project: observable.ref,
|
||||
estimate: observable.ref,
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
created_by: observable.ref,
|
||||
updated_by: observable.ref,
|
||||
// observables
|
||||
error: observable.ref,
|
||||
// computed
|
||||
asJson: computed,
|
||||
// actions
|
||||
updateEstimatePoint: action,
|
||||
});
|
||||
this.id = this.data.id;
|
||||
this.key = this.data.key;
|
||||
this.value = this.data.value;
|
||||
this.description = this.data.description;
|
||||
this.workspace = this.data.workspace;
|
||||
this.project = this.data.project;
|
||||
this.estimate = this.data.estimate;
|
||||
this.created_at = this.data.created_at;
|
||||
this.updated_at = this.data.updated_at;
|
||||
this.created_by = this.data.created_by;
|
||||
this.updated_by = this.data.updated_by;
|
||||
}
|
||||
|
||||
// computed
|
||||
get asJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
key: this.key,
|
||||
value: this.value,
|
||||
description: this.description,
|
||||
workspace: this.workspace,
|
||||
project: this.project,
|
||||
estimate: this.estimate,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
created_by: this.created_by,
|
||||
updated_by: this.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
// helper actions
|
||||
/**
|
||||
* @description updating an estimate point object in local store
|
||||
* @param { Partial<IEstimatePointType> } estimatePoint
|
||||
* @returns { void }
|
||||
*/
|
||||
updateEstimatePointObject = (estimatePoint: Partial<IEstimatePointType>) => {
|
||||
Object.keys(estimatePoint).map((key) => {
|
||||
const estimatePointKey = key as keyof IEstimatePointType;
|
||||
set(this, estimatePointKey, estimatePoint[estimatePointKey]);
|
||||
});
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description updating an estimate point
|
||||
* @param { Partial<IEstimatePointType> } payload
|
||||
* @returns { IEstimatePointType | undefined }
|
||||
*/
|
||||
updateEstimatePoint = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: Partial<IEstimatePointType>
|
||||
): Promise<IEstimatePointType | undefined> => {
|
||||
try {
|
||||
if (!this.projectEstimate?.id || !this.id || !payload) return undefined;
|
||||
|
||||
const estimatePoint = await estimateService.updateEstimatePoint(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
this.projectEstimate?.id,
|
||||
this.id,
|
||||
payload
|
||||
);
|
||||
if (estimatePoint) {
|
||||
runInAction(() => {
|
||||
Object.keys(payload).map((key) => {
|
||||
const estimatePointKey = key as keyof IEstimatePointType;
|
||||
set(this, estimatePointKey, estimatePoint[estimatePointKey]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimatePoint;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
313
apps/web/core/store/estimates/project-estimate.store.ts
Normal file
313
apps/web/core/store/estimates/project-estimate.store.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { unset, orderBy, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { IEstimate as IEstimateType, IEstimateFormData, TEstimateSystemKeys } from "@plane/types";
|
||||
// plane web services
|
||||
import estimateService from "@/plane-web/services/project/estimate.service";
|
||||
// plane web store
|
||||
import type { IEstimate } from "@/plane-web/store/estimates/estimate";
|
||||
import { Estimate } from "@/plane-web/store/estimates/estimate";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
type TEstimateLoader = "init-loader" | "mutation-loader" | undefined;
|
||||
type TErrorCodes = {
|
||||
status: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface IProjectEstimateStore {
|
||||
// observables
|
||||
loader: TEstimateLoader;
|
||||
estimates: Record<string, IEstimate>;
|
||||
error: TErrorCodes | undefined;
|
||||
// computed
|
||||
currentActiveEstimateId: string | undefined;
|
||||
currentActiveEstimate: IEstimate | undefined;
|
||||
archivedEstimateIds: string[] | undefined;
|
||||
currentProjectEstimateType: TEstimateSystemKeys | undefined;
|
||||
areEstimateEnabledByProjectId: (projectId: string) => boolean;
|
||||
estimateIdsByProjectId: (projectId: string) => string[] | undefined;
|
||||
currentActiveEstimateIdByProjectId: (projectId: string) => string | undefined;
|
||||
estimateById: (estimateId: string) => IEstimate | undefined;
|
||||
// actions
|
||||
getWorkspaceEstimates: (workspaceSlug: string, loader?: TEstimateLoader) => Promise<IEstimateType[] | undefined>;
|
||||
getProjectEstimates: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loader?: TEstimateLoader
|
||||
) => Promise<IEstimateType[] | undefined>;
|
||||
getEstimateById: (estimateId: string) => IEstimate | undefined;
|
||||
createEstimate: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: IEstimateFormData
|
||||
) => Promise<IEstimateType | undefined>;
|
||||
deleteEstimate: (workspaceSlug: string, projectId: string, estimateId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectEstimateStore implements IProjectEstimateStore {
|
||||
// observables
|
||||
loader: TEstimateLoader = undefined;
|
||||
estimates: Record<string, IEstimate> = {}; // estimate_id -> estimate
|
||||
error: TErrorCodes | undefined = undefined;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
estimates: observable,
|
||||
error: observable,
|
||||
// computed
|
||||
currentActiveEstimateId: computed,
|
||||
currentActiveEstimate: computed,
|
||||
archivedEstimateIds: computed,
|
||||
currentProjectEstimateType: computed,
|
||||
// actions
|
||||
getWorkspaceEstimates: action,
|
||||
getProjectEstimates: action,
|
||||
getEstimateById: action,
|
||||
createEstimate: action,
|
||||
deleteEstimate: action,
|
||||
});
|
||||
}
|
||||
|
||||
// computed
|
||||
|
||||
get currentProjectEstimateType(): TEstimateSystemKeys | undefined {
|
||||
return this.currentActiveEstimateId ? this.estimates[this.currentActiveEstimateId]?.type : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get current active estimate id for a project
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
get currentActiveEstimateId(): string | undefined {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
const currentActiveEstimateId = Object.values(this.estimates || {}).find(
|
||||
(p) => p.project === projectId && p.last_used
|
||||
);
|
||||
return currentActiveEstimateId?.id ?? undefined;
|
||||
}
|
||||
|
||||
// computed
|
||||
/**
|
||||
* @description get current active estimate for a project
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
get currentActiveEstimate(): IEstimate | undefined {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
const currentActiveEstimate = Object.values(this.estimates || {}).find(
|
||||
(p) => p.project === projectId && p.last_used
|
||||
);
|
||||
return currentActiveEstimate ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get all archived estimate ids for a project
|
||||
* @returns { string[] | undefined }
|
||||
*/
|
||||
get archivedEstimateIds(): string[] | undefined {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
const archivedEstimates = orderBy(
|
||||
Object.values(this.estimates || {}).filter((p) => p.project === projectId && !p.last_used),
|
||||
["created_at"],
|
||||
"desc"
|
||||
);
|
||||
const archivedEstimateIds = archivedEstimates.map((p) => p.id) as string[];
|
||||
return archivedEstimateIds ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get estimates are enabled in the project or not
|
||||
* @returns { boolean }
|
||||
*/
|
||||
areEstimateEnabledByProjectId = computedFn((projectId: string) => {
|
||||
if (!projectId) return false;
|
||||
const projectDetails = this.store.projectRoot.project.getProjectById(projectId);
|
||||
if (!projectDetails) return false;
|
||||
return Boolean(projectDetails.estimate) || false;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get all estimate ids for a project
|
||||
* @returns { string[] | undefined }
|
||||
*/
|
||||
estimateIdsByProjectId = computedFn((projectId: string) => {
|
||||
if (!projectId) return undefined;
|
||||
const projectEstimatesIds = Object.values(this.estimates || {})
|
||||
.filter((p) => p.project === projectId)
|
||||
.map((p) => p.id) as string[];
|
||||
return projectEstimatesIds ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get current active estimate id for a project
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
currentActiveEstimateIdByProjectId = computedFn((projectId: string): string | undefined => {
|
||||
if (!projectId) return undefined;
|
||||
const currentActiveEstimateId = Object.values(this.estimates || {}).find(
|
||||
(p) => p.project === projectId && p.last_used
|
||||
);
|
||||
return currentActiveEstimateId?.id ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get estimate by id
|
||||
* @returns { IEstimate | undefined }
|
||||
*/
|
||||
estimateById = computedFn((estimateId: string) => {
|
||||
if (!estimateId) return undefined;
|
||||
return this.estimates[estimateId] ?? undefined;
|
||||
});
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description fetch all estimates for a workspace
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { IEstimateType[] | undefined }
|
||||
*/
|
||||
getWorkspaceEstimates = async (
|
||||
workspaceSlug: string,
|
||||
loader: TEstimateLoader = "mutation-loader"
|
||||
): Promise<IEstimateType[] | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
if (Object.keys(this.estimates || {}).length <= 0) this.loader = loader ? loader : "init-loader";
|
||||
|
||||
const estimates = await estimateService.fetchWorkspaceEstimates(workspaceSlug);
|
||||
if (estimates && estimates.length > 0) {
|
||||
runInAction(() => {
|
||||
estimates.forEach((estimate) => {
|
||||
if (estimate.id)
|
||||
set(
|
||||
this.estimates,
|
||||
[estimate.id],
|
||||
new Estimate(this.store, { ...estimate, type: estimate.type?.toLowerCase() as TEstimateSystemKeys })
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimates;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error fetching estimates",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all estimates for a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { IEstimateType[] | undefined }
|
||||
*/
|
||||
getProjectEstimates = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loader: TEstimateLoader = "mutation-loader"
|
||||
): Promise<IEstimateType[] | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
if (!this.estimateIdsByProjectId(projectId)) this.loader = loader ? loader : "init-loader";
|
||||
|
||||
const estimates = await estimateService.fetchProjectEstimates(workspaceSlug, projectId);
|
||||
if (estimates && estimates.length > 0) {
|
||||
runInAction(() => {
|
||||
estimates.forEach((estimate) => {
|
||||
if (estimate.id)
|
||||
set(
|
||||
this.estimates,
|
||||
[estimate.id],
|
||||
new Estimate(this.store, { ...estimate, type: estimate.type?.toLowerCase() as TEstimateSystemKeys })
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimates;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error fetching estimates",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { string } estimateId
|
||||
* @returns IEstimateType | undefined
|
||||
*/
|
||||
getEstimateById = (estimateId: string): IEstimate | undefined => this.estimates[estimateId];
|
||||
|
||||
/**
|
||||
* @description create an estimate for a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { Partial<IEstimateFormData> } payload
|
||||
* @returns
|
||||
*/
|
||||
createEstimate = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: IEstimateFormData
|
||||
): Promise<IEstimateType | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
|
||||
const estimate = await estimateService.createEstimate(workspaceSlug, projectId, payload);
|
||||
if (estimate) {
|
||||
// update estimate_id in current project
|
||||
// await this.store.projectRoot.project.updateProject(workspaceSlug, projectId, {
|
||||
// estimate: estimate.id,
|
||||
// });
|
||||
runInAction(() => {
|
||||
if (estimate.id)
|
||||
set(
|
||||
this.estimates,
|
||||
[estimate.id],
|
||||
new Estimate(this.store, { ...estimate, type: estimate.type?.toLowerCase() as TEstimateSystemKeys })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return estimate;
|
||||
} catch (error) {
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error creating estimate",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description delete the estimate for a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param estimateId
|
||||
*/
|
||||
deleteEstimate = async (workspaceSlug: string, projectId: string, estimateId: string) => {
|
||||
try {
|
||||
await estimateService.deleteEstimate(workspaceSlug, projectId, estimateId);
|
||||
runInAction(() => estimateId && unset(this.estimates, [estimateId]));
|
||||
} catch (error) {
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error deleting estimate",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
406
apps/web/core/store/favorite.store.ts
Normal file
406
apps/web/core/store/favorite.store.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { orderBy, uniqBy, set } from "lodash-es";
|
||||
import { action, observable, makeObservable, runInAction, computed } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { IFavorite } from "@plane/types";
|
||||
import { FavoriteService } from "@/services/favorite";
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IFavoriteStore {
|
||||
// observables
|
||||
|
||||
favoriteIds: string[];
|
||||
favoriteMap: {
|
||||
[favoriteId: string]: IFavorite;
|
||||
};
|
||||
entityMap: {
|
||||
[entityId: string]: IFavorite;
|
||||
};
|
||||
// computed actions
|
||||
existingFolders: string[];
|
||||
groupedFavorites: { [favoriteId: string]: IFavorite };
|
||||
// actions
|
||||
fetchFavorite: (workspaceSlug: string) => Promise<IFavorite[]>;
|
||||
// CRUD actions
|
||||
addFavorite: (workspaceSlug: string, data: Partial<IFavorite>) => Promise<IFavorite>;
|
||||
updateFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<IFavorite>;
|
||||
deleteFavorite: (workspaceSlug: string, favoriteId: string) => Promise<void>;
|
||||
getGroupedFavorites: (workspaceSlug: string, favoriteId: string) => Promise<IFavorite[]>;
|
||||
moveFavoriteToFolder: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
|
||||
removeFavoriteEntity: (workspaceSlug: string, entityId: string) => Promise<void>;
|
||||
reOrderFavorite: (
|
||||
workspaceSlug: string,
|
||||
favoriteId: string,
|
||||
destinationId: string,
|
||||
edge: string | undefined
|
||||
) => Promise<void>;
|
||||
removeFromFavoriteFolder: (workspaceSlug: string, favoriteId: string) => Promise<void>;
|
||||
removeFavoriteFromStore: (entity_identifier: string) => void;
|
||||
}
|
||||
|
||||
export class FavoriteStore implements IFavoriteStore {
|
||||
// observables
|
||||
favoriteIds: string[] = [];
|
||||
favoriteMap: {
|
||||
[favoriteId: string]: IFavorite;
|
||||
} = {};
|
||||
entityMap: {
|
||||
[entityId: string]: IFavorite;
|
||||
} = {};
|
||||
// service
|
||||
favoriteService;
|
||||
viewStore;
|
||||
projectStore;
|
||||
pageStore;
|
||||
cycleStore;
|
||||
moduleStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
favoriteMap: observable,
|
||||
entityMap: observable,
|
||||
favoriteIds: observable,
|
||||
//computed
|
||||
existingFolders: computed,
|
||||
groupedFavorites: computed,
|
||||
// action
|
||||
fetchFavorite: action,
|
||||
// CRUD actions
|
||||
addFavorite: action,
|
||||
getGroupedFavorites: action,
|
||||
moveFavoriteToFolder: action,
|
||||
removeFavoriteEntity: action,
|
||||
reOrderFavorite: action,
|
||||
removeFavoriteEntityFromStore: action,
|
||||
removeFromFavoriteFolder: action,
|
||||
});
|
||||
this.favoriteService = new FavoriteService();
|
||||
this.viewStore = _rootStore.projectView;
|
||||
this.projectStore = _rootStore.projectRoot.project;
|
||||
this.moduleStore = _rootStore.module;
|
||||
this.cycleStore = _rootStore.cycle;
|
||||
this.pageStore = _rootStore.projectPages;
|
||||
}
|
||||
|
||||
get existingFolders() {
|
||||
return Object.values(this.favoriteMap).map((fav) => fav.name);
|
||||
}
|
||||
|
||||
get groupedFavorites() {
|
||||
const data: { [favoriteId: string]: IFavorite } = JSON.parse(JSON.stringify(this.favoriteMap));
|
||||
|
||||
Object.values(data).forEach((fav) => {
|
||||
if (fav.parent && data[fav.parent]) {
|
||||
if (data[fav.parent].children) {
|
||||
if (!data[fav.parent].children.some((f) => f.id === fav.id)) {
|
||||
data[fav.parent].children.push(fav);
|
||||
}
|
||||
} else {
|
||||
data[fav.parent].children = [fav];
|
||||
}
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a favorite in the workspace and adds it to the store
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
* @returns Promise<IFavorite>
|
||||
*/
|
||||
addFavorite = async (workspaceSlug: string, data: Partial<IFavorite>) => {
|
||||
data = { ...data, parent: null, is_folder: data.entity_type === "folder" };
|
||||
|
||||
if (data.entity_identifier && this.entityMap[data.entity_identifier]) return this.entityMap[data.entity_identifier];
|
||||
const id = uuidv4();
|
||||
try {
|
||||
// optimistic addition
|
||||
runInAction(() => {
|
||||
set(this.favoriteMap, [id], data);
|
||||
data.entity_identifier && set(this.entityMap, [data.entity_identifier], data);
|
||||
this.favoriteIds = [id, ...this.favoriteIds];
|
||||
});
|
||||
const response = await this.favoriteService.addFavorite(workspaceSlug, data);
|
||||
|
||||
// overwrite the temp id
|
||||
runInAction(() => {
|
||||
delete this.favoriteMap[id];
|
||||
set(this.favoriteMap, [response.id], response);
|
||||
response.entity_identifier && set(this.entityMap, [response.entity_identifier], response);
|
||||
this.favoriteIds = [response.id, ...this.favoriteIds.filter((favId) => favId !== id)];
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
delete this.favoriteMap[id];
|
||||
data.entity_identifier && delete this.entityMap[data.entity_identifier];
|
||||
this.favoriteIds = this.favoriteIds.filter((favId) => favId !== id);
|
||||
|
||||
console.error("Failed to create favorite from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a favorite in the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @param data
|
||||
* @returns Promise<IFavorite>
|
||||
*/
|
||||
updateFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
|
||||
const initialState = this.favoriteMap[favoriteId];
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.favoriteMap, [favoriteId], { ...this.favoriteMap[favoriteId], ...data });
|
||||
});
|
||||
const response = await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to update favorite from favorite store");
|
||||
runInAction(() => {
|
||||
set(this.favoriteMap, [favoriteId], initialState);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a favorite in the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @param data
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
moveFavoriteToFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
|
||||
try {
|
||||
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
|
||||
runInAction(() => {
|
||||
// add parent of the favorite
|
||||
set(this.favoriteMap, [favoriteId, "parent"], data.parent);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move favorite to folder", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
reOrderFavorite = async (
|
||||
workspaceSlug: string,
|
||||
favoriteId: string,
|
||||
destinationId: string,
|
||||
edge: string | undefined
|
||||
) => {
|
||||
try {
|
||||
let resultSequence = 10000;
|
||||
if (edge) {
|
||||
const sortedIds = orderBy(Object.values(this.favoriteMap), "sequence", "desc").map((fav: IFavorite) => fav.id);
|
||||
const destinationSequence = this.favoriteMap[destinationId]?.sequence || undefined;
|
||||
if (destinationSequence) {
|
||||
const destinationIndex = sortedIds.findIndex((id) => id === destinationId);
|
||||
if (edge === "reorder-above") {
|
||||
const prevSequence = this.favoriteMap[sortedIds[destinationIndex - 1]]?.sequence || undefined;
|
||||
if (prevSequence) {
|
||||
resultSequence = (destinationSequence + prevSequence) / 2;
|
||||
} else {
|
||||
resultSequence = destinationSequence + resultSequence;
|
||||
}
|
||||
} else {
|
||||
resultSequence = destinationSequence - resultSequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { sequence: resultSequence });
|
||||
|
||||
runInAction(() => {
|
||||
set(this.favoriteMap, [favoriteId, "sequence"], resultSequence);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move favorite folder");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeFromFavoriteFolder = async (workspaceSlug: string, favoriteId: string) => {
|
||||
try {
|
||||
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { parent: null });
|
||||
runInAction(() => {
|
||||
//remove parent
|
||||
set(this.favoriteMap, [favoriteId, "parent"], null);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move favorite");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeFavoriteEntityFromStore = (entity_identifier: string, entity_type: string) => {
|
||||
switch (entity_type) {
|
||||
case "view":
|
||||
return (
|
||||
this.viewStore.viewMap[entity_identifier] && (this.viewStore.viewMap[entity_identifier].is_favorite = false)
|
||||
);
|
||||
case "module":
|
||||
return (
|
||||
this.moduleStore.moduleMap[entity_identifier] &&
|
||||
(this.moduleStore.moduleMap[entity_identifier].is_favorite = false)
|
||||
);
|
||||
case "page":
|
||||
return this.pageStore.data[entity_identifier] && (this.pageStore.data[entity_identifier].is_favorite = false);
|
||||
case "cycle":
|
||||
return (
|
||||
this.cycleStore.cycleMap[entity_identifier] &&
|
||||
(this.cycleStore.cycleMap[entity_identifier].is_favorite = false)
|
||||
);
|
||||
case "project":
|
||||
return (
|
||||
this.projectStore.projectMap[entity_identifier] &&
|
||||
(this.projectStore.projectMap[entity_identifier].is_favorite = false)
|
||||
);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a favorite from the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
deleteFavorite = async (workspaceSlug: string, favoriteId: string) => {
|
||||
if (!this.favoriteMap[favoriteId]) return;
|
||||
const parent = this.favoriteMap[favoriteId].parent;
|
||||
const children = this.groupedFavorites[favoriteId].children;
|
||||
const entity_identifier = this.favoriteMap[favoriteId].entity_identifier;
|
||||
const initialState = this.favoriteMap[favoriteId];
|
||||
|
||||
try {
|
||||
await this.favoriteService.deleteFavorite(workspaceSlug, favoriteId);
|
||||
runInAction(() => {
|
||||
delete this.favoriteMap[favoriteId];
|
||||
entity_identifier && delete this.entityMap[entity_identifier];
|
||||
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
|
||||
});
|
||||
runInAction(() => {
|
||||
entity_identifier && this.removeFavoriteEntityFromStore(entity_identifier, initialState.entity_type);
|
||||
if (children) {
|
||||
children.forEach((child) => {
|
||||
if (!child.entity_identifier) return;
|
||||
this.removeFavoriteEntityFromStore(child.entity_identifier, child.entity_type);
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete favorite from favorite store", error);
|
||||
runInAction(() => {
|
||||
if (parent) set(this.favoriteMap, [parent, "children"], [...this.favoriteMap[parent].children, initialState]);
|
||||
set(this.favoriteMap, [favoriteId], initialState);
|
||||
entity_identifier && set(this.entityMap, [entity_identifier], initialState);
|
||||
this.favoriteIds = [favoriteId, ...this.favoriteIds];
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a favorite entity from the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param entityId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
removeFavoriteEntity = async (workspaceSlug: string, entityId: string) => {
|
||||
const initialState = this.entityMap[entityId];
|
||||
try {
|
||||
const favoriteId = this.entityMap[entityId].id;
|
||||
await this.deleteFavorite(workspaceSlug, favoriteId);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove favorite entity from favorite store", error);
|
||||
runInAction(() => {
|
||||
set(this.entityMap, [entityId], initialState);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeFavoriteFromStore = (entity_identifier: string) => {
|
||||
try {
|
||||
const favoriteId = this.entityMap[entity_identifier]?.id;
|
||||
const oldData = this.favoriteMap[favoriteId];
|
||||
const projectData = Object.values(this.favoriteMap).filter(
|
||||
(fav) => fav.project_id === entity_identifier && fav.entity_type !== "project"
|
||||
);
|
||||
runInAction(() => {
|
||||
projectData &&
|
||||
projectData.forEach(async (fav) => {
|
||||
this.removeFavoriteFromStore(fav.entity_identifier!);
|
||||
this.removeFavoriteEntityFromStore(fav.entity_identifier!, fav.entity_type);
|
||||
});
|
||||
|
||||
if (!favoriteId) return;
|
||||
delete this.favoriteMap[favoriteId];
|
||||
this.removeFavoriteEntityFromStore(entity_identifier!, oldData.entity_type);
|
||||
|
||||
delete this.entityMap[entity_identifier];
|
||||
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remove favorite from favorite store", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* get Grouped Favorites
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @returns Promise<IFavorite[]>
|
||||
*/
|
||||
getGroupedFavorites = async (workspaceSlug: string, favoriteId: string) => {
|
||||
if (!favoriteId) return [];
|
||||
try {
|
||||
const response = await this.favoriteService.getGroupedFavorites(workspaceSlug, favoriteId);
|
||||
runInAction(() => {
|
||||
// add the favorites to the map
|
||||
response.forEach((favorite) => {
|
||||
set(this.favoriteMap, [favorite.id], favorite);
|
||||
this.favoriteIds.push(favorite.id);
|
||||
this.favoriteIds = uniqBy(this.favoriteIds, (id) => id);
|
||||
if (favorite.entity_identifier) set(this.entityMap, [favorite.entity_identifier], favorite);
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to get grouped favorites from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* get Workspace favorite using workspace slug
|
||||
* @param workspaceSlug
|
||||
* @returns Promise<IFavorite[]>
|
||||
*
|
||||
*/
|
||||
fetchFavorite = async (workspaceSlug: string) => {
|
||||
try {
|
||||
this.favoriteIds = [];
|
||||
this.favoriteMap = {};
|
||||
this.entityMap = {};
|
||||
const favorites = await this.favoriteService.getFavorites(workspaceSlug);
|
||||
runInAction(() => {
|
||||
favorites.forEach((favorite) => {
|
||||
set(this.favoriteMap, [favorite.id], favorite);
|
||||
this.favoriteIds.push(favorite.id);
|
||||
favorite.entity_identifier && set(this.entityMap, [favorite.entity_identifier], favorite);
|
||||
});
|
||||
});
|
||||
return favorites;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch favorites from workspace store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
197
apps/web/core/store/global-view.store.ts
Normal file
197
apps/web/core/store/global-view.store.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { set, cloneDeep, isEqual } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { IWorkspaceView } from "@plane/types";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IGlobalViewStore {
|
||||
// observables
|
||||
globalViewMap: Record<string, IWorkspaceView>;
|
||||
// computed
|
||||
currentWorkspaceViews: string[] | null;
|
||||
// computed actions
|
||||
getSearchedViews: (searchQuery: string) => string[] | null;
|
||||
getViewDetailsById: (viewId: string) => IWorkspaceView | null;
|
||||
// fetch actions
|
||||
fetchAllGlobalViews: (workspaceSlug: string) => Promise<IWorkspaceView[]>;
|
||||
fetchGlobalViewDetails: (workspaceSlug: string, viewId: string) => Promise<IWorkspaceView>;
|
||||
// crud actions
|
||||
createGlobalView: (workspaceSlug: string, data: Partial<IWorkspaceView>) => Promise<IWorkspaceView>;
|
||||
updateGlobalView: (
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
data: Partial<IWorkspaceView>,
|
||||
shouldSyncFilters?: boolean
|
||||
) => Promise<IWorkspaceView | undefined>;
|
||||
deleteGlobalView: (workspaceSlug: string, viewId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export class GlobalViewStore implements IGlobalViewStore {
|
||||
// observables
|
||||
globalViewMap: Record<string, IWorkspaceView> = {};
|
||||
// root store
|
||||
rootStore;
|
||||
// services
|
||||
workspaceService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
globalViewMap: observable,
|
||||
// computed
|
||||
currentWorkspaceViews: computed,
|
||||
// actions
|
||||
fetchAllGlobalViews: action,
|
||||
fetchGlobalViewDetails: action,
|
||||
deleteGlobalView: action,
|
||||
updateGlobalView: action,
|
||||
createGlobalView: action,
|
||||
});
|
||||
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
|
||||
this.createGlobalView = this.createGlobalView.bind(this);
|
||||
this.updateGlobalView = this.updateGlobalView.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns list of views for current workspace
|
||||
*/
|
||||
get currentWorkspaceViews() {
|
||||
const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspaceDetails) return null;
|
||||
|
||||
return (
|
||||
Object.keys(this.globalViewMap ?? {})?.filter(
|
||||
(viewId) => this.globalViewMap[viewId]?.workspace === currentWorkspaceDetails.id
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns list of views for current workspace based on search query
|
||||
* @param searchQuery
|
||||
* @returns
|
||||
*/
|
||||
getSearchedViews = computedFn((searchQuery: string) => {
|
||||
const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspaceDetails) return null;
|
||||
|
||||
return (
|
||||
Object.keys(this.globalViewMap ?? {})?.filter(
|
||||
(viewId) =>
|
||||
this.globalViewMap[viewId]?.workspace === currentWorkspaceDetails.id &&
|
||||
this.globalViewMap[viewId]?.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) ?? null
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns view details for given viewId
|
||||
* @param viewId
|
||||
*/
|
||||
getViewDetailsById = computedFn((viewId: string): IWorkspaceView | null => this.globalViewMap[viewId] ?? null);
|
||||
|
||||
/**
|
||||
* @description fetch all global views for given workspace
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
fetchAllGlobalViews = async (workspaceSlug: string): Promise<IWorkspaceView[]> =>
|
||||
await this.workspaceService.getAllViews(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((view) => {
|
||||
set(this.globalViewMap, view.id, view);
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetch view details for given viewId
|
||||
* @param viewId
|
||||
*/
|
||||
fetchGlobalViewDetails = async (workspaceSlug: string, viewId: string): Promise<IWorkspaceView> =>
|
||||
await this.workspaceService.getViewDetails(workspaceSlug, viewId).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, viewId, response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description create new global view
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
async createGlobalView(workspaceSlug: string, data: Partial<IWorkspaceView>) {
|
||||
try {
|
||||
const response = await this.workspaceService.createView(workspaceSlug, data);
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, response.id, response);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description update global view
|
||||
* @param workspaceSlug
|
||||
* @param viewId
|
||||
* @param data
|
||||
*/
|
||||
async updateGlobalView(
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
data: Partial<IWorkspaceView>,
|
||||
shouldSyncFilters: boolean = true
|
||||
): Promise<IWorkspaceView | undefined> {
|
||||
const currentViewData = this.getViewDetailsById(viewId) ? cloneDeep(this.getViewDetailsById(viewId)) : undefined;
|
||||
try {
|
||||
Object.keys(data).forEach((key) => {
|
||||
const currentKey = key as keyof IWorkspaceView;
|
||||
set(this.globalViewMap, [viewId, currentKey], data[currentKey]);
|
||||
});
|
||||
|
||||
const currentView = await this.workspaceService.updateView(workspaceSlug, viewId, data);
|
||||
|
||||
// applying the filters in the global view
|
||||
if (shouldSyncFilters && !isEqual(currentViewData?.rich_filters || {}, currentView?.rich_filters || {})) {
|
||||
await this.rootStore.issue.workspaceIssuesFilter.updateFilterExpression(
|
||||
workspaceSlug,
|
||||
viewId,
|
||||
currentView?.rich_filters || {}
|
||||
);
|
||||
this.rootStore.issue.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation");
|
||||
}
|
||||
return currentView as IWorkspaceView;
|
||||
} catch {
|
||||
Object.keys(data).forEach((key) => {
|
||||
const currentKey = key as keyof IWorkspaceView;
|
||||
if (currentViewData) set(this.globalViewMap, [viewId, currentKey], currentViewData[currentKey]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description delete global view
|
||||
* @param workspaceSlug
|
||||
* @param viewId
|
||||
*/
|
||||
deleteGlobalView = async (workspaceSlug: string, viewId: string): Promise<any> =>
|
||||
await this.workspaceService.deleteView(workspaceSlug, viewId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.globalViewMap[viewId];
|
||||
});
|
||||
});
|
||||
}
|
||||
231
apps/web/core/store/inbox/inbox-issue.store.ts
Normal file
231
apps/web/core/store/inbox/inbox-issue.store.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { clone, set } from "lodash-es";
|
||||
import { makeObservable, observable, runInAction, action } from "mobx";
|
||||
import type {
|
||||
TInboxIssue,
|
||||
TInboxIssueStatus,
|
||||
EInboxIssueSource,
|
||||
TIssue,
|
||||
TInboxDuplicateIssueDetails,
|
||||
} from "@plane/types";
|
||||
import { EInboxIssueStatus } from "@plane/types";
|
||||
// helpers
|
||||
// local db
|
||||
import { addIssueToPersistanceLayer } from "@/local-db/utils/utils";
|
||||
// services
|
||||
import { InboxIssueService } from "@/services/inbox";
|
||||
import { IssueService } from "@/services/issue";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
export interface IInboxIssueStore {
|
||||
isLoading: boolean;
|
||||
id: string;
|
||||
status: TInboxIssueStatus;
|
||||
issue: Partial<TIssue>;
|
||||
snoozed_till: Date | undefined;
|
||||
source: EInboxIssueSource | undefined;
|
||||
duplicate_to: string | undefined;
|
||||
created_by: string | undefined;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
|
||||
// actions
|
||||
updateInboxIssueStatus: (status: TInboxIssueStatus) => Promise<void>; // accept, decline
|
||||
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
|
||||
updateInboxIssueSnoozeTill: (date: Date | undefined) => Promise<void>; // snooze the issue
|
||||
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
|
||||
updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
|
||||
fetchIssueActivity: () => Promise<void>; // fetching the issue activity
|
||||
}
|
||||
|
||||
export class InboxIssueStore implements IInboxIssueStore {
|
||||
// observables
|
||||
isLoading: boolean = false;
|
||||
id: string;
|
||||
status: TInboxIssueStatus = EInboxIssueStatus.PENDING;
|
||||
issue: Partial<TIssue> = {};
|
||||
snoozed_till: Date | undefined;
|
||||
source: EInboxIssueSource | undefined;
|
||||
duplicate_to: string | undefined;
|
||||
created_by: string | undefined;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined = undefined;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
// services
|
||||
inboxIssueService;
|
||||
issueService;
|
||||
|
||||
constructor(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: TInboxIssue,
|
||||
private store: CoreRootStore
|
||||
) {
|
||||
this.id = data.id;
|
||||
this.status = data.status;
|
||||
this.issue = data?.issue;
|
||||
this.snoozed_till = data?.snoozed_till || undefined;
|
||||
this.duplicate_to = data?.duplicate_to || undefined;
|
||||
this.created_by = data?.created_by || undefined;
|
||||
this.source = data?.source || undefined;
|
||||
this.duplicate_issue_detail = data?.duplicate_issue_detail || undefined;
|
||||
this.workspaceSlug = workspaceSlug;
|
||||
this.projectId = projectId;
|
||||
// services
|
||||
this.inboxIssueService = new InboxIssueService();
|
||||
this.issueService = new IssueService();
|
||||
// observable variables should be defined after the initialization of the values
|
||||
makeObservable(this, {
|
||||
id: observable,
|
||||
status: observable,
|
||||
issue: observable,
|
||||
snoozed_till: observable,
|
||||
duplicate_to: observable,
|
||||
duplicate_issue_detail: observable,
|
||||
created_by: observable,
|
||||
source: observable,
|
||||
// actions
|
||||
updateInboxIssueStatus: action,
|
||||
updateInboxIssueDuplicateTo: action,
|
||||
updateInboxIssueSnoozeTill: action,
|
||||
updateIssue: action,
|
||||
updateProjectIssue: action,
|
||||
fetchIssueActivity: action,
|
||||
});
|
||||
}
|
||||
|
||||
updateInboxIssueStatus = async (status: TInboxIssueStatus) => {
|
||||
const previousData: Partial<TInboxIssue> = {
|
||||
status: this.status,
|
||||
};
|
||||
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
|
||||
const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
|
||||
status: status,
|
||||
});
|
||||
runInAction(() => set(this, "status", inboxIssue?.status));
|
||||
|
||||
// If issue accepted sync issue to local db
|
||||
if (status === EInboxIssueStatus.ACCEPTED) {
|
||||
const updatedIssue = { ...this.issue, ...inboxIssue.issue };
|
||||
this.store.issue.issues.addIssue([updatedIssue]);
|
||||
await addIssueToPersistanceLayer(updatedIssue);
|
||||
}
|
||||
} catch {
|
||||
runInAction(() => set(this, "status", previousData.status));
|
||||
}
|
||||
};
|
||||
|
||||
updateInboxIssueDuplicateTo = async (issueId: string) => {
|
||||
const inboxStatus = EInboxIssueStatus.DUPLICATE;
|
||||
const previousData: Partial<TInboxIssue> = {
|
||||
status: this.status,
|
||||
duplicate_to: this.duplicate_to,
|
||||
duplicate_issue_detail: this.duplicate_issue_detail,
|
||||
};
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
|
||||
status: inboxStatus,
|
||||
duplicate_to: issueId,
|
||||
});
|
||||
runInAction(() => {
|
||||
set(this, "status", inboxIssue?.status);
|
||||
set(this, "duplicate_to", inboxIssue?.duplicate_to);
|
||||
set(this, "duplicate_issue_detail", inboxIssue?.duplicate_issue_detail);
|
||||
});
|
||||
} catch {
|
||||
runInAction(() => {
|
||||
set(this, "status", previousData.status);
|
||||
set(this, "duplicate_to", previousData.duplicate_to);
|
||||
set(this, "duplicate_issue_detail", previousData.duplicate_issue_detail);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateInboxIssueSnoozeTill = async (date: Date | undefined) => {
|
||||
const inboxStatus = date ? EInboxIssueStatus.SNOOZED : EInboxIssueStatus.PENDING;
|
||||
const previousData: Partial<TInboxIssue> = {
|
||||
status: this.status,
|
||||
snoozed_till: this.snoozed_till,
|
||||
};
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
|
||||
status: inboxStatus,
|
||||
snoozed_till: date ? new Date(date) : null,
|
||||
});
|
||||
runInAction(() => {
|
||||
set(this, "status", inboxIssue?.status);
|
||||
set(this, "snoozed_till", inboxIssue?.snoozed_till);
|
||||
});
|
||||
} catch {
|
||||
runInAction(() => {
|
||||
set(this, "status", previousData.status);
|
||||
set(this, "snoozed_till", previousData.snoozed_till);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateIssue = async (issue: Partial<TIssue>) => {
|
||||
const inboxIssue = clone(this.issue);
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
Object.keys(issue).forEach((key) => {
|
||||
const issueKey = key as keyof TIssue;
|
||||
set(this.issue, issueKey, issue[issueKey]);
|
||||
});
|
||||
await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
|
||||
// fetching activity
|
||||
this.fetchIssueActivity();
|
||||
} catch {
|
||||
Object.keys(issue).forEach((key) => {
|
||||
const issueKey = key as keyof TIssue;
|
||||
set(this.issue, issueKey, inboxIssue[issueKey]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateProjectIssue = async (issue: Partial<TIssue>) => {
|
||||
const inboxIssue = clone(this.issue);
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
Object.keys(issue).forEach((key) => {
|
||||
const issueKey = key as keyof TIssue;
|
||||
set(this.issue, issueKey, issue[issueKey]);
|
||||
});
|
||||
await this.issueService.patchIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
|
||||
if (issue.cycle_id) {
|
||||
await this.store.issue.issueDetail.addIssueToCycle(this.workspaceSlug, this.projectId, issue.cycle_id, [
|
||||
this.issue.id,
|
||||
]);
|
||||
}
|
||||
if (issue.module_ids) {
|
||||
await this.store.issue.issueDetail.changeModulesInIssue(
|
||||
this.workspaceSlug,
|
||||
this.projectId,
|
||||
this.issue.id,
|
||||
issue.module_ids,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// fetching activity
|
||||
this.fetchIssueActivity();
|
||||
} catch {
|
||||
Object.keys(issue).forEach((key) => {
|
||||
const issueKey = key as keyof TIssue;
|
||||
set(this.issue, issueKey, inboxIssue[issueKey]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchIssueActivity = async () => {
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
await this.store.issue.issueDetail.fetchActivities(this.workspaceSlug, this.projectId, this.issue.id);
|
||||
} catch {
|
||||
console.error("Failed to fetch issue activity");
|
||||
}
|
||||
};
|
||||
}
|
||||
507
apps/web/core/store/inbox/project-inbox.store.ts
Normal file
507
apps/web/core/store/inbox/project-inbox.store.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { uniq, update, isEmpty, omit, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { EPastDurationFilters } from "@plane/constants";
|
||||
// types
|
||||
import type {
|
||||
TInboxIssue,
|
||||
TInboxIssueCurrentTab,
|
||||
TInboxIssueFilter,
|
||||
TInboxIssueSorting,
|
||||
TInboxIssuePaginationInfo,
|
||||
TInboxIssueSortingOrderByQueryParam,
|
||||
} from "@plane/types";
|
||||
import { EInboxIssueCurrentTab, EInboxIssueStatus } from "@plane/types";
|
||||
import { getCustomDates } from "@plane/utils";
|
||||
// helpers
|
||||
// services
|
||||
import { InboxIssueService } from "@/services/inbox";
|
||||
// root store
|
||||
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
import { InboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
type TLoader =
|
||||
| "init-loading"
|
||||
| "mutation-loading"
|
||||
| "filter-loading"
|
||||
| "pagination-loading"
|
||||
| "issue-loading"
|
||||
| undefined;
|
||||
|
||||
export interface IProjectInboxStore {
|
||||
currentTab: TInboxIssueCurrentTab;
|
||||
loader: TLoader;
|
||||
error: { message: string; status: "init-error" | "pagination-error" } | undefined;
|
||||
currentInboxProjectId: string;
|
||||
filtersMap: Record<string, Partial<TInboxIssueFilter>>; // projectId -> Partial<TInboxIssueFilter>
|
||||
sortingMap: Record<string, Partial<TInboxIssueSorting>>; // projectId -> Partial<TInboxIssueSorting>
|
||||
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined;
|
||||
inboxIssues: Record<string, IInboxIssueStore>; // issue_id -> IInboxIssueStore
|
||||
inboxIssueIds: string[];
|
||||
// computed
|
||||
inboxFilters: Partial<TInboxIssueFilter>; // computed project inbox filters
|
||||
inboxSorting: Partial<TInboxIssueSorting>; // computed project inbox sorting
|
||||
getAppliedFiltersCount: number;
|
||||
filteredInboxIssueIds: string[];
|
||||
// computed functions
|
||||
getIssueInboxByIssueId: (issueId: string) => IInboxIssueStore;
|
||||
getIsIssueAvailable: (inboxIssueId: string) => boolean;
|
||||
// helper actions
|
||||
inboxIssueQueryParams: (
|
||||
inboxFilters: Partial<TInboxIssueFilter>,
|
||||
inboxSorting: Partial<TInboxIssueSorting>,
|
||||
pagePerCount: number,
|
||||
paginationCursor: string
|
||||
) => Partial<Record<keyof TInboxIssueFilter, string>>;
|
||||
createOrUpdateInboxIssue: (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => void;
|
||||
initializeDefaultFilters: (projectId: string, tab: TInboxIssueCurrentTab) => void;
|
||||
// actions
|
||||
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => void;
|
||||
handleInboxIssueFilters: <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => void; // if user sends me undefined, I will remove the value from the filter key
|
||||
handleInboxIssueSorting: <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => void; // if user sends me undefined, I will remove the value from the filter key
|
||||
fetchInboxIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadingType?: TLoader,
|
||||
tab?: TInboxIssueCurrentTab | undefined
|
||||
) => Promise<void>;
|
||||
fetchInboxPaginationIssues: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
fetchInboxIssueById: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise<TInboxIssue>;
|
||||
createInboxIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: Partial<TInboxIssue>
|
||||
) => Promise<TInboxIssue | undefined>;
|
||||
deleteInboxIssue: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectInboxStore implements IProjectInboxStore {
|
||||
// constants
|
||||
PER_PAGE_COUNT = 10;
|
||||
// observables
|
||||
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
|
||||
loader: TLoader = "init-loading";
|
||||
error: { message: string; status: "init-error" | "pagination-error" } | undefined = undefined;
|
||||
currentInboxProjectId: string = "";
|
||||
filtersMap: Record<string, Partial<TInboxIssueFilter>> = {};
|
||||
sortingMap: Record<string, Partial<TInboxIssueSorting>> = {};
|
||||
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined = undefined;
|
||||
inboxIssues: Record<string, IInboxIssueStore> = {};
|
||||
inboxIssueIds: string[] = [];
|
||||
// services
|
||||
inboxIssueService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
currentTab: observable.ref,
|
||||
loader: observable.ref,
|
||||
error: observable,
|
||||
currentInboxProjectId: observable.ref,
|
||||
filtersMap: observable,
|
||||
sortingMap: observable,
|
||||
inboxIssuePaginationInfo: observable,
|
||||
inboxIssues: observable,
|
||||
inboxIssueIds: observable,
|
||||
// computed
|
||||
inboxFilters: computed,
|
||||
inboxSorting: computed,
|
||||
getAppliedFiltersCount: computed,
|
||||
filteredInboxIssueIds: computed,
|
||||
// actions
|
||||
handleInboxIssueFilters: action,
|
||||
handleInboxIssueSorting: action,
|
||||
fetchInboxIssues: action,
|
||||
fetchInboxPaginationIssues: action,
|
||||
fetchInboxIssueById: action,
|
||||
createInboxIssue: action,
|
||||
deleteInboxIssue: action,
|
||||
});
|
||||
this.inboxIssueService = new InboxIssueService();
|
||||
}
|
||||
|
||||
// computed
|
||||
/**
|
||||
* @description computed project inbox filters
|
||||
*/
|
||||
get inboxFilters() {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return {} as TInboxIssueFilter;
|
||||
return this.filtersMap?.[projectId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description computed project inbox sorting
|
||||
*/
|
||||
get inboxSorting() {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return {} as TInboxIssueSorting;
|
||||
return this.sortingMap?.[projectId];
|
||||
}
|
||||
|
||||
get getAppliedFiltersCount() {
|
||||
let count = 0;
|
||||
this.inboxFilters != undefined &&
|
||||
Object.keys(this.inboxFilters).forEach((key) => {
|
||||
const filterKey = key as keyof TInboxIssueFilter;
|
||||
if (this.inboxFilters[filterKey] && this.inboxFilters?.[filterKey])
|
||||
count = count + (this.inboxFilters?.[filterKey]?.length ?? 0);
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
get filteredInboxIssueIds() {
|
||||
let appliedFilters =
|
||||
this.currentTab === EInboxIssueCurrentTab.OPEN
|
||||
? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED]
|
||||
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE];
|
||||
appliedFilters = appliedFilters.filter((filter) => this.inboxFilters?.status?.includes(filter));
|
||||
const currentTime = new Date().getTime();
|
||||
|
||||
return this.currentTab === EInboxIssueCurrentTab.OPEN
|
||||
? this.inboxIssueIds.filter((id) => {
|
||||
if (appliedFilters.length == 2) return true;
|
||||
if (appliedFilters[0] === EInboxIssueStatus.SNOOZED)
|
||||
return (
|
||||
this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED &&
|
||||
currentTime < new Date(this.inboxIssues[id].snoozed_till!).getTime()
|
||||
);
|
||||
if (appliedFilters[0] === EInboxIssueStatus.PENDING)
|
||||
return (
|
||||
appliedFilters.includes(this.inboxIssues[id].status) ||
|
||||
(this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED &&
|
||||
currentTime > new Date(this.inboxIssues[id].snoozed_till!).getTime())
|
||||
);
|
||||
})
|
||||
: this.inboxIssueIds.filter((id) => appliedFilters.includes(this.inboxIssues[id].status));
|
||||
}
|
||||
|
||||
getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]);
|
||||
|
||||
getIsIssueAvailable = computedFn((inboxIssueId: string) => {
|
||||
if (!this.inboxIssueIds) return true;
|
||||
return this.inboxIssueIds.includes(inboxIssueId);
|
||||
});
|
||||
|
||||
inboxIssueQueryParams = (
|
||||
inboxFilters: Partial<TInboxIssueFilter>,
|
||||
inboxSorting: Partial<TInboxIssueSorting>,
|
||||
pagePerCount: number,
|
||||
paginationCursor: string
|
||||
) => {
|
||||
const filters: Partial<Record<keyof TInboxIssueFilter, string>> = {};
|
||||
!isEmpty(inboxFilters) &&
|
||||
Object.keys(inboxFilters).forEach((key) => {
|
||||
const filterKey = key as keyof TInboxIssueFilter;
|
||||
if (inboxFilters[filterKey] && inboxFilters[filterKey]?.length) {
|
||||
if (["created_at", "updated_at"].includes(filterKey) && (inboxFilters[filterKey] || [])?.length > 0) {
|
||||
const appliedDateFilters: string[] = [];
|
||||
inboxFilters[filterKey]?.forEach((value) => {
|
||||
const dateValue = value as EPastDurationFilters;
|
||||
appliedDateFilters.push(getCustomDates(dateValue));
|
||||
});
|
||||
filters[filterKey] = appliedDateFilters?.join(",");
|
||||
} else filters[filterKey] = inboxFilters[filterKey]?.join(",");
|
||||
}
|
||||
});
|
||||
|
||||
const sorting: TInboxIssueSortingOrderByQueryParam = {
|
||||
order_by: "-issue__created_at",
|
||||
};
|
||||
if (inboxSorting?.order_by && inboxSorting?.sort_by) {
|
||||
switch (inboxSorting.order_by) {
|
||||
case "issue__created_at":
|
||||
if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__created_at`;
|
||||
else sorting.order_by = "issue__created_at";
|
||||
break;
|
||||
case "issue__updated_at":
|
||||
if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__updated_at`;
|
||||
else sorting.order_by = "issue__updated_at";
|
||||
break;
|
||||
case "issue__sequence_id":
|
||||
if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__sequence_id`;
|
||||
else sorting.order_by = "issue__sequence_id";
|
||||
break;
|
||||
default:
|
||||
sorting.order_by = "-issue__created_at";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...filters,
|
||||
...sorting,
|
||||
per_page: pagePerCount,
|
||||
cursor: paginationCursor,
|
||||
};
|
||||
};
|
||||
|
||||
createOrUpdateInboxIssue = (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => {
|
||||
if (inboxIssues && inboxIssues.length > 0) {
|
||||
inboxIssues.forEach((inbox: TInboxIssue) => {
|
||||
const existingInboxIssueDetail = this.getIssueInboxByIssueId(inbox?.issue?.id);
|
||||
if (existingInboxIssueDetail)
|
||||
Object.assign(existingInboxIssueDetail, {
|
||||
...inbox,
|
||||
issue: {
|
||||
...existingInboxIssueDetail.issue,
|
||||
...inbox.issue,
|
||||
},
|
||||
});
|
||||
else
|
||||
set(this.inboxIssues, [inbox?.issue?.id], new InboxIssueStore(workspaceSlug, projectId, inbox, this.store));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// actions
|
||||
handleCurrentTab = (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
|
||||
if (workspaceSlug && projectId) {
|
||||
runInAction(() => {
|
||||
set(this, "currentTab", tab);
|
||||
set(this, ["inboxIssueIds"], []);
|
||||
set(this, ["inboxIssuePaginationInfo"], undefined);
|
||||
set(this.sortingMap, [projectId], { order_by: "issue__created_at", sort_by: "desc" });
|
||||
set(this.filtersMap, [projectId], {
|
||||
status:
|
||||
tab === EInboxIssueCurrentTab.OPEN
|
||||
? [EInboxIssueStatus.PENDING]
|
||||
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE],
|
||||
});
|
||||
});
|
||||
this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
|
||||
}
|
||||
};
|
||||
|
||||
handleInboxIssueFilters = <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (workspaceSlug && projectId) {
|
||||
runInAction(() => {
|
||||
set(this.filtersMap, [projectId, key], value);
|
||||
set(this, ["inboxIssuePaginationInfo"], undefined);
|
||||
});
|
||||
this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
|
||||
}
|
||||
};
|
||||
|
||||
handleInboxIssueSorting = <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (workspaceSlug && projectId) {
|
||||
runInAction(() => {
|
||||
set(this.sortingMap, [projectId, key], value);
|
||||
set(this, ["inboxIssuePaginationInfo"], undefined);
|
||||
});
|
||||
this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
|
||||
}
|
||||
};
|
||||
|
||||
initializeDefaultFilters = (projectId: string, tab: TInboxIssueCurrentTab) => {
|
||||
if (!projectId || !tab) return;
|
||||
if (isEmpty(this.inboxFilters)) {
|
||||
set(this.filtersMap, [projectId], {
|
||||
status:
|
||||
tab === EInboxIssueCurrentTab.OPEN
|
||||
? [EInboxIssueStatus.PENDING]
|
||||
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE],
|
||||
});
|
||||
}
|
||||
if (isEmpty(this.inboxSorting)) {
|
||||
set(this.sortingMap, [projectId], { order_by: "issue__created_at", sort_by: "desc" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch intake issues with paginated data
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchInboxIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadingType: TLoader = undefined,
|
||||
tab: TInboxIssueCurrentTab | undefined = undefined
|
||||
) => {
|
||||
try {
|
||||
if (loadingType === undefined && tab) this.initializeDefaultFilters(projectId, tab);
|
||||
|
||||
if (this.currentInboxProjectId != projectId) {
|
||||
runInAction(() => {
|
||||
set(this, ["currentInboxProjectId"], projectId);
|
||||
set(this, ["inboxIssues"], {});
|
||||
set(this, ["inboxIssueIds"], []);
|
||||
set(this, ["inboxIssuePaginationInfo"], undefined);
|
||||
});
|
||||
}
|
||||
if (Object.keys(this.inboxIssueIds).length === 0) this.loader = "init-loading";
|
||||
else this.loader = "mutation-loading";
|
||||
if (loadingType) this.loader = loadingType;
|
||||
|
||||
const status = this.inboxFilters?.status;
|
||||
const queryParams = this.inboxIssueQueryParams(
|
||||
{ ...this.inboxFilters, status },
|
||||
this.inboxSorting,
|
||||
this.PER_PAGE_COUNT,
|
||||
`${this.PER_PAGE_COUNT}:0:0`
|
||||
);
|
||||
const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
set(this, "inboxIssuePaginationInfo", paginationInfo);
|
||||
if (results) {
|
||||
const issueIds = results.map((value) => value?.issue?.id);
|
||||
set(this, ["inboxIssueIds"], issueIds);
|
||||
this.createOrUpdateInboxIssue(results, workspaceSlug, projectId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching the intake issues", error);
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
message: "Error fetching the intake work items please try again later.",
|
||||
status: "init-error",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch intake issues with paginated data
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchInboxPaginationIssues = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
if (
|
||||
this.inboxIssuePaginationInfo &&
|
||||
(!this.inboxIssuePaginationInfo?.total_results ||
|
||||
(this.inboxIssuePaginationInfo?.total_results &&
|
||||
this.inboxIssueIds.length < this.inboxIssuePaginationInfo?.total_results))
|
||||
) {
|
||||
const queryParams = this.inboxIssueQueryParams(
|
||||
this.inboxFilters,
|
||||
this.inboxSorting,
|
||||
this.PER_PAGE_COUNT,
|
||||
this.inboxIssuePaginationInfo?.next_cursor || `${this.PER_PAGE_COUNT}:0:0`
|
||||
);
|
||||
const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams);
|
||||
|
||||
runInAction(() => {
|
||||
set(this, "inboxIssuePaginationInfo", paginationInfo);
|
||||
if (results && results.length > 0) {
|
||||
const issueIds = results.map((value) => value?.issue?.id);
|
||||
update(this, ["inboxIssueIds"], (ids) => uniq([...ids, ...issueIds]));
|
||||
this.createOrUpdateInboxIssue(results, workspaceSlug, projectId);
|
||||
}
|
||||
});
|
||||
} else set(this, ["inboxIssuePaginationInfo", "next_page_results"], false);
|
||||
} catch (error) {
|
||||
console.error("Error fetching the intake issues", error);
|
||||
this.error = {
|
||||
message: "Error fetching the paginated intake work items please try again later.",
|
||||
status: "pagination-error",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch intake issue with issue id
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param inboxIssueId
|
||||
*/
|
||||
fetchInboxIssueById = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxIssueId: string
|
||||
): Promise<TInboxIssue> => {
|
||||
try {
|
||||
this.loader = "issue-loading";
|
||||
const inboxIssue = await this.inboxIssueService.retrieve(workspaceSlug, projectId, inboxIssueId);
|
||||
const issueId = inboxIssue?.issue?.id || undefined;
|
||||
|
||||
if (inboxIssue && issueId) {
|
||||
runInAction(() => {
|
||||
this.createOrUpdateInboxIssue([inboxIssue], workspaceSlug, projectId);
|
||||
set(this, "loader", undefined);
|
||||
});
|
||||
await Promise.all([
|
||||
// fetching reactions
|
||||
this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId),
|
||||
// fetching activity
|
||||
this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId),
|
||||
// fetching comments
|
||||
this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId),
|
||||
// fetching attachments
|
||||
this.store.issue.issueDetail.fetchAttachments(workspaceSlug, projectId, issueId),
|
||||
]);
|
||||
}
|
||||
return inboxIssue;
|
||||
} catch (error) {
|
||||
console.error("Error fetching the intake issue with intake issue id");
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description create intake issue
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
*/
|
||||
createInboxIssue = async (workspaceSlug: string, projectId: string, data: Partial<TInboxIssue>) => {
|
||||
try {
|
||||
const inboxIssueResponse = await this.inboxIssueService.create(workspaceSlug, projectId, data);
|
||||
if (inboxIssueResponse)
|
||||
runInAction(() => {
|
||||
update(this, ["inboxIssueIds"], (ids) => [...ids, inboxIssueResponse?.issue?.id]);
|
||||
set(
|
||||
this.inboxIssues,
|
||||
[inboxIssueResponse?.issue?.id],
|
||||
new InboxIssueStore(workspaceSlug, projectId, inboxIssueResponse, this.store)
|
||||
);
|
||||
set(
|
||||
this,
|
||||
["inboxIssuePaginationInfo", "total_results"],
|
||||
(this.inboxIssuePaginationInfo?.total_results || 0) + 1
|
||||
);
|
||||
});
|
||||
return inboxIssueResponse;
|
||||
} catch {
|
||||
console.error("Error creating the intake issue");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description delete intake issue
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param inboxIssueId
|
||||
*/
|
||||
deleteInboxIssue = async (workspaceSlug: string, projectId: string, inboxIssueId: string) => {
|
||||
const currentIssue = this.inboxIssues?.[inboxIssueId];
|
||||
try {
|
||||
if (!currentIssue) return;
|
||||
await this.inboxIssueService.destroy(workspaceSlug, projectId, inboxIssueId).then(() => {
|
||||
runInAction(() => {
|
||||
set(
|
||||
this,
|
||||
["inboxIssuePaginationInfo", "total_results"],
|
||||
(this.inboxIssuePaginationInfo?.total_results || 0) - 1
|
||||
);
|
||||
set(this, "inboxIssues", omit(this.inboxIssues, inboxIssueId));
|
||||
set(
|
||||
this,
|
||||
["inboxIssueIds"],
|
||||
this.inboxIssueIds.filter((id) => id !== inboxIssueId)
|
||||
);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error removing the intake issue");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
72
apps/web/core/store/instance.store.ts
Normal file
72
apps/web/core/store/instance.store.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import type { IInstance, IInstanceConfig } from "@plane/types";
|
||||
// services
|
||||
import { InstanceService } from "@/services/instance.service";
|
||||
|
||||
type TError = {
|
||||
status: string;
|
||||
message: string;
|
||||
data?: {
|
||||
is_activated: boolean;
|
||||
is_setup_done: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
isLoading: boolean;
|
||||
instance: IInstance | undefined;
|
||||
config: IInstanceConfig | undefined;
|
||||
error: TError | undefined;
|
||||
// action
|
||||
fetchInstanceInfo: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class InstanceStore implements IInstanceStore {
|
||||
isLoading: boolean = true;
|
||||
instance: IInstance | undefined = undefined;
|
||||
config: IInstanceConfig | undefined = undefined;
|
||||
error: TError | undefined = undefined;
|
||||
// services
|
||||
instanceService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isLoading: observable.ref,
|
||||
instance: observable,
|
||||
config: observable,
|
||||
error: observable,
|
||||
// actions
|
||||
fetchInstanceInfo: action,
|
||||
});
|
||||
// services
|
||||
this.instanceService = new InstanceService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description fetching instance information
|
||||
*/
|
||||
fetchInstanceInfo = async () => {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error = undefined;
|
||||
const instanceInfo = await this.instanceService.getInstanceInfo();
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.instance = instanceInfo.instance;
|
||||
this.config = instanceInfo.config;
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Failed to fetch instance info",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
294
apps/web/core/store/issue/archived/filter.store.ts
Normal file
294
apps/web/core/store/issue/archived/filter.store.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// base class
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
import { IssueFiltersService } from "@/services/issue_filter.service";
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
// constants
|
||||
// services
|
||||
|
||||
export interface IArchivedIssuesFilter extends IBaseIssueFilterStore {
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
projectId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
getIssueFilters(projectId: string): IIssueFilters | undefined;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
updateFilterExpression: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filters: TWorkItemFilterExpression
|
||||
) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArchivedIssuesFilter {
|
||||
// observables
|
||||
filters: { [projectId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new IssueFiltersService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const projectId = this.rootIssueStore.projectId;
|
||||
if (!projectId) return undefined;
|
||||
|
||||
return this.getIssueFilters(projectId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const projectId = this.rootIssueStore.projectId;
|
||||
if (!projectId) return undefined;
|
||||
|
||||
return this.getAppliedFilters(projectId);
|
||||
}
|
||||
|
||||
getIssueFilters(projectId: string) {
|
||||
const displayFilters = this.filters[projectId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
}
|
||||
|
||||
getAppliedFilters(projectId: string) {
|
||||
const userFilters = this.getIssueFilters(projectId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
projectId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
const filterParams = this.getAppliedFilters(projectId);
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, projectId: string) => {
|
||||
const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.ARCHIVED, workspaceSlug, projectId, undefined);
|
||||
|
||||
const richFilters: TWorkItemFilterExpression = _filters?.richFilters;
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters({
|
||||
..._filters?.display_filters,
|
||||
sub_issue: true,
|
||||
});
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
kanbanFilters.group_by = _filters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _filters?.kanban_filters?.sub_group_by || [];
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [projectId, "richFilters"], richFilters);
|
||||
set(this.filters, [projectId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [projectId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [projectId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IArchivedIssuesFilter["updateFilterExpression"] = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
filters
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [projectId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.archivedIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation");
|
||||
this.handleIssuesLocalFilters.set(
|
||||
EIssuesStoreType.ARCHIVED,
|
||||
EIssueFilterType.FILTERS,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
undefined,
|
||||
{
|
||||
rich_filters: filters,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IArchivedIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[projectId])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[projectId].richFilters,
|
||||
displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to state if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.getShouldReFetchIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.archivedIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation");
|
||||
}
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.fetchFilters(workspaceSlug, projectId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/archived/index.ts
Normal file
2
apps/web/core/store/issue/archived/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
202
apps/web/core/store/issue/archived/issue.store.ts
Normal file
202
apps/web/core/store/issue/archived/issue.store.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { action, makeObservable, runInAction } from "mobx";
|
||||
// base class
|
||||
import type { TLoader, IssuePaginationOptions, TIssuesResponse, ViewFlags, TBulkOperationsPayload } from "@plane/types";
|
||||
// services
|
||||
// types
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IArchivedIssuesFilter } from "./filter.store";
|
||||
|
||||
export interface IArchivedIssues extends IBaseIssuesStore {
|
||||
// observable
|
||||
viewFlags: ViewFlags;
|
||||
// actions
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
option: IssuePaginationOptions
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
restoreIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
|
||||
updateIssue: undefined;
|
||||
archiveIssue: undefined;
|
||||
archiveBulkIssues: undefined;
|
||||
quickAddIssue: undefined;
|
||||
}
|
||||
|
||||
export class ArchivedIssues extends BaseIssuesStore implements IArchivedIssues {
|
||||
// filter store
|
||||
issueFilterStore: IArchivedIssuesFilter;
|
||||
|
||||
//viewData
|
||||
viewFlags = {
|
||||
enableQuickAdd: false,
|
||||
enableIssueCreation: false,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: IArchivedIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore, true);
|
||||
makeObservable(this, {
|
||||
// action
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
|
||||
restoreIssue: action,
|
||||
});
|
||||
// filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the project details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchParentStats = async (workspaceSlug: string, projectId?: string) => {
|
||||
projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
/** */
|
||||
updateParentStats = () => {};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader = "init-loader",
|
||||
options: IssuePaginationOptions,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
});
|
||||
this.clear(!isExistingPaginationOptions);
|
||||
|
||||
// get params from pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(options, projectId, undefined, undefined, undefined);
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.issueArchiveService.getArchivedIssues(workspaceSlug, projectId, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, projectId, undefined, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined if errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (workspaceSlug: string, projectId: string, groupId?: string, subGroupId?: string) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
projectId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.issueArchiveService.getArchivedIssues(workspaceSlug, projectId, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader = "mutation"
|
||||
) => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Restored the current issue from the archived issue
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param issueId
|
||||
* @returns
|
||||
*/
|
||||
restoreIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
// call API to restore the issue
|
||||
const response = await this.issueArchiveService.restoreIssue(workspaceSlug, projectId, issueId);
|
||||
|
||||
// update the store and remove from the archived issues list once restored
|
||||
runInAction(() => {
|
||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||
archived_at: null,
|
||||
});
|
||||
this.removeIssueFromList(issueId);
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Setting them as undefined as they can not performed on Archived issues
|
||||
updateIssue = undefined;
|
||||
archiveIssue = undefined;
|
||||
archiveBulkIssues = undefined;
|
||||
quickAddIssue = undefined;
|
||||
}
|
||||
314
apps/web/core/store/issue/cycle/filter.store.ts
Normal file
314
apps/web/core/store/issue/cycle/filter.store.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// base class
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
import { IssueFiltersService } from "@/services/issue_filter.service";
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
// constants
|
||||
// services
|
||||
|
||||
export interface ICycleIssuesFilter extends IBaseIssueFilterStore {
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
cycleId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
getIssueFilters(cycleId: string): IIssueFilters | undefined;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
|
||||
updateFilterExpression: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
filters: TWorkItemFilterExpression
|
||||
) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate,
|
||||
cycleId: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleIssuesFilter {
|
||||
// observables
|
||||
filters: { [cycleId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new IssueFiltersService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const cycleId = this.rootIssueStore.cycleId;
|
||||
if (!cycleId) return undefined;
|
||||
|
||||
return this.getIssueFilters(cycleId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const cycleId = this.rootIssueStore.cycleId;
|
||||
if (!cycleId) return undefined;
|
||||
|
||||
return this.getAppliedFilters(cycleId);
|
||||
}
|
||||
|
||||
getIssueFilters(cycleId: string) {
|
||||
const displayFilters = this.filters[cycleId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
}
|
||||
|
||||
getAppliedFilters(cycleId: string) {
|
||||
const userFilters = this.getIssueFilters(cycleId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
if (filteredParams.includes("cycle")) filteredParams.splice(filteredParams.indexOf("cycle"), 1);
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
cycleId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
let filterParams = this.getAppliedFilters(cycleId);
|
||||
|
||||
if (!filterParams) {
|
||||
filterParams = {};
|
||||
}
|
||||
filterParams["cycle"] = cycleId;
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
const _filters = await this.issueFilterService.fetchCycleIssueFilters(workspaceSlug, projectId, cycleId);
|
||||
|
||||
const richFilters: TWorkItemFilterExpression = _filters?.rich_filters;
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
|
||||
// fetching the kanban toggle helpers in the local storage
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId) {
|
||||
const _kanbanFilters = this.handleIssuesLocalFilters.get(
|
||||
EIssuesStoreType.CYCLE,
|
||||
workspaceSlug,
|
||||
cycleId,
|
||||
currentUserId
|
||||
);
|
||||
kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || [];
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [cycleId, "richFilters"], richFilters);
|
||||
set(this.filters, [cycleId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [cycleId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [cycleId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: ICycleIssuesFilter["updateFilterExpression"] = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
filters
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [cycleId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.cycleIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation", cycleId);
|
||||
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
|
||||
rich_filters: filters,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: ICycleIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, cycleId) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[cycleId])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[cycleId].richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: this.filters[cycleId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[cycleId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[cycleId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to state if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[cycleId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.getShouldClearIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.cycleIssues.clear(true, true); // clear issues for local store when some filters like layout changes
|
||||
}
|
||||
|
||||
if (this.getShouldReFetchIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.cycleIssues.fetchIssuesWithExistingPagination(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
"mutation",
|
||||
cycleId
|
||||
);
|
||||
}
|
||||
|
||||
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[cycleId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.CYCLE, type, workspaceSlug, cycleId, currentUserId, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[cycleId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (cycleId) this.fetchFilters(workspaceSlug, projectId, cycleId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/cycle/index.ts
Normal file
2
apps/web/core/store/issue/cycle/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
437
apps/web/core/store/issue/cycle/issue.store.ts
Normal file
437
apps/web/core/store/issue/cycle/issue.store.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { get, set, concat, uniq, update } from "lodash-es";
|
||||
import { action, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import { ALL_ISSUES } from "@plane/constants";
|
||||
import type {
|
||||
TIssue,
|
||||
TLoader,
|
||||
IssuePaginationOptions,
|
||||
TIssuesResponse,
|
||||
ViewFlags,
|
||||
TBulkOperationsPayload,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { getDistributionPathsPostUpdate } from "@plane/utils";
|
||||
//local
|
||||
import { storage } from "@/lib/local-storage";
|
||||
import { persistence } from "@/local-db/storage.sqlite";
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
//
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { ICycleIssuesFilter } from "./filter.store";
|
||||
|
||||
export const ACTIVE_CYCLE_ISSUES = "ACTIVE_CYCLE_ISSUES";
|
||||
|
||||
export interface ActiveCycleIssueDetails {
|
||||
issueIds: string[];
|
||||
issueCount: number;
|
||||
nextCursor: string;
|
||||
nextPageResults: boolean;
|
||||
perPageCount: number;
|
||||
}
|
||||
|
||||
export interface ICycleIssues extends IBaseIssuesStore {
|
||||
viewFlags: ViewFlags;
|
||||
activeCycleIds: Record<string, ActiveCycleIssueDetails>;
|
||||
//action helpers
|
||||
getActiveCycleById: (cycleId: string) => ActiveCycleIssueDetails | undefined;
|
||||
// actions
|
||||
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined;
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
cycleId: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
cycleId: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
fetchActiveCycleIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
perPageCount: number,
|
||||
cycleId: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextActiveCycleIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>, cycleId: string) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
quickAddIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: TIssue,
|
||||
cycleId: string
|
||||
) => Promise<TIssue | undefined>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
|
||||
transferIssuesFromCycle: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
payload: {
|
||||
new_cycle_id: string;
|
||||
}
|
||||
) => Promise<TIssue>;
|
||||
}
|
||||
|
||||
export class CycleIssues extends BaseIssuesStore implements ICycleIssues {
|
||||
activeCycleIds: Record<string, ActiveCycleIssueDetails> = {};
|
||||
viewFlags = {
|
||||
enableQuickAdd: true,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
// filter store
|
||||
issueFilterStore;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: ICycleIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore);
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
activeCycleIds: observable,
|
||||
// action
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
|
||||
transferIssuesFromCycle: action,
|
||||
fetchActiveCycleIssues: action,
|
||||
|
||||
quickAddIssue: action,
|
||||
});
|
||||
// filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
}
|
||||
|
||||
getActiveCycleById = computedFn((cycleId: string) => this.activeCycleIds[cycleId]);
|
||||
|
||||
/**
|
||||
* Fetches the cycle details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param id is the cycle Id
|
||||
*/
|
||||
fetchParentStats = (workspaceSlug: string, projectId?: string | undefined, id?: string | undefined) => {
|
||||
const cycleId = id ?? this.cycleId;
|
||||
|
||||
if (projectId && cycleId) {
|
||||
this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||
}
|
||||
// fetch cycle progress
|
||||
const isSidebarCollapsed = storage.get("cycle_sidebar_collapsed");
|
||||
if (
|
||||
projectId &&
|
||||
cycleId &&
|
||||
this.rootIssueStore.rootStore.cycle.getCycleById(cycleId)?.version === 2 &&
|
||||
isSidebarCollapsed &&
|
||||
JSON.parse(isSidebarCollapsed) === false
|
||||
) {
|
||||
this.rootIssueStore.rootStore.cycle.fetchActiveCycleProgressPro(workspaceSlug, projectId, cycleId);
|
||||
}
|
||||
};
|
||||
|
||||
updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => {
|
||||
try {
|
||||
const distributionUpdates = getDistributionPathsPostUpdate(
|
||||
prevIssueState,
|
||||
nextIssueState,
|
||||
this.rootIssueStore.rootStore.state.stateMap,
|
||||
this.rootIssueStore.rootStore.projectEstimate?.currentActiveEstimate?.estimatePointById
|
||||
);
|
||||
|
||||
const cycleId = id ?? this.cycleId;
|
||||
if (cycleId) {
|
||||
this.rootIssueStore.rootStore.cycle.updateCycleDistribution(distributionUpdates, cycleId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("could not update cycle statistics");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
cycleId: string,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
this.clear(!isExistingPaginationOptions, false); // clear while fetching from server.
|
||||
if (!this.groupBy) this.clear(!isExistingPaginationOptions, true); // clear while using local to have the no load effect.
|
||||
});
|
||||
|
||||
// get params from pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(options, cycleId, undefined, undefined, undefined);
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, projectId, cycleId, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined once errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
cycleId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
cycleId: string
|
||||
) => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions, cycleId, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Override inherited create issue, to also add issue to cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
override createIssue = async (workspaceSlug: string, projectId: string, data: Partial<TIssue>, cycleId: string) => {
|
||||
const response = await super.createIssue(workspaceSlug, projectId, data, cycleId, false);
|
||||
await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id], false);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is used to transfer issues from completed cycles to a new cycle
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @param payload contains new cycle Id
|
||||
* @returns
|
||||
*/
|
||||
transferIssuesFromCycle = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
payload: {
|
||||
new_cycle_id: string;
|
||||
}
|
||||
) => {
|
||||
// call API call to transfer issues
|
||||
const response = await this.cycleService.transferIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
cycleId as string,
|
||||
payload
|
||||
);
|
||||
// call fetch issues
|
||||
if (this.paginationOptions) {
|
||||
await persistence.syncIssues(projectId.toString());
|
||||
await this.fetchIssues(workspaceSlug, projectId, "mutation", this.paginationOptions, cycleId);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* This is Pagination for active cycle issues
|
||||
* This method is called to fetch the first page of issues pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param perPageCount
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchActiveCycleIssues = async (workspaceSlug: string, projectId: string, perPageCount: number, cycleId: string) => {
|
||||
// set loader
|
||||
set(this.activeCycleIds, [cycleId], undefined);
|
||||
|
||||
// set params for urgent and high
|
||||
const params = { priority: `urgent,high`, cursor: `${perPageCount}:0:0`, per_page: perPageCount };
|
||||
// call the fetch issues API
|
||||
const response = await this.cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, params);
|
||||
|
||||
// Process issue response
|
||||
const { issueList, groupedIssues } = this.processIssueResponse(response);
|
||||
|
||||
// add issues to the main Issue Map
|
||||
this.rootIssueStore.issues.addIssue(issueList);
|
||||
const activeIssueIds = groupedIssues[ALL_ISSUES] as string[];
|
||||
|
||||
// store the processed data in the current store
|
||||
set(this.activeCycleIds, [cycleId], {
|
||||
issueIds: activeIssueIds,
|
||||
issueCount: response.total_count,
|
||||
nextCursor: response.next_cursor,
|
||||
nextPageResults: response.next_page_results,
|
||||
perPageCount: perPageCount,
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* This is Pagination for active cycle issues
|
||||
* This method is called subsequent pages of pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextActiveCycleIssues = async (workspaceSlug: string, projectId: string, cycleId: string) => {
|
||||
//get the previous pagination data for the cycle id
|
||||
const activeCycle = get(this.activeCycleIds, [cycleId]);
|
||||
|
||||
// if there is no active cycle and the next pages does not exist return
|
||||
if (!activeCycle || !activeCycle.nextPageResults) return;
|
||||
|
||||
// create params
|
||||
const params = { priority: `urgent,high`, cursor: activeCycle.nextCursor, per_page: activeCycle.perPageCount };
|
||||
// fetch API response
|
||||
const response = await this.cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, params);
|
||||
|
||||
// Process the response
|
||||
const { issueList, groupedIssues } = this.processIssueResponse(response);
|
||||
|
||||
// add issues to main issue Map
|
||||
this.rootIssueStore.issues.addIssue(issueList);
|
||||
|
||||
const activeIssueIds = groupedIssues[ALL_ISSUES] as string[];
|
||||
|
||||
// store the processed data for subsequent pages
|
||||
set(this.activeCycleIds, [cycleId, "issueCount"], response.total_count);
|
||||
set(this.activeCycleIds, [cycleId, "nextCursor"], response.next_cursor);
|
||||
set(this.activeCycleIds, [cycleId, "nextPageResults"], response.next_page_results);
|
||||
set(this.activeCycleIds, [cycleId, "issueCount"], response.total_count);
|
||||
update(this.activeCycleIds, [cycleId, "issueIds"], (issueIds: string[] = []) =>
|
||||
this.issuesSortWithOrderBy(uniq(concat(issueIds, activeIssueIds)), this.orderBy)
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method overrides the base quickAdd issue
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue, cycleId: string) => {
|
||||
// add temporary issue to store list
|
||||
this.addIssue(data);
|
||||
|
||||
// call overridden create issue
|
||||
const response = await this.createIssue(workspaceSlug, projectId, data, cycleId);
|
||||
|
||||
// remove temp Issue from store list
|
||||
runInAction(() => {
|
||||
this.removeIssueFromList(data.id);
|
||||
this.rootIssueStore.issues.removeIssue(data.id);
|
||||
});
|
||||
|
||||
const currentModuleIds =
|
||||
data.module_ids && data.module_ids.length > 0 ? data.module_ids.filter((moduleId) => moduleId != "None") : [];
|
||||
|
||||
if (currentModuleIds.length > 0) {
|
||||
await this.changeModulesInIssue(workspaceSlug, projectId, response.id, currentModuleIds, []);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Using aliased names as they cannot be overridden in other stores
|
||||
archiveBulkIssues = this.bulkArchiveIssues;
|
||||
updateIssue = this.issueUpdate;
|
||||
archiveIssue = this.issueArchive;
|
||||
}
|
||||
385
apps/web/core/store/issue/helpers/base-issues-utils.ts
Normal file
385
apps/web/core/store/issue/helpers/base-issues-utils.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { uniq, orderBy, isEmpty, indexOf, groupBy, cloneDeep, set } from "lodash-es";
|
||||
import { ALL_ISSUES, EIssueFilterType, FILTER_TO_ISSUE_MAP, ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
ISubWorkItemFilters,
|
||||
TIssue,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
} from "@plane/types";
|
||||
import { checkDateCriteria, convertToISODateString, parseDateFilter } from "@plane/utils";
|
||||
import { store } from "@/lib/store-context";
|
||||
import { EIssueGroupedAction, ISSUE_GROUP_BY_KEY } from "./base-issues.store";
|
||||
|
||||
/**
|
||||
* returns,
|
||||
* A compound key, if both groupId & subGroupId are defined
|
||||
* groupId, only if groupId is defined
|
||||
* ALL_ISSUES, if both groupId & subGroupId are not defined
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
export const getGroupKey = (groupId?: string, subGroupId?: string) => {
|
||||
if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`;
|
||||
|
||||
if (groupId) return groupId;
|
||||
|
||||
return ALL_ISSUES;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns the issue key actions for based on the difference in issue properties of grouped values
|
||||
* @param addArray Array of groupIds at which the issue needs to be added
|
||||
* @param deleteArray Array of groupIds at which the issue needs to be deleted
|
||||
* @returns an array of objects that contains the issue Path at which it needs to be updated and the action that needs to be performed at the path as well
|
||||
*/
|
||||
export const getGroupIssueKeyActions = (
|
||||
addArray: string[],
|
||||
deleteArray: string[]
|
||||
): { path: string[]; action: EIssueGroupedAction }[] => {
|
||||
const issueKeyActions = [];
|
||||
|
||||
// Add all the groupIds as IssueKey and action as Add
|
||||
for (const addKey of addArray) {
|
||||
issueKeyActions.push({ path: [addKey], action: EIssueGroupedAction.ADD });
|
||||
}
|
||||
|
||||
// Add all the groupIds as IssueKey and action as Delete
|
||||
for (const deleteKey of deleteArray) {
|
||||
issueKeyActions.push({ path: [deleteKey], action: EIssueGroupedAction.DELETE });
|
||||
}
|
||||
|
||||
return issueKeyActions;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns the issue key actions for based on the difference in issue properties of grouped and subGrouped values
|
||||
* @param groupActionsArray Addition and Deletion arrays of groupIds at which the issue needs to be added and deleted
|
||||
* @param subGroupActionsArray Addition and Deletion arrays of subGroupIds at which the issue needs to be added and deleted
|
||||
* @param previousIssueGroupProperties previous value of the issue property that on which grouping is dependent on
|
||||
* @param currentIssueGroupProperties current value of the issue property that on which grouping is dependent on
|
||||
* @param previousIssueSubGroupProperties previous value of the issue property that on which subGrouping is dependent on
|
||||
* @param currentIssueSubGroupProperties current value of the issue property that on which subGrouping is dependent on
|
||||
* @returns an array of objects that contains the issue Path at which it needs to be updated and the action that needs to be performed at the path as well
|
||||
*/
|
||||
export const getSubGroupIssueKeyActions = (
|
||||
groupActionsArray: {
|
||||
[EIssueGroupedAction.ADD]: string[];
|
||||
[EIssueGroupedAction.DELETE]: string[];
|
||||
},
|
||||
subGroupActionsArray: {
|
||||
[EIssueGroupedAction.ADD]: string[];
|
||||
[EIssueGroupedAction.DELETE]: string[];
|
||||
},
|
||||
previousIssueGroupProperties: string[],
|
||||
currentIssueGroupProperties: string[],
|
||||
previousIssueSubGroupProperties: string[],
|
||||
currentIssueSubGroupProperties: string[]
|
||||
): { path: string[]; action: EIssueGroupedAction }[] => {
|
||||
const issueKeyActions: { [key: string]: { path: string[]; action: EIssueGroupedAction } } = {};
|
||||
|
||||
// For every groupId path for issue Id List, that needs to be added,
|
||||
// It needs to be added at all the current Issue Properties that on which subGrouping depends on
|
||||
for (const addKey of groupActionsArray[EIssueGroupedAction.ADD]) {
|
||||
for (const subGroupProperty of currentIssueSubGroupProperties) {
|
||||
issueKeyActions[getGroupKey(addKey, subGroupProperty)] = {
|
||||
path: [addKey, subGroupProperty],
|
||||
action: EIssueGroupedAction.ADD,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For every groupId path for issue Id List, that needs to be deleted,
|
||||
// It needs to be deleted at all the previous Issue Properties that on which subGrouping depends on
|
||||
for (const deleteKey of groupActionsArray[EIssueGroupedAction.DELETE]) {
|
||||
for (const subGroupProperty of previousIssueSubGroupProperties) {
|
||||
issueKeyActions[getGroupKey(deleteKey, subGroupProperty)] = {
|
||||
path: [deleteKey, subGroupProperty],
|
||||
action: EIssueGroupedAction.DELETE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For every subGroupId path for issue Id List, that needs to be added,
|
||||
// It needs to be added at all the current Issue Properties that on which grouping depends on
|
||||
for (const addKey of subGroupActionsArray[EIssueGroupedAction.ADD]) {
|
||||
for (const groupProperty of currentIssueGroupProperties) {
|
||||
issueKeyActions[getGroupKey(groupProperty, addKey)] = {
|
||||
path: [groupProperty, addKey],
|
||||
action: EIssueGroupedAction.ADD,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For every subGroupId path for issue Id List, that needs to be deleted,
|
||||
// It needs to be deleted at all the previous Issue Properties that on which grouping depends on
|
||||
for (const deleteKey of subGroupActionsArray[EIssueGroupedAction.DELETE]) {
|
||||
for (const groupProperty of previousIssueGroupProperties) {
|
||||
issueKeyActions[getGroupKey(groupProperty, deleteKey)] = {
|
||||
path: [groupProperty, deleteKey],
|
||||
action: EIssueGroupedAction.DELETE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(issueKeyActions);
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method is used to get the difference between two arrays
|
||||
* @param current
|
||||
* @param previous
|
||||
* @param action
|
||||
* @returns returns two arrays, ADD and DELETE.
|
||||
* Whatever is newly added to current is added to ADD array
|
||||
* Whatever is removed from previous is added to DELETE array
|
||||
*/
|
||||
export const getDifference = (
|
||||
current: string[],
|
||||
previous: string[],
|
||||
action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE
|
||||
): { [EIssueGroupedAction.ADD]: string[]; [EIssueGroupedAction.DELETE]: string[] } => {
|
||||
const ADD = [];
|
||||
const DELETE = [];
|
||||
|
||||
// For all the current issues values that are not in the previous array, Add them to the ADD array
|
||||
for (const currentValue of current) {
|
||||
if (previous.includes(currentValue)) continue;
|
||||
ADD.push(currentValue);
|
||||
}
|
||||
|
||||
// For all the previous issues values that are not in the current array, Add them to the ADD array
|
||||
for (const previousValue of previous) {
|
||||
if (current.includes(previousValue)) continue;
|
||||
DELETE.push(previousValue);
|
||||
}
|
||||
|
||||
// if there are no action provided, return the arrays
|
||||
if (!action) return { [EIssueGroupedAction.ADD]: ADD, [EIssueGroupedAction.DELETE]: DELETE };
|
||||
|
||||
// If there is an action provided, return the values of both arrays under that array
|
||||
if (action === EIssueGroupedAction.ADD)
|
||||
return { [EIssueGroupedAction.ADD]: uniq([...ADD]), [EIssueGroupedAction.DELETE]: [] };
|
||||
else return { [EIssueGroupedAction.DELETE]: uniq([...DELETE]), [EIssueGroupedAction.ADD]: [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method is mainly used to filter out empty values in the beginning
|
||||
* @param key key of the value that is to be checked if empty
|
||||
* @param object any object in which the key's value is to be checked
|
||||
* @returns 1 if empty, 0 if not empty
|
||||
*/
|
||||
export const getSortOrderToFilterEmptyValues = (key: string, object: any) => {
|
||||
const value = object?.[key];
|
||||
|
||||
if (typeof value !== "number" && isEmpty(value)) return 1;
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
// get IssueIds from Issue data List
|
||||
export const getIssueIds = (issues: TIssue[]) => issues.map((issue) => issue?.id);
|
||||
|
||||
/**
|
||||
* Checks if an issue meets the date filter criteria
|
||||
* @param issue The issue to check
|
||||
* @param filterKey The date field to check ('start_date' or 'target_date')
|
||||
* @param dateFilters Array of date filter strings
|
||||
* @returns boolean indicating if the issue meets the date criteria
|
||||
*/
|
||||
export const checkIssueDateFilter = (
|
||||
issue: TIssue,
|
||||
filterKey: "start_date" | "target_date",
|
||||
dateFilters: string[]
|
||||
): boolean => {
|
||||
if (!dateFilters || dateFilters.length === 0) return true;
|
||||
|
||||
const issueDate = issue[filterKey];
|
||||
if (!issueDate) return false;
|
||||
|
||||
// Issue should match all the date filters (AND operation)
|
||||
return dateFilters.every((filterValue) => {
|
||||
const parsed = parseDateFilter(filterValue);
|
||||
if (!parsed?.date || !parsed?.type) {
|
||||
// ignore invalid filter instead of failing the whole evaluation
|
||||
console.warn(`[filters] Ignoring unparsable date filter "${filterValue}"`);
|
||||
return true;
|
||||
}
|
||||
return checkDateCriteria(new Date(issueDate), parsed.date, parsed.type);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper method to get previous issues state
|
||||
* @param issues - The array of issues to get the previous state for.
|
||||
* @returns The previous state of the issues.
|
||||
*/
|
||||
export const getPreviousIssuesState = (issues: TIssue[]) => {
|
||||
const issueIds = issues.map((issue) => issue.id);
|
||||
const issuesPreviousState: Record<string, TIssue> = {};
|
||||
issueIds.forEach((issueId) => {
|
||||
if (store.issue.issues.issuesMap[issueId]) {
|
||||
issuesPreviousState[issueId] = cloneDeep(store.issue.issues.issuesMap[issueId]);
|
||||
}
|
||||
});
|
||||
return issuesPreviousState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters the given work items based on the provided filters and display filters.
|
||||
* @param work items - The array of work items to be filtered.
|
||||
* @param filters - The filters to be applied to the issues.
|
||||
* @param displayFilters - The display filters to be applied to the issues.
|
||||
* @returns The filtered array of issues.
|
||||
*/
|
||||
export const getFilteredWorkItems = (workItems: TIssue[], filters: IIssueFilterOptions | undefined): TIssue[] => {
|
||||
if (!filters) return workItems;
|
||||
// Get all active filters
|
||||
const activeFilters = Object.entries(filters).filter(([, value]) => value && value.length > 0);
|
||||
// If no active filters, return all issues
|
||||
if (activeFilters.length === 0) {
|
||||
return workItems;
|
||||
}
|
||||
|
||||
return workItems.filter((workItem) =>
|
||||
// Check all filter conditions (AND operation between different filters)
|
||||
activeFilters.every(([filterKey, filterValues]) => {
|
||||
// Handle date filters separately
|
||||
if (filterKey === "start_date" || filterKey === "target_date") {
|
||||
return checkIssueDateFilter(workItem, filterKey as "start_date" | "target_date", filterValues as string[]);
|
||||
}
|
||||
// Handle regular filters
|
||||
const issueKey = FILTER_TO_ISSUE_MAP[filterKey as keyof IIssueFilterOptions];
|
||||
if (!issueKey) return true; // Skip if no mapping exists
|
||||
const issueValue = workItem[issueKey as keyof TIssue];
|
||||
// Handle array-based properties vs single value properties
|
||||
if (Array.isArray(issueValue)) {
|
||||
return filterValues!.some((filterValue: any) => issueValue.includes(filterValue));
|
||||
} else {
|
||||
return filterValues!.includes(issueValue as string);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Orders the given work items based on the provided order by key.
|
||||
* @param workItems - The array of work items to be ordered.
|
||||
* @param orderByKey - The key to order the issues by.
|
||||
* @returns The ordered array of work items.
|
||||
*/
|
||||
export const getOrderedWorkItems = (workItems: TIssue[], orderByKey: TIssueOrderByOptions): string[] => {
|
||||
switch (orderByKey) {
|
||||
case "-updated_at":
|
||||
return getIssueIds(orderBy(workItems, (item) => convertToISODateString(item["updated_at"]), ["desc"]));
|
||||
|
||||
case "-created_at":
|
||||
return getIssueIds(orderBy(workItems, (item) => convertToISODateString(item["created_at"]), ["desc"]));
|
||||
|
||||
case "-start_date":
|
||||
return getIssueIds(
|
||||
orderBy(
|
||||
workItems,
|
||||
[getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below
|
||||
["asc", "desc"]
|
||||
)
|
||||
);
|
||||
|
||||
case "-priority": {
|
||||
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
|
||||
return getIssueIds(
|
||||
orderBy(workItems, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority), ["asc"])
|
||||
);
|
||||
}
|
||||
default:
|
||||
return getIssueIds(workItems);
|
||||
}
|
||||
};
|
||||
|
||||
export const getGroupedWorkItemIds = (
|
||||
workItems: TIssue[],
|
||||
groupByKey?: TIssueGroupByOptions,
|
||||
orderByKey: TIssueOrderByOptions = "-created_at"
|
||||
): Record<string, string[]> => {
|
||||
// If group by is not set set default as ALL ISSUES
|
||||
if (!groupByKey) {
|
||||
return {
|
||||
[ALL_ISSUES]: getOrderedWorkItems(workItems, orderByKey),
|
||||
};
|
||||
}
|
||||
|
||||
// Get the default key for the group by key
|
||||
const getDefaultGroupKey = (groupByKey: TIssueGroupByOptions) => {
|
||||
switch (groupByKey) {
|
||||
case "state_detail.group":
|
||||
return "state__group";
|
||||
case null:
|
||||
return null;
|
||||
default:
|
||||
return ISSUE_GROUP_BY_KEY[groupByKey];
|
||||
}
|
||||
};
|
||||
|
||||
// Group work items
|
||||
const groupKey = getDefaultGroupKey(groupByKey);
|
||||
const groupedWorkItems = groupBy(workItems, (item) => {
|
||||
const value = groupKey ? item[groupKey] : null;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "None";
|
||||
// Sort & join to build deterministic set-like key
|
||||
return value.slice().sort().join(",");
|
||||
}
|
||||
return value ?? "None";
|
||||
});
|
||||
|
||||
// Convert to Record type
|
||||
const groupedWorkItemsRecord: Record<string, string[]> = {};
|
||||
Object.entries(groupedWorkItems).forEach(([key, items]) => {
|
||||
groupedWorkItemsRecord[key] = getOrderedWorkItems(items as TIssue[], orderByKey);
|
||||
});
|
||||
|
||||
return groupedWorkItemsRecord;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the filters for a given work item.
|
||||
* @param filtersMap - The map of filters for the work item.
|
||||
* @param filterType - The type of filter to update.
|
||||
* @param filters - The filters to update.
|
||||
* @param workItemId - The ID of the work item to update.
|
||||
*/
|
||||
export const updateSubWorkItemFilters = (
|
||||
filtersMap: Record<string, Partial<ISubWorkItemFilters>>,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||
workItemId: string
|
||||
) => {
|
||||
const existingFilters = filtersMap[workItemId] ?? {};
|
||||
const _filters = {
|
||||
filters: existingFilters.filters,
|
||||
displayFilters: existingFilters.displayFilters,
|
||||
displayProperties: existingFilters.displayProperties,
|
||||
};
|
||||
|
||||
switch (filterType) {
|
||||
case EIssueFilterType.FILTERS: {
|
||||
const updatedFilters = filters as IIssueFilterOptions;
|
||||
_filters.filters = { ..._filters.filters, ...updatedFilters };
|
||||
set(filtersMap, [workItemId, "filters"], { ..._filters.filters, ...updatedFilters });
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
set(filtersMap, [workItemId, "displayFilters"], { ..._filters.displayFilters, ...filters });
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES:
|
||||
set(filtersMap, [workItemId, "displayProperties"], {
|
||||
..._filters.displayProperties,
|
||||
...filters,
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
1977
apps/web/core/store/issue/helpers/base-issues.store.ts
Normal file
1977
apps/web/core/store/issue/helpers/base-issues.store.ts
Normal file
File diff suppressed because it is too large
Load Diff
340
apps/web/core/store/issue/helpers/issue-filter-helper.store.ts
Normal file
340
apps/web/core/store/issue/helpers/issue-filter-helper.store.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { isEmpty } from "lodash-es";
|
||||
// plane constants
|
||||
import type { EIssueFilterType } from "@plane/constants";
|
||||
import {
|
||||
EIssueGroupByToServerOptions,
|
||||
EServerGroupByToFilterOptions,
|
||||
ENABLE_ISSUE_DEPENDENCIES,
|
||||
} from "@plane/constants";
|
||||
import type {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
IIssueFiltersResponse,
|
||||
IssuePaginationOptions,
|
||||
TIssueKanbanFilters,
|
||||
TIssueParams,
|
||||
TStaticViewTypes,
|
||||
TWorkItemFilterExpression,
|
||||
} from "@plane/types";
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
// helpers
|
||||
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@plane/utils";
|
||||
// lib
|
||||
import { storage } from "@/lib/local-storage";
|
||||
|
||||
interface ILocalStoreIssueFilters {
|
||||
key: EIssuesStoreType;
|
||||
workspaceSlug: string;
|
||||
viewId: string | undefined; // It can be projectId, moduleId, cycleId, projectViewId
|
||||
userId: string | undefined;
|
||||
filters: IIssueFilters;
|
||||
}
|
||||
|
||||
export interface IBaseIssueFilterStore {
|
||||
// observables
|
||||
filters: Record<string, IIssueFilters>;
|
||||
//computed
|
||||
appliedFilters: Partial<Record<TIssueParams, string | boolean>> | undefined;
|
||||
issueFilters: IIssueFilters | undefined;
|
||||
}
|
||||
|
||||
export interface IIssueFilterHelperStore {
|
||||
computedIssueFilters(filters: IIssueFilters): IIssueFilters;
|
||||
computedFilteredParams(
|
||||
richFilters: TWorkItemFilterExpression,
|
||||
displayFilters: IIssueDisplayFilterOptions | undefined,
|
||||
acceptableParamsByLayout: TIssueParams[]
|
||||
): Partial<Record<TIssueParams, string | boolean>>;
|
||||
computedFilters(filters: IIssueFilterOptions): IIssueFilterOptions;
|
||||
getFilterConditionBasedOnViews: (
|
||||
currentUserId: string | undefined,
|
||||
type: TStaticViewTypes
|
||||
) => Partial<Record<TIssueParams, string>> | undefined;
|
||||
computedDisplayFilters(
|
||||
displayFilters: IIssueDisplayFilterOptions,
|
||||
defaultValues?: IIssueDisplayFilterOptions
|
||||
): IIssueDisplayFilterOptions;
|
||||
computedDisplayProperties(filters: IIssueDisplayProperties): IIssueDisplayProperties;
|
||||
}
|
||||
|
||||
export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* @description This method is used to apply the display filters on the issues
|
||||
* @param {IIssueFilters} filters
|
||||
* @returns {IIssueFilters}
|
||||
*/
|
||||
computedIssueFilters = (filters: IIssueFilters): IIssueFilters => ({
|
||||
richFilters: isEmpty(filters?.richFilters) ? {} : filters?.richFilters,
|
||||
displayFilters: isEmpty(filters?.displayFilters) ? undefined : filters?.displayFilters,
|
||||
displayProperties: isEmpty(filters?.displayProperties) ? undefined : filters?.displayProperties,
|
||||
kanbanFilters: isEmpty(filters?.kanbanFilters) ? undefined : filters?.kanbanFilters,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method is used to convert the filters array params to string params
|
||||
* @param {TWorkItemFilterExpression} richFilters
|
||||
* @param {IIssueDisplayFilterOptions} displayFilters
|
||||
* @param {string[]} acceptableParamsByLayout
|
||||
* @returns {Partial<Record<TIssueParams, string | boolean>>}
|
||||
*/
|
||||
computedFilteredParams = (
|
||||
richFilters: TWorkItemFilterExpression,
|
||||
displayFilters: IIssueDisplayFilterOptions | undefined,
|
||||
acceptableParamsByLayout: TIssueParams[]
|
||||
): Partial<Record<TIssueParams, string | boolean>> => {
|
||||
const computedDisplayFilters: Partial<Record<TIssueParams, undefined | string[] | boolean | string>> = {
|
||||
group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined,
|
||||
sub_group_by: displayFilters?.sub_group_by
|
||||
? EIssueGroupByToServerOptions[displayFilters.sub_group_by]
|
||||
: undefined,
|
||||
order_by: displayFilters?.order_by || undefined,
|
||||
sub_issue: displayFilters?.sub_issue ?? true,
|
||||
};
|
||||
|
||||
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
|
||||
Object.keys(computedDisplayFilters).forEach((key) => {
|
||||
const _key = key as TIssueParams;
|
||||
const _value: string | boolean | string[] | undefined = computedDisplayFilters[_key];
|
||||
const nonEmptyArrayValue = Array.isArray(_value) && _value.length === 0 ? undefined : _value;
|
||||
if (nonEmptyArrayValue != undefined && acceptableParamsByLayout.includes(_key))
|
||||
issueFiltersParams[_key] = Array.isArray(nonEmptyArrayValue)
|
||||
? nonEmptyArrayValue.join(",")
|
||||
: nonEmptyArrayValue;
|
||||
});
|
||||
|
||||
// work item filters
|
||||
if (richFilters) issueFiltersParams.filters = JSON.stringify(richFilters);
|
||||
|
||||
if (displayFilters?.layout) issueFiltersParams.layout = displayFilters?.layout;
|
||||
|
||||
if (ENABLE_ISSUE_DEPENDENCIES && displayFilters?.layout === EIssueLayoutTypes.GANTT)
|
||||
issueFiltersParams["expand"] = "issue_relation,issue_related";
|
||||
|
||||
return issueFiltersParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to apply the filters on the issues
|
||||
* @param {IIssueFilterOptions} filters
|
||||
* @returns {IIssueFilterOptions}
|
||||
*/
|
||||
computedFilters = (filters: IIssueFilterOptions): IIssueFilterOptions => ({
|
||||
priority: filters?.priority || null,
|
||||
state: filters?.state || null,
|
||||
state_group: filters?.state_group || null,
|
||||
assignees: filters?.assignees || null,
|
||||
mentions: filters?.mentions || null,
|
||||
created_by: filters?.created_by || null,
|
||||
labels: filters?.labels || null,
|
||||
cycle: filters?.cycle || null,
|
||||
module: filters?.module || null,
|
||||
start_date: filters?.start_date || null,
|
||||
target_date: filters?.target_date || null,
|
||||
project: filters?.project || null,
|
||||
team_project: filters?.team_project || null,
|
||||
subscriber: filters?.subscriber || null,
|
||||
issue_type: filters?.issue_type || null,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method is used to get the filter conditions based on the views
|
||||
* @param currentUserId
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
getFilterConditionBasedOnViews: IIssueFilterHelperStore["getFilterConditionBasedOnViews"] = (currentUserId, type) => {
|
||||
if (!currentUserId) return undefined;
|
||||
switch (type) {
|
||||
case "assigned":
|
||||
return {
|
||||
assignees: currentUserId,
|
||||
};
|
||||
case "created":
|
||||
return {
|
||||
created_by: currentUserId,
|
||||
};
|
||||
case "subscribed":
|
||||
return {
|
||||
subscriber: currentUserId,
|
||||
};
|
||||
case "all-issues":
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to apply the display filters on the issues
|
||||
* @param {IIssueDisplayFilterOptions} displayFilters
|
||||
* @returns {IIssueDisplayFilterOptions}
|
||||
*/
|
||||
computedDisplayFilters = (
|
||||
displayFilters: IIssueDisplayFilterOptions,
|
||||
defaultValues?: IIssueDisplayFilterOptions
|
||||
): IIssueDisplayFilterOptions => getComputedDisplayFilters(displayFilters, defaultValues);
|
||||
|
||||
/**
|
||||
* @description This method is used to apply the display properties on the issues
|
||||
* @param {IIssueDisplayProperties} displayProperties
|
||||
* @returns {IIssueDisplayProperties}
|
||||
*/
|
||||
computedDisplayProperties = (displayProperties: IIssueDisplayProperties): IIssueDisplayProperties =>
|
||||
getComputedDisplayProperties(displayProperties);
|
||||
|
||||
handleIssuesLocalFilters = {
|
||||
fetchFiltersFromStorage: () => {
|
||||
const _filters = storage.get("issue_local_filters");
|
||||
return _filters ? JSON.parse(_filters) : [];
|
||||
},
|
||||
|
||||
get: (
|
||||
currentView: EIssuesStoreType,
|
||||
workspaceSlug: string,
|
||||
viewId: string | undefined, // It can be projectId, moduleId, cycleId, projectViewId
|
||||
userId: string | undefined
|
||||
) => {
|
||||
const storageFilters = this.handleIssuesLocalFilters.fetchFiltersFromStorage();
|
||||
const currentFilterIndex = storageFilters.findIndex(
|
||||
(filter: ILocalStoreIssueFilters) =>
|
||||
filter.key === currentView &&
|
||||
filter.workspaceSlug === workspaceSlug &&
|
||||
filter.viewId === viewId &&
|
||||
filter.userId === userId
|
||||
);
|
||||
if (!currentFilterIndex && currentFilterIndex.length < 0) return undefined;
|
||||
|
||||
return storageFilters[currentFilterIndex]?.filters || {};
|
||||
},
|
||||
|
||||
set: (
|
||||
currentView: EIssuesStoreType,
|
||||
filterType: EIssueFilterType,
|
||||
workspaceSlug: string,
|
||||
viewId: string | undefined, // It can be projectId, moduleId, cycleId, projectViewId
|
||||
userId: string | undefined,
|
||||
filters: Partial<IIssueFiltersResponse & { kanban_filters: TIssueKanbanFilters }>
|
||||
) => {
|
||||
const storageFilters = this.handleIssuesLocalFilters.fetchFiltersFromStorage();
|
||||
const currentFilterIndex = storageFilters.findIndex(
|
||||
(filter: ILocalStoreIssueFilters) =>
|
||||
filter.key === currentView &&
|
||||
filter.workspaceSlug === workspaceSlug &&
|
||||
filter.viewId === viewId &&
|
||||
filter.userId === userId
|
||||
);
|
||||
|
||||
if (currentFilterIndex < 0)
|
||||
storageFilters.push({
|
||||
key: currentView,
|
||||
workspaceSlug: workspaceSlug,
|
||||
viewId: viewId,
|
||||
userId: userId,
|
||||
filters: filters,
|
||||
});
|
||||
else
|
||||
storageFilters[currentFilterIndex] = {
|
||||
...storageFilters[currentFilterIndex],
|
||||
filters: {
|
||||
...storageFilters[currentFilterIndex].filters,
|
||||
[filterType]: filters[filterType as keyof IIssueFiltersResponse],
|
||||
},
|
||||
};
|
||||
// All group_by "filters" are stored in a single array, will cause inconsistency in case of duplicated values
|
||||
storage.set("issue_local_filters", JSON.stringify(storageFilters));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method returns true if the display properties changed requires a server side update
|
||||
* @param displayFilters
|
||||
* @returns
|
||||
*/
|
||||
getShouldReFetchIssues = (displayFilters: IIssueDisplayFilterOptions) => {
|
||||
const NON_SERVER_DISPLAY_FILTERS = ["order_by", "sub_issue", "type"];
|
||||
const displayFilterKeys = Object.keys(displayFilters);
|
||||
|
||||
return NON_SERVER_DISPLAY_FILTERS.some((serverDisplayfilter: string) =>
|
||||
displayFilterKeys.includes(serverDisplayfilter)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method returns true if the display properties changed requires a server side update
|
||||
* @param displayFilters
|
||||
* @returns
|
||||
*/
|
||||
getShouldClearIssues = (displayFilters: IIssueDisplayFilterOptions) => {
|
||||
const NON_SERVER_DISPLAY_FILTERS = ["layout"];
|
||||
const displayFilterKeys = Object.keys(displayFilters);
|
||||
|
||||
return NON_SERVER_DISPLAY_FILTERS.some((serverDisplayfilter: string) =>
|
||||
displayFilterKeys.includes(serverDisplayfilter)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method is used to construct the url params along with paginated values
|
||||
* @param filterParams params generated from filters
|
||||
* @param options pagination options
|
||||
* @param cursor cursor if exists
|
||||
* @param groupId groupId if to fetch By group
|
||||
* @param subGroupId groupId if to fetch By sub group
|
||||
* @returns
|
||||
*/
|
||||
getPaginationParams(
|
||||
filterParams: Partial<Record<TIssueParams, string | boolean>> | undefined,
|
||||
options: IssuePaginationOptions,
|
||||
cursor: string | undefined,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) {
|
||||
// if cursor exists, use the cursor. If it doesn't exist construct the cursor based on per page count
|
||||
const pageCursor = cursor ? cursor : groupId ? `${options.perPageCount}:1:0` : `${options.perPageCount}:0:0`;
|
||||
|
||||
// pagination params
|
||||
const paginationParams: Partial<Record<TIssueParams, string | boolean>> = {
|
||||
...filterParams,
|
||||
cursor: pageCursor,
|
||||
per_page: options.perPageCount.toString(),
|
||||
};
|
||||
|
||||
// If group by is specifically sent through options, like that for calendar layout, use that to group
|
||||
if (options.groupedBy) {
|
||||
paginationParams.group_by = options.groupedBy;
|
||||
}
|
||||
|
||||
// If before and after dates are sent from option to filter by then, add them to filter the options
|
||||
if (options.after && options.before) {
|
||||
paginationParams["target_date"] = `${options.after};after,${options.before};before`;
|
||||
}
|
||||
|
||||
// If groupId is passed down, add a filter param for that group Id
|
||||
if (groupId) {
|
||||
const groupBy = paginationParams["group_by"] as EIssueGroupByToServerOptions | undefined;
|
||||
delete paginationParams["group_by"];
|
||||
|
||||
if (groupBy) {
|
||||
const groupByFilterOption = EServerGroupByToFilterOptions[groupBy];
|
||||
paginationParams[groupByFilterOption] = groupId;
|
||||
}
|
||||
}
|
||||
|
||||
// If subGroupId is passed down, add a filter param for that subGroup Id
|
||||
if (subGroupId) {
|
||||
const subGroupBy = paginationParams["sub_group_by"] as EIssueGroupByToServerOptions | undefined;
|
||||
delete paginationParams["sub_group_by"];
|
||||
|
||||
if (subGroupBy) {
|
||||
const subGroupByFilterOption = EServerGroupByToFilterOptions[subGroupBy];
|
||||
paginationParams[subGroupByFilterOption] = subGroupId;
|
||||
}
|
||||
}
|
||||
|
||||
return paginationParams;
|
||||
}
|
||||
}
|
||||
202
apps/web/core/store/issue/issue-details/attachment.store.ts
Normal file
202
apps/web/core/store/issue/issue-details/attachment.store.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { uniq, pull, set, debounce, update, concat } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// types
|
||||
import type { TIssueAttachment, TIssueAttachmentMap, TIssueAttachmentIdMap, TIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import { IssueAttachmentService } from "@/services/issue";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
|
||||
export type TAttachmentUploadStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
size: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export interface IIssueAttachmentStoreActions {
|
||||
// actions
|
||||
addAttachments: (issueId: string, attachments: TIssueAttachment[]) => void;
|
||||
fetchAttachments: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssueAttachment[]>;
|
||||
createAttachment: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
file: File
|
||||
) => Promise<TIssueAttachment>;
|
||||
removeAttachment: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string
|
||||
) => Promise<TIssueAttachment>;
|
||||
}
|
||||
|
||||
export interface IIssueAttachmentStore extends IIssueAttachmentStoreActions {
|
||||
// observables
|
||||
attachments: TIssueAttachmentIdMap;
|
||||
attachmentMap: TIssueAttachmentMap;
|
||||
attachmentsUploadStatusMap: Record<string, Record<string, TAttachmentUploadStatus>>;
|
||||
// computed
|
||||
issueAttachments: string[] | undefined;
|
||||
// helper methods
|
||||
getAttachmentsUploadStatusByIssueId: (issueId: string) => TAttachmentUploadStatus[] | undefined;
|
||||
getAttachmentsByIssueId: (issueId: string) => string[] | undefined;
|
||||
getAttachmentById: (attachmentId: string) => TIssueAttachment | undefined;
|
||||
getAttachmentsCountByIssueId: (issueId: string) => number;
|
||||
}
|
||||
|
||||
export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||
// observables
|
||||
attachments: TIssueAttachmentIdMap = {};
|
||||
attachmentMap: TIssueAttachmentMap = {};
|
||||
attachmentsUploadStatusMap: Record<string, Record<string, TAttachmentUploadStatus>> = {};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
issueAttachmentService;
|
||||
|
||||
constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
attachments: observable,
|
||||
attachmentMap: observable,
|
||||
attachmentsUploadStatusMap: observable,
|
||||
// computed
|
||||
issueAttachments: computed,
|
||||
// actions
|
||||
addAttachments: action.bound,
|
||||
fetchAttachments: action,
|
||||
createAttachment: action,
|
||||
removeAttachment: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = rootStore;
|
||||
this.rootIssueDetailStore = rootStore.issueDetail;
|
||||
// services
|
||||
this.issueAttachmentService = new IssueAttachmentService(serviceType);
|
||||
}
|
||||
|
||||
// computed
|
||||
get issueAttachments() {
|
||||
const issueId = this.rootIssueDetailStore.peekIssue?.issueId;
|
||||
if (!issueId) return undefined;
|
||||
return this.attachments[issueId] ?? undefined;
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getAttachmentsUploadStatusByIssueId = computedFn((issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
const attachmentsUploadStatus = Object.values(this.attachmentsUploadStatusMap[issueId] ?? {});
|
||||
return attachmentsUploadStatus ?? undefined;
|
||||
});
|
||||
|
||||
getAttachmentsByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.attachments[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
getAttachmentById = (attachmentId: string) => {
|
||||
if (!attachmentId) return undefined;
|
||||
return this.attachmentMap[attachmentId] ?? undefined;
|
||||
};
|
||||
|
||||
getAttachmentsCountByIssueId = (issueId: string) => {
|
||||
const attachments = this.getAttachmentsByIssueId(issueId);
|
||||
return attachments?.length ?? 0;
|
||||
};
|
||||
|
||||
// actions
|
||||
addAttachments = (issueId: string, attachments: TIssueAttachment[]) => {
|
||||
if (attachments && attachments.length > 0) {
|
||||
const newAttachmentIds = attachments.map((attachment) => attachment.id);
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, newAttachmentIds)));
|
||||
attachments.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const response = await this.issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
|
||||
this.addAttachments(issueId, response);
|
||||
return response;
|
||||
};
|
||||
|
||||
private debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => {
|
||||
runInAction(() => {
|
||||
set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progress);
|
||||
});
|
||||
}, 16);
|
||||
|
||||
createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => {
|
||||
const tempId = uuidv4();
|
||||
try {
|
||||
// update attachment upload status
|
||||
runInAction(() => {
|
||||
set(this.attachmentsUploadStatusMap, [issueId, tempId], {
|
||||
id: tempId,
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
});
|
||||
const response = await this.issueAttachmentService.uploadIssueAttachment(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
file,
|
||||
(progressEvent) => {
|
||||
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
|
||||
this.debouncedUpdateProgress(issueId, tempId, progressPercentage);
|
||||
}
|
||||
);
|
||||
|
||||
if (response && response.id) {
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
|
||||
set(this.attachmentMap, response.id, response);
|
||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error in uploading issue attachment:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
delete this.attachmentsUploadStatusMap[issueId][tempId];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
|
||||
const response = await this.issueAttachmentService.deleteIssueAttachment(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachmentId
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => {
|
||||
if (attachmentIds.includes(attachmentId)) pull(attachmentIds, attachmentId);
|
||||
return attachmentIds;
|
||||
});
|
||||
delete this.attachmentMap[attachmentId];
|
||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
177
apps/web/core/store/issue/issue-details/comment.store.ts
Normal file
177
apps/web/core/store/issue/issue-details/comment.store.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { pull, concat, update, uniq, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// Plane Imports
|
||||
import type { TIssueComment, TIssueCommentMap, TIssueCommentIdMap, TIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import { IssueCommentService } from "@/services/issue";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
|
||||
export type TCommentLoader = "fetch" | "create" | "update" | "delete" | "mutate" | undefined;
|
||||
|
||||
export interface IIssueCommentStoreActions {
|
||||
fetchComments: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
loaderType?: TCommentLoader
|
||||
) => Promise<TIssueComment[]>;
|
||||
createComment: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: Partial<TIssueComment>
|
||||
) => Promise<any>;
|
||||
updateComment: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
data: Partial<TIssueComment>
|
||||
) => Promise<any>;
|
||||
removeComment: (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface IIssueCommentStore extends IIssueCommentStoreActions {
|
||||
// observables
|
||||
loader: TCommentLoader;
|
||||
comments: TIssueCommentIdMap;
|
||||
commentMap: TIssueCommentMap;
|
||||
// helper methods
|
||||
getCommentsByIssueId: (issueId: string) => string[] | undefined;
|
||||
getCommentById: (activityId: string) => TIssueComment | undefined;
|
||||
}
|
||||
|
||||
export class IssueCommentStore implements IIssueCommentStore {
|
||||
// observables
|
||||
loader: TCommentLoader = "fetch";
|
||||
comments: TIssueCommentIdMap = {};
|
||||
commentMap: TIssueCommentMap = {};
|
||||
serviceType;
|
||||
// root store
|
||||
rootIssueDetail: IIssueDetail;
|
||||
// services
|
||||
issueCommentService;
|
||||
|
||||
constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
comments: observable,
|
||||
commentMap: observable,
|
||||
// actions
|
||||
fetchComments: action,
|
||||
createComment: action,
|
||||
updateComment: action,
|
||||
removeComment: action,
|
||||
});
|
||||
// root store
|
||||
this.serviceType = serviceType;
|
||||
this.rootIssueDetail = rootStore;
|
||||
// services
|
||||
this.issueCommentService = new IssueCommentService(serviceType);
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getCommentsByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.comments[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
getCommentById = (commentId: string) => {
|
||||
if (!commentId) return undefined;
|
||||
return this.commentMap[commentId] ?? undefined;
|
||||
};
|
||||
|
||||
fetchComments = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
loaderType: TCommentLoader = "fetch"
|
||||
) => {
|
||||
this.loader = loaderType;
|
||||
|
||||
let props = {};
|
||||
const _commentIds = this.getCommentsByIssueId(issueId);
|
||||
if (_commentIds && _commentIds.length > 0) {
|
||||
const _comment = this.getCommentById(_commentIds[_commentIds.length - 1]);
|
||||
if (_comment) props = { created_at__gt: _comment.created_at };
|
||||
}
|
||||
|
||||
const comments = await this.issueCommentService.getIssueComments(workspaceSlug, projectId, issueId, props);
|
||||
|
||||
const commentIds = comments.map((comment) => comment.id);
|
||||
runInAction(() => {
|
||||
update(this.comments, issueId, (_commentIds) => {
|
||||
if (!_commentIds) return commentIds;
|
||||
return uniq(concat(_commentIds, commentIds));
|
||||
});
|
||||
comments.forEach((comment) => {
|
||||
this.rootIssueDetail.commentReaction.applyCommentReactions(comment.id, comment?.comment_reactions || []);
|
||||
set(this.commentMap, comment.id, comment);
|
||||
});
|
||||
this.loader = undefined;
|
||||
});
|
||||
|
||||
return comments;
|
||||
};
|
||||
|
||||
createComment = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssueComment>) => {
|
||||
const response = await this.issueCommentService.createIssueComment(workspaceSlug, projectId, issueId, data);
|
||||
|
||||
runInAction(() => {
|
||||
update(this.comments, issueId, (_commentIds) => {
|
||||
if (!_commentIds) return [response.id];
|
||||
return uniq(concat(_commentIds, [response.id]));
|
||||
});
|
||||
set(this.commentMap, response.id, response);
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
updateComment = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
data: Partial<TIssueComment>
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
set(this.commentMap, [commentId, key], data[key as keyof TIssueComment]);
|
||||
});
|
||||
});
|
||||
|
||||
const response = await this.issueCommentService.patchIssueComment(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
commentId,
|
||||
data
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.commentMap, [commentId, "updated_at"], response.updated_at);
|
||||
set(this.commentMap, [commentId, "edited_at"], response.edited_at);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.rootIssueDetail.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeComment = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => {
|
||||
const response = await this.issueCommentService.deleteIssueComment(workspaceSlug, projectId, issueId, commentId);
|
||||
|
||||
runInAction(() => {
|
||||
pull(this.comments[issueId], commentId);
|
||||
delete this.commentMap[commentId];
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { pull, find, concat, update, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// Plane Imports
|
||||
import type { TIssueCommentReaction, TIssueCommentReactionIdMap, TIssueCommentReactionMap } from "@plane/types";
|
||||
import { groupReactions } from "@plane/utils";
|
||||
// services
|
||||
import { IssueReactionService } from "@/services/issue";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
|
||||
export interface IIssueCommentReactionStoreActions {
|
||||
// actions
|
||||
fetchCommentReactions: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string
|
||||
) => Promise<TIssueCommentReaction[]>;
|
||||
applyCommentReactions: (commentId: string, commentReactions: TIssueCommentReaction[]) => void;
|
||||
createCommentReaction: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string,
|
||||
reaction: string
|
||||
) => Promise<any>;
|
||||
removeCommentReaction: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string,
|
||||
reaction: string,
|
||||
userId: string
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface IIssueCommentReactionStore extends IIssueCommentReactionStoreActions {
|
||||
// observables
|
||||
commentReactions: TIssueCommentReactionIdMap;
|
||||
commentReactionMap: TIssueCommentReactionMap;
|
||||
// helper methods
|
||||
getCommentReactionsByCommentId: (commentId: string) => { [reaction_id: string]: string[] } | undefined;
|
||||
getCommentReactionById: (reactionId: string) => TIssueCommentReaction | undefined;
|
||||
commentReactionsByUser: (commentId: string, userId: string) => TIssueCommentReaction[];
|
||||
}
|
||||
|
||||
export class IssueCommentReactionStore implements IIssueCommentReactionStore {
|
||||
// observables
|
||||
commentReactions: TIssueCommentReactionIdMap = {};
|
||||
commentReactionMap: TIssueCommentReactionMap = {};
|
||||
// root store
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
issueReactionService;
|
||||
|
||||
constructor(rootStore: IIssueDetail) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
commentReactions: observable,
|
||||
commentReactionMap: observable,
|
||||
// actions
|
||||
fetchCommentReactions: action,
|
||||
applyCommentReactions: action,
|
||||
createCommentReaction: action,
|
||||
removeCommentReaction: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueDetailStore = rootStore;
|
||||
// services
|
||||
this.issueReactionService = new IssueReactionService();
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getCommentReactionsByCommentId = (commentId: string) => {
|
||||
if (!commentId) return undefined;
|
||||
return this.commentReactions[commentId] ?? undefined;
|
||||
};
|
||||
|
||||
getCommentReactionById = (reactionId: string) => {
|
||||
if (!reactionId) return undefined;
|
||||
return this.commentReactionMap[reactionId] ?? undefined;
|
||||
};
|
||||
|
||||
commentReactionsByUser = (commentId: string, userId: string) => {
|
||||
if (!commentId || !userId) return [];
|
||||
|
||||
const reactions = this.getCommentReactionsByCommentId(commentId);
|
||||
if (!reactions) return [];
|
||||
|
||||
const _userReactions: TIssueCommentReaction[] = [];
|
||||
Object.keys(reactions).forEach((reaction) => {
|
||||
if (reactions?.[reaction])
|
||||
reactions?.[reaction].map((reactionId) => {
|
||||
const currentReaction = this.getCommentReactionById(reactionId);
|
||||
if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction);
|
||||
});
|
||||
});
|
||||
|
||||
return _userReactions;
|
||||
};
|
||||
|
||||
// actions
|
||||
fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) => {
|
||||
try {
|
||||
const response = await this.issueReactionService.listIssueCommentReactions(workspaceSlug, projectId, commentId);
|
||||
|
||||
const groupedReactions = groupReactions(response || [], "reaction");
|
||||
|
||||
const commentReactionIdsMap: { [reaction: string]: string[] } = {};
|
||||
|
||||
Object.keys(groupedReactions).map((reactionId) => {
|
||||
const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id);
|
||||
commentReactionIdsMap[reactionId] = reactionIds;
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
set(this.commentReactions, commentId, commentReactionIdsMap);
|
||||
response.forEach((reaction) => set(this.commentReactionMap, reaction.id, reaction));
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
applyCommentReactions = (commentId: string, commentReactions: TIssueCommentReaction[]) => {
|
||||
const groupedReactions = groupReactions(commentReactions || [], "reaction");
|
||||
|
||||
const commentReactionIdsMap: { [reaction: string]: string[] } = {};
|
||||
|
||||
Object.keys(groupedReactions).map((reactionId) => {
|
||||
const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id);
|
||||
commentReactionIdsMap[reactionId] = reactionIds;
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
set(this.commentReactions, commentId, commentReactionIdsMap);
|
||||
commentReactions.forEach((reaction) => set(this.commentReactionMap, reaction.id, reaction));
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => {
|
||||
try {
|
||||
const response = await this.issueReactionService.createIssueCommentReaction(workspaceSlug, projectId, commentId, {
|
||||
reaction,
|
||||
});
|
||||
|
||||
if (!this.commentReactions[commentId]) this.commentReactions[commentId] = {};
|
||||
runInAction(() => {
|
||||
update(this.commentReactions, `${commentId}.${reaction}`, (reactionId) => {
|
||||
if (!reactionId) return [response.id];
|
||||
return concat(reactionId, response.id);
|
||||
});
|
||||
set(this.commentReactionMap, response.id, response);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeCommentReaction = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string,
|
||||
reaction: string,
|
||||
userId: string
|
||||
) => {
|
||||
try {
|
||||
const userReactions = this.commentReactionsByUser(commentId, userId);
|
||||
const currentReaction = find(userReactions, { actor: userId, reaction: reaction });
|
||||
|
||||
if (currentReaction && currentReaction.id) {
|
||||
runInAction(() => {
|
||||
pull(this.commentReactions[commentId][reaction], currentReaction.id);
|
||||
delete this.commentReactionMap[reaction];
|
||||
});
|
||||
}
|
||||
|
||||
const response = await this.issueReactionService.deleteIssueCommentReaction(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
commentId,
|
||||
reaction
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.fetchCommentReactions(workspaceSlug, projectId, commentId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
352
apps/web/core/store/issue/issue-details/issue.store.ts
Normal file
352
apps/web/core/store/issue/issue-details/issue.store.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { makeObservable, observable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// local
|
||||
import { persistence } from "@/local-db/storage.sqlite";
|
||||
// services
|
||||
import { IssueArchiveService, WorkspaceDraftService, IssueService } from "@/services/issue";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
|
||||
export interface IIssueStoreActions {
|
||||
// actions
|
||||
fetchIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
|
||||
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
changeModulesInIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => Promise<void>;
|
||||
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
|
||||
fetchIssueWithIdentifier: (workspaceSlug: string, project_identifier: string, sequence_id: string) => Promise<TIssue>;
|
||||
}
|
||||
|
||||
export interface IIssueStore extends IIssueStoreActions {
|
||||
getIsFetchingIssueDetails: (issueId: string | undefined) => boolean;
|
||||
getIsLocalDBIssueDescription: (issueId: string | undefined) => boolean;
|
||||
// helper methods
|
||||
getIssueById: (issueId: string) => TIssue | undefined;
|
||||
getIssueIdByIdentifier: (issueIdentifier: string) => string | undefined;
|
||||
}
|
||||
|
||||
export class IssueStore implements IIssueStore {
|
||||
fetchingIssueDetails: string | undefined = undefined;
|
||||
localDBIssueDescription: string | undefined = undefined;
|
||||
// root store
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
serviceType;
|
||||
issueService;
|
||||
epicService;
|
||||
issueArchiveService;
|
||||
draftWorkItemService;
|
||||
|
||||
constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
fetchingIssueDetails: observable.ref,
|
||||
localDBIssueDescription: observable.ref,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueDetailStore = rootStore;
|
||||
// services
|
||||
this.serviceType = serviceType;
|
||||
this.issueService = new IssueService(serviceType);
|
||||
this.epicService = new IssueService(EIssueServiceType.EPICS);
|
||||
this.issueArchiveService = new IssueArchiveService(serviceType);
|
||||
this.draftWorkItemService = new WorkspaceDraftService();
|
||||
}
|
||||
|
||||
getIsFetchingIssueDetails = computedFn((issueId: string | undefined) => {
|
||||
if (!issueId) return false;
|
||||
|
||||
return this.fetchingIssueDetails === issueId;
|
||||
});
|
||||
|
||||
getIsLocalDBIssueDescription = computedFn((issueId: string | undefined) => {
|
||||
if (!issueId) return false;
|
||||
|
||||
return this.localDBIssueDescription === issueId;
|
||||
});
|
||||
|
||||
// helper methods
|
||||
getIssueById = computedFn((issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.rootIssueDetailStore.rootIssueStore.issues.getIssueById(issueId) ?? undefined;
|
||||
});
|
||||
|
||||
getIssueIdByIdentifier = computedFn((issueIdentifier: string) => {
|
||||
if (!issueIdentifier) return undefined;
|
||||
return this.rootIssueDetailStore.rootIssueStore.issues.getIssueIdByIdentifier(issueIdentifier) ?? undefined;
|
||||
});
|
||||
|
||||
// actions
|
||||
fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const query = {
|
||||
expand: "issue_reactions,issue_attachments,issue_link,parent",
|
||||
};
|
||||
|
||||
let issue: TIssue | undefined;
|
||||
|
||||
// fetch issue from local db
|
||||
if (this.serviceType === EIssueServiceType.ISSUES) {
|
||||
issue = await persistence.getIssue(issueId);
|
||||
}
|
||||
|
||||
this.fetchingIssueDetails = issueId;
|
||||
|
||||
if (issue) {
|
||||
this.addIssueToStore(issue);
|
||||
this.localDBIssueDescription = issueId;
|
||||
}
|
||||
|
||||
issue = await this.issueService.retrieve(workspaceSlug, projectId, issueId, query);
|
||||
|
||||
if (!issue) throw new Error("Work item not found");
|
||||
|
||||
const issuePayload = this.addIssueToStore(issue);
|
||||
this.localDBIssueDescription = undefined;
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload]);
|
||||
|
||||
// store handlers from issue detail
|
||||
// parent
|
||||
if (issue && issue?.parent && issue?.parent?.id && issue?.parent?.project_id) {
|
||||
this.issueService.retrieve(workspaceSlug, issue.parent.project_id, issue?.parent?.id).then((res) => {
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([res]);
|
||||
});
|
||||
}
|
||||
// assignees
|
||||
// labels
|
||||
// state
|
||||
|
||||
// issue reactions
|
||||
if (issue.issue_reactions) this.rootIssueDetailStore.addReactions(issueId, issue.issue_reactions);
|
||||
|
||||
// fetch issue links
|
||||
if (issue.issue_link) this.rootIssueDetailStore.addLinks(issueId, issue.issue_link);
|
||||
|
||||
// fetch issue attachments
|
||||
if (issue.issue_attachments) this.rootIssueDetailStore.addAttachments(issueId, issue.issue_attachments);
|
||||
|
||||
this.rootIssueDetailStore.addSubscription(issueId, issue.is_subscribed);
|
||||
|
||||
// fetch issue activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetch issue comments
|
||||
this.rootIssueDetailStore.comment.fetchComments(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetch sub issues
|
||||
this.rootIssueDetailStore.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetch issue relations
|
||||
this.rootIssueDetailStore.relation.fetchRelations(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetching states
|
||||
// TODO: check if this function is required
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.state.fetchProjectStates(workspaceSlug, projectId);
|
||||
|
||||
return issue;
|
||||
};
|
||||
|
||||
addIssueToStore = (issue: TIssue) => {
|
||||
const issuePayload: TIssue = {
|
||||
id: issue?.id,
|
||||
sequence_id: issue?.sequence_id,
|
||||
name: issue?.name,
|
||||
description_html: issue?.description_html,
|
||||
sort_order: issue?.sort_order,
|
||||
state_id: issue?.state_id,
|
||||
priority: issue?.priority,
|
||||
label_ids: issue?.label_ids,
|
||||
assignee_ids: issue?.assignee_ids,
|
||||
estimate_point: issue?.estimate_point,
|
||||
sub_issues_count: issue?.sub_issues_count,
|
||||
attachment_count: issue?.attachment_count,
|
||||
link_count: issue?.link_count,
|
||||
project_id: issue?.project_id,
|
||||
parent_id: issue?.parent_id,
|
||||
cycle_id: issue?.cycle_id,
|
||||
module_ids: issue?.module_ids,
|
||||
type_id: issue?.type_id,
|
||||
created_at: issue?.created_at,
|
||||
updated_at: issue?.updated_at,
|
||||
start_date: issue?.start_date,
|
||||
target_date: issue?.target_date,
|
||||
completed_at: issue?.completed_at,
|
||||
archived_at: issue?.archived_at,
|
||||
created_by: issue?.created_by,
|
||||
updated_by: issue?.updated_by,
|
||||
is_draft: issue?.is_draft,
|
||||
is_subscribed: issue?.is_subscribed,
|
||||
is_epic: issue?.is_epic,
|
||||
};
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload]);
|
||||
this.fetchingIssueDetails = undefined;
|
||||
|
||||
return issuePayload;
|
||||
};
|
||||
|
||||
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
const currentStore =
|
||||
this.serviceType === EIssueServiceType.EPICS
|
||||
? this.rootIssueDetailStore.rootIssueStore.projectEpics
|
||||
: this.rootIssueDetailStore.rootIssueStore.projectIssues;
|
||||
|
||||
await Promise.all([
|
||||
currentStore.updateIssue(workspaceSlug, projectId, issueId, data),
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId),
|
||||
]);
|
||||
};
|
||||
|
||||
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const currentStore =
|
||||
this.serviceType === EIssueServiceType.EPICS
|
||||
? this.rootIssueDetailStore.rootIssueStore.projectEpics
|
||||
: this.rootIssueDetailStore.rootIssueStore.projectIssues;
|
||||
currentStore.removeIssue(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
|
||||
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const currentStore =
|
||||
this.serviceType === EIssueServiceType.EPICS
|
||||
? this.rootIssueDetailStore.rootIssueStore.projectEpics
|
||||
: this.rootIssueDetailStore.rootIssueStore.projectIssues;
|
||||
currentStore.archiveIssue(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
|
||||
addCycleToIssue = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addCycleToIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
issueId
|
||||
);
|
||||
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
|
||||
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||
await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
issueIds,
|
||||
false
|
||||
);
|
||||
if (issueIds && issueIds.length > 0)
|
||||
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]);
|
||||
};
|
||||
|
||||
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
const cycle = await this.rootIssueDetailStore.rootIssueStore.cycleIssues.removeIssueFromCycle(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
issueId
|
||||
);
|
||||
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return cycle;
|
||||
};
|
||||
|
||||
changeModulesInIssue = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => {
|
||||
await this.rootIssueDetailStore.rootIssueStore.moduleIssues.changeModulesInIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
addModuleIds,
|
||||
removeModuleIds
|
||||
);
|
||||
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
|
||||
removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
|
||||
const currentModule = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.removeIssuesFromModule(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
moduleId,
|
||||
[issueId]
|
||||
);
|
||||
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return currentModule;
|
||||
};
|
||||
|
||||
fetchIssueWithIdentifier = async (workspaceSlug: string, project_identifier: string, sequence_id: string) => {
|
||||
const query = {
|
||||
expand: "issue_reactions,issue_attachments,issue_link,parent",
|
||||
};
|
||||
const issue = await this.issueService.retrieveWithIdentifier(workspaceSlug, project_identifier, sequence_id, query);
|
||||
const issueIdentifier = `${project_identifier}-${sequence_id}`;
|
||||
const issueId = issue?.id;
|
||||
const projectId = issue?.project_id;
|
||||
const rootWorkItemDetailStore = issue?.is_epic
|
||||
? this.rootIssueDetailStore.rootIssueStore.epicDetail
|
||||
: this.rootIssueDetailStore.rootIssueStore.issueDetail;
|
||||
|
||||
if (!issue || !projectId || !issueId) throw new Error("Issue not found");
|
||||
|
||||
const issuePayload = this.addIssueToStore(issue);
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload]);
|
||||
|
||||
// handle parent issue if exists
|
||||
if (issue?.parent && issue?.parent?.id && issue?.parent?.project_id) {
|
||||
this.issueService.retrieve(workspaceSlug, issue.parent.project_id, issue.parent.id).then((res) => {
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([res]);
|
||||
});
|
||||
}
|
||||
|
||||
// add identifiers to map
|
||||
rootWorkItemDetailStore.rootIssueStore.issues.addIssueIdentifier(issueIdentifier, issueId);
|
||||
|
||||
// add related data
|
||||
if (issue.issue_reactions) rootWorkItemDetailStore.addReactions(issue.id, issue.issue_reactions);
|
||||
if (issue.issue_link) rootWorkItemDetailStore.addLinks(issue.id, issue.issue_link);
|
||||
if (issue.issue_attachments) rootWorkItemDetailStore.addAttachments(issue.id, issue.issue_attachments);
|
||||
rootWorkItemDetailStore.addSubscription(issue.id, issue.is_subscribed);
|
||||
|
||||
// fetch related data
|
||||
// issue reactions
|
||||
if (issue.issue_reactions) rootWorkItemDetailStore.addReactions(issueId, issue.issue_reactions);
|
||||
|
||||
// fetch issue links
|
||||
if (issue.issue_link) rootWorkItemDetailStore.addLinks(issueId, issue.issue_link);
|
||||
|
||||
// fetch issue attachments
|
||||
if (issue.issue_attachments) rootWorkItemDetailStore.addAttachments(issueId, issue.issue_attachments);
|
||||
|
||||
rootWorkItemDetailStore.addSubscription(issueId, issue.is_subscribed);
|
||||
|
||||
// fetch issue activity
|
||||
rootWorkItemDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetch issue comments
|
||||
rootWorkItemDetailStore.comment.fetchComments(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetch sub issues
|
||||
rootWorkItemDetailStore.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetch issue relations
|
||||
rootWorkItemDetailStore.relation.fetchRelations(workspaceSlug, projectId, issueId);
|
||||
|
||||
// fetching states
|
||||
// TODO: check if this function is required
|
||||
rootWorkItemDetailStore.rootIssueStore.rootStore.state.fetchProjectStates(workspaceSlug, projectId);
|
||||
|
||||
return issue;
|
||||
};
|
||||
}
|
||||
165
apps/web/core/store/issue/issue-details/link.store.ts
Normal file
165
apps/web/core/store/issue/issue-details/link.store.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// services
|
||||
import type { TIssueLink, TIssueLinkMap, TIssueLinkIdMap, TIssueServiceType } from "@plane/types";
|
||||
import { IssueService } from "@/services/issue";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
|
||||
export interface IIssueLinkStoreActions {
|
||||
addLinks: (issueId: string, links: TIssueLink[]) => void;
|
||||
fetchLinks: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssueLink[]>;
|
||||
createLink: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: Partial<TIssueLink>
|
||||
) => Promise<TIssueLink>;
|
||||
updateLink: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
linkId: string,
|
||||
data: Partial<TIssueLink>
|
||||
) => Promise<TIssueLink>;
|
||||
removeLink: (workspaceSlug: string, projectId: string, issueId: string, linkId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IIssueLinkStore extends IIssueLinkStoreActions {
|
||||
// observables
|
||||
links: TIssueLinkIdMap;
|
||||
linkMap: TIssueLinkMap;
|
||||
// computed
|
||||
issueLinks: string[] | undefined;
|
||||
// helper methods
|
||||
getLinksByIssueId: (issueId: string) => string[] | undefined;
|
||||
getLinkById: (linkId: string) => TIssueLink | undefined;
|
||||
}
|
||||
|
||||
export class IssueLinkStore implements IIssueLinkStore {
|
||||
// observables
|
||||
links: TIssueLinkIdMap = {};
|
||||
linkMap: TIssueLinkMap = {};
|
||||
// root store
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
issueService;
|
||||
serviceType;
|
||||
|
||||
constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
links: observable,
|
||||
linkMap: observable,
|
||||
// computed
|
||||
issueLinks: computed,
|
||||
// actions
|
||||
addLinks: action.bound,
|
||||
fetchLinks: action,
|
||||
createLink: action,
|
||||
updateLink: action,
|
||||
removeLink: action,
|
||||
});
|
||||
this.serviceType = serviceType;
|
||||
// root store
|
||||
this.rootIssueDetailStore = rootStore;
|
||||
// services
|
||||
this.issueService = new IssueService(serviceType);
|
||||
}
|
||||
|
||||
// computed
|
||||
get issueLinks() {
|
||||
const issueId = this.rootIssueDetailStore.peekIssue?.issueId;
|
||||
if (!issueId) return undefined;
|
||||
return this.links[issueId] ?? undefined;
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getLinksByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.links[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
getLinkById = (linkId: string) => {
|
||||
if (!linkId) return undefined;
|
||||
return this.linkMap[linkId] ?? undefined;
|
||||
};
|
||||
|
||||
// actions
|
||||
addLinks = (issueId: string, links: TIssueLink[]) => {
|
||||
runInAction(() => {
|
||||
this.links[issueId] = links.map((link) => link.id);
|
||||
links.forEach((link) => set(this.linkMap, link.id, link));
|
||||
});
|
||||
};
|
||||
|
||||
fetchLinks = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const response = await this.issueService.fetchIssueLinks(workspaceSlug, projectId, issueId);
|
||||
this.addLinks(issueId, response);
|
||||
return response;
|
||||
};
|
||||
|
||||
createLink = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssueLink>) => {
|
||||
const response = await this.issueService.createIssueLink(workspaceSlug, projectId, issueId, data);
|
||||
const issueLinkCount = this.getLinksByIssueId(issueId)?.length ?? 0;
|
||||
runInAction(() => {
|
||||
this.links[issueId].push(response.id);
|
||||
set(this.linkMap, response.id, response);
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.updateIssue(issueId, {
|
||||
link_count: issueLinkCount + 1, // increment link count
|
||||
});
|
||||
});
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return response;
|
||||
};
|
||||
|
||||
updateLink = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
linkId: string,
|
||||
data: Partial<TIssueLink>
|
||||
) => {
|
||||
const initialData = { ...this.linkMap[linkId] };
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
set(this.linkMap, [linkId, key], data[key as keyof TIssueLink]);
|
||||
});
|
||||
});
|
||||
|
||||
const response = await this.issueService.updateIssueLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("error", error);
|
||||
runInAction(() => {
|
||||
Object.keys(initialData).forEach((key) => {
|
||||
set(this.linkMap, [linkId, key], initialData[key as keyof TIssueLink]);
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeLink = async (workspaceSlug: string, projectId: string, issueId: string, linkId: string) => {
|
||||
const issueLinkCount = this.getLinksByIssueId(issueId)?.length ?? 0;
|
||||
await this.issueService.deleteIssueLink(workspaceSlug, projectId, issueId, linkId);
|
||||
|
||||
const linkIndex = this.links[issueId].findIndex((_comment) => _comment === linkId);
|
||||
if (linkIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.links[issueId].splice(linkIndex, 1);
|
||||
delete this.linkMap[linkId];
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.updateIssue(issueId, {
|
||||
link_count: issueLinkCount - 1, // decrement link count
|
||||
});
|
||||
});
|
||||
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
}
|
||||
156
apps/web/core/store/issue/issue-details/reaction.store.ts
Normal file
156
apps/web/core/store/issue/issue-details/reaction.store.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { pull, find, concat, set, update } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// Plane Imports
|
||||
import type { TIssueReaction, TIssueReactionMap, TIssueReactionIdMap, TIssueServiceType } from "@plane/types";
|
||||
import { groupReactions } from "@plane/utils";
|
||||
// services
|
||||
import { IssueReactionService } from "@/services/issue";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
|
||||
export interface IIssueReactionStoreActions {
|
||||
// actions
|
||||
addReactions: (issueId: string, reactions: TIssueReaction[]) => void;
|
||||
fetchReactions: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssueReaction[]>;
|
||||
createReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise<any>;
|
||||
removeReaction: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
reaction: string,
|
||||
userId: string
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface IIssueReactionStore extends IIssueReactionStoreActions {
|
||||
// observables
|
||||
reactions: TIssueReactionIdMap;
|
||||
reactionMap: TIssueReactionMap;
|
||||
// helper methods
|
||||
getReactionsByIssueId: (issueId: string) => { [reaction_id: string]: string[] } | undefined;
|
||||
getReactionById: (reactionId: string) => TIssueReaction | undefined;
|
||||
reactionsByUser: (issueId: string, userId: string) => TIssueReaction[];
|
||||
}
|
||||
|
||||
export class IssueReactionStore implements IIssueReactionStore {
|
||||
// observables
|
||||
reactions: TIssueReactionIdMap = {};
|
||||
reactionMap: TIssueReactionMap = {};
|
||||
// root store
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
issueReactionService;
|
||||
serviceType;
|
||||
|
||||
constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
reactions: observable,
|
||||
reactionMap: observable,
|
||||
// actions
|
||||
addReactions: action.bound,
|
||||
fetchReactions: action,
|
||||
createReaction: action,
|
||||
removeReaction: action,
|
||||
});
|
||||
this.serviceType = serviceType;
|
||||
// root store
|
||||
this.rootIssueDetailStore = rootStore;
|
||||
// services
|
||||
this.issueReactionService = new IssueReactionService(serviceType);
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getReactionsByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.reactions[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
getReactionById = (reactionId: string) => {
|
||||
if (!reactionId) return undefined;
|
||||
return this.reactionMap[reactionId] ?? undefined;
|
||||
};
|
||||
|
||||
reactionsByUser = (issueId: string, userId: string) => {
|
||||
if (!issueId || !userId) return [];
|
||||
|
||||
const reactions = this.getReactionsByIssueId(issueId);
|
||||
if (!reactions) return [];
|
||||
|
||||
const _userReactions: TIssueReaction[] = [];
|
||||
Object.keys(reactions).forEach((reaction) => {
|
||||
if (reactions?.[reaction])
|
||||
reactions?.[reaction].map((reactionId) => {
|
||||
const currentReaction = this.getReactionById(reactionId);
|
||||
if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction);
|
||||
});
|
||||
});
|
||||
|
||||
return _userReactions;
|
||||
};
|
||||
|
||||
addReactions = (issueId: string, reactions: TIssueReaction[]) => {
|
||||
const groupedReactions = groupReactions(reactions || [], "reaction");
|
||||
|
||||
const issueReactionIdsMap: { [reaction: string]: string[] } = {};
|
||||
|
||||
Object.keys(groupedReactions).map((reactionId) => {
|
||||
const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id);
|
||||
issueReactionIdsMap[reactionId] = reactionIds;
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
set(this.reactions, issueId, issueReactionIdsMap);
|
||||
reactions.forEach((reaction) => set(this.reactionMap, reaction.id, reaction));
|
||||
});
|
||||
};
|
||||
|
||||
// actions
|
||||
fetchReactions = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const response = await this.issueReactionService.listIssueReactions(workspaceSlug, projectId, issueId);
|
||||
this.addReactions(issueId, response);
|
||||
return response;
|
||||
};
|
||||
|
||||
createReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => {
|
||||
const response = await this.issueReactionService.createIssueReaction(workspaceSlug, projectId, issueId, {
|
||||
reaction,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
update(this.reactions, [issueId, reaction], (reactionId) => {
|
||||
if (!reactionId) return [response.id];
|
||||
return concat(reactionId, response.id);
|
||||
});
|
||||
set(this.reactionMap, response.id, response);
|
||||
});
|
||||
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return response;
|
||||
};
|
||||
|
||||
removeReaction = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
reaction: string,
|
||||
userId: string
|
||||
) => {
|
||||
const userReactions = this.reactionsByUser(issueId, userId);
|
||||
const currentReaction = find(userReactions, { actor: userId, reaction: reaction });
|
||||
|
||||
if (currentReaction && currentReaction.id) {
|
||||
runInAction(() => {
|
||||
pull(this.reactions[issueId][reaction], currentReaction.id);
|
||||
delete this.reactionMap[reaction];
|
||||
});
|
||||
}
|
||||
|
||||
const response = await this.issueReactionService.deleteIssueReaction(workspaceSlug, projectId, issueId, reaction);
|
||||
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
306
apps/web/core/store/issue/issue-details/relation.store.ts
Normal file
306
apps/web/core/store/issue/issue-details/relation.store.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { uniq, get, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TIssueRelationIdMap, TIssueRelationMap, TIssueRelation, TIssue } from "@plane/types";
|
||||
// components
|
||||
import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations";
|
||||
// Plane-web
|
||||
import { REVERSE_RELATIONS } from "@/plane-web/constants/gantt-chart";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
// services
|
||||
import { IssueRelationService } from "@/services/issue";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
export interface IIssueRelationStoreActions {
|
||||
// actions
|
||||
fetchRelations: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssueRelation>;
|
||||
createRelation: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
relationType: TIssueRelationTypes,
|
||||
issues: string[]
|
||||
) => Promise<TIssue[]>;
|
||||
removeRelation: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
relationType: TIssueRelationTypes,
|
||||
related_issue: string,
|
||||
updateLocally?: boolean
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IIssueRelationStore extends IIssueRelationStoreActions {
|
||||
// observables
|
||||
relationMap: TIssueRelationMap; // Record defines relationType as key and reactions as value
|
||||
// computed
|
||||
issueRelations: TIssueRelationIdMap | undefined;
|
||||
// helper methods
|
||||
getRelationsByIssueId: (issueId: string) => TIssueRelationIdMap | undefined;
|
||||
getRelationCountByIssueId: (
|
||||
issueId: string,
|
||||
ISSUE_RELATION_OPTIONS: { [key in TIssueRelationTypes]?: TRelationObject }
|
||||
) => number;
|
||||
getRelationByIssueIdRelationType: (issueId: string, relationType: TIssueRelationTypes) => string[] | undefined;
|
||||
extractRelationsFromIssues: (issues: TIssue[]) => void;
|
||||
createCurrentRelation: (issueId: string, relationType: TIssueRelationTypes, relatedIssueId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class IssueRelationStore implements IIssueRelationStore {
|
||||
// observables
|
||||
relationMap: TIssueRelationMap = {};
|
||||
// root store
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
issueRelationService;
|
||||
|
||||
constructor(rootStore: IIssueDetail) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
relationMap: observable,
|
||||
// computed
|
||||
issueRelations: computed,
|
||||
// actions
|
||||
fetchRelations: action,
|
||||
createRelation: action,
|
||||
createCurrentRelation: action,
|
||||
removeRelation: action,
|
||||
extractRelationsFromIssues: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueDetailStore = rootStore;
|
||||
// services
|
||||
this.issueRelationService = new IssueRelationService();
|
||||
}
|
||||
|
||||
// computed
|
||||
get issueRelations() {
|
||||
const issueId = this.rootIssueDetailStore.peekIssue?.issueId;
|
||||
if (!issueId) return undefined;
|
||||
return this.relationMap?.[issueId] ?? undefined;
|
||||
}
|
||||
|
||||
// // helper methods
|
||||
getRelationsByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.relationMap?.[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
getRelationCountByIssueId = computedFn(
|
||||
(issueId: string, ISSUE_RELATION_OPTIONS: { [key in TIssueRelationTypes]?: TRelationObject }) => {
|
||||
const issueRelations = this.getRelationsByIssueId(issueId);
|
||||
|
||||
const issueRelationKeys = (Object.keys(issueRelations ?? {}) as TIssueRelationTypes[]).filter(
|
||||
(relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey]
|
||||
);
|
||||
|
||||
return issueRelationKeys.reduce((acc, curr) => acc + (issueRelations?.[curr]?.length ?? 0), 0);
|
||||
}
|
||||
);
|
||||
|
||||
getRelationByIssueIdRelationType = (issueId: string, relationType: TIssueRelationTypes) => {
|
||||
if (!issueId || !relationType) return undefined;
|
||||
return this.relationMap?.[issueId]?.[relationType] ?? undefined;
|
||||
};
|
||||
|
||||
// actions
|
||||
fetchRelations = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const response = await this.issueRelationService.listIssueRelations(workspaceSlug, projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(response).forEach((key) => {
|
||||
const relation_key = key as TIssueRelationTypes;
|
||||
const relation_issues = response[relation_key];
|
||||
const issues = relation_issues.flat().map((issue) => issue);
|
||||
if (issues && issues.length > 0) this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issues);
|
||||
set(
|
||||
this.relationMap,
|
||||
[issueId, relation_key],
|
||||
issues && issues.length > 0 ? issues.map((issue) => issue.id) : []
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
createRelation = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
relationType: TIssueRelationTypes,
|
||||
issues: string[]
|
||||
) => {
|
||||
const response = await this.issueRelationService.createIssueRelations(workspaceSlug, projectId, issueId, {
|
||||
relation_type: relationType,
|
||||
issues,
|
||||
});
|
||||
|
||||
const reverseRelatedType = REVERSE_RELATIONS[relationType];
|
||||
|
||||
const issuesOfRelation = get(this.relationMap, [issueId, relationType]) ?? [];
|
||||
|
||||
if (response && response.length > 0)
|
||||
runInAction(() => {
|
||||
response.forEach((issue) => {
|
||||
const issuesOfRelated = get(this.relationMap, [issue.id, reverseRelatedType]);
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue]);
|
||||
issuesOfRelation.push(issue.id);
|
||||
|
||||
if (!issuesOfRelated) {
|
||||
set(this.relationMap, [issue.id, reverseRelatedType], [issueId]);
|
||||
} else {
|
||||
set(this.relationMap, [issue.id, reverseRelatedType], uniq([...issuesOfRelated, issueId]));
|
||||
}
|
||||
});
|
||||
set(this.relationMap, [issueId, relationType], uniq(issuesOfRelation));
|
||||
});
|
||||
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* create Relation in current project optimistically
|
||||
* @param issueId
|
||||
* @param relationType
|
||||
* @param relatedIssueId
|
||||
* @returns
|
||||
*/
|
||||
createCurrentRelation = async (issueId: string, relationType: TIssueRelationTypes, relatedIssueId: string) => {
|
||||
const workspaceSlug = this.rootIssueDetailStore.rootIssueStore.workspaceSlug;
|
||||
const projectId = this.rootIssueDetailStore.issue.getIssueById(issueId)?.project_id;
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const reverseRelatedType = REVERSE_RELATIONS[relationType];
|
||||
|
||||
const issuesOfRelation = get(this.relationMap, [issueId, relationType]);
|
||||
const issuesOfRelated = get(this.relationMap, [relatedIssueId, reverseRelatedType]);
|
||||
|
||||
try {
|
||||
// update relations before API call
|
||||
runInAction(() => {
|
||||
if (!issuesOfRelation) {
|
||||
set(this.relationMap, [issueId, relationType], [relatedIssueId]);
|
||||
} else {
|
||||
set(this.relationMap, [issueId, relationType], uniq([...issuesOfRelation, relatedIssueId]));
|
||||
}
|
||||
|
||||
if (!issuesOfRelated) {
|
||||
set(this.relationMap, [relatedIssueId, reverseRelatedType], [issueId]);
|
||||
} else {
|
||||
set(this.relationMap, [relatedIssueId, reverseRelatedType], uniq([...issuesOfRelated, issueId]));
|
||||
}
|
||||
});
|
||||
|
||||
// perform API call
|
||||
await this.issueRelationService.createIssueRelations(workspaceSlug, projectId, issueId, {
|
||||
relation_type: relationType,
|
||||
issues: [relatedIssueId],
|
||||
});
|
||||
} catch (e) {
|
||||
// Revert back store changes if API fails
|
||||
runInAction(() => {
|
||||
if (issuesOfRelation) {
|
||||
set(this.relationMap, [issueId, relationType], issuesOfRelation);
|
||||
}
|
||||
|
||||
if (issuesOfRelated) {
|
||||
set(this.relationMap, [relatedIssueId, reverseRelatedType], issuesOfRelated);
|
||||
}
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
removeRelation = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
relationType: TIssueRelationTypes,
|
||||
related_issue: string,
|
||||
updateLocally = false
|
||||
) => {
|
||||
try {
|
||||
const relationIndex = this.relationMap[issueId]?.[relationType]?.findIndex(
|
||||
(_issueId) => _issueId === related_issue
|
||||
);
|
||||
if (relationIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.relationMap[issueId]?.[relationType]?.splice(relationIndex, 1);
|
||||
});
|
||||
|
||||
if (!updateLocally) {
|
||||
await this.issueRelationService.deleteIssueRelation(workspaceSlug, projectId, issueId, {
|
||||
relation_type: relationType,
|
||||
related_issue,
|
||||
});
|
||||
}
|
||||
|
||||
// While removing one relation, reverse of the relation should also be removed
|
||||
const reverseRelatedType = REVERSE_RELATIONS[relationType];
|
||||
const relatedIndex = this.relationMap[related_issue]?.[reverseRelatedType]?.findIndex(
|
||||
(_issueId) => _issueId === related_issue
|
||||
);
|
||||
if (relationIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.relationMap[related_issue]?.[reverseRelatedType]?.splice(relatedIndex, 1);
|
||||
});
|
||||
|
||||
// fetching activity
|
||||
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
this.fetchRelations(workspaceSlug, projectId, issueId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract Relation from the issue Array objects and store it in this Store
|
||||
* @param issues
|
||||
*/
|
||||
extractRelationsFromIssues = (issues: TIssue[]) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
for (const issue of issues) {
|
||||
const { issue_relation, issue_related, id: issueId } = issue;
|
||||
|
||||
const issueRelations: { [key in TIssueRelationTypes]?: string[] } = {};
|
||||
|
||||
if (issue_relation && Array.isArray(issue_relation) && issue_relation.length) {
|
||||
for (const relation of issue_relation) {
|
||||
const { relation_type, id } = relation;
|
||||
|
||||
if (!relation_type) continue;
|
||||
|
||||
if (issueRelations[relation_type]) issueRelations[relation_type]?.push(id);
|
||||
else issueRelations[relation_type] = [id];
|
||||
}
|
||||
}
|
||||
|
||||
if (issue_related && Array.isArray(issue_related) && issue_related.length) {
|
||||
for (const relation of issue_related) {
|
||||
const { relation_type, id } = relation;
|
||||
|
||||
if (!relation_type) continue;
|
||||
|
||||
const reverseRelatedType = REVERSE_RELATIONS[relation_type as TIssueRelationTypes];
|
||||
|
||||
if (issueRelations[reverseRelatedType]) issueRelations[reverseRelatedType]?.push(id);
|
||||
else issueRelations[reverseRelatedType] = [id];
|
||||
}
|
||||
}
|
||||
|
||||
set(this.relationMap, [issueId], issueRelations);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error while extracting issue relations from issues");
|
||||
}
|
||||
};
|
||||
}
|
||||
414
apps/web/core/store/issue/issue-details/root.store.ts
Normal file
414
apps/web/core/store/issue/issue-details/root.store.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { action, computed, makeObservable, observable } from "mobx";
|
||||
// types
|
||||
import type {
|
||||
TIssue,
|
||||
TIssueAttachment,
|
||||
TIssueComment,
|
||||
TIssueCommentReaction,
|
||||
TIssueLink,
|
||||
TIssueReaction,
|
||||
TIssueServiceType,
|
||||
TWorkItemWidgets,
|
||||
} from "@plane/types";
|
||||
// plane web store
|
||||
import { IssueActivityStore } from "@/plane-web/store/issue/issue-details/activity.store";
|
||||
import type {
|
||||
IIssueActivityStore,
|
||||
IIssueActivityStoreActions,
|
||||
TActivityLoader,
|
||||
} from "@/plane-web/store/issue/issue-details/activity.store";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import { IssueAttachmentStore } from "./attachment.store";
|
||||
import type { IIssueAttachmentStore, IIssueAttachmentStoreActions } from "./attachment.store";
|
||||
import { IssueCommentStore } from "./comment.store";
|
||||
import type { IIssueCommentStore, IIssueCommentStoreActions, TCommentLoader } from "./comment.store";
|
||||
import { IssueCommentReactionStore } from "./comment_reaction.store";
|
||||
import type { IIssueCommentReactionStore, IIssueCommentReactionStoreActions } from "./comment_reaction.store";
|
||||
import { IssueStore } from "./issue.store";
|
||||
import type { IIssueStore, IIssueStoreActions } from "./issue.store";
|
||||
import { IssueLinkStore } from "./link.store";
|
||||
import type { IIssueLinkStore, IIssueLinkStoreActions } from "./link.store";
|
||||
import { IssueReactionStore } from "./reaction.store";
|
||||
import type { IIssueReactionStore, IIssueReactionStoreActions } from "./reaction.store";
|
||||
import { IssueRelationStore } from "./relation.store";
|
||||
import type { IIssueRelationStore, IIssueRelationStoreActions } from "./relation.store";
|
||||
import { IssueSubIssuesStore } from "./sub_issues.store";
|
||||
import type { IIssueSubIssuesStore, IIssueSubIssuesStoreActions } from "./sub_issues.store";
|
||||
import { IssueSubscriptionStore } from "./subscription.store";
|
||||
import type { IIssueSubscriptionStore, IIssueSubscriptionStoreActions } from "./subscription.store";
|
||||
|
||||
export type TPeekIssue = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
nestingLevel?: number;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
export type TIssueRelationModal = {
|
||||
issueId: string | null;
|
||||
relationType: TIssueRelationTypes | null;
|
||||
};
|
||||
|
||||
export type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export type TIssueCrudOperationState = {
|
||||
create: TIssueCrudState;
|
||||
existing: TIssueCrudState;
|
||||
};
|
||||
|
||||
export interface IIssueDetail
|
||||
extends IIssueStoreActions,
|
||||
IIssueReactionStoreActions,
|
||||
IIssueLinkStoreActions,
|
||||
IIssueSubIssuesStoreActions,
|
||||
IIssueSubscriptionStoreActions,
|
||||
IIssueAttachmentStoreActions,
|
||||
IIssueRelationStoreActions,
|
||||
IIssueActivityStoreActions,
|
||||
IIssueCommentStoreActions,
|
||||
IIssueCommentReactionStoreActions {
|
||||
// observables
|
||||
peekIssue: TPeekIssue | undefined;
|
||||
relationKey: TIssueRelationTypes | null;
|
||||
issueLinkData: TIssueLink | null;
|
||||
issueCrudOperationState: TIssueCrudOperationState;
|
||||
openWidgets: TWorkItemWidgets[];
|
||||
lastWidgetAction: TWorkItemWidgets | null;
|
||||
isCreateIssueModalOpen: boolean;
|
||||
isIssueLinkModalOpen: boolean;
|
||||
isParentIssueModalOpen: string | null;
|
||||
isDeleteIssueModalOpen: string | null;
|
||||
isArchiveIssueModalOpen: string | null;
|
||||
isRelationModalOpen: TIssueRelationModal | null;
|
||||
isSubIssuesModalOpen: string | null;
|
||||
attachmentDeleteModalId: string | null;
|
||||
// computed
|
||||
isAnyModalOpen: boolean;
|
||||
isPeekOpen: boolean;
|
||||
// helper actions
|
||||
getIsIssuePeeked: (issueId: string) => boolean;
|
||||
// actions
|
||||
setPeekIssue: (peekIssue: TPeekIssue | undefined) => void;
|
||||
setIssueLinkData: (issueLinkData: TIssueLink | null) => void;
|
||||
toggleCreateIssueModal: (value: boolean) => void;
|
||||
toggleIssueLinkModal: (value: boolean) => void;
|
||||
toggleParentIssueModal: (issueId: string | null) => void;
|
||||
toggleDeleteIssueModal: (issueId: string | null) => void;
|
||||
toggleArchiveIssueModal: (value: string | null) => void;
|
||||
toggleRelationModal: (issueId: string | null, relationType: TIssueRelationTypes | null) => void;
|
||||
toggleSubIssuesModal: (value: string | null) => void;
|
||||
toggleDeleteAttachmentModal: (attachmentId: string | null) => void;
|
||||
setOpenWidgets: (state: TWorkItemWidgets[]) => void;
|
||||
setLastWidgetAction: (action: TWorkItemWidgets) => void;
|
||||
toggleOpenWidget: (state: TWorkItemWidgets) => void;
|
||||
setRelationKey: (relationKey: TIssueRelationTypes | null) => void;
|
||||
setIssueCrudOperationState: (state: TIssueCrudOperationState) => void;
|
||||
// store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
issue: IIssueStore;
|
||||
reaction: IIssueReactionStore;
|
||||
attachment: IIssueAttachmentStore;
|
||||
activity: IIssueActivityStore;
|
||||
comment: IIssueCommentStore;
|
||||
commentReaction: IIssueCommentReactionStore;
|
||||
subIssues: IIssueSubIssuesStore;
|
||||
link: IIssueLinkStore;
|
||||
subscription: IIssueSubscriptionStore;
|
||||
relation: IIssueRelationStore;
|
||||
}
|
||||
|
||||
export abstract class IssueDetail implements IIssueDetail {
|
||||
// observables
|
||||
peekIssue: TPeekIssue | undefined = undefined;
|
||||
relationKey: TIssueRelationTypes | null = null;
|
||||
issueLinkData: TIssueLink | null = null;
|
||||
issueCrudOperationState: TIssueCrudOperationState = {
|
||||
create: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
existing: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
};
|
||||
openWidgets: TWorkItemWidgets[] = ["sub-work-items", "links", "attachments"];
|
||||
lastWidgetAction: TWorkItemWidgets | null = null;
|
||||
isCreateIssueModalOpen: boolean = false;
|
||||
isIssueLinkModalOpen: boolean = false;
|
||||
isParentIssueModalOpen: string | null = null;
|
||||
isDeleteIssueModalOpen: string | null = null;
|
||||
isArchiveIssueModalOpen: string | null = null;
|
||||
isRelationModalOpen: TIssueRelationModal | null = null;
|
||||
isSubIssuesModalOpen: string | null = null;
|
||||
attachmentDeleteModalId: string | null = null;
|
||||
// service type
|
||||
serviceType: TIssueServiceType;
|
||||
// store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
issue: IIssueStore;
|
||||
reaction: IIssueReactionStore;
|
||||
attachment: IIssueAttachmentStore;
|
||||
subIssues: IIssueSubIssuesStore;
|
||||
link: IIssueLinkStore;
|
||||
subscription: IIssueSubscriptionStore;
|
||||
relation: IIssueRelationStore;
|
||||
activity: IIssueActivityStore;
|
||||
comment: IIssueCommentStore;
|
||||
commentReaction: IIssueCommentReactionStore;
|
||||
|
||||
constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
peekIssue: observable,
|
||||
relationKey: observable,
|
||||
issueLinkData: observable,
|
||||
issueCrudOperationState: observable,
|
||||
isCreateIssueModalOpen: observable,
|
||||
isIssueLinkModalOpen: observable.ref,
|
||||
isParentIssueModalOpen: observable.ref,
|
||||
isDeleteIssueModalOpen: observable.ref,
|
||||
isArchiveIssueModalOpen: observable.ref,
|
||||
isRelationModalOpen: observable.ref,
|
||||
isSubIssuesModalOpen: observable.ref,
|
||||
attachmentDeleteModalId: observable.ref,
|
||||
openWidgets: observable.ref,
|
||||
lastWidgetAction: observable.ref,
|
||||
// computed
|
||||
isAnyModalOpen: computed,
|
||||
isPeekOpen: computed,
|
||||
// action
|
||||
setPeekIssue: action,
|
||||
setIssueLinkData: action,
|
||||
toggleCreateIssueModal: action,
|
||||
toggleIssueLinkModal: action,
|
||||
toggleParentIssueModal: action,
|
||||
toggleDeleteIssueModal: action,
|
||||
toggleArchiveIssueModal: action,
|
||||
toggleRelationModal: action,
|
||||
toggleSubIssuesModal: action,
|
||||
toggleDeleteAttachmentModal: action,
|
||||
setOpenWidgets: action,
|
||||
setLastWidgetAction: action,
|
||||
toggleOpenWidget: action,
|
||||
setRelationKey: action,
|
||||
setIssueCrudOperationState: action,
|
||||
});
|
||||
|
||||
// store
|
||||
this.serviceType = serviceType;
|
||||
this.rootIssueStore = rootStore;
|
||||
this.issue = new IssueStore(this, serviceType);
|
||||
this.reaction = new IssueReactionStore(this, serviceType);
|
||||
this.attachment = new IssueAttachmentStore(rootStore, serviceType);
|
||||
this.activity = new IssueActivityStore(rootStore.rootStore as RootStore, serviceType);
|
||||
this.comment = new IssueCommentStore(this, serviceType);
|
||||
this.commentReaction = new IssueCommentReactionStore(this);
|
||||
this.subIssues = new IssueSubIssuesStore(this, serviceType);
|
||||
this.link = new IssueLinkStore(this, serviceType);
|
||||
this.subscription = new IssueSubscriptionStore(this, serviceType);
|
||||
this.relation = new IssueRelationStore(this);
|
||||
}
|
||||
|
||||
// computed
|
||||
get isAnyModalOpen() {
|
||||
return (
|
||||
this.isCreateIssueModalOpen ||
|
||||
this.isIssueLinkModalOpen ||
|
||||
!!this.isParentIssueModalOpen ||
|
||||
!!this.isDeleteIssueModalOpen ||
|
||||
!!this.isArchiveIssueModalOpen ||
|
||||
!!this.isRelationModalOpen?.issueId ||
|
||||
!!this.isSubIssuesModalOpen ||
|
||||
!!this.attachmentDeleteModalId
|
||||
);
|
||||
}
|
||||
|
||||
get isPeekOpen() {
|
||||
return !!this.peekIssue;
|
||||
}
|
||||
|
||||
// helper actions
|
||||
getIsIssuePeeked = (issueId: string) => this.peekIssue?.issueId === issueId;
|
||||
|
||||
// actions
|
||||
setRelationKey = (relationKey: TIssueRelationTypes | null) => (this.relationKey = relationKey);
|
||||
setIssueCrudOperationState = (state: TIssueCrudOperationState) => (this.issueCrudOperationState = state);
|
||||
setPeekIssue = (peekIssue: TPeekIssue | undefined) => (this.peekIssue = peekIssue);
|
||||
toggleCreateIssueModal = (value: boolean) => (this.isCreateIssueModalOpen = value);
|
||||
toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value);
|
||||
toggleParentIssueModal = (issueId: string | null) => (this.isParentIssueModalOpen = issueId);
|
||||
toggleDeleteIssueModal = (issueId: string | null) => (this.isDeleteIssueModalOpen = issueId);
|
||||
toggleArchiveIssueModal = (issueId: string | null) => (this.isArchiveIssueModalOpen = issueId);
|
||||
toggleRelationModal = (issueId: string | null, relationType: TIssueRelationTypes | null) =>
|
||||
(this.isRelationModalOpen = { issueId, relationType });
|
||||
toggleSubIssuesModal = (issueId: string | null) => (this.isSubIssuesModalOpen = issueId);
|
||||
toggleDeleteAttachmentModal = (attachmentId: string | null) => (this.attachmentDeleteModalId = attachmentId);
|
||||
setOpenWidgets = (state: TWorkItemWidgets[]) => {
|
||||
this.openWidgets = state;
|
||||
if (this.lastWidgetAction) this.lastWidgetAction = null;
|
||||
};
|
||||
setLastWidgetAction = (action: TWorkItemWidgets) => {
|
||||
this.openWidgets = [action];
|
||||
};
|
||||
toggleOpenWidget = (state: TWorkItemWidgets) => {
|
||||
if (this.openWidgets && this.openWidgets.includes(state))
|
||||
this.openWidgets = this.openWidgets.filter((s) => s !== state);
|
||||
else this.openWidgets = [state, ...this.openWidgets];
|
||||
};
|
||||
setIssueLinkData = (issueLinkData: TIssueLink | null) => (this.issueLinkData = issueLinkData);
|
||||
|
||||
// issue
|
||||
fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.issue.fetchIssue(workspaceSlug, projectId, issueId);
|
||||
fetchIssueWithIdentifier = async (workspaceSlug: string, projectIdentifier: string, sequenceId: string) =>
|
||||
this.issue.fetchIssueWithIdentifier(workspaceSlug, projectIdentifier, sequenceId);
|
||||
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) =>
|
||||
this.issue.updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.issue.removeIssue(workspaceSlug, projectId, issueId);
|
||||
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.issue.archiveIssue(workspaceSlug, projectId, issueId);
|
||||
addCycleToIssue = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) =>
|
||||
this.issue.addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
|
||||
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) =>
|
||||
this.issue.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) =>
|
||||
this.issue.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
changeModulesInIssue = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => this.issue.changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
|
||||
removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) =>
|
||||
this.issue.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
|
||||
|
||||
// reactions
|
||||
addReactions = (issueId: string, reactions: TIssueReaction[]) => this.reaction.addReactions(issueId, reactions);
|
||||
fetchReactions = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.reaction.fetchReactions(workspaceSlug, projectId, issueId);
|
||||
createReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) =>
|
||||
this.reaction.createReaction(workspaceSlug, projectId, issueId, reaction);
|
||||
removeReaction = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
reaction: string,
|
||||
userId: string
|
||||
) => this.reaction.removeReaction(workspaceSlug, projectId, issueId, reaction, userId);
|
||||
|
||||
// attachments
|
||||
addAttachments = (issueId: string, attachments: TIssueAttachment[]) =>
|
||||
this.attachment.addAttachments(issueId, attachments);
|
||||
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.attachment.fetchAttachments(workspaceSlug, projectId, issueId);
|
||||
createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) =>
|
||||
this.attachment.createAttachment(workspaceSlug, projectId, issueId, file);
|
||||
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) =>
|
||||
this.attachment.removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
|
||||
|
||||
// link
|
||||
addLinks = (issueId: string, links: TIssueLink[]) => this.link.addLinks(issueId, links);
|
||||
fetchLinks = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.link.fetchLinks(workspaceSlug, projectId, issueId);
|
||||
createLink = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssueLink>) =>
|
||||
this.link.createLink(workspaceSlug, projectId, issueId, data);
|
||||
updateLink = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
linkId: string,
|
||||
data: Partial<TIssueLink>
|
||||
) => this.link.updateLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
removeLink = async (workspaceSlug: string, projectId: string, issueId: string, linkId: string) =>
|
||||
this.link.removeLink(workspaceSlug, projectId, issueId, linkId);
|
||||
|
||||
// sub issues
|
||||
fetchSubIssues = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId);
|
||||
createSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string, data: string[]) =>
|
||||
this.subIssues.createSubIssues(workspaceSlug, projectId, parentIssueId, data);
|
||||
updateSubIssue = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string,
|
||||
issueData: Partial<TIssue>,
|
||||
oldIssue?: Partial<TIssue>,
|
||||
fromModal?: boolean
|
||||
) => this.subIssues.updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
|
||||
removeSubIssue = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) =>
|
||||
this.subIssues.removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
deleteSubIssue = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) =>
|
||||
this.subIssues.deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
|
||||
// subscription
|
||||
addSubscription = (issueId: string, isSubscribed: boolean | undefined | null) =>
|
||||
this.subscription.addSubscription(issueId, isSubscribed);
|
||||
fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.subscription.fetchSubscriptions(workspaceSlug, projectId, issueId);
|
||||
createSubscription = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.subscription.createSubscription(workspaceSlug, projectId, issueId);
|
||||
removeSubscription = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.subscription.removeSubscription(workspaceSlug, projectId, issueId);
|
||||
|
||||
// relations
|
||||
fetchRelations = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.relation.fetchRelations(workspaceSlug, projectId, issueId);
|
||||
createRelation = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
relationType: TIssueRelationTypes,
|
||||
issues: string[]
|
||||
) => this.relation.createRelation(workspaceSlug, projectId, issueId, relationType, issues);
|
||||
removeRelation = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
relationType: TIssueRelationTypes,
|
||||
relatedIssue: string,
|
||||
updateLocally?: boolean
|
||||
) => this.relation.removeRelation(workspaceSlug, projectId, issueId, relationType, relatedIssue, updateLocally);
|
||||
|
||||
// activity
|
||||
fetchActivities = async (workspaceSlug: string, projectId: string, issueId: string, loaderType?: TActivityLoader) =>
|
||||
this.activity.fetchActivities(workspaceSlug, projectId, issueId, loaderType);
|
||||
|
||||
// comment
|
||||
fetchComments = async (workspaceSlug: string, projectId: string, issueId: string, loaderType?: TCommentLoader) =>
|
||||
this.comment.fetchComments(workspaceSlug, projectId, issueId, loaderType);
|
||||
createComment = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssueComment>) =>
|
||||
this.comment.createComment(workspaceSlug, projectId, issueId, data);
|
||||
updateComment = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
data: Partial<TIssueComment>
|
||||
) => this.comment.updateComment(workspaceSlug, projectId, issueId, commentId, data);
|
||||
removeComment = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) =>
|
||||
this.comment.removeComment(workspaceSlug, projectId, issueId, commentId);
|
||||
|
||||
// comment reaction
|
||||
fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) =>
|
||||
this.commentReaction.fetchCommentReactions(workspaceSlug, projectId, commentId);
|
||||
applyCommentReactions = async (commentId: string, commentReactions: TIssueCommentReaction[]) =>
|
||||
this.commentReaction.applyCommentReactions(commentId, commentReactions);
|
||||
createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) =>
|
||||
this.commentReaction.createCommentReaction(workspaceSlug, projectId, commentId, reaction);
|
||||
removeCommentReaction = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string,
|
||||
reaction: string,
|
||||
userId: string
|
||||
) => this.commentReaction.removeCommentReaction(workspaceSlug, projectId, commentId, reaction, userId);
|
||||
}
|
||||
369
apps/web/core/store/issue/issue-details/sub_issues.store.ts
Normal file
369
apps/web/core/store/issue/issue-details/sub_issues.store.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { pull, concat, uniq, set, update } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// Plane Imports
|
||||
import type {
|
||||
TIssue,
|
||||
TIssueSubIssues,
|
||||
TIssueSubIssuesStateDistributionMap,
|
||||
TIssueSubIssuesIdMap,
|
||||
TSubIssuesStateDistribution,
|
||||
TIssueServiceType,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import { updatePersistentLayer } from "@/local-db/utils/utils";
|
||||
import { IssueService } from "@/services/issue";
|
||||
// store
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
import type { IWorkItemSubIssueFiltersStore } from "./sub_issues_filter.store";
|
||||
import { WorkItemSubIssueFiltersStore } from "./sub_issues_filter.store";
|
||||
|
||||
export interface IIssueSubIssuesStoreActions {
|
||||
fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise<TIssueSubIssues>;
|
||||
createSubIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueIds: string[]
|
||||
) => Promise<void>;
|
||||
updateSubIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string,
|
||||
issueData: Partial<TIssue>,
|
||||
oldIssue?: Partial<TIssue>,
|
||||
fromModal?: boolean
|
||||
) => Promise<void>;
|
||||
removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
|
||||
deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type TSubIssueHelpersKeys = "issue_visibility" | "preview_loader" | "issue_loader";
|
||||
type TSubIssueHelpers = Record<TSubIssueHelpersKeys, string[]>;
|
||||
export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
|
||||
// observables
|
||||
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap;
|
||||
subIssues: TIssueSubIssuesIdMap;
|
||||
subIssueHelpers: Record<string, TSubIssueHelpers>; // parent_issue_id -> TSubIssueHelpers
|
||||
loader: TLoader;
|
||||
filters: IWorkItemSubIssueFiltersStore;
|
||||
// helper methods
|
||||
stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined;
|
||||
subIssuesByIssueId: (issueId: string) => string[] | undefined;
|
||||
subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers;
|
||||
// actions
|
||||
fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
|
||||
setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void;
|
||||
}
|
||||
|
||||
export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||
// observables
|
||||
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {};
|
||||
subIssues: TIssueSubIssuesIdMap = {};
|
||||
subIssueHelpers: Record<string, TSubIssueHelpers> = {};
|
||||
loader: TLoader = undefined;
|
||||
|
||||
filters: IWorkItemSubIssueFiltersStore;
|
||||
// root store
|
||||
rootIssueDetailStore: IIssueDetail;
|
||||
// services
|
||||
serviceType;
|
||||
issueService;
|
||||
|
||||
constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
subIssuesStateDistribution: observable,
|
||||
subIssues: observable,
|
||||
subIssueHelpers: observable,
|
||||
loader: observable.ref,
|
||||
// actions
|
||||
setSubIssueHelpers: action,
|
||||
fetchSubIssues: action,
|
||||
createSubIssues: action,
|
||||
updateSubIssue: action,
|
||||
removeSubIssue: action,
|
||||
deleteSubIssue: action,
|
||||
fetchOtherProjectProperties: action,
|
||||
});
|
||||
this.filters = new WorkItemSubIssueFiltersStore(this);
|
||||
// root store
|
||||
this.rootIssueDetailStore = rootStore;
|
||||
// services
|
||||
this.serviceType = serviceType;
|
||||
this.issueService = new IssueService(serviceType);
|
||||
}
|
||||
|
||||
// helper methods
|
||||
stateDistributionByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.subIssuesStateDistribution[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
subIssuesByIssueId = computedFn((issueId: string) => this.subIssues[issueId]);
|
||||
|
||||
subIssueHelpersByIssueId = (issueId: string) => ({
|
||||
preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [],
|
||||
issue_visibility: this.subIssueHelpers?.[issueId]?.issue_visibility || [],
|
||||
issue_loader: this.subIssueHelpers?.[issueId]?.issue_loader || [],
|
||||
});
|
||||
|
||||
// actions
|
||||
setSubIssueHelpers = (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => {
|
||||
if (!parentIssueId || !key || !value) return;
|
||||
|
||||
update(this.subIssueHelpers, [parentIssueId, key], (_subIssueHelpers: string[] = []) => {
|
||||
if (_subIssueHelpers.includes(value)) return pull(_subIssueHelpers, value);
|
||||
return concat(_subIssueHelpers, value);
|
||||
});
|
||||
};
|
||||
|
||||
fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
|
||||
this.loader = "init-loader";
|
||||
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId);
|
||||
|
||||
const subIssuesStateDistribution = response?.state_distribution ?? {};
|
||||
|
||||
const issueList = (response.sub_issues ?? []) as TIssue[];
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList);
|
||||
|
||||
// fetch other issues states and members when sub-issues are from different project
|
||||
if (issueList && issueList.length > 0) {
|
||||
const otherProjectIds = uniq(
|
||||
issueList.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
|
||||
) as string[];
|
||||
this.fetchOtherProjectProperties(workspaceSlug, otherProjectIds);
|
||||
}
|
||||
if (issueList) {
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.updateIssue(parentIssueId, {
|
||||
sub_issues_count: issueList.length,
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution);
|
||||
set(
|
||||
this.subIssues,
|
||||
parentIssueId,
|
||||
issueList.map((issue) => issue.id)
|
||||
);
|
||||
});
|
||||
|
||||
this.loader = undefined;
|
||||
return response;
|
||||
};
|
||||
|
||||
createSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
|
||||
const response = await this.issueService.addSubIssues(workspaceSlug, projectId, parentIssueId, {
|
||||
sub_issue_ids: issueIds,
|
||||
});
|
||||
|
||||
const subIssuesStateDistribution = response?.state_distribution;
|
||||
const subIssues = response.sub_issues as TIssue[];
|
||||
|
||||
// fetch other issues states and members when sub-issues are from different project
|
||||
if (subIssues && subIssues.length > 0) {
|
||||
const otherProjectIds = uniq(
|
||||
subIssues.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
|
||||
) as string[];
|
||||
this.fetchOtherProjectProperties(workspaceSlug, otherProjectIds);
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(subIssuesStateDistribution).forEach((key) => {
|
||||
const stateGroup = key as keyof TSubIssuesStateDistribution;
|
||||
update(this.subIssuesStateDistribution, [parentIssueId, stateGroup], (stateDistribution) => {
|
||||
if (!stateDistribution) return subIssuesStateDistribution[stateGroup];
|
||||
return concat(stateDistribution, subIssuesStateDistribution[stateGroup]);
|
||||
});
|
||||
});
|
||||
|
||||
const issueIds = subIssues.map((issue) => issue.id);
|
||||
update(this.subIssues, [parentIssueId], (issues) => {
|
||||
if (!issues) return issueIds;
|
||||
return concat(issues, issueIds);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(subIssues);
|
||||
|
||||
// update sub-issues_count of the parent issue
|
||||
set(
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.issuesMap,
|
||||
[parentIssueId, "sub_issues_count"],
|
||||
this.subIssues[parentIssueId].length
|
||||
);
|
||||
|
||||
if (this.serviceType === EIssueServiceType.ISSUES) {
|
||||
updatePersistentLayer([parentIssueId, ...issueIds]);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
updateSubIssue = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string,
|
||||
issueData: Partial<TIssue>,
|
||||
oldIssue: Partial<TIssue> = {},
|
||||
fromModal: boolean = false
|
||||
) => {
|
||||
if (!fromModal)
|
||||
await this.rootIssueDetailStore.rootIssueStore.projectIssues.updateIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
issueData
|
||||
);
|
||||
|
||||
// parent update
|
||||
if (issueData.hasOwnProperty("parent_id") && issueData.parent_id !== oldIssue.parent_id) {
|
||||
runInAction(() => {
|
||||
if (oldIssue.parent_id) pull(this.subIssues[oldIssue.parent_id], issueId);
|
||||
if (issueData.parent_id)
|
||||
set(this.subIssues, [issueData.parent_id], concat(this.subIssues[issueData.parent_id], issueId));
|
||||
});
|
||||
}
|
||||
|
||||
// state update
|
||||
if (issueData.hasOwnProperty("state_id") && issueData.state_id !== oldIssue.state_id) {
|
||||
let oldIssueStateGroup: string | undefined = undefined;
|
||||
let issueStateGroup: string | undefined = undefined;
|
||||
|
||||
if (oldIssue.state_id) {
|
||||
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(oldIssue.state_id);
|
||||
if (state?.group) oldIssueStateGroup = state.group;
|
||||
}
|
||||
|
||||
if (issueData.state_id) {
|
||||
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(issueData.state_id);
|
||||
if (state?.group) issueStateGroup = state.group;
|
||||
}
|
||||
|
||||
if (oldIssueStateGroup && issueStateGroup && issueStateGroup !== oldIssueStateGroup) {
|
||||
runInAction(() => {
|
||||
if (oldIssueStateGroup)
|
||||
update(this.subIssuesStateDistribution, [parentIssueId, oldIssueStateGroup], (stateDistribution) => {
|
||||
if (!stateDistribution) return;
|
||||
return pull(stateDistribution, issueId);
|
||||
});
|
||||
|
||||
if (issueStateGroup)
|
||||
update(this.subIssuesStateDistribution, [parentIssueId, issueStateGroup], (stateDistribution) => {
|
||||
if (!stateDistribution) return [issueId];
|
||||
return concat(stateDistribution, issueId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
removeSubIssue = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
|
||||
await this.rootIssueDetailStore.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, {
|
||||
parent_id: null,
|
||||
});
|
||||
|
||||
const issue = this.rootIssueDetailStore.issue.getIssueById(issueId);
|
||||
if (issue && issue.state_id) {
|
||||
let issueStateGroup: string | undefined = undefined;
|
||||
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(issue.state_id);
|
||||
if (state?.group) issueStateGroup = state.group;
|
||||
|
||||
if (issueStateGroup) {
|
||||
runInAction(() => {
|
||||
if (issueStateGroup)
|
||||
update(this.subIssuesStateDistribution, [parentIssueId, issueStateGroup], (stateDistribution) => {
|
||||
if (!stateDistribution) return;
|
||||
return pull(stateDistribution, issueId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
pull(this.subIssues[parentIssueId], issueId);
|
||||
// update sub-issues_count of the parent issue
|
||||
set(
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.issuesMap,
|
||||
[parentIssueId, "sub_issues_count"],
|
||||
this.subIssues[parentIssueId]?.length
|
||||
);
|
||||
});
|
||||
|
||||
if (this.serviceType === EIssueServiceType.ISSUES) {
|
||||
updatePersistentLayer([parentIssueId]);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
deleteSubIssue = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
|
||||
await this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
|
||||
|
||||
const issue = this.rootIssueDetailStore.issue.getIssueById(issueId);
|
||||
if (issue && issue.state_id) {
|
||||
let issueStateGroup: string | undefined = undefined;
|
||||
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(issue.state_id);
|
||||
if (state?.group) issueStateGroup = state.group;
|
||||
|
||||
if (issueStateGroup) {
|
||||
runInAction(() => {
|
||||
if (issueStateGroup)
|
||||
update(this.subIssuesStateDistribution, [parentIssueId, issueStateGroup], (stateDistribution) => {
|
||||
if (!stateDistribution) return;
|
||||
return pull(stateDistribution, issueId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
pull(this.subIssues[parentIssueId], issueId);
|
||||
// update sub-issues_count of the parent issue
|
||||
set(
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.issuesMap,
|
||||
[parentIssueId, "sub_issues_count"],
|
||||
this.subIssues[parentIssueId]?.length
|
||||
);
|
||||
});
|
||||
|
||||
if (this.serviceType === EIssueServiceType.ISSUES) {
|
||||
updatePersistentLayer([parentIssueId]);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
fetchOtherProjectProperties = async (workspaceSlug: string, projectIds: string[]) => {
|
||||
if (projectIds.length > 0) {
|
||||
for (const projectId of projectIds) {
|
||||
// fetching other project states
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.state.fetchProjectStates(workspaceSlug, projectId);
|
||||
// fetching other project members
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.memberRoot.project.fetchProjectMembers(
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
// fetching other project labels
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.label.fetchProjectLabels(workspaceSlug, projectId);
|
||||
// fetching other project cycles
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.cycle.fetchAllCycles(workspaceSlug, projectId);
|
||||
// fetching other project modules
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.module.fetchModules(workspaceSlug, projectId);
|
||||
// fetching other project estimates
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.projectEstimate.getProjectEstimates(
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
ISubWorkItemFilters,
|
||||
TGroupedIssues,
|
||||
TIssue,
|
||||
} from "@plane/types";
|
||||
import { getFilteredWorkItems, getGroupedWorkItemIds, updateSubWorkItemFilters } from "../helpers/base-issues-utils";
|
||||
import type { IssueSubIssuesStore } from "./sub_issues.store";
|
||||
|
||||
export const DEFAULT_DISPLAY_PROPERTIES = {
|
||||
key: true,
|
||||
issue_type: true,
|
||||
assignee: true,
|
||||
start_date: true,
|
||||
due_date: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
state: true,
|
||||
};
|
||||
export interface IWorkItemSubIssueFiltersStore {
|
||||
subIssueFilters: Record<string, Partial<ISubWorkItemFilters>>;
|
||||
// helpers methods
|
||||
updateSubWorkItemFilters: (
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||
workItemId: string
|
||||
) => void;
|
||||
getGroupedSubWorkItems: (workItemId: string) => TGroupedIssues;
|
||||
getFilteredSubWorkItems: (workItemId: string, filters: IIssueFilterOptions) => TIssue[];
|
||||
getSubIssueFilters: (workItemId: string) => Partial<ISubWorkItemFilters>;
|
||||
resetFilters: (workItemId: string) => void;
|
||||
}
|
||||
|
||||
export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore {
|
||||
// observables
|
||||
subIssueFilters: Record<string, Partial<ISubWorkItemFilters>> = {};
|
||||
|
||||
// root store
|
||||
subIssueStore: IssueSubIssuesStore;
|
||||
|
||||
constructor(subIssueStore: IssueSubIssuesStore) {
|
||||
makeObservable(this, {
|
||||
subIssueFilters: observable,
|
||||
updateSubWorkItemFilters: action,
|
||||
getSubIssueFilters: action,
|
||||
});
|
||||
|
||||
// root store
|
||||
this.subIssueStore = subIssueStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This method is used to get the sub issue filters
|
||||
* @param workItemId
|
||||
* @returns
|
||||
*/
|
||||
getSubIssueFilters = (workItemId: string) => {
|
||||
if (!this.subIssueFilters[workItemId]) {
|
||||
this.initializeFilters(workItemId);
|
||||
}
|
||||
return this.subIssueFilters[workItemId];
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to initialize the sub issue filters
|
||||
* @param workItemId
|
||||
*/
|
||||
initializeFilters = (workItemId: string) => {
|
||||
set(this.subIssueFilters, [workItemId, "displayProperties"], DEFAULT_DISPLAY_PROPERTIES);
|
||||
set(this.subIssueFilters, [workItemId, "filters"], {});
|
||||
set(this.subIssueFilters, [workItemId, "displayFilters"], {});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method updates filters for sub issues.
|
||||
* @param filterType
|
||||
* @param filters
|
||||
*/
|
||||
updateSubWorkItemFilters = (
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||
workItemId: string
|
||||
) => {
|
||||
runInAction(() => {
|
||||
updateSubWorkItemFilters(this.subIssueFilters, filterType, filters, workItemId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to get the grouped sub work items
|
||||
* @param parentWorkItemId
|
||||
* @returns
|
||||
*/
|
||||
getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => {
|
||||
const subIssueFilters = this.getSubIssueFilters(parentWorkItemId);
|
||||
|
||||
const filteredWorkItems = this.getFilteredSubWorkItems(parentWorkItemId, subIssueFilters.filters ?? {});
|
||||
|
||||
// get group by and order by
|
||||
const groupByKey = subIssueFilters.displayFilters?.group_by;
|
||||
const orderByKey = subIssueFilters.displayFilters?.order_by;
|
||||
|
||||
const groupedWorkItemIds = getGroupedWorkItemIds(filteredWorkItems, groupByKey, orderByKey);
|
||||
|
||||
return groupedWorkItemIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method is used to get the filtered sub work items
|
||||
* @param workItemId
|
||||
* @returns
|
||||
*/
|
||||
getFilteredSubWorkItems = computedFn((workItemId: string, filters: IIssueFilterOptions) => {
|
||||
const subIssueIds = this.subIssueStore.subIssuesByIssueId(workItemId);
|
||||
const workItems = this.subIssueStore.rootIssueDetailStore.rootIssueStore.issues.getIssuesByIds(
|
||||
subIssueIds,
|
||||
"un-archived"
|
||||
);
|
||||
|
||||
const filteredWorkItems = getFilteredWorkItems(workItems, filters);
|
||||
|
||||
return filteredWorkItems;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method is used to reset the filters
|
||||
* @param workItemId
|
||||
*/
|
||||
resetFilters = (workItemId: string) => {
|
||||
this.initializeFilters(workItemId);
|
||||
};
|
||||
}
|
||||
104
apps/web/core/store/issue/issue-details/subscription.store.ts
Normal file
104
apps/web/core/store/issue/issue-details/subscription.store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// services
|
||||
import type { EIssueServiceType } from "@plane/types";
|
||||
import { IssueService } from "@/services/issue/issue.service";
|
||||
// types
|
||||
import type { IIssueDetail } from "./root.store";
|
||||
export interface IIssueSubscriptionStoreActions {
|
||||
addSubscription: (issueId: string, isSubscribed: boolean | undefined | null) => void;
|
||||
fetchSubscriptions: (workspaceSlug: string, projectId: string, issueId: string) => Promise<boolean>;
|
||||
createSubscription: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
removeSubscription: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IIssueSubscriptionStore extends IIssueSubscriptionStoreActions {
|
||||
// observables
|
||||
subscriptionMap: Record<string, Record<string, boolean>>; // Record defines subscriptionId as key and link as value
|
||||
// helper methods
|
||||
getSubscriptionByIssueId: (issueId: string) => boolean | undefined;
|
||||
}
|
||||
|
||||
export class IssueSubscriptionStore implements IIssueSubscriptionStore {
|
||||
// observables
|
||||
subscriptionMap: Record<string, Record<string, boolean>> = {};
|
||||
// root store
|
||||
rootIssueDetail: IIssueDetail;
|
||||
// services
|
||||
issueService;
|
||||
|
||||
constructor(rootStore: IIssueDetail, serviceType: EIssueServiceType) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
subscriptionMap: observable,
|
||||
// actions
|
||||
addSubscription: action.bound,
|
||||
fetchSubscriptions: action,
|
||||
createSubscription: action,
|
||||
removeSubscription: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueDetail = rootStore;
|
||||
// services
|
||||
this.issueService = new IssueService(serviceType);
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getSubscriptionByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
const currentUserId = this.rootIssueDetail.rootIssueStore.currentUserId;
|
||||
if (!currentUserId) return undefined;
|
||||
return this.subscriptionMap[issueId]?.[currentUserId] ?? undefined;
|
||||
};
|
||||
|
||||
addSubscription = (issueId: string, isSubscribed: boolean | undefined | null) => {
|
||||
const currentUserId = this.rootIssueDetail.rootIssueStore.currentUserId;
|
||||
if (!currentUserId) throw new Error("user id not available");
|
||||
|
||||
runInAction(() => {
|
||||
set(this.subscriptionMap, [issueId, currentUserId], isSubscribed ?? false);
|
||||
});
|
||||
};
|
||||
|
||||
fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const subscription = await this.issueService.getIssueNotificationSubscriptionStatus(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId
|
||||
);
|
||||
this.addSubscription(issueId, subscription?.subscribed);
|
||||
return subscription?.subscribed;
|
||||
};
|
||||
|
||||
createSubscription = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const currentUserId = this.rootIssueDetail.rootIssueStore.currentUserId;
|
||||
if (!currentUserId) throw new Error("user id not available");
|
||||
|
||||
runInAction(() => {
|
||||
set(this.subscriptionMap, [issueId, currentUserId], true);
|
||||
});
|
||||
|
||||
await this.issueService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
this.fetchSubscriptions(workspaceSlug, projectId, issueId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
removeSubscription = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const currentUserId = this.rootIssueDetail.rootIssueStore.currentUserId;
|
||||
if (!currentUserId) throw new Error("user id not available");
|
||||
|
||||
runInAction(() => {
|
||||
set(this.subscriptionMap, [issueId, currentUserId], false);
|
||||
});
|
||||
|
||||
await this.issueService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
this.fetchSubscriptions(workspaceSlug, projectId, issueId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
172
apps/web/core/store/issue/issue.store.ts
Normal file
172
apps/web/core/store/issue/issue.store.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { clone, set, update } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
// helpers
|
||||
import { getCurrentDateTimeInISO } from "@plane/utils";
|
||||
import { rootStore } from "@/lib/store-context";
|
||||
// services
|
||||
import { deleteIssueFromLocal } from "@/local-db/utils/load-issues";
|
||||
import { updatePersistentLayer } from "@/local-db/utils/utils";
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
export type IIssueStore = {
|
||||
// observables
|
||||
issuesMap: Record<string, TIssue>; // Record defines issue_id as key and TIssue as value
|
||||
issuesIdentifierMap: Record<string, string>; // Record defines issue_identifier as key and issue_id as value
|
||||
// actions
|
||||
getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]>;
|
||||
addIssue(issues: TIssue[]): void;
|
||||
addIssueIdentifier(issueIdentifier: string, issueId: string): void;
|
||||
updateIssue(issueId: string, issue: Partial<TIssue>): void;
|
||||
removeIssue(issueId: string): void;
|
||||
// helper methods
|
||||
getIssueById(issueId: string): undefined | TIssue;
|
||||
getIssueIdByIdentifier(issueIdentifier: string): undefined | string;
|
||||
getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): TIssue[]; // Record defines issue_id as key and TIssue as value
|
||||
};
|
||||
|
||||
export class IssueStore implements IIssueStore {
|
||||
// observables
|
||||
issuesMap: { [issue_id: string]: TIssue } = {};
|
||||
issuesIdentifierMap: { [issue_identifier: string]: string } = {};
|
||||
// service
|
||||
issueService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
issuesMap: observable,
|
||||
issuesIdentifierMap: observable,
|
||||
// actions
|
||||
addIssue: action,
|
||||
addIssueIdentifier: action,
|
||||
updateIssue: action,
|
||||
removeIssue: action,
|
||||
});
|
||||
this.issueService = new IssueService();
|
||||
}
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description This method will add issues to the issuesMap
|
||||
* @param {TIssue[]} issues
|
||||
* @returns {void}
|
||||
*/
|
||||
addIssue = (issues: TIssue[]) => {
|
||||
if (issues && issues.length <= 0) return;
|
||||
runInAction(() => {
|
||||
issues.forEach((issue) => {
|
||||
// add issue identifier to the issuesIdentifierMap
|
||||
const projectIdentifier = rootStore.projectRoot.project.getProjectIdentifierById(issue?.project_id);
|
||||
const workItemSequenceId = issue?.sequence_id;
|
||||
const issueIdentifier = `${projectIdentifier}-${workItemSequenceId}`;
|
||||
set(this.issuesIdentifierMap, issueIdentifier, issue.id);
|
||||
|
||||
if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue);
|
||||
else update(this.issuesMap, issue.id, (prevIssue) => ({ ...prevIssue, ...issue }));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method will add issue_identifier to the issuesIdentifierMap
|
||||
* @param issueIdentifier
|
||||
* @param issueId
|
||||
* @returns {void}
|
||||
*/
|
||||
addIssueIdentifier = (issueIdentifier: string, issueId: string) => {
|
||||
if (!issueIdentifier || !issueId) return;
|
||||
runInAction(() => {
|
||||
set(this.issuesIdentifierMap, issueIdentifier, issueId);
|
||||
});
|
||||
};
|
||||
|
||||
getIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {
|
||||
const issues = await this.issueService.retrieveIssues(workspaceSlug, projectId, issueIds);
|
||||
|
||||
runInAction(() => {
|
||||
issues.forEach((issue) => {
|
||||
if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue);
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method will update the issue in the issuesMap
|
||||
* @param {string} issueId
|
||||
* @param {Partial<TIssue>} issue
|
||||
* @returns {void}
|
||||
*/
|
||||
updateIssue = (issueId: string, issue: Partial<TIssue>) => {
|
||||
if (!issue || !issueId || !this.issuesMap[issueId]) return;
|
||||
const issueBeforeUpdate = clone(this.issuesMap[issueId]);
|
||||
runInAction(() => {
|
||||
set(this.issuesMap, [issueId, "updated_at"], getCurrentDateTimeInISO());
|
||||
Object.keys(issue).forEach((key) => {
|
||||
set(this.issuesMap, [issueId, key], issue[key as keyof TIssue]);
|
||||
});
|
||||
});
|
||||
|
||||
if (!issueBeforeUpdate.is_epic) {
|
||||
updatePersistentLayer(issueId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method will remove the issue from the issuesMap
|
||||
* @param {string} issueId
|
||||
* @returns {void}
|
||||
*/
|
||||
removeIssue = (issueId: string) => {
|
||||
if (!issueId || !this.issuesMap[issueId]) return;
|
||||
runInAction(() => {
|
||||
delete this.issuesMap[issueId];
|
||||
});
|
||||
deleteIssueFromLocal(issueId);
|
||||
};
|
||||
|
||||
// helper methods
|
||||
/**
|
||||
* @description This method will return the issue from the issuesMap
|
||||
* @param {string} issueId
|
||||
* @returns {TIssue | undefined}
|
||||
*/
|
||||
getIssueById = computedFn((issueId: string) => {
|
||||
if (!issueId || !this.issuesMap[issueId]) return undefined;
|
||||
return this.issuesMap[issueId];
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method will return the issue_id from the issuesIdentifierMap
|
||||
* @param {string} issueIdentifier
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
getIssueIdByIdentifier = computedFn((issueIdentifier: string) => {
|
||||
if (!issueIdentifier || !this.issuesIdentifierMap[issueIdentifier]) return undefined;
|
||||
return this.issuesIdentifierMap[issueIdentifier];
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method will return the issues from the issuesMap
|
||||
* @param {string[]} issueIds
|
||||
* @param {boolean} archivedIssues
|
||||
* @returns {Record<string, TIssue> | undefined}
|
||||
*/
|
||||
getIssuesByIds = computedFn((issueIds: string[], type: "archived" | "un-archived") => {
|
||||
if (!issueIds || issueIds.length <= 0) return [];
|
||||
const filteredIssues: TIssue[] = [];
|
||||
Object.values(issueIds).forEach((issueId) => {
|
||||
// if type is archived then check archived_at is not null
|
||||
// if type is un-archived then check archived_at is null
|
||||
const issue = this.issuesMap[issueId];
|
||||
if (issue && ((type === "archived" && issue.archived_at) || (type === "un-archived" && !issue?.archived_at))) {
|
||||
filteredIssues.push(issue);
|
||||
}
|
||||
});
|
||||
return filteredIssues;
|
||||
});
|
||||
}
|
||||
189
apps/web/core/store/issue/issue_calendar_view.store.ts
Normal file
189
apps/web/core/store/issue/issue_calendar_view.store.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { observable, action, makeObservable, runInAction, computed, reaction } from "mobx";
|
||||
|
||||
// helpers
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { ICalendarPayload, ICalendarWeek } from "@plane/types";
|
||||
import { EStartOfTheWeek } from "@plane/types";
|
||||
import { generateCalendarData, getWeekNumberOfDate } from "@plane/utils";
|
||||
// types
|
||||
import type { IIssueRootStore } from "./root.store";
|
||||
|
||||
export interface ICalendarStore {
|
||||
calendarFilters: {
|
||||
activeMonthDate: Date;
|
||||
activeWeekDate: Date;
|
||||
};
|
||||
calendarPayload: ICalendarPayload | null;
|
||||
|
||||
// action
|
||||
updateCalendarFilters: (filters: Partial<{ activeMonthDate: Date; activeWeekDate: Date }>) => void;
|
||||
updateCalendarPayload: (date: Date) => void;
|
||||
regenerateCalendar: () => void;
|
||||
|
||||
// computed
|
||||
allWeeksOfActiveMonth:
|
||||
| {
|
||||
[weekNumber: string]: ICalendarWeek;
|
||||
}
|
||||
| undefined;
|
||||
activeWeekNumber: number;
|
||||
allDaysOfActiveWeek: ICalendarWeek | undefined;
|
||||
getStartAndEndDate: (layout: "week" | "month") => { startDate: string; endDate: string } | undefined;
|
||||
}
|
||||
|
||||
export class CalendarStore implements ICalendarStore {
|
||||
loader: boolean = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any | null = null;
|
||||
|
||||
// observables
|
||||
calendarFilters: { activeMonthDate: Date; activeWeekDate: Date } = {
|
||||
activeMonthDate: new Date(),
|
||||
activeWeekDate: new Date(),
|
||||
};
|
||||
calendarPayload: ICalendarPayload | null = null;
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
makeObservable(this, {
|
||||
loader: observable.ref,
|
||||
error: observable.ref,
|
||||
|
||||
// observables
|
||||
calendarFilters: observable.ref,
|
||||
calendarPayload: observable.ref,
|
||||
|
||||
// actions
|
||||
updateCalendarFilters: action,
|
||||
updateCalendarPayload: action,
|
||||
regenerateCalendar: action,
|
||||
|
||||
//computed
|
||||
allWeeksOfActiveMonth: computed,
|
||||
activeWeekNumber: computed,
|
||||
allDaysOfActiveWeek: computed,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
this.initCalendar();
|
||||
|
||||
// Watch for changes in startOfWeek preference and regenerate calendar
|
||||
reaction(
|
||||
() => this.rootStore.rootStore.user.userProfile.data?.start_of_the_week,
|
||||
() => {
|
||||
// Regenerate calendar when startOfWeek preference changes
|
||||
this.regenerateCalendar();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get allWeeksOfActiveMonth() {
|
||||
if (!this.calendarPayload) return undefined;
|
||||
|
||||
const { activeMonthDate } = this.calendarFilters;
|
||||
|
||||
const year = activeMonthDate.getFullYear();
|
||||
const month = activeMonthDate.getMonth();
|
||||
|
||||
// Get the weeks for the current month
|
||||
const weeks = this.calendarPayload[`y-${year}`][`m-${month}`];
|
||||
|
||||
// If no weeks exist, return undefined
|
||||
if (!weeks) return undefined;
|
||||
|
||||
// Create a new object to store the reordered weeks
|
||||
const reorderedWeeks: { [weekNumber: string]: ICalendarWeek } = {};
|
||||
|
||||
// Get all week numbers and sort them
|
||||
const weekNumbers = Object.keys(weeks).map((key) => parseInt(key.replace("w-", "")));
|
||||
weekNumbers.sort((a, b) => a - b);
|
||||
|
||||
// Reorder weeks based on start_of_week
|
||||
weekNumbers.forEach((weekNumber) => {
|
||||
const weekKey = `w-${weekNumber}`;
|
||||
reorderedWeeks[weekKey] = weeks[weekKey];
|
||||
});
|
||||
|
||||
return reorderedWeeks;
|
||||
}
|
||||
|
||||
get activeWeekNumber() {
|
||||
return getWeekNumberOfDate(this.calendarFilters.activeWeekDate);
|
||||
}
|
||||
|
||||
get allDaysOfActiveWeek() {
|
||||
if (!this.calendarPayload) return undefined;
|
||||
|
||||
const { activeWeekDate } = this.calendarFilters;
|
||||
|
||||
return this.calendarPayload[`y-${activeWeekDate.getFullYear()}`][`m-${activeWeekDate.getMonth()}`][
|
||||
`w-${this.activeWeekNumber - 1}`
|
||||
];
|
||||
}
|
||||
|
||||
getStartAndEndDate = computedFn((layout: "week" | "month") => {
|
||||
switch (layout) {
|
||||
case "week": {
|
||||
if (!this.allDaysOfActiveWeek) return;
|
||||
const dates = Object.keys(this.allDaysOfActiveWeek);
|
||||
return { startDate: dates[0], endDate: dates[dates.length - 1] };
|
||||
}
|
||||
case "month": {
|
||||
if (!this.allWeeksOfActiveMonth) return;
|
||||
const weeks = Object.keys(this.allWeeksOfActiveMonth);
|
||||
const firstWeekDates = Object.keys(this.allWeeksOfActiveMonth[weeks[0]]);
|
||||
const lastWeekDates = Object.keys(this.allWeeksOfActiveMonth[weeks[weeks.length - 1]]);
|
||||
|
||||
return { startDate: firstWeekDates[0], endDate: lastWeekDates[lastWeekDates.length - 1] };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateCalendarFilters = (filters: Partial<{ activeMonthDate: Date; activeWeekDate: Date }>) => {
|
||||
this.updateCalendarPayload(filters.activeMonthDate || filters.activeWeekDate || new Date());
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarFilters = {
|
||||
...this.calendarFilters,
|
||||
...filters,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
updateCalendarPayload = (date: Date) => {
|
||||
if (!this.calendarPayload) return null;
|
||||
|
||||
const nextDate = new Date(date);
|
||||
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarPayload = generateCalendarData(this.calendarPayload, nextDate, startOfWeek);
|
||||
});
|
||||
};
|
||||
|
||||
initCalendar = () => {
|
||||
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
|
||||
const newCalendarPayload = generateCalendarData(null, new Date(), startOfWeek);
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarPayload = newCalendarPayload;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Force complete regeneration of calendar data
|
||||
* This should be called when startOfWeek preference changes
|
||||
*/
|
||||
regenerateCalendar = () => {
|
||||
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
|
||||
const { activeMonthDate } = this.calendarFilters;
|
||||
|
||||
// Force complete regeneration by passing null to clear all cached data
|
||||
const newCalendarPayload = generateCalendarData(null, activeMonthDate, startOfWeek);
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarPayload = newCalendarPayload;
|
||||
});
|
||||
};
|
||||
}
|
||||
95
apps/web/core/store/issue/issue_gantt_view.store.ts
Normal file
95
apps/web/core/store/issue/issue_gantt_view.store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// helpers
|
||||
import type { ChartDataType, TGanttViews } from "@plane/types";
|
||||
import { currentViewDataWithView } from "@/components/gantt-chart/data";
|
||||
// types
|
||||
|
||||
export interface IGanttStore {
|
||||
// observables
|
||||
currentView: TGanttViews;
|
||||
currentViewData: ChartDataType | undefined;
|
||||
activeBlockId: string | null;
|
||||
renderView: any;
|
||||
// computed functions
|
||||
isBlockActive: (blockId: string) => boolean;
|
||||
// actions
|
||||
updateCurrentView: (view: TGanttViews) => void;
|
||||
updateCurrentViewData: (data: ChartDataType | undefined) => void;
|
||||
updateActiveBlockId: (blockId: string | null) => void;
|
||||
updateRenderView: (data: any[]) => void;
|
||||
}
|
||||
|
||||
export class GanttStore implements IGanttStore {
|
||||
// observables
|
||||
currentView: TGanttViews = "month";
|
||||
currentViewData: ChartDataType | undefined = undefined;
|
||||
activeBlockId: string | null = null;
|
||||
renderView: any[] = [];
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
currentView: observable.ref,
|
||||
currentViewData: observable,
|
||||
activeBlockId: observable.ref,
|
||||
renderView: observable,
|
||||
// actions
|
||||
updateCurrentView: action.bound,
|
||||
updateCurrentViewData: action.bound,
|
||||
updateActiveBlockId: action.bound,
|
||||
updateRenderView: action.bound,
|
||||
});
|
||||
|
||||
this.initGantt();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description check if block is active
|
||||
* @param {string} blockId
|
||||
*/
|
||||
isBlockActive = computedFn((blockId: string): boolean => this.activeBlockId === blockId);
|
||||
|
||||
/**
|
||||
* @description update current view
|
||||
* @param {TGanttViews} view
|
||||
*/
|
||||
updateCurrentView = (view: TGanttViews) => {
|
||||
this.currentView = view;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update current view data
|
||||
* @param {ChartDataType | undefined} data
|
||||
*/
|
||||
updateCurrentViewData = (data: ChartDataType | undefined) => {
|
||||
this.currentViewData = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update active block
|
||||
* @param {string | null} block
|
||||
*/
|
||||
updateActiveBlockId = (blockId: string | null) => {
|
||||
this.activeBlockId = blockId;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update render view
|
||||
* @param {any[]} data
|
||||
*/
|
||||
updateRenderView = (data: any[]) => {
|
||||
this.renderView = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description initialize gantt chart with month view
|
||||
*/
|
||||
initGantt = () => {
|
||||
const newCurrentViewData = currentViewDataWithView(this.currentView);
|
||||
|
||||
runInAction(() => {
|
||||
this.currentViewData = newCurrentViewData;
|
||||
});
|
||||
};
|
||||
}
|
||||
83
apps/web/core/store/issue/issue_kanban_view.store.ts
Normal file
83
apps/web/core/store/issue/issue_kanban_view.store.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { action, computed, makeObservable, observable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
|
||||
// types
|
||||
import type { TIssueGroupByOptions } from "@plane/types";
|
||||
// constants
|
||||
// store
|
||||
import type { IssueRootStore } from "./root.store";
|
||||
|
||||
export interface IIssueKanBanViewStore {
|
||||
kanBanToggle: {
|
||||
groupByHeaderMinMax: string[];
|
||||
subgroupByIssuesVisibility: string[];
|
||||
};
|
||||
isDragging: boolean;
|
||||
// computed
|
||||
getCanUserDragDrop: (
|
||||
group_by: TIssueGroupByOptions | undefined,
|
||||
sub_group_by: TIssueGroupByOptions | undefined
|
||||
) => boolean;
|
||||
canUserDragDropVertically: boolean;
|
||||
canUserDragDropHorizontally: boolean;
|
||||
// actions
|
||||
handleKanBanToggle: (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => void;
|
||||
setIsDragging: (isDragging: boolean) => void;
|
||||
}
|
||||
|
||||
export class IssueKanBanViewStore implements IIssueKanBanViewStore {
|
||||
kanBanToggle: {
|
||||
groupByHeaderMinMax: string[];
|
||||
subgroupByIssuesVisibility: string[];
|
||||
} = { groupByHeaderMinMax: [], subgroupByIssuesVisibility: [] };
|
||||
isDragging = false;
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(_rootStore: IssueRootStore) {
|
||||
makeObservable(this, {
|
||||
kanBanToggle: observable,
|
||||
isDragging: observable.ref,
|
||||
// computed
|
||||
canUserDragDropVertically: computed,
|
||||
canUserDragDropHorizontally: computed,
|
||||
|
||||
// actions
|
||||
handleKanBanToggle: action,
|
||||
setIsDragging: action.bound,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
setIsDragging = (isDragging: boolean) => {
|
||||
this.isDragging = isDragging;
|
||||
};
|
||||
|
||||
getCanUserDragDrop = computedFn(
|
||||
(group_by: TIssueGroupByOptions | undefined, sub_group_by: TIssueGroupByOptions | undefined) => {
|
||||
if (group_by && DRAG_ALLOWED_GROUPS.includes(group_by)) {
|
||||
if (!sub_group_by) return true;
|
||||
if (sub_group_by && DRAG_ALLOWED_GROUPS.includes(sub_group_by)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
get canUserDragDropVertically() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get canUserDragDropHorizontally() {
|
||||
return false;
|
||||
}
|
||||
|
||||
handleKanBanToggle = (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => {
|
||||
this.kanBanToggle = {
|
||||
...this.kanBanToggle,
|
||||
[toggle]: this.kanBanToggle[toggle].includes(value)
|
||||
? this.kanBanToggle[toggle].filter((v) => v !== value)
|
||||
: [...this.kanBanToggle[toggle], value],
|
||||
};
|
||||
};
|
||||
}
|
||||
319
apps/web/core/store/issue/module/filter.store.ts
Normal file
319
apps/web/core/store/issue/module/filter.store.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// base class
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
import { IssueFiltersService } from "@/services/issue_filter.service";
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
// constants
|
||||
// services
|
||||
|
||||
export interface IModuleIssuesFilter extends IBaseIssueFilterStore {
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
moduleId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
getIssueFilters(moduleId: string): IIssueFilters | undefined;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
|
||||
updateFilterExpression: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
filters: TWorkItemFilterExpression
|
||||
) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate,
|
||||
moduleId: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModuleIssuesFilter {
|
||||
// observables
|
||||
filters: { [moduleId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new IssueFiltersService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const moduleId = this.rootIssueStore.moduleId;
|
||||
if (!moduleId) return undefined;
|
||||
|
||||
return this.getIssueFilters(moduleId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const moduleId = this.rootIssueStore.moduleId;
|
||||
if (!moduleId) return undefined;
|
||||
|
||||
return this.getAppliedFilters(moduleId);
|
||||
}
|
||||
|
||||
getIssueFilters(moduleId: string) {
|
||||
const displayFilters = this.filters[moduleId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
}
|
||||
|
||||
getAppliedFilters(moduleId: string) {
|
||||
const userFilters = this.getIssueFilters(moduleId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
if (filteredParams.includes("module")) filteredParams.splice(filteredParams.indexOf("module"), 1);
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
moduleId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
let filterParams = this.getAppliedFilters(moduleId);
|
||||
|
||||
if (!filterParams) {
|
||||
filterParams = {};
|
||||
}
|
||||
filterParams["module"] = moduleId;
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
const _filters = await this.issueFilterService.fetchModuleIssueFilters(workspaceSlug, projectId, moduleId);
|
||||
|
||||
const richFilters: TWorkItemFilterExpression = _filters?.rich_filters;
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
|
||||
// fetching the kanban toggle helpers in the local storage
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId) {
|
||||
const _kanbanFilters = this.handleIssuesLocalFilters.get(
|
||||
EIssuesStoreType.MODULE,
|
||||
workspaceSlug,
|
||||
moduleId,
|
||||
currentUserId
|
||||
);
|
||||
kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || [];
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [moduleId, "richFilters"], richFilters);
|
||||
set(this.filters, [moduleId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [moduleId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [moduleId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IModuleIssuesFilter["updateFilterExpression"] = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
moduleId,
|
||||
filters
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [moduleId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.moduleIssues.fetchIssuesWithExistingPagination(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
"mutation",
|
||||
moduleId
|
||||
);
|
||||
await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, {
|
||||
rich_filters: filters,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IModuleIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, moduleId) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[moduleId])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[moduleId].richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: this.filters[moduleId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[moduleId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[moduleId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to state if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[moduleId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.getShouldClearIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.moduleIssues.clear(true, true); // clear issues for local store when some filters like layout changes
|
||||
}
|
||||
|
||||
if (this.getShouldReFetchIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.moduleIssues.fetchIssuesWithExistingPagination(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
"mutation",
|
||||
moduleId
|
||||
);
|
||||
}
|
||||
|
||||
await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[moduleId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.MODULE, type, workspaceSlug, moduleId, currentUserId, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[moduleId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (moduleId) this.fetchFilters(workspaceSlug, projectId, moduleId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/module/index.ts
Normal file
2
apps/web/core/store/issue/module/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
284
apps/web/core/store/issue/module/issue.store.ts
Normal file
284
apps/web/core/store/issue/module/issue.store.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { action, makeObservable, runInAction } from "mobx";
|
||||
// base class
|
||||
import type {
|
||||
TIssue,
|
||||
TLoader,
|
||||
ViewFlags,
|
||||
IssuePaginationOptions,
|
||||
TIssuesResponse,
|
||||
TBulkOperationsPayload,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { getDistributionPathsPostUpdate } from "@plane/utils";
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
//
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IModuleIssuesFilter } from "./filter.store";
|
||||
|
||||
export interface IModuleIssues extends IBaseIssuesStore {
|
||||
viewFlags: ViewFlags;
|
||||
// actions
|
||||
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined;
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
moduleId: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
moduleId: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>, moduleId: string) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
quickAddIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: TIssue,
|
||||
moduleId: string
|
||||
) => Promise<TIssue | undefined>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ModuleIssues extends BaseIssuesStore implements IModuleIssues {
|
||||
viewFlags = {
|
||||
enableQuickAdd: true,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
// filter store
|
||||
issueFilterStore: IModuleIssuesFilter;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: IModuleIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore);
|
||||
|
||||
makeObservable(this, {
|
||||
// action
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
|
||||
quickAddIssue: action,
|
||||
});
|
||||
// filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the module details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param id is the module Id
|
||||
*/
|
||||
fetchParentStats = (workspaceSlug: string, projectId?: string | undefined, id?: string | undefined) => {
|
||||
const moduleId = id ?? this.moduleId;
|
||||
projectId &&
|
||||
moduleId &&
|
||||
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Parent stats before fetching from server
|
||||
* @param prevIssueState
|
||||
* @param nextIssueState
|
||||
* @param id
|
||||
*/
|
||||
updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => {
|
||||
try {
|
||||
// get distribution updates
|
||||
const distributionUpdates = getDistributionPathsPostUpdate(
|
||||
prevIssueState,
|
||||
nextIssueState,
|
||||
this.rootIssueStore.rootStore.state.stateMap,
|
||||
this.rootIssueStore.rootStore.projectEstimate?.currentActiveEstimate?.estimatePointById
|
||||
);
|
||||
|
||||
const moduleId = id ?? this.moduleId;
|
||||
|
||||
moduleId && this.rootIssueStore.rootStore.module.updateModuleDistribution(distributionUpdates, moduleId);
|
||||
} catch (e) {
|
||||
console.warn("could not update module statistics");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
moduleId: string,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
this.clear(!isExistingPaginationOptions, false); // clear while fetching from server.
|
||||
if (!this.groupBy) this.clear(!isExistingPaginationOptions, true); // clear while using local to have the no load effect.
|
||||
});
|
||||
|
||||
// get params from pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(options, moduleId, undefined, undefined, undefined);
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, projectId, moduleId, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined once errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
moduleId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
moduleId: string
|
||||
) => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions, moduleId, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Override inherited create issue, to also add issue to module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
override createIssue = async (workspaceSlug: string, projectId: string, data: Partial<TIssue>, moduleId: string) => {
|
||||
try {
|
||||
const response = await super.createIssue(workspaceSlug, projectId, data, moduleId, false);
|
||||
const moduleIds = data.module_ids && data.module_ids.length > 1 ? data.module_ids : [moduleId];
|
||||
await this.addModulesToIssue(workspaceSlug, projectId, response.id, moduleIds);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method overrides the base quickAdd issue
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue, moduleId: string) => {
|
||||
try {
|
||||
// add temporary issue to store list
|
||||
this.addIssue(data);
|
||||
|
||||
// call overridden create issue
|
||||
const response = await this.createIssue(workspaceSlug, projectId, data, moduleId);
|
||||
|
||||
// remove temp Issue from store list
|
||||
runInAction(() => {
|
||||
this.removeIssueFromList(data.id);
|
||||
this.rootIssueStore.issues.removeIssue(data.id);
|
||||
});
|
||||
|
||||
const currentCycleId = data.cycle_id !== "" && data.cycle_id === "None" ? undefined : data.cycle_id;
|
||||
|
||||
if (currentCycleId) {
|
||||
await this.addCycleToIssue(workspaceSlug, projectId, currentCycleId, response.id);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Using aliased names as they cannot be overridden in other stores
|
||||
archiveBulkIssues = this.bulkArchiveIssues;
|
||||
updateIssue = this.issueUpdate;
|
||||
archiveIssue = this.issueArchive;
|
||||
}
|
||||
284
apps/web/core/store/issue/profile/filter.store.ts
Normal file
284
apps/web/core/store/issue/profile/filter.store.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// base class
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
import { IssueFiltersService } from "@/services/issue_filter.service";
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
// constants
|
||||
// services
|
||||
|
||||
export interface IProfileIssuesFilter extends IBaseIssueFilterStore {
|
||||
// observables
|
||||
userId: string;
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
userId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string, userId: string) => Promise<void>;
|
||||
updateFilterExpression: (workspaceSlug: string, userId: string, filters: TWorkItemFilterExpression) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string | undefined,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate,
|
||||
userId: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProfileIssuesFilter {
|
||||
// observables
|
||||
userId: string = "";
|
||||
filters: { [userId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
userId: observable.ref,
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new IssueFiltersService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const userId = this.rootIssueStore.userId;
|
||||
if (!userId) return undefined;
|
||||
|
||||
return this.getIssueFilters(userId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const userId = this.rootIssueStore.userId;
|
||||
if (!userId) return undefined;
|
||||
|
||||
return this.getAppliedFilters(userId);
|
||||
}
|
||||
|
||||
getIssueFilters(userId: string) {
|
||||
const displayFilters = this.filters[userId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
}
|
||||
|
||||
getAppliedFilters(userId: string) {
|
||||
const userFilters = this.getIssueFilters(userId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "profile_issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
userId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
const filterParams = this.getAppliedFilters(userId);
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, userId: string) => {
|
||||
this.userId = userId;
|
||||
const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.PROFILE, workspaceSlug, userId, undefined);
|
||||
|
||||
const richFilters: TWorkItemFilterExpression = _filters?.rich_filters;
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
const kanbanFilters = {
|
||||
group_by: _filters?.kanban_filters?.group_by || [],
|
||||
sub_group_by: _filters?.kanban_filters?.sub_group_by || [],
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [userId, "richFilters"], richFilters);
|
||||
set(this.filters, [userId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [userId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [userId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IProfileIssuesFilter["updateFilterExpression"] = async (workspaceSlug, userId, filters) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [userId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, userId, "mutation");
|
||||
this.handleIssuesLocalFilters.set(
|
||||
EIssuesStoreType.PROFILE,
|
||||
EIssueFilterType.FILTERS,
|
||||
workspaceSlug,
|
||||
userId,
|
||||
undefined,
|
||||
{
|
||||
rich_filters: filters,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IProfileIssuesFilter["updateFilters"] = async (workspaceSlug, _projectId, type, filters, userId) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[userId])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[userId].richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: this.filters[userId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[userId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[userId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to priority if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "priority";
|
||||
updatedDisplayFilters.group_by = "priority";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[userId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, userId, "mutation");
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[userId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[userId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (userId) this.fetchFilters(workspaceSlug, userId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/profile/index.ts
Normal file
2
apps/web/core/store/issue/profile/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
231
apps/web/core/store/issue/profile/issue.store.ts
Normal file
231
apps/web/core/store/issue/profile/issue.store.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { action, observable, makeObservable, computed, runInAction } from "mobx";
|
||||
// base class
|
||||
import type {
|
||||
TIssue,
|
||||
TLoader,
|
||||
IssuePaginationOptions,
|
||||
TIssuesResponse,
|
||||
ViewFlags,
|
||||
TBulkOperationsPayload,
|
||||
TProfileViews,
|
||||
} from "@plane/types";
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
// services
|
||||
// types
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IProfileIssuesFilter } from "./filter.store";
|
||||
|
||||
export interface IProfileIssues extends IBaseIssuesStore {
|
||||
// observable
|
||||
currentView: TProfileViews;
|
||||
viewFlags: ViewFlags;
|
||||
// actions
|
||||
setViewId: (viewId: TProfileViews) => void;
|
||||
// action
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
userId: string,
|
||||
loadType: TLoader,
|
||||
option: IssuePaginationOptions,
|
||||
view: TProfileViews,
|
||||
isExistingPaginationOptions?: boolean
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
userId: string,
|
||||
loadType: TLoader
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
userId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
|
||||
quickAddIssue: undefined;
|
||||
}
|
||||
|
||||
export class ProfileIssues extends BaseIssuesStore implements IProfileIssues {
|
||||
currentView: TProfileViews = "assigned";
|
||||
// filter store
|
||||
issueFilterStore: IProfileIssuesFilter;
|
||||
// services
|
||||
userService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: IProfileIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore);
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
currentView: observable.ref,
|
||||
// computed
|
||||
viewFlags: computed,
|
||||
// action
|
||||
setViewId: action.bound,
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
});
|
||||
// filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
// services
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
get viewFlags() {
|
||||
if (this.currentView === "subscribed")
|
||||
return {
|
||||
enableQuickAdd: false,
|
||||
enableIssueCreation: false,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
return {
|
||||
enableQuickAdd: false,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
}
|
||||
|
||||
setViewId(viewId: TProfileViews) {
|
||||
this.currentView = viewId;
|
||||
}
|
||||
|
||||
fetchParentStats = () => {};
|
||||
|
||||
/** */
|
||||
updateParentStats = () => {};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param userId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @param view
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues: IProfileIssues["fetchIssues"] = async (
|
||||
workspaceSlug: string,
|
||||
userId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
view: TProfileViews,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
});
|
||||
this.clear(!isExistingPaginationOptions);
|
||||
|
||||
// set ViewId
|
||||
this.setViewId(view);
|
||||
|
||||
// get params from pagination options
|
||||
let params = this.issueFilterStore?.getFilterParams(options, userId, undefined, undefined, undefined);
|
||||
params = {
|
||||
...params,
|
||||
assignees: undefined,
|
||||
created_by: undefined,
|
||||
subscriber: undefined,
|
||||
};
|
||||
// modify params based on view
|
||||
if (this.currentView === "assigned") params = { ...params, assignees: userId };
|
||||
else if (this.currentView === "created") params = { ...params, created_by: userId };
|
||||
else if (this.currentView === "subscribed") params = { ...params, subscriber: userId };
|
||||
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.userService.getUserProfileIssues(workspaceSlug, userId, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, undefined, undefined, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined if errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param userId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (workspaceSlug: string, userId: string, groupId?: string, subGroupId?: string) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
let params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
userId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
params = {
|
||||
...params,
|
||||
assignees: undefined,
|
||||
created_by: undefined,
|
||||
subscriber: undefined,
|
||||
};
|
||||
if (this.currentView === "assigned") params = { ...params, assignees: userId };
|
||||
else if (this.currentView === "created") params = { ...params, created_by: userId };
|
||||
else if (this.currentView === "subscribed") params = { ...params, subscriber: userId };
|
||||
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.userService.getUserProfileIssues(workspaceSlug, userId, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param userId
|
||||
* @param loadType
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (workspaceSlug: string, userId: string, loadType: TLoader) => {
|
||||
if (!this.paginationOptions || !this.currentView) return;
|
||||
return await this.fetchIssues(workspaceSlug, userId, loadType, this.paginationOptions, this.currentView, true);
|
||||
};
|
||||
|
||||
// Using aliased names as they cannot be overridden in other stores
|
||||
archiveBulkIssues = this.bulkArchiveIssues;
|
||||
updateIssue = this.issueUpdate;
|
||||
archiveIssue = this.issueArchive;
|
||||
|
||||
// Setting them as undefined as they can not performed on profile issues
|
||||
quickAddIssue = undefined;
|
||||
}
|
||||
338
apps/web/core/store/issue/project-views/filter.store.ts
Normal file
338
apps/web/core/store/issue/project-views/filter.store.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// base class
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
IProjectView,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
// services
|
||||
import { ViewService } from "@/plane-web/services";
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
// constants
|
||||
|
||||
export interface IProjectViewIssuesFilter extends IBaseIssueFilterStore {
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
viewId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
getIssueFilters(viewId: string): IIssueFilters | undefined;
|
||||
// helper actions
|
||||
mutateFilters: (workspaceSlug: string, viewId: string, viewDetails: IProjectView) => void;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string, projectId: string, viewId: string) => Promise<void>;
|
||||
updateFilterExpression: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
filters: TWorkItemFilterExpression
|
||||
) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate,
|
||||
viewId: string
|
||||
) => Promise<void>;
|
||||
resetFilters: (workspaceSlug: string, viewId: string) => void;
|
||||
}
|
||||
|
||||
export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements IProjectViewIssuesFilter {
|
||||
// observables
|
||||
filters: { [viewId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
resetFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new ViewService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const viewId = this.rootIssueStore.viewId;
|
||||
if (!viewId) return undefined;
|
||||
|
||||
return this.getIssueFilters(viewId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const viewId = this.rootIssueStore.viewId;
|
||||
if (!viewId) return undefined;
|
||||
|
||||
return this.getAppliedFilters(viewId);
|
||||
}
|
||||
|
||||
getIssueFilters(viewId: string) {
|
||||
const displayFilters = this.filters[viewId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
}
|
||||
|
||||
getAppliedFilters(viewId: string) {
|
||||
const userFilters = this.getIssueFilters(viewId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
viewId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
const filterParams = this.getAppliedFilters(viewId);
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
mutateFilters: IProjectViewIssuesFilter["mutateFilters"] = action((workspaceSlug, viewId, viewDetails) => {
|
||||
const richFilters: TWorkItemFilterExpression = viewDetails?.rich_filters;
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(viewDetails?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(viewDetails?.display_properties);
|
||||
|
||||
// fetching the kanban toggle helpers in the local storage
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId) {
|
||||
const _kanbanFilters = this.handleIssuesLocalFilters.get(
|
||||
EIssuesStoreType.PROJECT_VIEW,
|
||||
workspaceSlug,
|
||||
viewId,
|
||||
currentUserId
|
||||
);
|
||||
kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || [];
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [viewId, "richFilters"], richFilters);
|
||||
set(this.filters, [viewId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [viewId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [viewId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
});
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const viewDetails = await this.issueFilterService.getViewDetails(workspaceSlug, projectId, viewId);
|
||||
this.mutateFilters(workspaceSlug, viewId, viewDetails);
|
||||
} catch (error) {
|
||||
console.log("error while fetching project view filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IProjectViewIssuesFilter["updateFilterExpression"] = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId,
|
||||
filters
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [viewId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.projectViewIssues.fetchIssuesWithExistingPagination(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId,
|
||||
"mutation"
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IProjectViewIssuesFilter["updateFilters"] = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
type,
|
||||
filters,
|
||||
viewId
|
||||
) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[viewId])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[viewId].richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: this.filters[viewId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[viewId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[viewId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to state if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[viewId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.getShouldClearIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.projectIssues.clear(true, true); // clear issues for local store when some filters like layout changes
|
||||
}
|
||||
|
||||
if (this.getShouldReFetchIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.projectViewIssues.fetchIssuesWithExistingPagination(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId,
|
||||
"mutation"
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[viewId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(
|
||||
EIssuesStoreType.PROJECT_VIEW,
|
||||
type,
|
||||
workspaceSlug,
|
||||
viewId,
|
||||
currentUserId,
|
||||
{
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
}
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[viewId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (viewId) this.fetchFilters(workspaceSlug, projectId, viewId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description resets the filters for a project view
|
||||
* @param workspaceSlug
|
||||
* @param viewId
|
||||
*/
|
||||
resetFilters: IProjectViewIssuesFilter["resetFilters"] = action((workspaceSlug, viewId) => {
|
||||
const viewDetails = this.rootIssueStore.rootStore.projectView.getViewById(viewId);
|
||||
if (!viewDetails) return;
|
||||
this.mutateFilters(workspaceSlug, viewId, viewDetails);
|
||||
});
|
||||
}
|
||||
2
apps/web/core/store/issue/project-views/index.ts
Normal file
2
apps/web/core/store/issue/project-views/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
186
apps/web/core/store/issue/project-views/issue.store.ts
Normal file
186
apps/web/core/store/issue/project-views/issue.store.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { action, makeObservable, runInAction } from "mobx";
|
||||
// base class
|
||||
import type {
|
||||
TIssue,
|
||||
TLoader,
|
||||
ViewFlags,
|
||||
IssuePaginationOptions,
|
||||
TIssuesResponse,
|
||||
TBulkOperationsPayload,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
// types
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IProjectViewIssuesFilter } from "./filter.store";
|
||||
|
||||
export interface IProjectViewIssues extends IBaseIssuesStore {
|
||||
viewFlags: ViewFlags;
|
||||
// actions
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
loadType: TLoader
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue | undefined>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectViewIssues extends BaseIssuesStore implements IProjectViewIssues {
|
||||
viewFlags = {
|
||||
enableQuickAdd: true,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
//filter store
|
||||
issueFilterStore: IProjectViewIssuesFilter;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: IProjectViewIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore);
|
||||
makeObservable(this, {
|
||||
// action
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
});
|
||||
//filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
}
|
||||
|
||||
fetchParentStats = async () => {};
|
||||
|
||||
/** */
|
||||
updateParentStats = () => {};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
this.clear(!isExistingPaginationOptions, false); // clear while fetching from server.
|
||||
if (!this.groupBy) this.clear(!isExistingPaginationOptions, true); // clear while using local to have the no load effect.
|
||||
});
|
||||
|
||||
// get params from pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(options, viewId, undefined, undefined, undefined);
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, projectId, viewId, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined if errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
viewId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
loadType: TLoader
|
||||
) => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchIssues(workspaceSlug, projectId, viewId, loadType, this.paginationOptions, true);
|
||||
};
|
||||
|
||||
// Using aliased names as they cannot be overridden in other stores
|
||||
archiveBulkIssues = this.bulkArchiveIssues;
|
||||
quickAddIssue = this.issueQuickAdd;
|
||||
updateIssue = this.issueUpdate;
|
||||
archiveIssue = this.issueArchive;
|
||||
}
|
||||
297
apps/web/core/store/issue/project/filter.store.ts
Normal file
297
apps/web/core/store/issue/project/filter.store.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// base class
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
import { IssueFiltersService } from "@/services/issue_filter.service";
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// helpers
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
// constants
|
||||
// services
|
||||
|
||||
export interface IProjectIssuesFilter extends IBaseIssueFilterStore {
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
projectId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
getIssueFilters(projectId: string): IIssueFilters | undefined;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
updateFilterExpression: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filters: TWorkItemFilterExpression
|
||||
) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProjectIssuesFilter {
|
||||
// observables
|
||||
filters: { [projectId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilterExpression: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new IssueFiltersService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const projectId = this.rootIssueStore.projectId;
|
||||
if (!projectId) return undefined;
|
||||
|
||||
return this.getIssueFilters(projectId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const projectId = this.rootIssueStore.projectId;
|
||||
if (!projectId) return undefined;
|
||||
|
||||
return this.getAppliedFilters(projectId);
|
||||
}
|
||||
|
||||
getIssueFilters(projectId: string) {
|
||||
const displayFilters = this.filters[projectId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
return this.computedIssueFilters(displayFilters);
|
||||
}
|
||||
|
||||
getAppliedFilters(projectId: string) {
|
||||
const userFilters = this.getIssueFilters(projectId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
projectId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
const filterParams = this.getAppliedFilters(projectId);
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, projectId: string) => {
|
||||
const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId);
|
||||
|
||||
const richFilters = _filters?.rich_filters;
|
||||
const displayFilters = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
|
||||
// fetching the kanban toggle helpers in the local storage
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId) {
|
||||
const _kanbanFilters = this.handleIssuesLocalFilters.get(
|
||||
EIssuesStoreType.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
currentUserId
|
||||
);
|
||||
kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || [];
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [projectId, "richFilters"], richFilters);
|
||||
set(this.filters, [projectId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [projectId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [projectId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IProjectIssuesFilter["updateFilterExpression"] = async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
filters
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [projectId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation");
|
||||
await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, {
|
||||
rich_filters: filters,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IProjectIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[projectId])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[projectId].richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to state if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.getShouldClearIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.projectIssues.clear(true, true); // clear issues for local store when some filters like layout changes
|
||||
}
|
||||
|
||||
if (this.getShouldReFetchIssues(updatedDisplayFilters)) {
|
||||
this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation");
|
||||
}
|
||||
|
||||
await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, currentUserId, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.fetchFilters(workspaceSlug, projectId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/project/index.ts
Normal file
2
apps/web/core/store/issue/project/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
199
apps/web/core/store/issue/project/issue.store.ts
Normal file
199
apps/web/core/store/issue/project/issue.store.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { action, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import type {
|
||||
TIssue,
|
||||
TLoader,
|
||||
ViewFlags,
|
||||
IssuePaginationOptions,
|
||||
TIssuesResponse,
|
||||
TBulkOperationsPayload,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
// base class
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
// services
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IProjectIssuesFilter } from "./filter.store";
|
||||
|
||||
export interface IProjectIssues extends IBaseIssuesStore {
|
||||
viewFlags: ViewFlags;
|
||||
// action
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader,
|
||||
option: IssuePaginationOptions
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue | undefined>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectIssues extends BaseIssuesStore implements IProjectIssues {
|
||||
viewFlags = {
|
||||
enableQuickAdd: true,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
router;
|
||||
|
||||
// filter store
|
||||
issueFilterStore: IProjectIssuesFilter;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: IProjectIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore);
|
||||
makeObservable(this, {
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
|
||||
quickAddIssue: action,
|
||||
});
|
||||
// filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
this.router = _rootStore.rootStore.router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the project details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchParentStats = async (workspaceSlug: string, projectId?: string) => {
|
||||
projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
/** */
|
||||
updateParentStats = () => {};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader = "init-loader",
|
||||
options: IssuePaginationOptions,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
this.clear(!isExistingPaginationOptions, false); // clear while fetching from server.
|
||||
if (!this.groupBy) this.clear(!isExistingPaginationOptions, true); // clear while using local to have the no load effect.
|
||||
});
|
||||
|
||||
// get params from pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(options, projectId, undefined, undefined, undefined);
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, projectId, undefined, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined if errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (workspaceSlug: string, projectId: string, groupId?: string, subGroupId?: string) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
projectId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.issueService.getIssues(workspaceSlug, projectId, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param loadType
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loadType: TLoader = "mutation"
|
||||
) => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Override inherited create issue, to update list only if user is on current project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
override createIssue = async (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => {
|
||||
const response = await super.createIssue(workspaceSlug, projectId, data, "", projectId === this.router.projectId);
|
||||
return response;
|
||||
};
|
||||
|
||||
// Using aliased names as they cannot be overridden in other stores
|
||||
archiveBulkIssues = this.bulkArchiveIssues;
|
||||
quickAddIssue = this.issueQuickAdd;
|
||||
updateIssue = this.issueUpdate;
|
||||
archiveIssue = this.issueArchive;
|
||||
}
|
||||
274
apps/web/core/store/issue/root.store.ts
Normal file
274
apps/web/core/store/issue/root.store.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { autorun, makeObservable, observable } from "mobx";
|
||||
// types
|
||||
import type { ICycle, IIssueLabel, IModule, IProject, IState, IUserLite, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// plane web store
|
||||
import type { IProjectEpics, IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
|
||||
import { ProjectEpics, ProjectEpicsFilter } from "@/plane-web/store/issue/epic";
|
||||
import type { IIssueDetail } from "@/plane-web/store/issue/issue-details/root.store";
|
||||
import { IssueDetail } from "@/plane-web/store/issue/issue-details/root.store";
|
||||
import type { ITeamIssuesFilter, ITeamIssues } from "@/plane-web/store/issue/team";
|
||||
import { TeamIssues, TeamIssuesFilter } from "@/plane-web/store/issue/team";
|
||||
import type { ITeamProjectWorkItemsFilter } from "@/plane-web/store/issue/team-project/filter.store";
|
||||
import { TeamProjectWorkItemsFilter } from "@/plane-web/store/issue/team-project/filter.store";
|
||||
import type { ITeamProjectWorkItems } from "@/plane-web/store/issue/team-project/issue.store";
|
||||
import { TeamProjectWorkItems } from "@/plane-web/store/issue/team-project/issue.store";
|
||||
import type { ITeamViewIssues, ITeamViewIssuesFilter } from "@/plane-web/store/issue/team-views";
|
||||
import { TeamViewIssues, TeamViewIssuesFilter } from "@/plane-web/store/issue/team-views";
|
||||
// root store
|
||||
import type { IWorkspaceIssues } from "@/plane-web/store/issue/workspace/issue.store";
|
||||
import { WorkspaceIssues } from "@/plane-web/store/issue/workspace/issue.store";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import type { IWorkspaceMembership } from "@/store/member/workspace/workspace-member.store";
|
||||
// issues data store
|
||||
import type { IArchivedIssuesFilter, IArchivedIssues } from "./archived";
|
||||
import { ArchivedIssuesFilter, ArchivedIssues } from "./archived";
|
||||
import type { ICycleIssuesFilter, ICycleIssues } from "./cycle";
|
||||
import { CycleIssuesFilter, CycleIssues } from "./cycle";
|
||||
import type { IIssueStore } from "./issue.store";
|
||||
import { IssueStore } from "./issue.store";
|
||||
import type { ICalendarStore } from "./issue_calendar_view.store";
|
||||
import { CalendarStore } from "./issue_calendar_view.store";
|
||||
import type { IIssueKanBanViewStore } from "./issue_kanban_view.store";
|
||||
import { IssueKanBanViewStore } from "./issue_kanban_view.store";
|
||||
import type { IModuleIssuesFilter, IModuleIssues } from "./module";
|
||||
import { ModuleIssuesFilter, ModuleIssues } from "./module";
|
||||
import type { IProfileIssuesFilter, IProfileIssues } from "./profile";
|
||||
import { ProfileIssuesFilter, ProfileIssues } from "./profile";
|
||||
import type { IProjectIssuesFilter, IProjectIssues } from "./project";
|
||||
import { ProjectIssuesFilter, ProjectIssues } from "./project";
|
||||
import type { IProjectViewIssuesFilter, IProjectViewIssues } from "./project-views";
|
||||
import { ProjectViewIssuesFilter, ProjectViewIssues } from "./project-views";
|
||||
import type { IWorkspaceIssuesFilter } from "./workspace";
|
||||
import { WorkspaceIssuesFilter } from "./workspace";
|
||||
import type { IWorkspaceDraftIssues, IWorkspaceDraftIssuesFilter } from "./workspace-draft";
|
||||
import { WorkspaceDraftIssues, WorkspaceDraftIssuesFilter } from "./workspace-draft";
|
||||
|
||||
export interface IIssueRootStore {
|
||||
currentUserId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
teamspaceId: string | undefined;
|
||||
projectId: string | undefined;
|
||||
cycleId: string | undefined;
|
||||
moduleId: string | undefined;
|
||||
viewId: string | undefined;
|
||||
globalViewId: string | undefined; // all issues view id
|
||||
userId: string | undefined; // user profile detail Id
|
||||
stateMap: Record<string, IState> | undefined;
|
||||
stateDetails: IState[] | undefined;
|
||||
workspaceStateDetails: IState[] | undefined;
|
||||
labelMap: Record<string, IIssueLabel> | undefined;
|
||||
workSpaceMemberRolesMap: Record<string, IWorkspaceMembership> | undefined;
|
||||
memberMap: Record<string, IUserLite> | undefined;
|
||||
projectMap: Record<string, IProject> | undefined;
|
||||
moduleMap: Record<string, IModule> | undefined;
|
||||
cycleMap: Record<string, ICycle> | undefined;
|
||||
|
||||
rootStore: RootStore;
|
||||
serviceType: TIssueServiceType;
|
||||
|
||||
issues: IIssueStore;
|
||||
|
||||
issueDetail: IIssueDetail;
|
||||
epicDetail: IIssueDetail;
|
||||
|
||||
workspaceIssuesFilter: IWorkspaceIssuesFilter;
|
||||
workspaceIssues: IWorkspaceIssues;
|
||||
|
||||
workspaceDraftIssuesFilter: IWorkspaceDraftIssuesFilter;
|
||||
workspaceDraftIssues: IWorkspaceDraftIssues;
|
||||
|
||||
profileIssuesFilter: IProfileIssuesFilter;
|
||||
profileIssues: IProfileIssues;
|
||||
|
||||
teamIssuesFilter: ITeamIssuesFilter;
|
||||
teamIssues: ITeamIssues;
|
||||
|
||||
projectIssuesFilter: IProjectIssuesFilter;
|
||||
projectIssues: IProjectIssues;
|
||||
|
||||
cycleIssuesFilter: ICycleIssuesFilter;
|
||||
cycleIssues: ICycleIssues;
|
||||
|
||||
moduleIssuesFilter: IModuleIssuesFilter;
|
||||
moduleIssues: IModuleIssues;
|
||||
|
||||
teamViewIssuesFilter: ITeamViewIssuesFilter;
|
||||
teamViewIssues: ITeamViewIssues;
|
||||
|
||||
teamProjectWorkItemsFilter: ITeamProjectWorkItemsFilter;
|
||||
teamProjectWorkItems: ITeamProjectWorkItems;
|
||||
|
||||
projectViewIssuesFilter: IProjectViewIssuesFilter;
|
||||
projectViewIssues: IProjectViewIssues;
|
||||
|
||||
archivedIssuesFilter: IArchivedIssuesFilter;
|
||||
archivedIssues: IArchivedIssues;
|
||||
|
||||
issueKanBanView: IIssueKanBanViewStore;
|
||||
issueCalendarView: ICalendarStore;
|
||||
|
||||
projectEpicsFilter: IProjectEpicsFilter;
|
||||
projectEpics: IProjectEpics;
|
||||
}
|
||||
|
||||
export class IssueRootStore implements IIssueRootStore {
|
||||
currentUserId: string | undefined = undefined;
|
||||
workspaceSlug: string | undefined = undefined;
|
||||
teamspaceId: string | undefined = undefined;
|
||||
projectId: string | undefined = undefined;
|
||||
cycleId: string | undefined = undefined;
|
||||
moduleId: string | undefined = undefined;
|
||||
viewId: string | undefined = undefined;
|
||||
globalViewId: string | undefined = undefined;
|
||||
userId: string | undefined = undefined;
|
||||
stateMap: Record<string, IState> | undefined = undefined;
|
||||
stateDetails: IState[] | undefined = undefined;
|
||||
workspaceStateDetails: IState[] | undefined = undefined;
|
||||
labelMap: Record<string, IIssueLabel> | undefined = undefined;
|
||||
workSpaceMemberRolesMap: Record<string, IWorkspaceMembership> | undefined = undefined;
|
||||
memberMap: Record<string, IUserLite> | undefined = undefined;
|
||||
projectMap: Record<string, IProject> | undefined = undefined;
|
||||
moduleMap: Record<string, IModule> | undefined = undefined;
|
||||
cycleMap: Record<string, ICycle> | undefined = undefined;
|
||||
|
||||
rootStore: RootStore;
|
||||
serviceType: TIssueServiceType;
|
||||
|
||||
issues: IIssueStore;
|
||||
|
||||
issueDetail: IIssueDetail;
|
||||
epicDetail: IIssueDetail;
|
||||
|
||||
workspaceIssuesFilter: IWorkspaceIssuesFilter;
|
||||
workspaceIssues: IWorkspaceIssues;
|
||||
|
||||
workspaceDraftIssuesFilter: IWorkspaceDraftIssuesFilter;
|
||||
workspaceDraftIssues: IWorkspaceDraftIssues;
|
||||
|
||||
profileIssuesFilter: IProfileIssuesFilter;
|
||||
profileIssues: IProfileIssues;
|
||||
|
||||
teamIssuesFilter: ITeamIssuesFilter;
|
||||
teamIssues: ITeamIssues;
|
||||
|
||||
projectIssuesFilter: IProjectIssuesFilter;
|
||||
projectIssues: IProjectIssues;
|
||||
|
||||
cycleIssuesFilter: ICycleIssuesFilter;
|
||||
cycleIssues: ICycleIssues;
|
||||
|
||||
moduleIssuesFilter: IModuleIssuesFilter;
|
||||
moduleIssues: IModuleIssues;
|
||||
|
||||
teamViewIssuesFilter: ITeamViewIssuesFilter;
|
||||
teamViewIssues: ITeamViewIssues;
|
||||
|
||||
projectViewIssuesFilter: IProjectViewIssuesFilter;
|
||||
projectViewIssues: IProjectViewIssues;
|
||||
|
||||
teamProjectWorkItemsFilter: ITeamProjectWorkItemsFilter;
|
||||
teamProjectWorkItems: ITeamProjectWorkItems;
|
||||
|
||||
archivedIssuesFilter: IArchivedIssuesFilter;
|
||||
archivedIssues: IArchivedIssues;
|
||||
|
||||
issueKanBanView: IIssueKanBanViewStore;
|
||||
issueCalendarView: ICalendarStore;
|
||||
|
||||
projectEpicsFilter: IProjectEpicsFilter;
|
||||
projectEpics: IProjectEpics;
|
||||
|
||||
constructor(rootStore: RootStore, serviceType: TIssueServiceType = EIssueServiceType.ISSUES) {
|
||||
makeObservable(this, {
|
||||
workspaceSlug: observable.ref,
|
||||
teamspaceId: observable.ref,
|
||||
projectId: observable.ref,
|
||||
cycleId: observable.ref,
|
||||
moduleId: observable.ref,
|
||||
viewId: observable.ref,
|
||||
userId: observable.ref,
|
||||
globalViewId: observable.ref,
|
||||
stateMap: observable,
|
||||
stateDetails: observable,
|
||||
workspaceStateDetails: observable,
|
||||
labelMap: observable,
|
||||
memberMap: observable,
|
||||
workSpaceMemberRolesMap: observable,
|
||||
projectMap: observable,
|
||||
moduleMap: observable,
|
||||
cycleMap: observable,
|
||||
});
|
||||
|
||||
this.serviceType = serviceType;
|
||||
this.rootStore = rootStore;
|
||||
|
||||
autorun(() => {
|
||||
if (rootStore?.user?.data?.id) this.currentUserId = rootStore?.user?.data?.id;
|
||||
if (this.workspaceSlug !== rootStore.router.workspaceSlug) this.workspaceSlug = rootStore.router.workspaceSlug;
|
||||
if (this.teamspaceId !== rootStore.router.teamspaceId) this.teamspaceId = rootStore.router.teamspaceId;
|
||||
if (this.projectId !== rootStore.router.projectId) this.projectId = rootStore.router.projectId;
|
||||
if (this.cycleId !== rootStore.router.cycleId) this.cycleId = rootStore.router.cycleId;
|
||||
if (this.moduleId !== rootStore.router.moduleId) this.moduleId = rootStore.router.moduleId;
|
||||
if (this.viewId !== rootStore.router.viewId) this.viewId = rootStore.router.viewId;
|
||||
if (this.globalViewId !== rootStore.router.globalViewId) this.globalViewId = rootStore.router.globalViewId;
|
||||
if (this.userId !== rootStore.router.userId) this.userId = rootStore.router.userId;
|
||||
if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap;
|
||||
if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates;
|
||||
if (!isEmpty(rootStore?.state?.workspaceStates)) this.workspaceStateDetails = rootStore?.state?.workspaceStates;
|
||||
if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap;
|
||||
if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap))
|
||||
this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined;
|
||||
if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined;
|
||||
if (!isEmpty(rootStore?.projectRoot?.project?.projectMap))
|
||||
this.projectMap = rootStore?.projectRoot?.project?.projectMap;
|
||||
if (!isEmpty(rootStore?.module?.moduleMap)) this.moduleMap = rootStore?.module?.moduleMap;
|
||||
if (!isEmpty(rootStore?.cycle?.cycleMap)) this.cycleMap = rootStore?.cycle?.cycleMap;
|
||||
});
|
||||
|
||||
this.issues = new IssueStore();
|
||||
|
||||
this.issueDetail = new IssueDetail(this, EIssueServiceType.ISSUES);
|
||||
this.epicDetail = new IssueDetail(this, EIssueServiceType.EPICS);
|
||||
|
||||
this.workspaceIssuesFilter = new WorkspaceIssuesFilter(this);
|
||||
this.workspaceIssues = new WorkspaceIssues(this, this.workspaceIssuesFilter);
|
||||
|
||||
this.profileIssuesFilter = new ProfileIssuesFilter(this);
|
||||
this.profileIssues = new ProfileIssues(this, this.profileIssuesFilter);
|
||||
|
||||
this.workspaceDraftIssuesFilter = new WorkspaceDraftIssuesFilter(this);
|
||||
this.workspaceDraftIssues = new WorkspaceDraftIssues(this);
|
||||
|
||||
this.projectIssuesFilter = new ProjectIssuesFilter(this);
|
||||
this.projectIssues = new ProjectIssues(this, this.projectIssuesFilter);
|
||||
|
||||
this.teamIssuesFilter = new TeamIssuesFilter(this);
|
||||
this.teamIssues = new TeamIssues(this, this.teamIssuesFilter);
|
||||
|
||||
this.cycleIssuesFilter = new CycleIssuesFilter(this);
|
||||
this.cycleIssues = new CycleIssues(this, this.cycleIssuesFilter);
|
||||
|
||||
this.moduleIssuesFilter = new ModuleIssuesFilter(this);
|
||||
this.moduleIssues = new ModuleIssues(this, this.moduleIssuesFilter);
|
||||
|
||||
this.teamViewIssuesFilter = new TeamViewIssuesFilter(this);
|
||||
this.teamViewIssues = new TeamViewIssues(this, this.teamViewIssuesFilter);
|
||||
|
||||
this.projectViewIssuesFilter = new ProjectViewIssuesFilter(this);
|
||||
this.projectViewIssues = new ProjectViewIssues(this, this.projectViewIssuesFilter);
|
||||
|
||||
this.teamProjectWorkItemsFilter = new TeamProjectWorkItemsFilter(this);
|
||||
this.teamProjectWorkItems = new TeamProjectWorkItems(this, this.teamProjectWorkItemsFilter);
|
||||
|
||||
this.archivedIssuesFilter = new ArchivedIssuesFilter(this);
|
||||
this.archivedIssues = new ArchivedIssues(this, this.archivedIssuesFilter);
|
||||
|
||||
this.issueKanBanView = new IssueKanBanViewStore(this);
|
||||
this.issueCalendarView = new CalendarStore(this);
|
||||
|
||||
this.projectEpicsFilter = new ProjectEpicsFilter(this);
|
||||
this.projectEpics = new ProjectEpics(this, this.projectEpicsFilter);
|
||||
}
|
||||
}
|
||||
268
apps/web/core/store/issue/workspace-draft/filter.store.ts
Normal file
268
apps/web/core/store/issue/workspace-draft/filter.store.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// Plane Imports
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
// services
|
||||
import { IssueFiltersService } from "@/services/issue_filter.service";
|
||||
// helpers
|
||||
import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
|
||||
export interface IWorkspaceDraftIssuesFilter extends IBaseIssueFilterStore {
|
||||
// observables
|
||||
workspaceSlug: string;
|
||||
//helper actions
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
userId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
// action
|
||||
fetchFilters: (workspaceSlug: string) => Promise<void>;
|
||||
updateFilterExpression: (workspaceSlug: string, userId: string, filters: TWorkItemFilterExpression) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class WorkspaceDraftIssuesFilter extends IssueFilterHelperStore implements IWorkspaceDraftIssuesFilter {
|
||||
// observables
|
||||
workspaceSlug: string = "";
|
||||
filters: { [userId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore: IIssueRootStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
workspaceSlug: observable.ref,
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new IssueFiltersService();
|
||||
}
|
||||
|
||||
get issueFilters() {
|
||||
const workspaceSlug = this.rootIssueStore.workspaceSlug;
|
||||
if (!workspaceSlug) return undefined;
|
||||
|
||||
return this.getIssueFilters(workspaceSlug);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const workspaceSlug = this.rootIssueStore.workspaceSlug;
|
||||
if (!workspaceSlug) return undefined;
|
||||
|
||||
return this.getAppliedFilters(workspaceSlug);
|
||||
}
|
||||
|
||||
getIssueFilters(workspaceSlug: string) {
|
||||
const displayFilters = this.filters[workspaceSlug] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
}
|
||||
|
||||
getAppliedFilters(workspaceSlug: string) {
|
||||
const userFilters = this.getIssueFilters(workspaceSlug);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "profile_issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
userId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
const filterParams = this.getAppliedFilters(this.workspaceSlug);
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string) => {
|
||||
this.workspaceSlug = workspaceSlug;
|
||||
const _filters = this.handleIssuesLocalFilters.get(
|
||||
EIssuesStoreType.PROFILE,
|
||||
workspaceSlug,
|
||||
workspaceSlug,
|
||||
undefined
|
||||
);
|
||||
|
||||
const richFilters: TWorkItemFilterExpression = _filters?.rich_filters;
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
const kanbanFilters = {
|
||||
group_by: _filters?.kanban_filters?.group_by || [],
|
||||
sub_group_by: _filters?.kanban_filters?.sub_group_by || [],
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [workspaceSlug, "richFilters"], richFilters);
|
||||
set(this.filters, [workspaceSlug, "displayFilters"], displayFilters);
|
||||
set(this.filters, [workspaceSlug, "displayProperties"], displayProperties);
|
||||
set(this.filters, [workspaceSlug, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IWorkspaceDraftIssuesFilter["updateFilterExpression"] = async (
|
||||
workspaceSlug,
|
||||
userId,
|
||||
filters
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [workspaceSlug, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, workspaceSlug, "mutation");
|
||||
this.handleIssuesLocalFilters.set(
|
||||
EIssuesStoreType.PROFILE,
|
||||
EIssueFilterType.FILTERS,
|
||||
workspaceSlug,
|
||||
workspaceSlug,
|
||||
undefined,
|
||||
{
|
||||
rich_filters: filters,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IWorkspaceDraftIssuesFilter["updateFilters"] = async (workspaceSlug, type, filters) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[workspaceSlug])) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: this.filters[workspaceSlug].richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: this.filters[workspaceSlug].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[workspaceSlug].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[workspaceSlug].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to priority if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "priority";
|
||||
updatedDisplayFilters.group_by = "priority";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[workspaceSlug, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, workspaceSlug, "mutation");
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, workspaceSlug, undefined, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[workspaceSlug, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, workspaceSlug, undefined, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (workspaceSlug) this.fetchFilters(workspaceSlug);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/workspace-draft/index.ts
Normal file
2
apps/web/core/store/issue/workspace-draft/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./issue.store";
|
||||
export * from "./filter.store";
|
||||
420
apps/web/core/store/issue/workspace-draft/issue.store.ts
Normal file
420
apps/web/core/store/issue/workspace-draft/issue.store.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { clone, update, unset, orderBy, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import { EDraftIssuePaginationType } from "@plane/constants";
|
||||
import type {
|
||||
TWorkspaceDraftIssue,
|
||||
TWorkspaceDraftPaginationInfo,
|
||||
TWorkspaceDraftIssueLoader,
|
||||
TWorkspaceDraftQueryParams,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
TGroupedIssues,
|
||||
TSubGroupedIssues,
|
||||
ViewFlags,
|
||||
TIssue,
|
||||
TBulkOperationsPayload,
|
||||
} from "@plane/types";
|
||||
import { getCurrentDateTimeInISO, convertToISODateString } from "@plane/utils";
|
||||
// local-db
|
||||
import { addIssueToPersistanceLayer } from "@/local-db/utils/utils";
|
||||
// services
|
||||
import workspaceDraftService from "@/services/issue/workspace_draft.service";
|
||||
// types
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
|
||||
export type TDraftIssuePaginationType = EDraftIssuePaginationType;
|
||||
|
||||
export interface IWorkspaceDraftIssues {
|
||||
// observables
|
||||
loader: TWorkspaceDraftIssueLoader;
|
||||
paginationInfo: Omit<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue>, "results"> | undefined;
|
||||
issuesMap: Record<string, TWorkspaceDraftIssue>; // issue_id -> issue;
|
||||
issueMapIds: Record<string, string[]>; // workspace_id -> issue_ids;
|
||||
// computed
|
||||
issueIds: string[];
|
||||
// computed functions
|
||||
getIssueById: (issueId: string) => TWorkspaceDraftIssue | undefined;
|
||||
// helper actions
|
||||
addIssue: (issues: TWorkspaceDraftIssue[]) => void;
|
||||
mutateIssue: (issueId: string, data: Partial<TWorkspaceDraftIssue>) => void;
|
||||
removeIssue: (issueId: string) => Promise<void>;
|
||||
// actions
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
loadType: TWorkspaceDraftIssueLoader,
|
||||
paginationType?: TDraftIssuePaginationType
|
||||
) => Promise<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue> | undefined>;
|
||||
createIssue: (
|
||||
workspaceSlug: string,
|
||||
payload: Partial<TWorkspaceDraftIssue | TIssue>
|
||||
) => Promise<TWorkspaceDraftIssue | undefined>;
|
||||
updateIssue: (
|
||||
workspaceSlug: string,
|
||||
issueId: string,
|
||||
payload: Partial<TWorkspaceDraftIssue | TIssue>
|
||||
) => Promise<TWorkspaceDraftIssue | undefined>;
|
||||
deleteIssue: (workspaceSlug: string, issueId: string) => Promise<void>;
|
||||
moveIssue: (workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue>) => Promise<TIssue>;
|
||||
addCycleToIssue: (
|
||||
workspaceSlug: string,
|
||||
issueId: string,
|
||||
cycleId: string
|
||||
) => Promise<TWorkspaceDraftIssue | undefined>;
|
||||
addModulesToIssue: (
|
||||
workspaceSlug: string,
|
||||
issueId: string,
|
||||
moduleIds: string[]
|
||||
) => Promise<TWorkspaceDraftIssue | undefined>;
|
||||
|
||||
// dummies
|
||||
viewFlags: ViewFlags;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined;
|
||||
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined;
|
||||
getPaginationData(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined;
|
||||
getIssueLoader(groupId?: string, subGroupId?: string): TLoader;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
addIssueToCycle: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
issueIds: string[],
|
||||
fetchAddedIssues?: boolean
|
||||
) => Promise<void>;
|
||||
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
|
||||
removeIssuesFromModule: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
issueIds: string[]
|
||||
) => Promise<void>;
|
||||
changeModulesInIssue(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
): Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export class WorkspaceDraftIssues implements IWorkspaceDraftIssues {
|
||||
// local constants
|
||||
paginatedCount = 50;
|
||||
// observables
|
||||
loader: TWorkspaceDraftIssueLoader = undefined;
|
||||
paginationInfo: Omit<TWorkspaceDraftPaginationInfo<TWorkspaceDraftIssue>, "results"> | undefined = undefined;
|
||||
issuesMap: Record<string, TWorkspaceDraftIssue> = {};
|
||||
issueMapIds: Record<string, string[]> = {};
|
||||
|
||||
constructor(public issueStore: IIssueRootStore) {
|
||||
makeObservable(this, {
|
||||
loader: observable.ref,
|
||||
paginationInfo: observable,
|
||||
issuesMap: observable,
|
||||
issueMapIds: observable,
|
||||
// computed
|
||||
issueIds: computed,
|
||||
// action
|
||||
fetchIssues: action,
|
||||
createIssue: action,
|
||||
updateIssue: action,
|
||||
deleteIssue: action,
|
||||
moveIssue: action,
|
||||
addCycleToIssue: action,
|
||||
addModulesToIssue: action,
|
||||
});
|
||||
}
|
||||
|
||||
private updateWorkspaceUserDraftIssueCount(workspaceSlug: string, increment: number) {
|
||||
const workspaceUserInfo = this.issueStore.rootStore.user.permission.workspaceUserInfo;
|
||||
const currentCount = workspaceUserInfo[workspaceSlug]?.draft_issue_count ?? 0;
|
||||
|
||||
set(workspaceUserInfo, [workspaceSlug, "draft_issue_count"], currentCount + increment);
|
||||
}
|
||||
|
||||
// computed
|
||||
get issueIds() {
|
||||
const workspaceSlug = this.issueStore.workspaceSlug;
|
||||
if (!workspaceSlug) return [];
|
||||
if (!this.issueMapIds[workspaceSlug]) return [];
|
||||
const issueIds = this.issueMapIds[workspaceSlug];
|
||||
return orderBy(issueIds, (issueId) => convertToISODateString(this.issuesMap[issueId]?.created_at), ["desc"]);
|
||||
}
|
||||
|
||||
// computed functions
|
||||
getIssueById = computedFn((issueId: string) => {
|
||||
if (!issueId || !this.issuesMap[issueId]) return undefined;
|
||||
return this.issuesMap[issueId];
|
||||
});
|
||||
|
||||
// helper actions
|
||||
addIssue = (issues: TWorkspaceDraftIssue[]) => {
|
||||
if (issues && issues.length <= 0) return;
|
||||
runInAction(() => {
|
||||
issues.forEach((issue) => {
|
||||
if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue);
|
||||
else update(this.issuesMap, issue.id, (prevIssue) => ({ ...prevIssue, ...issue }));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
mutateIssue = (issueId: string, issue: Partial<TWorkspaceDraftIssue>) => {
|
||||
if (!issue || !issueId || !this.issuesMap[issueId]) return;
|
||||
runInAction(() => {
|
||||
set(this.issuesMap, [issueId, "updated_at"], getCurrentDateTimeInISO());
|
||||
Object.keys(issue).forEach((key) => {
|
||||
set(this.issuesMap, [issueId, key], issue[key as keyof TWorkspaceDraftIssue]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
removeIssue = async (issueId: string) => {
|
||||
if (!issueId || !this.issuesMap[issueId]) return;
|
||||
runInAction(() => unset(this.issuesMap, issueId));
|
||||
};
|
||||
|
||||
generateNotificationQueryParams = (
|
||||
paramType: TDraftIssuePaginationType,
|
||||
filterParams = {}
|
||||
): TWorkspaceDraftQueryParams => {
|
||||
const queryCursorNext: string =
|
||||
paramType === EDraftIssuePaginationType.INIT
|
||||
? `${this.paginatedCount}:0:0`
|
||||
: paramType === EDraftIssuePaginationType.CURRENT
|
||||
? `${this.paginatedCount}:${0}:0`
|
||||
: paramType === EDraftIssuePaginationType.NEXT && this.paginationInfo
|
||||
? (this.paginationInfo?.next_cursor ?? `${this.paginatedCount}:${0}:0`)
|
||||
: `${this.paginatedCount}:${0}:0`;
|
||||
|
||||
const queryParams: TWorkspaceDraftQueryParams = {
|
||||
per_page: this.paginatedCount,
|
||||
cursor: queryCursorNext,
|
||||
...filterParams,
|
||||
};
|
||||
|
||||
return queryParams;
|
||||
};
|
||||
|
||||
// actions
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
loadType: TWorkspaceDraftIssueLoader,
|
||||
paginationType: TDraftIssuePaginationType = EDraftIssuePaginationType.INIT
|
||||
) => {
|
||||
try {
|
||||
this.loader = loadType;
|
||||
|
||||
// filter params and pagination params
|
||||
const filterParams = {};
|
||||
const params = this.generateNotificationQueryParams(paginationType, filterParams);
|
||||
|
||||
// fetching the paginated workspace draft issues
|
||||
const draftIssuesResponse = await workspaceDraftService.getIssues(workspaceSlug, { ...params });
|
||||
if (!draftIssuesResponse) return undefined;
|
||||
|
||||
const { results, ...paginationInfo } = draftIssuesResponse;
|
||||
runInAction(() => {
|
||||
if (results && results.length > 0) {
|
||||
// adding issueIds
|
||||
const issueIds = results.map((issue) => issue.id);
|
||||
const existingIssueIds = this.issueMapIds[workspaceSlug] ?? [];
|
||||
// new issueIds
|
||||
const newIssueIds = issueIds.filter((issueId) => !existingIssueIds.includes(issueId));
|
||||
this.addIssue(results);
|
||||
// issue map update
|
||||
update(this.issueMapIds, [workspaceSlug], (existingIssueIds = []) => [...newIssueIds, ...existingIssueIds]);
|
||||
this.loader = undefined;
|
||||
} else {
|
||||
this.loader = "empty-state";
|
||||
}
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
});
|
||||
return draftIssuesResponse;
|
||||
} catch (error) {
|
||||
// set loader to undefined if errored out
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
createIssue = async (
|
||||
workspaceSlug: string,
|
||||
payload: Partial<TWorkspaceDraftIssue | TIssue>
|
||||
): Promise<TWorkspaceDraftIssue | undefined> => {
|
||||
try {
|
||||
this.loader = "create";
|
||||
|
||||
const response = await workspaceDraftService.createIssue(workspaceSlug, payload);
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
this.addIssue([response]);
|
||||
update(this.issueMapIds, [workspaceSlug], (existingIssueIds = []) => [response.id, ...existingIssueIds]);
|
||||
// increase the count of issues in the pagination info
|
||||
if (this.paginationInfo?.total_count) {
|
||||
set(this, "paginationInfo", {
|
||||
...this.paginationInfo,
|
||||
total_count: this.paginationInfo.total_count + 1,
|
||||
});
|
||||
}
|
||||
// Update draft issue count in workspaceUserInfo
|
||||
this.updateWorkspaceUserDraftIssueCount(workspaceSlug, 1);
|
||||
});
|
||||
}
|
||||
|
||||
this.loader = undefined;
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateIssue = async (workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue | TIssue>) => {
|
||||
const issueBeforeUpdate = clone(this.getIssueById(issueId));
|
||||
try {
|
||||
this.loader = "update";
|
||||
runInAction(() => {
|
||||
set(this.issuesMap, [issueId], {
|
||||
...issueBeforeUpdate,
|
||||
...payload,
|
||||
...{ updated_at: getCurrentDateTimeInISO() },
|
||||
});
|
||||
});
|
||||
const response = await workspaceDraftService.updateIssue(workspaceSlug, issueId, payload);
|
||||
this.loader = undefined;
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
runInAction(() => {
|
||||
set(this.issuesMap, [issueId], issueBeforeUpdate);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
deleteIssue = async (workspaceSlug: string, issueId: string) => {
|
||||
try {
|
||||
this.loader = "delete";
|
||||
|
||||
const response = await workspaceDraftService.deleteIssue(workspaceSlug, issueId);
|
||||
runInAction(() => {
|
||||
// Remove the issue from the issueMapIds
|
||||
this.issueMapIds[workspaceSlug] = (this.issueMapIds[workspaceSlug] || []).filter((id) => id !== issueId);
|
||||
// Remove the issue from the issuesMap
|
||||
delete this.issuesMap[issueId];
|
||||
// reduce the count of issues in the pagination info
|
||||
if (this.paginationInfo?.total_count) {
|
||||
set(this, "paginationInfo", {
|
||||
...this.paginationInfo,
|
||||
total_count: this.paginationInfo.total_count - 1,
|
||||
});
|
||||
}
|
||||
// Update draft issue count in workspaceUserInfo
|
||||
this.updateWorkspaceUserDraftIssueCount(workspaceSlug, -1);
|
||||
});
|
||||
|
||||
this.loader = undefined;
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
moveIssue = async (workspaceSlug: string, issueId: string, payload: Partial<TWorkspaceDraftIssue>) => {
|
||||
try {
|
||||
this.loader = "move";
|
||||
|
||||
const response = await workspaceDraftService.moveIssue(workspaceSlug, issueId, payload);
|
||||
runInAction(() => {
|
||||
// Remove the issue from the issueMapIds
|
||||
this.issueMapIds[workspaceSlug] = (this.issueMapIds[workspaceSlug] || []).filter((id) => id !== issueId);
|
||||
// Remove the issue from the issuesMap
|
||||
delete this.issuesMap[issueId];
|
||||
// reduce the count of issues in the pagination info
|
||||
if (this.paginationInfo?.total_count) {
|
||||
set(this, "paginationInfo", {
|
||||
...this.paginationInfo,
|
||||
total_count: this.paginationInfo.total_count - 1,
|
||||
});
|
||||
}
|
||||
|
||||
// sync issue to local db
|
||||
addIssueToPersistanceLayer({ ...payload, ...response });
|
||||
|
||||
// Update draft issue count in workspaceUserInfo
|
||||
this.updateWorkspaceUserDraftIssueCount(workspaceSlug, -1);
|
||||
});
|
||||
|
||||
this.loader = undefined;
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
addCycleToIssue = async (workspaceSlug: string, issueId: string, cycleId: string) => {
|
||||
try {
|
||||
this.loader = "update";
|
||||
const response = await this.updateIssue(workspaceSlug, issueId, { cycle_id: cycleId });
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
addModulesToIssue = async (workspaceSlug: string, issueId: string, moduleIds: string[]) => {
|
||||
try {
|
||||
this.loader = "update";
|
||||
const response = this.updateIssue(workspaceSlug, issueId, { module_ids: moduleIds });
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// dummies
|
||||
viewFlags: ViewFlags = { enableQuickAdd: false, enableIssueCreation: false, enableInlineEditing: false };
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined = undefined;
|
||||
getIssueIds = (groupId?: string, subGroupId?: string) => undefined;
|
||||
getPaginationData = (groupId: string | undefined, subGroupId: string | undefined) => undefined;
|
||||
getIssueLoader = (groupId?: string, subGroupId?: string) => "loaded" as TLoader;
|
||||
getGroupIssueCount = (groupId: string | undefined, subGroupId: string | undefined, isSubGroupCumulative: boolean) =>
|
||||
undefined;
|
||||
removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {};
|
||||
addIssueToCycle = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
issueIds: string[],
|
||||
fetchAddedIssues?: boolean
|
||||
) => {};
|
||||
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {};
|
||||
|
||||
removeIssuesFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {};
|
||||
changeModulesInIssue = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
addModuleIds: string[],
|
||||
removeModuleIds: string[]
|
||||
) => {};
|
||||
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {};
|
||||
archiveBulkIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {};
|
||||
removeBulkIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {};
|
||||
bulkUpdateProperties = async (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => {};
|
||||
}
|
||||
313
apps/web/core/store/issue/workspace/filter.store.ts
Normal file
313
apps/web/core/store/issue/workspace/filter.store.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { isEmpty, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
TStaticViewTypes,
|
||||
IssuePaginationOptions,
|
||||
TWorkItemFilterExpression,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes, STATIC_VIEW_TYPES } from "@plane/types";
|
||||
import { handleIssueQueryParamsByLayout } from "@plane/utils";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// local imports
|
||||
import type { IBaseIssueFilterStore, IIssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
|
||||
type TWorkspaceFilters = TStaticViewTypes | string;
|
||||
|
||||
export type TBaseFilterStore = IBaseIssueFilterStore & IIssueFilterHelperStore;
|
||||
|
||||
export interface IWorkspaceIssuesFilter extends TBaseFilterStore {
|
||||
// fetch action
|
||||
fetchFilters: (workspaceSlug: string, viewId: string) => Promise<void>;
|
||||
updateFilterExpression: (workspaceSlug: string, viewId: string, filters: TWorkItemFilterExpression) => Promise<void>;
|
||||
updateFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string | undefined,
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate,
|
||||
viewId: string
|
||||
) => Promise<void>;
|
||||
//helper action
|
||||
getIssueFilters: (viewId: string | undefined) => IIssueFilters | undefined;
|
||||
getAppliedFilters: (viewId: string) => Partial<Record<TIssueParams, string | boolean>> | undefined;
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
viewId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
}
|
||||
|
||||
export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWorkspaceIssuesFilter {
|
||||
// observables
|
||||
filters: { [viewId: string]: IIssueFilters } = {};
|
||||
// root store
|
||||
rootIssueStore;
|
||||
// services
|
||||
issueFilterService;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
super();
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// fetch actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
// services
|
||||
this.issueFilterService = new WorkspaceService();
|
||||
}
|
||||
|
||||
getIssueFilters = (viewId: string | undefined) => {
|
||||
if (!viewId) return undefined;
|
||||
|
||||
const displayFilters = this.filters[viewId] || undefined;
|
||||
if (isEmpty(displayFilters)) return undefined;
|
||||
|
||||
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
|
||||
|
||||
return _filters;
|
||||
};
|
||||
|
||||
getAppliedFilters = (viewId: string | undefined) => {
|
||||
if (!viewId) return undefined;
|
||||
|
||||
const userFilters = this.getIssueFilters(viewId);
|
||||
if (!userFilters) return undefined;
|
||||
|
||||
const filteredParams = handleIssueQueryParamsByLayout(EIssueLayoutTypes.SPREADSHEET, "my_issues");
|
||||
if (!filteredParams) return undefined;
|
||||
|
||||
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
|
||||
userFilters?.richFilters,
|
||||
userFilters?.displayFilters,
|
||||
filteredParams
|
||||
);
|
||||
|
||||
return filteredRouteParams;
|
||||
};
|
||||
|
||||
get issueFilters() {
|
||||
const viewId = this.rootIssueStore.globalViewId;
|
||||
return this.getIssueFilters(viewId);
|
||||
}
|
||||
|
||||
get appliedFilters() {
|
||||
const viewId = this.rootIssueStore.globalViewId;
|
||||
return this.getAppliedFilters(viewId);
|
||||
}
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
viewId: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
let filterParams = this.getAppliedFilters(viewId);
|
||||
|
||||
if (!filterParams) {
|
||||
filterParams = {};
|
||||
}
|
||||
|
||||
if (STATIC_VIEW_TYPES.includes(viewId)) {
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
const paramForStaticView = this.getFilterConditionBasedOnViews(currentUserId, viewId as TStaticViewTypes);
|
||||
if (paramForStaticView) {
|
||||
filterParams = { ...filterParams, ...paramForStaticView };
|
||||
}
|
||||
}
|
||||
|
||||
const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
fetchFilters = async (workspaceSlug: string, viewId: TWorkspaceFilters) => {
|
||||
let richFilters: TWorkItemFilterExpression;
|
||||
let displayFilters: IIssueDisplayFilterOptions;
|
||||
let displayProperties: IIssueDisplayProperties;
|
||||
let kanbanFilters: TIssueKanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
|
||||
const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId);
|
||||
displayFilters = this.computedDisplayFilters(_filters?.display_filters, {
|
||||
layout: EIssueLayoutTypes.SPREADSHEET,
|
||||
order_by: "-created_at",
|
||||
});
|
||||
displayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
kanbanFilters = {
|
||||
group_by: _filters?.kanban_filters?.group_by || [],
|
||||
sub_group_by: _filters?.kanban_filters?.sub_group_by || [],
|
||||
};
|
||||
|
||||
// Get the view details if the view is not a static view
|
||||
if (STATIC_VIEW_TYPES.includes(viewId) === false) {
|
||||
const _filters = await this.issueFilterService.getViewDetails(workspaceSlug, viewId);
|
||||
richFilters = _filters?.rich_filters;
|
||||
displayFilters = this.computedDisplayFilters(_filters?.display_filters, {
|
||||
layout: EIssueLayoutTypes.SPREADSHEET,
|
||||
order_by: "-created_at",
|
||||
});
|
||||
displayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
}
|
||||
|
||||
// override existing order by if ordered by manual sort_order
|
||||
if (displayFilters.order_by === "sort_order") {
|
||||
displayFilters.order_by = "-created_at";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [viewId, "richFilters"], richFilters);
|
||||
set(this.filters, [viewId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [viewId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [viewId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* NOTE: This method is designed as a fallback function for the work item filter store.
|
||||
* Only use this method directly when initializing filter instances.
|
||||
* For regular filter updates, use this method as a fallback function for the work item filter store methods instead.
|
||||
*/
|
||||
updateFilterExpression: IWorkspaceIssuesFilter["updateFilterExpression"] = async (workspaceSlug, viewId, filters) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.filters, [viewId, "richFilters"], filters);
|
||||
});
|
||||
|
||||
this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation");
|
||||
} catch (error) {
|
||||
console.log("error while updating rich filters", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilters: IWorkspaceIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, viewId) => {
|
||||
try {
|
||||
const issueFilters = this.getIssueFilters(viewId);
|
||||
|
||||
if (!issueFilters) return;
|
||||
|
||||
const _filters = {
|
||||
richFilters: issueFilters.richFilters as TWorkItemFilterExpression,
|
||||
displayFilters: issueFilters.displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: issueFilters.displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: issueFilters.kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
const updatedDisplayFilters = filters as IIssueDisplayFilterOptions;
|
||||
_filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters };
|
||||
|
||||
// set sub_group_by to null if group_by is set to null
|
||||
if (_filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same
|
||||
if (
|
||||
_filters.displayFilters.layout === "kanban" &&
|
||||
_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by
|
||||
) {
|
||||
_filters.displayFilters.sub_group_by = null;
|
||||
updatedDisplayFilters.sub_group_by = null;
|
||||
}
|
||||
// set group_by to state if layout is switched to kanban and group_by is null
|
||||
if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) {
|
||||
_filters.displayFilters.group_by = "state";
|
||||
updatedDisplayFilters.group_by = "state";
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[viewId, "displayFilters", _key],
|
||||
updatedDisplayFilters[_key as keyof IIssueDisplayFilterOptions]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation");
|
||||
|
||||
if (["all-issues", "assigned", "created", "subscribed"].includes(viewId))
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
|
||||
display_filters: _filters.displayFilters,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES: {
|
||||
const updatedDisplayProperties = filters as IIssueDisplayProperties;
|
||||
_filters.displayProperties = { ..._filters.displayProperties, ...updatedDisplayProperties };
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedDisplayProperties).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[viewId, "displayProperties", _key],
|
||||
updatedDisplayProperties[_key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
});
|
||||
if (["all-issues", "assigned", "created", "subscribed"].includes(viewId))
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS: {
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[viewId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (viewId) this.fetchFilters(workspaceSlug, viewId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
2
apps/web/core/store/issue/workspace/index.ts
Normal file
2
apps/web/core/store/issue/workspace/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filter.store";
|
||||
export * from "./issue.store";
|
||||
181
apps/web/core/store/issue/workspace/issue.store.ts
Normal file
181
apps/web/core/store/issue/workspace/issue.store.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { action, makeObservable, runInAction } from "mobx";
|
||||
// base class
|
||||
import type {
|
||||
IssuePaginationOptions,
|
||||
TBulkOperationsPayload,
|
||||
TIssue,
|
||||
TIssuesResponse,
|
||||
TLoader,
|
||||
ViewFlags,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// types
|
||||
import type { IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import { BaseIssuesStore } from "../helpers/base-issues.store";
|
||||
import type { IIssueRootStore } from "../root.store";
|
||||
import type { IWorkspaceIssuesFilter } from "./filter.store";
|
||||
|
||||
export interface IWorkspaceIssues extends IBaseIssuesStore {
|
||||
// observable
|
||||
viewFlags: ViewFlags;
|
||||
// actions
|
||||
fetchIssues: (
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchIssuesWithExistingPagination: (
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
loadType: TLoader
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
fetchNextIssues: (
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
groupId?: string,
|
||||
subGroupId?: string
|
||||
) => Promise<TIssuesResponse | undefined>;
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise<void>;
|
||||
|
||||
quickAddIssue: undefined;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export class WorkspaceIssues extends BaseIssuesStore implements IWorkspaceIssues {
|
||||
viewFlags = {
|
||||
enableQuickAdd: true,
|
||||
enableIssueCreation: true,
|
||||
enableInlineEditing: true,
|
||||
};
|
||||
// service
|
||||
workspaceService;
|
||||
// filterStore
|
||||
issueFilterStore;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore, issueFilterStore: IWorkspaceIssuesFilter) {
|
||||
super(_rootStore, issueFilterStore);
|
||||
|
||||
makeObservable(this, {
|
||||
// action
|
||||
fetchIssues: action,
|
||||
fetchNextIssues: action,
|
||||
fetchIssuesWithExistingPagination: action,
|
||||
});
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
// filter store
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
}
|
||||
|
||||
fetchParentStats = () => {};
|
||||
|
||||
/** */
|
||||
updateParentStats = () => {};
|
||||
|
||||
/**
|
||||
* This method is called to fetch the first issues of pagination
|
||||
* @param workspaceSlug
|
||||
* @param viewId
|
||||
* @param loadType
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
fetchIssues = async (
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.setLoader(loadType);
|
||||
});
|
||||
this.clear(!isExistingPaginationOptions);
|
||||
|
||||
// get params from pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(options, viewId, undefined, undefined, undefined);
|
||||
// call the fetch issues API with the params
|
||||
const response = await this.workspaceService.getViewIssues(workspaceSlug, params, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options, workspaceSlug, undefined, undefined, !isExistingPaginationOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set loader to undefined if errored out
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called subsequent pages of pagination
|
||||
* if groupId/subgroupId is provided, only that specific group's next page is fetched
|
||||
* else all the groups' next page is fetched
|
||||
* @param workspaceSlug
|
||||
* @param viewId
|
||||
* @param groupId
|
||||
* @param subGroupId
|
||||
* @returns
|
||||
*/
|
||||
fetchNextIssues = async (workspaceSlug: string, viewId: string, groupId?: string, subGroupId?: string) => {
|
||||
const cursorObject = this.getPaginationData(groupId, subGroupId);
|
||||
// if there are no pagination options and the next page results do not exist the return
|
||||
if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return;
|
||||
try {
|
||||
// set Loader
|
||||
this.setLoader("pagination", groupId, subGroupId);
|
||||
|
||||
// get params from stored pagination options
|
||||
const params = this.issueFilterStore?.getFilterParams(
|
||||
this.paginationOptions,
|
||||
viewId,
|
||||
this.getNextCursor(groupId, subGroupId),
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.workspaceService.getViewIssues(workspaceSlug, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method exists to fetch the first page of the issues with the existing stored pagination
|
||||
* This is useful for refetching when filters, groupBy, orderBy etc changes
|
||||
* @param workspaceSlug
|
||||
* @param viewId
|
||||
* @param loadType
|
||||
* @returns
|
||||
*/
|
||||
fetchIssuesWithExistingPagination = async (workspaceSlug: string, viewId: string, loadType: TLoader) => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchIssues(workspaceSlug, viewId, loadType, this.paginationOptions, true);
|
||||
};
|
||||
|
||||
// Using aliased names as they cannot be overridden in other stores
|
||||
archiveBulkIssues = this.bulkArchiveIssues;
|
||||
updateIssue = this.issueUpdate;
|
||||
archiveIssue = this.issueArchive;
|
||||
|
||||
// Setting them as undefined as they can not performed on workspace issues
|
||||
quickAddIssue = undefined;
|
||||
}
|
||||
305
apps/web/core/store/label.store.ts
Normal file
305
apps/web/core/store/label.store.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { set, sortBy } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { IIssueLabel, IIssueLabelTree } from "@plane/types";
|
||||
// helpers
|
||||
import { buildTree } from "@plane/utils";
|
||||
// services
|
||||
import { syncIssuesWithDeletedLabels } from "@/local-db/utils/load-workspace";
|
||||
import { IssueLabelService } from "@/services/issue";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface ILabelStore {
|
||||
//Loaders
|
||||
fetchedMap: Record<string, boolean>;
|
||||
//Observable
|
||||
labelMap: Record<string, IIssueLabel>;
|
||||
// computed
|
||||
projectLabels: IIssueLabel[] | undefined;
|
||||
projectLabelsTree: IIssueLabelTree[] | undefined;
|
||||
workspaceLabels: IIssueLabel[] | undefined;
|
||||
//computed actions
|
||||
getWorkspaceLabels: (workspaceSlug: string) => IIssueLabel[] | undefined;
|
||||
getWorkspaceLabelIds: (workspaceSlug: string) => string[] | undefined;
|
||||
getProjectLabels: (projectId: string | undefined | null) => IIssueLabel[] | undefined;
|
||||
getProjectLabelIds: (projectId: string | undefined | null) => string[] | undefined;
|
||||
getLabelById: (labelId: string) => IIssueLabel | null;
|
||||
// fetch actions
|
||||
fetchWorkspaceLabels: (workspaceSlug: string) => Promise<IIssueLabel[]>;
|
||||
fetchProjectLabels: (workspaceSlug: string, projectId: string) => Promise<IIssueLabel[]>;
|
||||
// crud actions
|
||||
createLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
|
||||
updateLabel: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
labelId: string,
|
||||
data: Partial<IIssueLabel>
|
||||
) => Promise<IIssueLabel>;
|
||||
updateLabelPosition: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
draggingLabelId: string,
|
||||
droppedParentId: string | null,
|
||||
droppedLabelId: string | undefined,
|
||||
dropAtEndOfList: boolean
|
||||
) => Promise<void>;
|
||||
deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class LabelStore implements ILabelStore {
|
||||
// root store
|
||||
rootStore;
|
||||
// root store labelMap
|
||||
labelMap: Record<string, IIssueLabel> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
// services
|
||||
issueLabelService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
labelMap: observable,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
projectLabels: computed,
|
||||
projectLabelsTree: computed,
|
||||
|
||||
fetchProjectLabels: action,
|
||||
createLabel: action,
|
||||
updateLabel: action,
|
||||
updateLabelPosition: action,
|
||||
deleteLabel: action,
|
||||
});
|
||||
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.issueLabelService = new IssueLabelService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the labelMap belongs to a specific workspace
|
||||
*/
|
||||
get workspaceLabels() {
|
||||
const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspaceDetails) return;
|
||||
return this.getWorkspaceLabels(currentWorkspaceDetails.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the labelMap belonging to the current project
|
||||
*/
|
||||
get projectLabels() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
const workspaceSlug = this.rootStore.router.workspaceSlug || "";
|
||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
||||
return sortBy(
|
||||
Object.values(this.labelMap).filter((label) => label?.project_id === projectId),
|
||||
"sort_order"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the labelMap in a tree format
|
||||
*/
|
||||
get projectLabelsTree() {
|
||||
if (!this.projectLabels) return;
|
||||
return buildTree(this.projectLabels);
|
||||
}
|
||||
|
||||
getWorkspaceLabels = computedFn((workspaceSlug: string) => {
|
||||
const workspaceDetails = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug);
|
||||
if (!workspaceDetails || !this.fetchedMap[workspaceSlug]) return;
|
||||
return sortBy(
|
||||
Object.values(this.labelMap).filter((label) => label.workspace_id === workspaceDetails.id),
|
||||
"sort_order"
|
||||
);
|
||||
});
|
||||
|
||||
getWorkspaceLabelIds = computedFn(
|
||||
(workspaceSlug: string) => this.getWorkspaceLabels(workspaceSlug)?.map((label) => label.id) ?? undefined
|
||||
);
|
||||
|
||||
getProjectLabels = computedFn((projectId: string | undefined | null) => {
|
||||
const workspaceSlug = this.rootStore.router.workspaceSlug || "";
|
||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
||||
return sortBy(
|
||||
Object.values(this.labelMap).filter((label) => label?.project_id === projectId),
|
||||
"sort_order"
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the label ids for a specific project
|
||||
* @param projectId
|
||||
* @returns string[]
|
||||
*/
|
||||
getProjectLabelIds = computedFn((projectId: string | undefined | null) => {
|
||||
const workspaceSlug = this.rootStore.router.workspaceSlug;
|
||||
if (!workspaceSlug || !projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug]))
|
||||
return undefined;
|
||||
return this.getProjectLabels(projectId)?.map((label) => label.id) ?? [];
|
||||
});
|
||||
|
||||
/**
|
||||
* get label info from the map of labels in the store using label id
|
||||
* @param labelId
|
||||
*/
|
||||
getLabelById = computedFn((labelId: string): IIssueLabel | null => this.labelMap?.[labelId] || null);
|
||||
|
||||
/**
|
||||
* Fetches all the labelMap belongs to a specific project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<IIssueLabel[]>
|
||||
*/
|
||||
fetchProjectLabels = async (workspaceSlug: string, projectId: string) =>
|
||||
await this.issueLabelService.getProjectLabels(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((label) => {
|
||||
set(this.labelMap, [label.id], label);
|
||||
});
|
||||
set(this.fetchedMap, projectId, true);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches all the labelMap belongs to a specific project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<IIssueLabel[]>
|
||||
*/
|
||||
fetchWorkspaceLabels = async (workspaceSlug: string) =>
|
||||
await this.issueLabelService.getWorkspaceIssueLabels(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((label) => {
|
||||
set(this.labelMap, [label.id], label);
|
||||
});
|
||||
set(this.fetchedMap, workspaceSlug, true);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new label for a specific project and add it to the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns Promise<IIssueLabel>
|
||||
*/
|
||||
createLabel = async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) =>
|
||||
await this.issueLabelService.createIssueLabel(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.labelMap, [response.id], response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates a label for a specific project and update it in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param labelId
|
||||
* @param data
|
||||
* @returns Promise<IIssueLabel>
|
||||
*/
|
||||
updateLabel = async (workspaceSlug: string, projectId: string, labelId: string, data: Partial<IIssueLabel>) => {
|
||||
const originalLabel = this.labelMap[labelId];
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.labelMap, [labelId], { ...originalLabel, ...data });
|
||||
});
|
||||
const response = await this.issueLabelService.patchIssueLabel(workspaceSlug, projectId, labelId, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to update label from project store");
|
||||
runInAction(() => {
|
||||
set(this.labelMap, [labelId], originalLabel);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* updates the sort order of a label and updates the label information using API.
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param labelId
|
||||
* @param parentId
|
||||
* @param index
|
||||
* @param isSameParent
|
||||
* @param prevIndex
|
||||
* @returns
|
||||
*/
|
||||
updateLabelPosition = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
draggingLabelId: string,
|
||||
droppedParentId: string | null,
|
||||
droppedLabelId: string | undefined,
|
||||
dropAtEndOfList: boolean
|
||||
) => {
|
||||
const currLabel = this.labelMap?.[draggingLabelId];
|
||||
const labelTree = this.projectLabelsTree;
|
||||
let currentArray: IIssueLabel[];
|
||||
|
||||
if (!currLabel || !labelTree) return;
|
||||
|
||||
//If its is dropped in the same parent then, there is not specific label on which it is mentioned then keep it's original position
|
||||
if (currLabel.parent === droppedParentId && !droppedLabelId) return;
|
||||
|
||||
const data: Partial<IIssueLabel> = { parent: droppedParentId };
|
||||
|
||||
// find array in which the label is to be added
|
||||
if (!droppedParentId) currentArray = labelTree;
|
||||
else currentArray = labelTree?.find((label) => label.id === droppedParentId)?.children || [];
|
||||
|
||||
let droppedLabelIndex = currentArray.findIndex((label) => label.id === droppedLabelId);
|
||||
//if the position of droppedLabelId cannot be determined then drop it at the end of the list
|
||||
if (dropAtEndOfList || droppedLabelIndex === -1) droppedLabelIndex = currentArray.length;
|
||||
|
||||
//if currently adding to a new array, then let backend assign a sort order
|
||||
if (currentArray.length > 0) {
|
||||
let prevSortOrder: number | undefined, nextSortOrder: number | undefined;
|
||||
|
||||
if (typeof currentArray[droppedLabelIndex - 1] !== "undefined") {
|
||||
prevSortOrder = currentArray[droppedLabelIndex - 1].sort_order;
|
||||
}
|
||||
if (typeof currentArray[droppedLabelIndex] !== "undefined") {
|
||||
nextSortOrder = currentArray[droppedLabelIndex].sort_order;
|
||||
}
|
||||
|
||||
let sortOrder: number = 65535;
|
||||
//based on the next and previous labelMap calculate current sort order
|
||||
if (prevSortOrder && nextSortOrder) {
|
||||
sortOrder = (prevSortOrder + nextSortOrder) / 2;
|
||||
} else if (nextSortOrder) {
|
||||
sortOrder = nextSortOrder / 2;
|
||||
} else if (prevSortOrder) {
|
||||
sortOrder = prevSortOrder + 10000;
|
||||
}
|
||||
data.sort_order = sortOrder;
|
||||
}
|
||||
|
||||
return this.updateLabel(workspaceSlug, projectId, draggingLabelId, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete the label from the project and remove it from the labelMap object
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param labelId
|
||||
*/
|
||||
deleteLabel = async (workspaceSlug: string, projectId: string, labelId: string) => {
|
||||
if (!this.labelMap[labelId]) return;
|
||||
await this.issueLabelService.deleteIssueLabel(workspaceSlug, projectId, labelId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.labelMap[labelId];
|
||||
});
|
||||
syncIssuesWithDeletedLabels([labelId]);
|
||||
});
|
||||
};
|
||||
}
|
||||
51
apps/web/core/store/member/index.ts
Normal file
51
apps/web/core/store/member/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { makeObservable, observable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { IUserLite } from "@plane/types";
|
||||
// plane web imports
|
||||
import type { IProjectMemberStore } from "@/plane-web/store/member/project-member.store";
|
||||
import { ProjectMemberStore } from "@/plane-web/store/member/project-member.store";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// local imports
|
||||
import type { IWorkspaceMemberStore } from "./workspace/workspace-member.store";
|
||||
import { WorkspaceMemberStore } from "./workspace/workspace-member.store";
|
||||
|
||||
export interface IMemberRootStore {
|
||||
// observables
|
||||
memberMap: Record<string, IUserLite>;
|
||||
// computed actions
|
||||
getMemberIds: () => string[];
|
||||
getUserDetails: (userId: string) => IUserLite | undefined;
|
||||
// sub-stores
|
||||
workspace: IWorkspaceMemberStore;
|
||||
project: IProjectMemberStore;
|
||||
}
|
||||
|
||||
export class MemberRootStore implements IMemberRootStore {
|
||||
// observables
|
||||
memberMap: Record<string, IUserLite> = {};
|
||||
// sub-stores
|
||||
workspace: IWorkspaceMemberStore;
|
||||
project: IProjectMemberStore;
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
memberMap: observable,
|
||||
});
|
||||
// sub-stores
|
||||
this.workspace = new WorkspaceMemberStore(this, _rootStore);
|
||||
this.project = new ProjectMemberStore(this, _rootStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get all member ids
|
||||
*/
|
||||
getMemberIds = computedFn(() => Object.keys(this.memberMap));
|
||||
|
||||
/**
|
||||
* @description get user details from userId
|
||||
* @param userId
|
||||
*/
|
||||
getUserDetails = computedFn((userId: string): IUserLite | undefined => this.memberMap?.[userId] ?? undefined);
|
||||
}
|
||||
410
apps/web/core/store/member/project/base-project-member.store.ts
Normal file
410
apps/web/core/store/member/project/base-project-member.store.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { uniq, unset, set, update, sortBy } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import type { EUserProjectRoles, IProjectBulkAddFormData, IUserLite, TProjectMembership } from "@plane/types";
|
||||
// plane web imports
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// services
|
||||
import { ProjectMemberService } from "@/services/project";
|
||||
// store
|
||||
import type { IProjectStore } from "@/store/project/project.store";
|
||||
import type { IRouterStore } from "@/store/router.store";
|
||||
import type { IUserStore } from "@/store/user";
|
||||
// local imports
|
||||
import type { IMemberRootStore } from "../index";
|
||||
import { sortProjectMembers } from "../utils";
|
||||
import type { IProjectMemberFiltersStore } from "./project-member-filters.store";
|
||||
import { ProjectMemberFiltersStore } from "./project-member-filters.store";
|
||||
|
||||
export interface IProjectMemberDetails extends Omit<TProjectMembership, "member"> {
|
||||
member: IUserLite;
|
||||
}
|
||||
|
||||
export interface IBaseProjectMemberStore {
|
||||
// observables
|
||||
projectMemberFetchStatusMap: {
|
||||
[projectId: string]: boolean;
|
||||
};
|
||||
projectMemberMap: {
|
||||
[projectId: string]: Record<string, TProjectMembership>;
|
||||
};
|
||||
// filters store
|
||||
filters: IProjectMemberFiltersStore;
|
||||
// computed
|
||||
projectMemberIds: string[] | null;
|
||||
// computed actions
|
||||
getProjectMemberFetchStatus: (projectId: string) => boolean;
|
||||
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
|
||||
getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null;
|
||||
getFilteredProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
|
||||
// fetch actions
|
||||
fetchProjectMembers: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
clearExistingMembers?: boolean
|
||||
) => Promise<TProjectMembership[]>;
|
||||
// bulk operation actions
|
||||
bulkAddMembersToProject: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: IProjectBulkAddFormData
|
||||
) => Promise<TProjectMembership[]>;
|
||||
// crud actions
|
||||
updateMemberRole: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
userId: string,
|
||||
role: EUserProjectRoles
|
||||
) => Promise<TProjectMembership>;
|
||||
removeMemberFromProject: (workspaceSlug: string, projectId: string, userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore {
|
||||
// observables
|
||||
projectMemberFetchStatusMap: {
|
||||
[projectId: string]: boolean;
|
||||
} = {};
|
||||
projectMemberMap: {
|
||||
[projectId: string]: Record<string, TProjectMembership>;
|
||||
} = {};
|
||||
// filters store
|
||||
filters: IProjectMemberFiltersStore;
|
||||
// stores
|
||||
routerStore: IRouterStore;
|
||||
userStore: IUserStore;
|
||||
memberRoot: IMemberRootStore;
|
||||
projectRoot: IProjectStore;
|
||||
rootStore: RootStore;
|
||||
// services
|
||||
projectMemberService;
|
||||
|
||||
constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
projectMemberMap: observable,
|
||||
// computed
|
||||
projectMemberIds: computed,
|
||||
// actions
|
||||
fetchProjectMembers: action,
|
||||
bulkAddMembersToProject: action,
|
||||
updateMemberRole: action,
|
||||
removeMemberFromProject: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
this.routerStore = _rootStore.router;
|
||||
this.userStore = _rootStore.user;
|
||||
this.memberRoot = _memberRoot;
|
||||
this.projectRoot = _rootStore.projectRoot.project;
|
||||
this.filters = new ProjectMemberFiltersStore();
|
||||
// services
|
||||
this.projectMemberService = new ProjectMemberService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get the list of all the user ids of all the members of the current project
|
||||
* Returns filtered and sorted member IDs based on current filters
|
||||
*/
|
||||
get projectMemberIds() {
|
||||
const projectId = this.routerStore.projectId;
|
||||
if (!projectId) return null;
|
||||
|
||||
const members = Object.values(this.projectMemberMap?.[projectId] ?? {});
|
||||
if (members.length === 0) return null;
|
||||
|
||||
// Access the filters directly to ensure MobX tracking
|
||||
const currentFilters = this.filters.filtersMap[projectId];
|
||||
|
||||
// Apply filters and sorting directly here to ensure MobX tracking
|
||||
const sortedMembers = sortProjectMembers(
|
||||
members,
|
||||
this.memberRoot?.memberMap || {},
|
||||
(member) => member.member,
|
||||
currentFilters
|
||||
);
|
||||
|
||||
return sortedMembers.map((member) => member.member);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get the fetch status of a project member
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectMemberFetchStatus = computedFn((projectId: string) => this.projectMemberFetchStatusMap?.[projectId]);
|
||||
|
||||
/**
|
||||
* @description get the project memberships
|
||||
* @param projectId
|
||||
*/
|
||||
protected getProjectMemberships = computedFn((projectId: string) =>
|
||||
Object.values(this.projectMemberMap?.[projectId] ?? {})
|
||||
);
|
||||
|
||||
/**
|
||||
* @description get the project membership by user id
|
||||
* @param userId
|
||||
* @param projectId
|
||||
*/
|
||||
protected getProjectMembershipByUserId = computedFn(
|
||||
(userId: string, projectId: string) => this.projectMemberMap?.[projectId]?.[userId]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description get the role from the project membership
|
||||
* @param userId
|
||||
* @param projectId
|
||||
*/
|
||||
protected getRoleFromProjectMembership = computedFn(
|
||||
(userId: string, projectId: string): EUserProjectRoles | undefined => {
|
||||
const projectMembership = this.getProjectMembershipByUserId(userId, projectId);
|
||||
if (!projectMembership) return undefined;
|
||||
const projectMembershipRole = projectMembership.original_role ?? projectMembership.role;
|
||||
return projectMembershipRole ? (projectMembershipRole as EUserProjectRoles) : undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Returns the project membership role for a user
|
||||
* @description This method is specifically used when adding new members to a project. For existing members,
|
||||
* the role is fetched directly from the backend during member listing.
|
||||
* @param { string } userId - The ID of the user
|
||||
* @param { string } projectId - The ID of the project
|
||||
* @returns { EUserProjectRoles | undefined } The user's role in the project, or undefined if not found
|
||||
*/
|
||||
abstract getUserProjectRole: (userId: string, projectId: string) => EUserProjectRoles | undefined;
|
||||
|
||||
/**
|
||||
* @description get the details of a project member
|
||||
* @param userId
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectMemberDetails = computedFn((userId: string, projectId: string) => {
|
||||
const projectMember = this.getProjectMembershipByUserId(userId, projectId);
|
||||
const userDetails = this.memberRoot?.memberMap?.[projectMember?.member];
|
||||
if (!projectMember || !userDetails) return null;
|
||||
const memberDetails: IProjectMemberDetails = {
|
||||
id: projectMember.id,
|
||||
role: projectMember.role,
|
||||
original_role: projectMember.original_role,
|
||||
member: {
|
||||
...userDetails,
|
||||
joining_date: projectMember.created_at ?? undefined,
|
||||
},
|
||||
created_at: projectMember.created_at,
|
||||
};
|
||||
return memberDetails;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the list of all the user ids of all the members of a project using projectId
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectMemberIds = computedFn((projectId: string, includeGuestUsers: boolean): string[] | null => {
|
||||
if (!this.projectMemberMap?.[projectId]) return null;
|
||||
let members = this.getProjectMemberships(projectId);
|
||||
if (includeGuestUsers === false) {
|
||||
members = members.filter((m) => m.role !== EUserPermissions.GUEST);
|
||||
}
|
||||
members = sortBy(members, [
|
||||
(m) => m.member !== this.userStore.data?.id,
|
||||
(m) => this.memberRoot?.memberMap?.[m.member]?.display_name?.toLowerCase(),
|
||||
]);
|
||||
const memberIds = members.map((m) => m.member);
|
||||
return memberIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the filtered project member details for a specific user
|
||||
* @param userId
|
||||
* @param projectId
|
||||
*/
|
||||
getFilteredProjectMemberDetails = computedFn((userId: string, projectId: string) => {
|
||||
const projectMember = this.getProjectMembershipByUserId(userId, projectId);
|
||||
const userDetails = this.memberRoot?.memberMap?.[projectMember?.member];
|
||||
if (!projectMember || !userDetails) return null;
|
||||
|
||||
// Check if this member passes the current filters
|
||||
const allMembers = this.getProjectMemberships(projectId);
|
||||
const filteredMemberIds = this.filters.getFilteredMemberIds(
|
||||
allMembers,
|
||||
this.memberRoot?.memberMap || {},
|
||||
(member) => member.member,
|
||||
projectId
|
||||
);
|
||||
|
||||
// Return null if this user doesn't pass the filters
|
||||
if (!filteredMemberIds.includes(userId)) return null;
|
||||
|
||||
const memberDetails: IProjectMemberDetails = {
|
||||
id: projectMember.id,
|
||||
role: projectMember.role,
|
||||
original_role: projectMember.original_role,
|
||||
member: {
|
||||
...userDetails,
|
||||
joining_date: projectMember.created_at ?? undefined,
|
||||
},
|
||||
created_at: projectMember.created_at,
|
||||
};
|
||||
return memberDetails;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetch the list of all the members of a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchProjectMembers = async (workspaceSlug: string, projectId: string, clearExistingMembers: boolean = false) =>
|
||||
await this.projectMemberService.fetchProjectMembers(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
if (clearExistingMembers) {
|
||||
unset(this.projectMemberMap, [projectId]);
|
||||
}
|
||||
response.forEach((member) => {
|
||||
set(this.projectMemberMap, [projectId, member.member], member);
|
||||
});
|
||||
set(this.projectMemberFetchStatusMap, [projectId], true);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description bulk add members to a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns Promise<TProjectMembership[]>
|
||||
*/
|
||||
bulkAddMembersToProject = async (workspaceSlug: string, projectId: string, data: IProjectBulkAddFormData) =>
|
||||
await this.projectMemberService.bulkAddMembersToProject(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((member) => {
|
||||
set(this.projectMemberMap, [projectId, member.member], {
|
||||
...member,
|
||||
role: this.getUserProjectRole(member.member, projectId) ?? member.role,
|
||||
original_role: member.role,
|
||||
});
|
||||
});
|
||||
});
|
||||
update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
|
||||
uniq([...memberIds, ...data.members.map((m) => m.member_id)])
|
||||
);
|
||||
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat(
|
||||
data.members.map((m) => m.member_id)
|
||||
);
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description update the role of a member in a project
|
||||
* @param projectId
|
||||
* @param userId
|
||||
* @param role
|
||||
*/
|
||||
abstract getProjectMemberRoleForUpdate: (
|
||||
projectId: string,
|
||||
userId: string,
|
||||
role: EUserProjectRoles
|
||||
) => EUserProjectRoles;
|
||||
|
||||
/**
|
||||
* @description update the role of a member in a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param userId
|
||||
* @param data
|
||||
*/
|
||||
updateMemberRole = async (workspaceSlug: string, projectId: string, userId: string, role: EUserProjectRoles) => {
|
||||
const memberDetails = this.getProjectMemberDetails(userId, projectId);
|
||||
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
|
||||
// original data to revert back in case of error
|
||||
const isCurrentUser = this.rootStore.user.data?.id === userId;
|
||||
const membershipBeforeUpdate = { ...this.getProjectMembershipByUserId(userId, projectId) };
|
||||
const permissionBeforeUpdate = isCurrentUser
|
||||
? this.rootStore.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId)
|
||||
: undefined;
|
||||
const updatedProjectRole = this.getProjectMemberRoleForUpdate(projectId, userId, role);
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.projectMemberMap, [projectId, userId, "original_role"], role);
|
||||
set(this.projectMemberMap, [projectId, userId, "role"], updatedProjectRole);
|
||||
if (isCurrentUser) {
|
||||
set(
|
||||
this.rootStore.user.permission.workspaceProjectsPermissions,
|
||||
[workspaceSlug, projectId],
|
||||
updatedProjectRole
|
||||
);
|
||||
}
|
||||
set(this.rootStore.user.permission.projectUserInfo, [workspaceSlug, projectId, "role"], updatedProjectRole);
|
||||
});
|
||||
const response = await this.projectMemberService.updateProjectMember(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
memberDetails?.id,
|
||||
{
|
||||
role,
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// revert back to original members in case of error
|
||||
runInAction(() => {
|
||||
set(this.projectMemberMap, [projectId, userId, "original_role"], membershipBeforeUpdate?.original_role);
|
||||
set(this.projectMemberMap, [projectId, userId, "role"], membershipBeforeUpdate?.role);
|
||||
if (isCurrentUser) {
|
||||
set(
|
||||
this.rootStore.user.permission.workspaceProjectsPermissions,
|
||||
[workspaceSlug, projectId],
|
||||
membershipBeforeUpdate?.original_role
|
||||
);
|
||||
set(
|
||||
this.rootStore.user.permission.projectUserInfo,
|
||||
[workspaceSlug, projectId, "role"],
|
||||
permissionBeforeUpdate
|
||||
);
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Handles the removal of a member from a project
|
||||
* @param projectId - The ID of the project to remove the member from
|
||||
* @param userId - The ID of the user to remove from the project
|
||||
*/
|
||||
protected handleMemberRemoval = (projectId: string, userId: string) => {
|
||||
unset(this.projectMemberMap, [projectId, userId]);
|
||||
set(
|
||||
this.projectRoot.projectMap,
|
||||
[projectId, "members"],
|
||||
this.projectRoot.projectMap?.[projectId]?.members?.filter((memberId) => memberId !== userId)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Processes the removal of a member from a project
|
||||
* This abstract method handles the cleanup of member data from the project member map
|
||||
* @param projectId - The ID of the project to remove the member from
|
||||
* @param userId - The ID of the user to remove from the project
|
||||
*/
|
||||
abstract processMemberRemoval: (projectId: string, userId: string) => void;
|
||||
|
||||
/**
|
||||
* @description remove a member from a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param userId
|
||||
*/
|
||||
removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => {
|
||||
const memberDetails = this.getProjectMemberDetails(userId, projectId);
|
||||
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
|
||||
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => {
|
||||
runInAction(() => {
|
||||
this.processMemberRemoval(projectId, userId);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { action, makeObservable, observable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { IUserLite, TProjectMembership } from "@plane/types";
|
||||
// local imports
|
||||
import type { IMemberFilters } from "../utils";
|
||||
import { sortProjectMembers } from "../utils";
|
||||
|
||||
export interface IProjectMemberFiltersStore {
|
||||
// observables
|
||||
filtersMap: Record<string, IMemberFilters>;
|
||||
// computed actions
|
||||
getFilteredMemberIds: (
|
||||
members: TProjectMembership[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: TProjectMembership) => string,
|
||||
projectId: string
|
||||
) => string[];
|
||||
// actions
|
||||
updateFilters: (projectId: string, filters: Partial<IMemberFilters>) => void;
|
||||
getFilters: (projectId: string) => IMemberFilters | undefined;
|
||||
}
|
||||
|
||||
export class ProjectMemberFiltersStore implements IProjectMemberFiltersStore {
|
||||
// observables
|
||||
filtersMap: Record<string, IMemberFilters> = {};
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filtersMap: observable,
|
||||
// actions
|
||||
updateFilters: action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get filtered and sorted member ids
|
||||
* @param members - array of project membership objects
|
||||
* @param memberDetailsMap - map of member details by user id
|
||||
* @param getMemberKey - function to get member key from membership object
|
||||
* @param projectId - project id to get filters for
|
||||
*/
|
||||
getFilteredMemberIds = computedFn(
|
||||
(
|
||||
members: TProjectMembership[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: TProjectMembership) => string,
|
||||
projectId: string
|
||||
): string[] => {
|
||||
if (!members || members.length === 0) return [];
|
||||
|
||||
// Apply filters and sorting
|
||||
const sortedMembers = sortProjectMembers(members, memberDetailsMap, getMemberKey, this.filtersMap[projectId]);
|
||||
|
||||
return sortedMembers.map(getMemberKey);
|
||||
}
|
||||
);
|
||||
|
||||
getFilters = (projectId: string) => this.filtersMap[projectId];
|
||||
|
||||
/**
|
||||
* @description update filters
|
||||
* @param projectId - project id
|
||||
* @param filters - partial filters to update
|
||||
*/
|
||||
updateFilters = (projectId: string, filters: Partial<IMemberFilters>) => {
|
||||
const current = this.filtersMap[projectId] ?? {};
|
||||
this.filtersMap[projectId] = { ...current, ...filters };
|
||||
};
|
||||
}
|
||||
187
apps/web/core/store/member/utils.ts
Normal file
187
apps/web/core/store/member/utils.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// Types and utilities for member filtering
|
||||
import type { EUserPermissions, TMemberOrderByOptions } from "@plane/constants";
|
||||
import type { IUserLite, TProjectMembership } from "@plane/types";
|
||||
|
||||
export interface IMemberFilters {
|
||||
order_by?: TMemberOrderByOptions;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
// Helper function to parse order key and direction
|
||||
export const parseOrderKey = (orderKey?: TMemberOrderByOptions): { field: string; direction: "asc" | "desc" } => {
|
||||
// Default to sorting by display_name in ascending order when no order key is provided
|
||||
if (!orderKey) {
|
||||
return {
|
||||
field: "display_name",
|
||||
direction: "asc",
|
||||
};
|
||||
}
|
||||
|
||||
const isDescending = orderKey.startsWith("-");
|
||||
const field = isDescending ? orderKey.slice(1) : orderKey;
|
||||
return {
|
||||
field,
|
||||
direction: isDescending ? "desc" : "asc",
|
||||
};
|
||||
};
|
||||
|
||||
// Unified function to get sort key for any member type
|
||||
export const getMemberSortKey = (memberDetails: IUserLite, field: string, memberRole?: string): string | Date => {
|
||||
switch (field) {
|
||||
case "display_name":
|
||||
return memberDetails.display_name?.toLowerCase() || "";
|
||||
case "full_name": {
|
||||
const firstName = memberDetails.first_name || "";
|
||||
const lastName = memberDetails.last_name || "";
|
||||
return `${firstName} ${lastName}`.toLowerCase().trim();
|
||||
}
|
||||
case "email":
|
||||
return memberDetails.email?.toLowerCase() || "";
|
||||
case "joining_date": {
|
||||
if (!memberDetails.joining_date) {
|
||||
// Return a very old date for missing dates to sort them last
|
||||
return new Date(0);
|
||||
}
|
||||
const date = new Date(memberDetails.joining_date);
|
||||
// Return a very old date for invalid dates to sort them last
|
||||
return isNaN(date.getTime()) ? new Date(0) : date;
|
||||
}
|
||||
case "role":
|
||||
return (memberRole ?? "").toString().toLowerCase();
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Filter functions
|
||||
export const filterProjectMembersByRole = (
|
||||
members: TProjectMembership[],
|
||||
roleFilters: string[]
|
||||
): TProjectMembership[] => {
|
||||
if (roleFilters.length === 0) return members;
|
||||
|
||||
return members.filter((member) => {
|
||||
const memberRole = String(member.role ?? member.original_role ?? "");
|
||||
return roleFilters.includes(memberRole);
|
||||
});
|
||||
};
|
||||
|
||||
export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
|
||||
members: T[],
|
||||
roleFilters: string[]
|
||||
): T[] => {
|
||||
if (roleFilters.length === 0) return members;
|
||||
|
||||
return members.filter((member) => {
|
||||
const memberRole = String(member.role ?? "");
|
||||
const isSuspended = member.is_active === false;
|
||||
|
||||
// Check if suspended is in the role filters
|
||||
const hasSuspendedFilter = roleFilters.includes("suspended");
|
||||
// Get non-suspended role filters
|
||||
const activeRoleFilters = roleFilters.filter((role) => role !== "suspended");
|
||||
|
||||
// For suspended users, include them only if suspended filter is selected
|
||||
if (isSuspended) {
|
||||
return hasSuspendedFilter;
|
||||
}
|
||||
|
||||
// For active users, include them only if their role matches any active role filter
|
||||
return activeRoleFilters.includes(memberRole);
|
||||
});
|
||||
};
|
||||
|
||||
// Unified sorting function
|
||||
export const sortMembers = <T>(
|
||||
members: T[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: T) => string,
|
||||
getMemberRole: (member: T) => string,
|
||||
orderBy?: TMemberOrderByOptions
|
||||
): T[] => {
|
||||
if (!orderBy) return members;
|
||||
|
||||
const { field, direction } = parseOrderKey(orderBy);
|
||||
|
||||
return [...members].sort((a, b) => {
|
||||
const aKey = getMemberKey(a);
|
||||
const bKey = getMemberKey(b);
|
||||
const aMemberDetails = memberDetailsMap[aKey];
|
||||
const bMemberDetails = memberDetailsMap[bKey];
|
||||
|
||||
if (!aMemberDetails || !bMemberDetails) return 0;
|
||||
|
||||
const aRole = getMemberRole(a);
|
||||
const bRole = getMemberRole(b);
|
||||
|
||||
const aValue = getMemberSortKey(aMemberDetails, field, aRole);
|
||||
const bValue = getMemberSortKey(bMemberDetails, field, bRole);
|
||||
|
||||
let comparison = 0;
|
||||
|
||||
if (field === "joining_date") {
|
||||
// For dates, we need to handle Date objects and ensure they're valid
|
||||
const aDate = aValue instanceof Date ? aValue : new Date(aValue);
|
||||
const bDate = bValue instanceof Date ? bValue : new Date(bValue);
|
||||
|
||||
// Handle invalid dates by treating them as very old dates
|
||||
const aTime = isNaN(aDate.getTime()) ? 0 : aDate.getTime();
|
||||
const bTime = isNaN(bDate.getTime()) ? 0 : bDate.getTime();
|
||||
|
||||
comparison = aTime - bTime;
|
||||
} else {
|
||||
// For strings, use localeCompare for proper alphabetical sorting
|
||||
const aStr = String(aValue);
|
||||
const bStr = String(bValue);
|
||||
comparison = aStr.localeCompare(bStr);
|
||||
}
|
||||
|
||||
return direction === "desc" ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
|
||||
// Specific implementations using the unified functions
|
||||
export const sortProjectMembers = (
|
||||
members: TProjectMembership[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: TProjectMembership) => string,
|
||||
filters?: IMemberFilters
|
||||
): TProjectMembership[] => {
|
||||
// Apply role filtering first
|
||||
const filteredMembers =
|
||||
filters?.roles && filters.roles.length > 0 ? filterProjectMembersByRole(members, filters.roles) : members;
|
||||
|
||||
// If no order_by filter, return filtered members
|
||||
if (!filters?.order_by) return filteredMembers;
|
||||
|
||||
// Apply sorting
|
||||
return sortMembers(
|
||||
filteredMembers,
|
||||
memberDetailsMap,
|
||||
getMemberKey,
|
||||
(member) => String(member.role ?? member.original_role ?? ""),
|
||||
filters.order_by
|
||||
);
|
||||
};
|
||||
|
||||
export const sortWorkspaceMembers = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
|
||||
members: T[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: T) => string,
|
||||
filters?: IMemberFilters
|
||||
): T[] => {
|
||||
const filteredMembers =
|
||||
filters?.roles && filters.roles.length > 0 ? filterWorkspaceMembersByRole(members, filters.roles) : members;
|
||||
|
||||
// If no order_by filter, return filtered members
|
||||
if (!filters?.order_by) return filteredMembers;
|
||||
|
||||
// Apply sorting
|
||||
return sortMembers(
|
||||
filteredMembers,
|
||||
memberDetailsMap,
|
||||
getMemberKey,
|
||||
(member) => String(member.role ?? ""),
|
||||
filters.order_by
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { action, makeObservable, observable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { EUserPermissions } from "@plane/constants";
|
||||
import type { IUserLite } from "@plane/types";
|
||||
// local imports
|
||||
import type { IMemberFilters } from "../utils";
|
||||
import { sortWorkspaceMembers } from "../utils";
|
||||
|
||||
// Workspace membership interface matching the store structure
|
||||
interface IWorkspaceMembership {
|
||||
id: string;
|
||||
member: string;
|
||||
role: EUserPermissions;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceMemberFiltersStore {
|
||||
// observables
|
||||
filters: IMemberFilters;
|
||||
// computed actions
|
||||
getFilteredMemberIds: (
|
||||
members: IWorkspaceMembership[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: IWorkspaceMembership) => string
|
||||
) => string[];
|
||||
// actions
|
||||
updateFilters: (filters: Partial<IMemberFilters>) => void;
|
||||
}
|
||||
|
||||
export class WorkspaceMemberFiltersStore implements IWorkspaceMemberFiltersStore {
|
||||
// observables
|
||||
filters: IMemberFilters = {};
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
filters: observable,
|
||||
// actions
|
||||
updateFilters: action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get filtered and sorted member ids
|
||||
* @param members - array of workspace membership objects
|
||||
* @param memberDetailsMap - map of member details by user id
|
||||
* @param getMemberKey - function to get member key from membership object
|
||||
*/
|
||||
getFilteredMemberIds = computedFn(
|
||||
(
|
||||
members: IWorkspaceMembership[],
|
||||
memberDetailsMap: Record<string, IUserLite>,
|
||||
getMemberKey: (member: IWorkspaceMembership) => string
|
||||
): string[] => {
|
||||
if (!members || members.length === 0) return [];
|
||||
|
||||
// Apply filters and sorting
|
||||
const sortedMembers = sortWorkspaceMembers(members, memberDetailsMap, getMemberKey, this.filters);
|
||||
|
||||
return sortedMembers.map(getMemberKey);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @description update filters
|
||||
* @param filters - partial filters to update
|
||||
*/
|
||||
updateFilters = (filters: Partial<IMemberFilters>) => {
|
||||
this.filters = { ...this.filters, ...filters };
|
||||
};
|
||||
}
|
||||
359
apps/web/core/store/member/workspace/workspace-member.store.ts
Normal file
359
apps/web/core/store/member/workspace/workspace-member.store.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { set, sortBy } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { EUserPermissions } from "@plane/constants";
|
||||
import type { IWorkspaceBulkInviteFormData, IWorkspaceMember, IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// plane-web constants
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// types
|
||||
import type { IRouterStore } from "@/store/router.store";
|
||||
import type { IUserStore } from "@/store/user";
|
||||
// store
|
||||
import type { CoreRootStore } from "../../root.store";
|
||||
import type { IMemberRootStore } from "../index.ts";
|
||||
import type { IWorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
|
||||
import { WorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
|
||||
|
||||
export interface IWorkspaceMembership {
|
||||
id: string;
|
||||
member: string;
|
||||
role: EUserPermissions;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceMemberStore {
|
||||
// observables
|
||||
workspaceMemberMap: Record<string, Record<string, IWorkspaceMembership>>;
|
||||
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]>;
|
||||
// filters store
|
||||
filtersStore: IWorkspaceMemberFiltersStore;
|
||||
// computed
|
||||
workspaceMemberIds: string[] | null;
|
||||
workspaceMemberInvitationIds: string[] | null;
|
||||
memberMap: Record<string, IWorkspaceMembership> | null;
|
||||
// computed actions
|
||||
getWorkspaceMemberIds: (workspaceSlug: string) => string[];
|
||||
getFilteredWorkspaceMemberIds: (workspaceSlug: string) => string[];
|
||||
getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null;
|
||||
getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null;
|
||||
getWorkspaceMemberDetails: (workspaceMemberId: string) => IWorkspaceMember | null;
|
||||
getWorkspaceInvitationDetails: (invitationId: string) => IWorkspaceMemberInvitation | null;
|
||||
// fetch actions
|
||||
fetchWorkspaceMembers: (workspaceSlug: string) => Promise<IWorkspaceMember[]>;
|
||||
fetchWorkspaceMemberInvitations: (workspaceSlug: string) => Promise<IWorkspaceMemberInvitation[]>;
|
||||
// crud actions
|
||||
updateMember: (workspaceSlug: string, userId: string, data: { role: EUserPermissions }) => Promise<void>;
|
||||
removeMemberFromWorkspace: (workspaceSlug: string, userId: string) => Promise<void>;
|
||||
// invite actions
|
||||
inviteMembersToWorkspace: (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => Promise<void>;
|
||||
updateMemberInvitation: (
|
||||
workspaceSlug: string,
|
||||
invitationId: string,
|
||||
data: Partial<IWorkspaceMemberInvitation>
|
||||
) => Promise<void>;
|
||||
deleteMemberInvitation: (workspaceSlug: string, invitationId: string) => Promise<void>;
|
||||
isUserSuspended: (userId: string, workspaceSlug: string) => boolean;
|
||||
}
|
||||
|
||||
export class WorkspaceMemberStore implements IWorkspaceMemberStore {
|
||||
// observables
|
||||
workspaceMemberMap: {
|
||||
[workspaceSlug: string]: Record<string, IWorkspaceMembership>;
|
||||
} = {}; // { workspaceSlug: { userId: userDetails } }
|
||||
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]> = {}; // { workspaceSlug: [invitations] }
|
||||
// filters store
|
||||
filtersStore: IWorkspaceMemberFiltersStore;
|
||||
// stores
|
||||
routerStore: IRouterStore;
|
||||
userStore: IUserStore;
|
||||
memberRoot: IMemberRootStore;
|
||||
// services
|
||||
workspaceService;
|
||||
|
||||
constructor(_memberRoot: IMemberRootStore, _rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
workspaceMemberMap: observable,
|
||||
workspaceMemberInvitations: observable,
|
||||
// computed
|
||||
workspaceMemberIds: computed,
|
||||
workspaceMemberInvitationIds: computed,
|
||||
memberMap: computed,
|
||||
// actions
|
||||
fetchWorkspaceMembers: action,
|
||||
updateMember: action,
|
||||
removeMemberFromWorkspace: action,
|
||||
fetchWorkspaceMemberInvitations: action,
|
||||
updateMemberInvitation: action,
|
||||
deleteMemberInvitation: action,
|
||||
});
|
||||
// initialize filters store
|
||||
this.filtersStore = new WorkspaceMemberFiltersStore();
|
||||
// root store
|
||||
this.routerStore = _rootStore.router;
|
||||
this.userStore = _rootStore.user;
|
||||
this.memberRoot = _memberRoot;
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get the list of all the user ids of all the members of the current workspace
|
||||
*/
|
||||
get workspaceMemberIds() {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return this.getWorkspaceMemberIds(workspaceSlug);
|
||||
}
|
||||
|
||||
get memberMap() {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
return this.workspaceMemberMap?.[workspaceSlug] ?? {};
|
||||
}
|
||||
|
||||
get workspaceMemberInvitationIds() {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
return this.workspaceMemberInvitations?.[workspaceSlug]?.map((inv) => inv.id);
|
||||
}
|
||||
|
||||
getWorkspaceMemberIds = computedFn((workspaceSlug: string) => {
|
||||
let members = Object.values(this.workspaceMemberMap?.[workspaceSlug] ?? {});
|
||||
members = sortBy(members, [
|
||||
(m) => m.member !== this.userStore?.data?.id,
|
||||
(m) => this.memberRoot?.memberMap?.[m.member]?.display_name?.toLowerCase(),
|
||||
]);
|
||||
//filter out bots
|
||||
const memberIds = members.filter((m) => !this.memberRoot?.memberMap?.[m.member]?.is_bot).map((m) => m.member);
|
||||
return memberIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the filtered and sorted list of all the user ids of all the members of the workspace
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
getFilteredWorkspaceMemberIds = computedFn((workspaceSlug: string) => {
|
||||
let members = Object.values(this.workspaceMemberMap?.[workspaceSlug] ?? {});
|
||||
//filter out bots and inactive members
|
||||
members = members.filter((m) => !this.memberRoot?.memberMap?.[m.member]?.is_bot);
|
||||
|
||||
// Use filters store to get filtered member ids
|
||||
const memberIds = this.filtersStore.getFilteredMemberIds(
|
||||
members,
|
||||
this.memberRoot?.memberMap || {},
|
||||
(member) => member.member
|
||||
);
|
||||
|
||||
return memberIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the list of all the user ids that match the search query of all the members of the current workspace
|
||||
* @param searchQuery
|
||||
*/
|
||||
getSearchedWorkspaceMemberIds = computedFn((searchQuery: string) => {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
const filteredMemberIds = this.getFilteredWorkspaceMemberIds(workspaceSlug);
|
||||
if (!filteredMemberIds) return null;
|
||||
const searchedWorkspaceMemberIds = filteredMemberIds.filter((userId) => {
|
||||
const memberDetails = this.getWorkspaceMemberDetails(userId);
|
||||
if (!memberDetails) return false;
|
||||
const memberSearchQuery = `${memberDetails.member.first_name} ${memberDetails.member.last_name} ${
|
||||
memberDetails.member?.display_name
|
||||
} ${memberDetails.member.email ?? ""}`;
|
||||
return memberSearchQuery.toLowerCase()?.includes(searchQuery.toLowerCase());
|
||||
});
|
||||
return searchedWorkspaceMemberIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the list of all the invitation ids that match the search query of all the member invitations of the current workspace
|
||||
* @param searchQuery
|
||||
*/
|
||||
getSearchedWorkspaceInvitationIds = computedFn((searchQuery: string) => {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
const workspaceMemberInvitationIds = this.workspaceMemberInvitationIds;
|
||||
if (!workspaceMemberInvitationIds) return null;
|
||||
const searchedWorkspaceMemberInvitationIds = workspaceMemberInvitationIds.filter((invitationId) => {
|
||||
const invitationDetails = this.getWorkspaceInvitationDetails(invitationId);
|
||||
if (!invitationDetails) return false;
|
||||
const invitationSearchQuery = `${invitationDetails.email}`;
|
||||
return invitationSearchQuery.toLowerCase()?.includes(searchQuery.toLowerCase());
|
||||
});
|
||||
return searchedWorkspaceMemberInvitationIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the details of a workspace member
|
||||
* @param userId
|
||||
*/
|
||||
getWorkspaceMemberDetails = computedFn((userId: string) => {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
const workspaceMember = this.workspaceMemberMap?.[workspaceSlug]?.[userId];
|
||||
if (!workspaceMember) return null;
|
||||
|
||||
const memberDetails: IWorkspaceMember = {
|
||||
id: workspaceMember.id,
|
||||
role: workspaceMember.role,
|
||||
member: this.memberRoot?.memberMap?.[workspaceMember.member],
|
||||
is_active: workspaceMember.is_active,
|
||||
};
|
||||
return memberDetails;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get the details of a workspace member invitation
|
||||
* @param workspaceSlug
|
||||
* @param memberId
|
||||
*/
|
||||
getWorkspaceInvitationDetails = computedFn((invitationId: string) => {
|
||||
const workspaceSlug = this.routerStore.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
const invitationsList = this.workspaceMemberInvitations?.[workspaceSlug];
|
||||
if (!invitationsList) return null;
|
||||
|
||||
const invitation = invitationsList.find((inv) => inv.id === invitationId);
|
||||
return invitation ?? null;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetch all the members of a workspace
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
fetchWorkspaceMembers = async (workspaceSlug: string) =>
|
||||
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((member) => {
|
||||
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
|
||||
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], {
|
||||
id: member.id,
|
||||
member: member.member.id,
|
||||
role: member.role,
|
||||
is_active: member.is_active,
|
||||
});
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description update the role of a workspace member
|
||||
* @param workspaceSlug
|
||||
* @param userId
|
||||
* @param data
|
||||
*/
|
||||
updateMember = async (workspaceSlug: string, userId: string, data: { role: EUserPermissions }) => {
|
||||
const memberDetails = this.getWorkspaceMemberDetails(userId);
|
||||
if (!memberDetails) throw new Error("Member not found");
|
||||
// original data to revert back in case of error
|
||||
const originalProjectMemberData = { ...this.workspaceMemberMap?.[workspaceSlug]?.[userId] };
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.workspaceMemberMap, [workspaceSlug, userId, "role"], data.role);
|
||||
});
|
||||
await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberDetails.id, data);
|
||||
} catch (error) {
|
||||
// revert back to original members in case of error
|
||||
runInAction(() => {
|
||||
set(this.workspaceMemberMap, [workspaceSlug, userId], originalProjectMemberData);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description remove a member from workspace
|
||||
* @param workspaceSlug
|
||||
* @param userId
|
||||
*/
|
||||
removeMemberFromWorkspace = async (workspaceSlug: string, userId: string) => {
|
||||
const memberDetails = this.getWorkspaceMemberDetails(userId);
|
||||
if (!memberDetails) throw new Error("Member not found");
|
||||
await this.workspaceService.deleteWorkspaceMember(workspaceSlug, memberDetails?.id).then(() => {
|
||||
runInAction(() => {
|
||||
set(this.workspaceMemberMap, [workspaceSlug, userId, "is_active"], false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all the member invitations of a workspace
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
fetchWorkspaceMemberInvitations = async (workspaceSlug: string) =>
|
||||
await this.workspaceService.workspaceInvitations(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.workspaceMemberInvitations, workspaceSlug, response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description bulk invite members to a workspace
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
inviteMembersToWorkspace = async (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => {
|
||||
const response = await this.workspaceService.inviteWorkspace(workspaceSlug, data);
|
||||
await this.fetchWorkspaceMemberInvitations(workspaceSlug);
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the role of a member invitation
|
||||
* @param workspaceSlug
|
||||
* @param invitationId
|
||||
* @param data
|
||||
*/
|
||||
updateMemberInvitation = async (
|
||||
workspaceSlug: string,
|
||||
invitationId: string,
|
||||
data: Partial<IWorkspaceMemberInvitation>
|
||||
) => {
|
||||
const originalMemberInvitations = [...this.workspaceMemberInvitations?.[workspaceSlug]]; // in case of error, we will revert back to original members
|
||||
try {
|
||||
const memberInvitations = originalMemberInvitations?.map((invitation) => ({
|
||||
...invitation,
|
||||
...(invitation.id === invitationId && data),
|
||||
}));
|
||||
// optimistic update
|
||||
runInAction(() => {
|
||||
set(this.workspaceMemberInvitations, workspaceSlug, memberInvitations);
|
||||
});
|
||||
await this.workspaceService.updateWorkspaceInvitation(workspaceSlug, invitationId, data);
|
||||
} catch (error) {
|
||||
// revert back to original members in case of error
|
||||
runInAction(() => {
|
||||
set(this.workspaceMemberInvitations, workspaceSlug, originalMemberInvitations);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description delete a member invitation
|
||||
* @param workspaceSlug
|
||||
* @param memberId
|
||||
*/
|
||||
deleteMemberInvitation = async (workspaceSlug: string, invitationId: string) =>
|
||||
await this.workspaceService.deleteWorkspaceInvitations(workspaceSlug.toString(), invitationId).then(() => {
|
||||
runInAction(() => {
|
||||
this.workspaceMemberInvitations[workspaceSlug] = this.workspaceMemberInvitations[workspaceSlug].filter(
|
||||
(inv) => inv.id !== invitationId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
isUserSuspended = computedFn((userId: string, workspaceSlug: string) => {
|
||||
if (!workspaceSlug) return false;
|
||||
const workspaceMember = this.workspaceMemberMap?.[workspaceSlug]?.[userId];
|
||||
return workspaceMember?.is_active === false;
|
||||
});
|
||||
}
|
||||
637
apps/web/core/store/module.store.ts
Normal file
637
apps/web/core/store/module.store.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import { update, concat, set, sortBy } from "lodash-es";
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { IModule, ILinkDetails, TModulePlotType } from "@plane/types";
|
||||
import type { DistributionUpdates } from "@plane/utils";
|
||||
import { updateDistribution, orderModules, shouldFilterModule } from "@plane/utils";
|
||||
// helpers
|
||||
// services
|
||||
import { syncIssuesWithDeletedModules } from "@/local-db/utils/load-workspace";
|
||||
import { ModuleService } from "@/services/module.service";
|
||||
import { ModuleArchiveService } from "@/services/module_archive.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IModuleStore {
|
||||
//Loaders
|
||||
loader: boolean;
|
||||
fetchedMap: Record<string, boolean>;
|
||||
plotType: Record<string, TModulePlotType>;
|
||||
// observables
|
||||
moduleMap: Record<string, IModule>;
|
||||
// computed
|
||||
projectModuleIds: string[] | null;
|
||||
projectArchivedModuleIds: string[] | null;
|
||||
// computed actions
|
||||
getModulesFetchStatusByProjectId: (projectId: string) => boolean;
|
||||
getFilteredModuleIds: (projectId: string) => string[] | null;
|
||||
getFilteredArchivedModuleIds: (projectId: string) => string[] | null;
|
||||
getModuleById: (moduleId: string) => IModule | null;
|
||||
getModuleNameById: (moduleId: string) => string;
|
||||
getProjectModuleDetails: (projectId: string) => IModule[] | null;
|
||||
getProjectModuleIds: (projectId: string) => string[] | null;
|
||||
getPlotTypeByModuleId: (moduleId: string) => TModulePlotType;
|
||||
// actions
|
||||
setPlotType: (moduleId: string, plotType: TModulePlotType) => void;
|
||||
// fetch
|
||||
updateModuleDistribution: (distributionUpdates: DistributionUpdates, moduleId: string) => void;
|
||||
fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>;
|
||||
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
|
||||
fetchModulesSlim: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
|
||||
fetchArchivedModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
|
||||
fetchArchivedModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<IModule>;
|
||||
fetchModuleDetails: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<IModule>;
|
||||
// crud
|
||||
createModule: (workspaceSlug: string, projectId: string, data: Partial<IModule>) => Promise<IModule>;
|
||||
updateModuleDetails: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
data: Partial<IModule>
|
||||
) => Promise<IModule>;
|
||||
deleteModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
|
||||
createModuleLink: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
data: Partial<ILinkDetails>
|
||||
) => Promise<ILinkDetails>;
|
||||
updateModuleLink: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
linkId: string,
|
||||
data: Partial<ILinkDetails>
|
||||
) => Promise<ILinkDetails>;
|
||||
deleteModuleLink: (workspaceSlug: string, projectId: string, moduleId: string, linkId: string) => Promise<void>;
|
||||
// favorites
|
||||
addModuleToFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
|
||||
removeModuleFromFavorites: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
|
||||
// archive
|
||||
archiveModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
|
||||
restoreModule: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ModulesStore implements IModuleStore {
|
||||
// observables
|
||||
loader: boolean = false;
|
||||
moduleMap: Record<string, IModule> = {};
|
||||
plotType: Record<string, TModulePlotType> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
// root store
|
||||
rootStore;
|
||||
// services
|
||||
projectService;
|
||||
moduleService;
|
||||
moduleArchiveService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
moduleMap: observable,
|
||||
plotType: observable.ref,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
projectModuleIds: computed,
|
||||
projectArchivedModuleIds: computed,
|
||||
// actions
|
||||
setPlotType: action,
|
||||
fetchWorkspaceModules: action,
|
||||
fetchModules: action,
|
||||
fetchArchivedModules: action,
|
||||
fetchArchivedModuleDetails: action,
|
||||
fetchModuleDetails: action,
|
||||
createModule: action,
|
||||
updateModuleDetails: action,
|
||||
deleteModule: action,
|
||||
createModuleLink: action,
|
||||
updateModuleLink: action,
|
||||
deleteModuleLink: action,
|
||||
addModuleToFavorites: action,
|
||||
removeModuleFromFavorites: action,
|
||||
archiveModule: action,
|
||||
restoreModule: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
|
||||
// services
|
||||
this.projectService = new ProjectService();
|
||||
this.moduleService = new ModuleService();
|
||||
this.moduleArchiveService = new ModuleArchiveService();
|
||||
}
|
||||
|
||||
// computed
|
||||
/**
|
||||
* get all module ids for the current project
|
||||
*/
|
||||
get projectModuleIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m?.archived_at);
|
||||
projectModules = sortBy(projectModules, [(m) => m.sort_order]);
|
||||
const projectModuleIds = projectModules.map((m) => m.id);
|
||||
return projectModuleIds || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* get all archived module ids for the current project
|
||||
*/
|
||||
get projectArchivedModuleIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
let archivedModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !!m?.archived_at);
|
||||
archivedModules = sortBy(archivedModules, [(m) => m.sort_order]);
|
||||
const projectModuleIds = archivedModules.map((m) => m.id);
|
||||
return projectModuleIds || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fetch status for a specific project
|
||||
* @param projectId
|
||||
* @returns boolean
|
||||
*/
|
||||
getModulesFetchStatusByProjectId = computedFn((projectId: string) => this.fetchedMap[projectId] ?? false);
|
||||
|
||||
/**
|
||||
* @description returns filtered module ids based on display filters and filters
|
||||
* @param {TModuleDisplayFilters} displayFilters
|
||||
* @param {TModuleFilters} filters
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredModuleIds = computedFn((projectId: string) => {
|
||||
const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId);
|
||||
const filters = this.rootStore.moduleFilter.getFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.moduleFilter.searchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let modules = Object.values(this.moduleMap ?? {}).filter(
|
||||
(m) =>
|
||||
m.project_id === projectId &&
|
||||
!m.archived_at &&
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterModule(m, displayFilters ?? {}, filters ?? {})
|
||||
);
|
||||
modules = orderModules(modules, displayFilters?.order_by);
|
||||
const moduleIds = modules.map((m) => m.id);
|
||||
return moduleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns filtered archived module ids based on display filters and filters
|
||||
* @param {string} projectId
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
getFilteredArchivedModuleIds = computedFn((projectId: string) => {
|
||||
const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId);
|
||||
const filters = this.rootStore.moduleFilter.getArchivedFiltersByProjectId(projectId);
|
||||
const searchQuery = this.rootStore.moduleFilter.archivedModulesSearchQuery;
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let modules = Object.values(this.moduleMap ?? {}).filter(
|
||||
(m) =>
|
||||
m.project_id === projectId &&
|
||||
!!m.archived_at &&
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
shouldFilterModule(m, displayFilters ?? {}, filters ?? {})
|
||||
);
|
||||
modules = orderModules(modules, displayFilters?.order_by);
|
||||
const moduleIds = modules.map((m) => m.id);
|
||||
return moduleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get module by id
|
||||
* @param moduleId
|
||||
* @returns IModule | null
|
||||
*/
|
||||
getModuleById = computedFn((moduleId: string) => this.moduleMap?.[moduleId] || null);
|
||||
|
||||
/**
|
||||
* @description get module by id
|
||||
* @param moduleId
|
||||
* @returns IModule | null
|
||||
*/
|
||||
getModuleNameById = computedFn((moduleId: string) => this.moduleMap?.[moduleId]?.name);
|
||||
|
||||
/**
|
||||
* @description returns list of module details of the project id passed as argument
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectModuleDetails = computedFn((projectId: string) => {
|
||||
if (!this.fetchedMap[projectId]) return null;
|
||||
let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId && !m.archived_at);
|
||||
projectModules = sortBy(projectModules, [(m) => m.sort_order]);
|
||||
return projectModules;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns list of module ids of the project id passed as argument
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectModuleIds = computedFn((projectId: string) => {
|
||||
const projectModules = this.getProjectModuleDetails(projectId);
|
||||
if (!projectModules) return null;
|
||||
const projectModuleIds = projectModules.map((m) => m.id);
|
||||
return projectModuleIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description gets the plot type for the module store
|
||||
* @param {TModulePlotType} plotType
|
||||
*/
|
||||
getPlotTypeByModuleId = (moduleId: string) => {
|
||||
const { projectId } = this.rootStore.router;
|
||||
|
||||
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
|
||||
? this.plotType[moduleId] || "burndown"
|
||||
: "burndown";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the plot type for the module store
|
||||
* @param {TModulePlotType} plotType
|
||||
*/
|
||||
setPlotType = (moduleId: string, plotType: TModulePlotType) => {
|
||||
set(this.plotType, [moduleId], plotType);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all modules
|
||||
* @param workspaceSlug
|
||||
* @returns IModule[]
|
||||
*/
|
||||
fetchWorkspaceModules = async (workspaceSlug: string) =>
|
||||
await this.moduleService.getWorkspaceModules(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((module) => {
|
||||
set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module });
|
||||
});
|
||||
// check for all unique project ids and update the fetchedMap
|
||||
const uniqueProjectIds = new Set(response.map((module) => module.project_id));
|
||||
uniqueProjectIds.forEach((projectId) => {
|
||||
set(this.fetchedMap, projectId, true);
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetch all modules
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns IModule[]
|
||||
*/
|
||||
fetchModules = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
await this.moduleService.getModules(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((module) => {
|
||||
set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module });
|
||||
});
|
||||
set(this.fetchedMap, projectId, true);
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
} catch {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all modules
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns IModule[]
|
||||
*/
|
||||
fetchModulesSlim = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
await this.moduleService.getWorkspaceModules(workspaceSlug).then((response) => {
|
||||
const projectModules = response.filter((module) => module.project_id === projectId);
|
||||
runInAction(() => {
|
||||
projectModules.forEach((module) => {
|
||||
set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module });
|
||||
});
|
||||
set(this.fetchedMap, projectId, true);
|
||||
this.loader = false;
|
||||
});
|
||||
return projectModules;
|
||||
});
|
||||
} catch {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all archived modules
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns IModule[]
|
||||
*/
|
||||
fetchArchivedModules = async (workspaceSlug: string, projectId: string) => {
|
||||
this.loader = true;
|
||||
return await this.moduleArchiveService
|
||||
.getArchivedModules(workspaceSlug, projectId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((module) => {
|
||||
set(this.moduleMap, [module.id], { ...this.moduleMap[module.id], ...module });
|
||||
});
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch module details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns IModule
|
||||
*/
|
||||
fetchArchivedModuleDetails = async (workspaceSlug: string, projectId: string, moduleId: string) =>
|
||||
await this.moduleArchiveService.getArchivedModuleDetails(workspaceSlug, projectId, moduleId).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [response.id], { ...this.moduleMap?.[response.id], ...response });
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* This method updates the module's stats locally without fetching the updated stats from backend
|
||||
* @param distributionUpdates
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
updateModuleDistribution = (distributionUpdates: DistributionUpdates, moduleId: string) => {
|
||||
const moduleInfo = this.moduleMap[moduleId];
|
||||
|
||||
if (!moduleInfo) return;
|
||||
|
||||
runInAction(() => {
|
||||
updateDistribution(moduleInfo, distributionUpdates);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch module details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns IModule
|
||||
*/
|
||||
fetchModuleDetails = async (workspaceSlug: string, projectId: string, moduleId: string) =>
|
||||
await this.moduleService.getModuleDetails(workspaceSlug, projectId, moduleId).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId], response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description creates a new module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns IModule
|
||||
*/
|
||||
createModule = async (workspaceSlug: string, projectId: string, data: Partial<IModule>) =>
|
||||
await this.moduleService.createModule(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [response?.id], response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description updates module details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @param data
|
||||
* @returns IModule
|
||||
*/
|
||||
updateModuleDetails = async (workspaceSlug: string, projectId: string, moduleId: string, data: Partial<IModule>) => {
|
||||
const originalModuleDetails = this.getModuleById(moduleId);
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId], { ...originalModuleDetails, ...data });
|
||||
});
|
||||
const response = await this.moduleService.patchModule(workspaceSlug, projectId, moduleId, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to update module in module store", error);
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId], { ...originalModuleDetails });
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description deletes a module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
*/
|
||||
deleteModule = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (!moduleDetails) return;
|
||||
await this.moduleService.deleteModule(workspaceSlug, projectId, moduleId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.moduleMap[moduleId];
|
||||
if (this.rootStore.favorite.entityMap[moduleId]) this.rootStore.favorite.removeFavoriteFromStore(moduleId);
|
||||
syncIssuesWithDeletedModules([moduleId]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description creates a new module link
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @param data
|
||||
* @returns ILinkDetails
|
||||
*/
|
||||
createModuleLink = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
data: Partial<ILinkDetails>
|
||||
) => {
|
||||
try {
|
||||
const moduleLink = await this.moduleService.createModuleLink(workspaceSlug, projectId, moduleId, data);
|
||||
runInAction(() => {
|
||||
update(this.moduleMap, [moduleId, "link_module"], (moduleLinks = []) => concat(moduleLinks, moduleLink));
|
||||
});
|
||||
return moduleLink;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates module link details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @param linkId
|
||||
* @param data
|
||||
* @returns ILinkDetails
|
||||
*/
|
||||
updateModuleLink = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
linkId: string,
|
||||
data: Partial<ILinkDetails>
|
||||
) => {
|
||||
const originalModuleDetails = this.getModuleById(moduleId);
|
||||
try {
|
||||
const linkModules = originalModuleDetails?.link_module?.map((link) =>
|
||||
link.id === linkId ? { ...link, ...data } : link
|
||||
);
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "link_module"], linkModules);
|
||||
});
|
||||
const response = await this.moduleService.updateModuleLink(workspaceSlug, projectId, moduleId, linkId, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to update module link in module store", error);
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "link_module"], originalModuleDetails?.link_module);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description deletes a module link
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @param linkId
|
||||
*/
|
||||
deleteModuleLink = async (workspaceSlug: string, projectId: string, moduleId: string, linkId: string) => {
|
||||
try {
|
||||
const moduleLink = await this.moduleService.deleteModuleLink(workspaceSlug, projectId, moduleId, linkId);
|
||||
runInAction(() => {
|
||||
update(this.moduleMap, [moduleId, "link_module"], (moduleLinks = []) =>
|
||||
moduleLinks.filter((link: ILinkDetails) => link.id !== linkId)
|
||||
);
|
||||
});
|
||||
return moduleLink;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description adds a module to favorites
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
addModuleToFavorites = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
try {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (moduleDetails?.is_favorite) return;
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "is_favorite"], true);
|
||||
});
|
||||
await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "module",
|
||||
entity_identifier: moduleId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.moduleMap[moduleId].name || "" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add module to favorites in module store", error);
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "is_favorite"], false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description removes a module from favorites
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
removeModuleFromFavorites = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
try {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (!moduleDetails?.is_favorite) return;
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "is_favorite"], false);
|
||||
});
|
||||
await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, moduleId);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove module from favorites in module store", error);
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "is_favorite"], true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description archives a module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
archiveModule = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (moduleDetails?.archived_at) return;
|
||||
await this.moduleArchiveService
|
||||
.archiveModule(workspaceSlug, projectId, moduleId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "archived_at"], response.archived_at);
|
||||
if (this.rootStore.favorite.entityMap[moduleId]) this.rootStore.favorite.removeFavoriteFromStore(moduleId);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to archive module in module store", error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description restores a module
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param moduleId
|
||||
* @returns
|
||||
*/
|
||||
restoreModule = async (workspaceSlug: string, projectId: string, moduleId: string) => {
|
||||
const moduleDetails = this.getModuleById(moduleId);
|
||||
if (!moduleDetails?.archived_at) return;
|
||||
await this.moduleArchiveService
|
||||
.restoreModule(workspaceSlug, projectId, moduleId)
|
||||
.then(() => {
|
||||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "archived_at"], null);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore module in module store", error);
|
||||
});
|
||||
};
|
||||
}
|
||||
249
apps/web/core/store/module_filter.store.ts
Normal file
249
apps/web/core/store/module_filter.store.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { TModuleDisplayFilters, TModuleFilters, TModuleFiltersByState } from "@plane/types";
|
||||
// helpers
|
||||
import { storage } from "@/lib/local-storage";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
// localStorage keys
|
||||
const MODULE_DISPLAY_FILTERS_KEY = "module_display_filters";
|
||||
const MODULE_FILTERS_KEY = "module_filters";
|
||||
|
||||
export interface IModuleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TModuleDisplayFilters>;
|
||||
filters: Record<string, TModuleFiltersByState>;
|
||||
searchQuery: string;
|
||||
archivedModulesSearchQuery: string;
|
||||
// computed
|
||||
currentProjectDisplayFilters: TModuleDisplayFilters | undefined;
|
||||
currentProjectFilters: TModuleFilters | undefined;
|
||||
currentProjectArchivedFilters: TModuleFilters | undefined;
|
||||
// computed functions
|
||||
getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined;
|
||||
getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
|
||||
getArchivedFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
|
||||
// actions
|
||||
updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void;
|
||||
updateFilters: (projectId: string, filters: TModuleFilters, state?: keyof TModuleFiltersByState) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
updateArchivedModulesSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (projectId: string, state?: keyof TModuleFiltersByState) => void;
|
||||
}
|
||||
|
||||
export class ModuleFilterStore implements IModuleFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TModuleDisplayFilters> = {};
|
||||
filters: Record<string, TModuleFiltersByState> = {};
|
||||
searchQuery: string = "";
|
||||
archivedModulesSearchQuery: string = "";
|
||||
// root store
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
displayFilters: observable,
|
||||
filters: observable,
|
||||
searchQuery: observable.ref,
|
||||
archivedModulesSearchQuery: observable.ref,
|
||||
// computed
|
||||
currentProjectDisplayFilters: computed,
|
||||
currentProjectFilters: computed,
|
||||
currentProjectArchivedFilters: computed,
|
||||
// actions
|
||||
updateDisplayFilters: action,
|
||||
updateFilters: action,
|
||||
updateSearchQuery: action,
|
||||
updateArchivedModulesSearchQuery: action,
|
||||
clearAllFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
|
||||
// initialize display filters of the current project
|
||||
reaction(
|
||||
() => this.rootStore.router.projectId,
|
||||
(projectId) => {
|
||||
if (!projectId) return;
|
||||
this.initProjectModuleFilters(projectId);
|
||||
this.searchQuery = "";
|
||||
}
|
||||
);
|
||||
|
||||
// Load initial data from localStorage after reactions are set up
|
||||
this.loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Load filters from localStorage
|
||||
*/
|
||||
loadFromLocalStorage = () => {
|
||||
try {
|
||||
const displayFiltersData = storage.get(MODULE_DISPLAY_FILTERS_KEY);
|
||||
const filtersData = storage.get(MODULE_FILTERS_KEY);
|
||||
|
||||
runInAction(() => {
|
||||
if (displayFiltersData) {
|
||||
const parsed = JSON.parse(displayFiltersData);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
this.displayFilters = parsed;
|
||||
}
|
||||
}
|
||||
if (filtersData) {
|
||||
const parsed = JSON.parse(filtersData);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
this.filters = parsed;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load module filters from localStorage:", error);
|
||||
// Reset to defaults on error
|
||||
runInAction(() => {
|
||||
this.displayFilters = {};
|
||||
this.filters = {};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Save display filters to localStorage (debounced)
|
||||
*/
|
||||
saveDisplayFiltersToLocalStorage = () => {
|
||||
storage.set(MODULE_DISPLAY_FILTERS_KEY, this.displayFilters);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Save filters to localStorage (debounced)
|
||||
*/
|
||||
saveFiltersToLocalStorage = () => {
|
||||
storage.set(MODULE_FILTERS_KEY, this.filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description get display filters of the current project
|
||||
*/
|
||||
get currentProjectDisplayFilters() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.displayFilters[projectId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get filters of the current project
|
||||
*/
|
||||
get currentProjectFilters() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId]?.default ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get archived filters of the current project
|
||||
*/
|
||||
get currentProjectArchivedFilters() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return;
|
||||
return this.filters[projectId].archived;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get display filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]);
|
||||
|
||||
/**
|
||||
* @description get filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]?.default ?? {});
|
||||
|
||||
/**
|
||||
* @description get archived filters of a project by projectId
|
||||
* @param {string} projectId
|
||||
*/
|
||||
getArchivedFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId].archived);
|
||||
|
||||
/**
|
||||
* @description initialize display filters and filters of a project
|
||||
* @param {string} projectId
|
||||
*/
|
||||
initProjectModuleFilters = (projectId: string) => {
|
||||
const displayFilters = this.getDisplayFiltersByProjectId(projectId);
|
||||
runInAction(() => {
|
||||
this.displayFilters[projectId] = {
|
||||
favorites: displayFilters?.favorites || false,
|
||||
layout: displayFilters?.layout || "list",
|
||||
order_by: displayFilters?.order_by || "name",
|
||||
};
|
||||
this.filters[projectId] = this.filters[projectId] ?? {
|
||||
default: {},
|
||||
archived: {},
|
||||
};
|
||||
});
|
||||
this.saveDisplayFiltersToLocalStorage();
|
||||
this.saveFiltersToLocalStorage();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update display filters of a project
|
||||
* @param {string} projectId
|
||||
* @param {TModuleDisplayFilters} displayFilters
|
||||
*/
|
||||
updateDisplayFilters = (projectId: string, displayFilters: TModuleDisplayFilters) => {
|
||||
runInAction(() => {
|
||||
Object.keys(displayFilters).forEach((key) => {
|
||||
set(this.displayFilters, [projectId, key], displayFilters[key as keyof TModuleDisplayFilters]);
|
||||
});
|
||||
});
|
||||
this.saveDisplayFiltersToLocalStorage();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update filters of a project
|
||||
* @param {string} projectId
|
||||
* @param {TModuleFilters} filters
|
||||
*/
|
||||
updateFilters = (projectId: string, filters: TModuleFilters, state: keyof TModuleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
Object.keys(filters).forEach((key) => {
|
||||
set(this.filters, [projectId, state, key], filters[key as keyof TModuleFilters]);
|
||||
});
|
||||
});
|
||||
this.saveFiltersToLocalStorage();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateSearchQuery = (query: string) => {
|
||||
this.searchQuery = query;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update archived search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateArchivedModulesSearchQuery = (query: string) => {
|
||||
this.archivedModulesSearchQuery = query;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description clear all filters of a project
|
||||
* @param {string} projectId
|
||||
*/
|
||||
clearAllFilters = (projectId: string, state: keyof TModuleFiltersByState = "default") => {
|
||||
runInAction(() => {
|
||||
this.filters[projectId][state] = {};
|
||||
this.displayFilters[projectId].favorites = false;
|
||||
});
|
||||
this.saveFiltersToLocalStorage();
|
||||
this.saveDisplayFiltersToLocalStorage();
|
||||
};
|
||||
}
|
||||
232
apps/web/core/store/multiple_select.store.ts
Normal file
232
apps/web/core/store/multiple_select.store.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { differenceWith, remove, isEqual } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// hooks
|
||||
import type { TEntityDetails } from "@/hooks/use-multiple-select";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
export type IMultipleSelectStore = {
|
||||
// computed functions
|
||||
isSelectionActive: boolean;
|
||||
selectedEntityIds: string[];
|
||||
// helper actions
|
||||
getIsEntitySelected: (entityID: string) => boolean;
|
||||
getIsEntityActive: (entityID: string) => boolean;
|
||||
getLastSelectedEntityDetails: () => TEntityDetails | null;
|
||||
getPreviousActiveEntity: () => TEntityDetails | null;
|
||||
getNextActiveEntity: () => TEntityDetails | null;
|
||||
getActiveEntityDetails: () => TEntityDetails | null;
|
||||
getEntityDetailsFromEntityID: (entityID: string) => TEntityDetails | null;
|
||||
// entity actions
|
||||
updateSelectedEntityDetails: (entityDetails: TEntityDetails, action: "add" | "remove") => void;
|
||||
bulkUpdateSelectedEntityDetails: (entitiesList: TEntityDetails[], action: "add" | "remove") => void;
|
||||
updateLastSelectedEntityDetails: (entityDetails: TEntityDetails | null) => void;
|
||||
updatePreviousActiveEntity: (entityDetails: TEntityDetails | null) => void;
|
||||
updateNextActiveEntity: (entityDetails: TEntityDetails | null) => void;
|
||||
updateActiveEntityDetails: (entityDetails: TEntityDetails | null) => void;
|
||||
clearSelection: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description the MultipleSelectStore manages multiple selection states by keeping track of the selected entities and providing a bunch of helper functions and actions to maintain the selected states
|
||||
* @description use the useMultipleSelectStore custom hook to access the observables
|
||||
* @description use the useMultipleSelect custom hook for added functionality on top of the store, including-
|
||||
* 1. Keyboard and mouse interaction
|
||||
* 2. Clear state on route change
|
||||
*/
|
||||
export class MultipleSelectStore implements IMultipleSelectStore {
|
||||
// observables
|
||||
selectedEntityDetails: TEntityDetails[] = [];
|
||||
lastSelectedEntityDetails: TEntityDetails | null = null;
|
||||
previousActiveEntity: TEntityDetails | null = null;
|
||||
nextActiveEntity: TEntityDetails | null = null;
|
||||
activeEntityDetails: TEntityDetails | null = null;
|
||||
// service
|
||||
issueService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
selectedEntityDetails: observable,
|
||||
lastSelectedEntityDetails: observable,
|
||||
previousActiveEntity: observable,
|
||||
nextActiveEntity: observable,
|
||||
activeEntityDetails: observable,
|
||||
// computed functions
|
||||
isSelectionActive: computed,
|
||||
selectedEntityIds: computed,
|
||||
// actions
|
||||
updateSelectedEntityDetails: action,
|
||||
bulkUpdateSelectedEntityDetails: action,
|
||||
updateLastSelectedEntityDetails: action,
|
||||
updatePreviousActiveEntity: action,
|
||||
updateNextActiveEntity: action,
|
||||
updateActiveEntityDetails: action,
|
||||
clearSelection: action,
|
||||
});
|
||||
|
||||
this.issueService = new IssueService();
|
||||
}
|
||||
|
||||
get isSelectionActive() {
|
||||
return this.selectedEntityDetails.length > 0;
|
||||
}
|
||||
|
||||
get selectedEntityIds() {
|
||||
return this.selectedEntityDetails.map((en) => en.entityID);
|
||||
}
|
||||
|
||||
// helper actions
|
||||
/**
|
||||
* @description returns if the entity is selected or not
|
||||
* @param {string} entityID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getIsEntitySelected = computedFn((entityID: string): boolean =>
|
||||
this.selectedEntityDetails.some((en) => en.entityID === entityID)
|
||||
);
|
||||
|
||||
/**
|
||||
* @description returns if the entity is active or not
|
||||
* @param {string} entityID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getIsEntityActive = computedFn((entityID: string): boolean => this.activeEntityDetails?.entityID === entityID);
|
||||
|
||||
/**
|
||||
* @description get the last selected entity details
|
||||
* @returns {TEntityDetails}
|
||||
*/
|
||||
getLastSelectedEntityDetails = computedFn(() => this.lastSelectedEntityDetails);
|
||||
|
||||
/**
|
||||
* @description get the details of the entity preceding the active entity
|
||||
* @returns {TEntityDetails}
|
||||
*/
|
||||
getPreviousActiveEntity = computedFn(() => this.previousActiveEntity);
|
||||
|
||||
/**
|
||||
* @description get the details of the entity succeeding the active entity
|
||||
* @returns {TEntityDetails}
|
||||
*/
|
||||
getNextActiveEntity = computedFn(() => this.nextActiveEntity);
|
||||
|
||||
/**
|
||||
* @description get the active entity details
|
||||
* @returns {TEntityDetails}
|
||||
*/
|
||||
getActiveEntityDetails = computedFn(() => this.activeEntityDetails);
|
||||
|
||||
/**
|
||||
* @description get the entity details from entityID
|
||||
* @param {string} entityID
|
||||
* @returns {TEntityDetails | null}
|
||||
*/
|
||||
getEntityDetailsFromEntityID = computedFn(
|
||||
(entityID: string): TEntityDetails | null =>
|
||||
this.selectedEntityDetails.find((en) => en.entityID === entityID) ?? null
|
||||
);
|
||||
|
||||
// entity actions
|
||||
/**
|
||||
* @description add or remove entities
|
||||
* @param {TEntityDetails} entityDetails
|
||||
* @param {"add" | "remove"} action
|
||||
*/
|
||||
updateSelectedEntityDetails = (entityDetails: TEntityDetails, action: "add" | "remove") => {
|
||||
if (action === "add") {
|
||||
runInAction(() => {
|
||||
if (this.getIsEntitySelected(entityDetails.entityID)) {
|
||||
remove(this.selectedEntityDetails, (en) => en.entityID === entityDetails.entityID);
|
||||
}
|
||||
this.selectedEntityDetails.push(entityDetails);
|
||||
this.updateLastSelectedEntityDetails(entityDetails);
|
||||
});
|
||||
} else {
|
||||
let currentSelection = [...this.selectedEntityDetails];
|
||||
currentSelection = currentSelection.filter((en) => en.entityID !== entityDetails.entityID);
|
||||
runInAction(() => {
|
||||
remove(this.selectedEntityDetails, (en) => en.entityID === entityDetails.entityID);
|
||||
this.updateLastSelectedEntityDetails(currentSelection[currentSelection.length - 1] ?? null);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description add or remove multiple entities
|
||||
* @param {TEntityDetails[]} entitiesList
|
||||
* @param {"add" | "remove"} action
|
||||
*/
|
||||
bulkUpdateSelectedEntityDetails = (entitiesList: TEntityDetails[], action: "add" | "remove") => {
|
||||
if (action === "add") {
|
||||
runInAction(() => {
|
||||
let newEntities: TEntityDetails[] = [];
|
||||
newEntities = differenceWith(this.selectedEntityDetails, entitiesList, isEqual);
|
||||
newEntities = newEntities.concat(entitiesList);
|
||||
this.selectedEntityDetails = newEntities;
|
||||
if (entitiesList.length > 0) this.updateLastSelectedEntityDetails(entitiesList[entitiesList.length - 1]);
|
||||
});
|
||||
} else {
|
||||
const newEntities = differenceWith(this.selectedEntityDetails, entitiesList, (obj1, obj2) =>
|
||||
isEqual(obj1.entityID, obj2.entityID)
|
||||
);
|
||||
runInAction(() => {
|
||||
this.selectedEntityDetails = newEntities;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update last selected entity
|
||||
* @param {TEntityDetails} entityDetails
|
||||
*/
|
||||
updateLastSelectedEntityDetails = (entityDetails: TEntityDetails | null) => {
|
||||
runInAction(() => {
|
||||
this.lastSelectedEntityDetails = entityDetails;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update previous active entity
|
||||
* @param {TEntityDetails} entityDetails
|
||||
*/
|
||||
updatePreviousActiveEntity = (entityDetails: TEntityDetails | null) => {
|
||||
runInAction(() => {
|
||||
this.previousActiveEntity = entityDetails;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update next active entity
|
||||
* @param {TEntityDetails} entityDetails
|
||||
*/
|
||||
updateNextActiveEntity = (entityDetails: TEntityDetails | null) => {
|
||||
runInAction(() => {
|
||||
this.nextActiveEntity = entityDetails;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update active entity
|
||||
* @param {TEntityDetails} entityDetails
|
||||
*/
|
||||
updateActiveEntityDetails = (entityDetails: TEntityDetails | null) => {
|
||||
runInAction(() => {
|
||||
this.activeEntityDetails = entityDetails;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description clear selection and reset all the observables
|
||||
*/
|
||||
clearSelection = () => {
|
||||
runInAction(() => {
|
||||
this.selectedEntityDetails = [];
|
||||
this.lastSelectedEntityDetails = null;
|
||||
this.previousActiveEntity = null;
|
||||
this.nextActiveEntity = null;
|
||||
this.activeEntityDetails = null;
|
||||
});
|
||||
};
|
||||
}
|
||||
319
apps/web/core/store/notifications/notification.ts
Normal file
319
apps/web/core/store/notifications/notification.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/* eslint-disable no-useless-catch */
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import type { IUserLite, TNotification, TNotificationData } from "@plane/types";
|
||||
// services
|
||||
import workspaceNotificationService from "@/services/workspace-notification.service";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
export interface INotification extends TNotification {
|
||||
// observables
|
||||
// computed
|
||||
asJson: TNotification;
|
||||
// computed functions
|
||||
// helper functions
|
||||
mutateNotification: (notification: Partial<TNotification>) => void;
|
||||
// actions
|
||||
updateNotification: (workspaceSlug: string, payload: Partial<TNotification>) => Promise<TNotification | undefined>;
|
||||
markNotificationAsRead: (workspaceSlug: string) => Promise<TNotification | undefined>;
|
||||
markNotificationAsUnRead: (workspaceSlug: string) => Promise<TNotification | undefined>;
|
||||
archiveNotification: (workspaceSlug: string) => Promise<TNotification | undefined>;
|
||||
unArchiveNotification: (workspaceSlug: string) => Promise<TNotification | undefined>;
|
||||
snoozeNotification: (workspaceSlug: string, snoozeTill: Date) => Promise<TNotification | undefined>;
|
||||
unSnoozeNotification: (workspaceSlug: string) => Promise<TNotification | undefined>;
|
||||
}
|
||||
|
||||
export class Notification implements INotification {
|
||||
// observables
|
||||
id: string;
|
||||
title: string | undefined = undefined;
|
||||
data: TNotificationData | undefined = undefined;
|
||||
entity_identifier: string | undefined = undefined;
|
||||
entity_name: string | undefined = undefined;
|
||||
message_html: string | undefined = undefined;
|
||||
message: undefined = undefined;
|
||||
message_stripped: undefined = undefined;
|
||||
sender: string | undefined = undefined;
|
||||
receiver: string | undefined = undefined;
|
||||
triggered_by: string | undefined = undefined;
|
||||
triggered_by_details: IUserLite | undefined = undefined;
|
||||
read_at: string | undefined = undefined;
|
||||
archived_at: string | undefined = undefined;
|
||||
snoozed_till: string | undefined = undefined;
|
||||
is_inbox_issue: boolean | undefined = undefined;
|
||||
is_mentioned_notification: boolean | undefined = undefined;
|
||||
workspace: string | undefined = undefined;
|
||||
project: string | undefined = undefined;
|
||||
created_at: string | undefined = undefined;
|
||||
updated_at: string | undefined = undefined;
|
||||
created_by: string | undefined = undefined;
|
||||
updated_by: string | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
private store: CoreRootStore,
|
||||
private notification: TNotification
|
||||
) {
|
||||
this.id = this.notification.id;
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
id: observable.ref,
|
||||
title: observable.ref,
|
||||
data: observable,
|
||||
entity_identifier: observable.ref,
|
||||
entity_name: observable.ref,
|
||||
message_html: observable.ref,
|
||||
message: observable.ref,
|
||||
message_stripped: observable.ref,
|
||||
sender: observable.ref,
|
||||
receiver: observable.ref,
|
||||
triggered_by: observable.ref,
|
||||
triggered_by_details: observable,
|
||||
read_at: observable.ref,
|
||||
archived_at: observable.ref,
|
||||
snoozed_till: observable.ref,
|
||||
is_inbox_issue: observable.ref,
|
||||
is_mentioned_notification: observable.ref,
|
||||
workspace: observable.ref,
|
||||
project: observable.ref,
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
created_by: observable.ref,
|
||||
updated_by: observable.ref,
|
||||
// computed
|
||||
asJson: computed,
|
||||
// actions
|
||||
updateNotification: action,
|
||||
markNotificationAsRead: action,
|
||||
markNotificationAsUnRead: action,
|
||||
archiveNotification: action,
|
||||
unArchiveNotification: action,
|
||||
snoozeNotification: action,
|
||||
unSnoozeNotification: action,
|
||||
});
|
||||
this.title = this.notification.title;
|
||||
this.data = this.notification.data;
|
||||
this.entity_identifier = this.notification.entity_identifier;
|
||||
this.entity_name = this.notification.entity_name;
|
||||
this.message_html = this.notification.message_html;
|
||||
this.message = this.notification.message;
|
||||
this.message_stripped = this.notification.message_stripped;
|
||||
this.sender = this.notification.sender;
|
||||
this.receiver = this.notification.receiver;
|
||||
this.triggered_by = this.notification.triggered_by;
|
||||
this.triggered_by_details = this.notification.triggered_by_details;
|
||||
this.read_at = this.notification.read_at;
|
||||
this.archived_at = this.notification.archived_at;
|
||||
this.snoozed_till = this.notification.snoozed_till;
|
||||
this.is_inbox_issue = this.notification.is_inbox_issue;
|
||||
this.is_mentioned_notification = this.notification.is_mentioned_notification;
|
||||
this.workspace = this.notification.workspace;
|
||||
this.project = this.notification.project;
|
||||
this.created_at = this.notification.created_at;
|
||||
this.updated_at = this.notification.updated_at;
|
||||
this.created_by = this.notification.created_by;
|
||||
this.updated_by = this.notification.updated_by;
|
||||
}
|
||||
|
||||
// computed
|
||||
/**
|
||||
* @description get notification as json
|
||||
*/
|
||||
get asJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
data: this.data,
|
||||
entity_identifier: this.entity_identifier,
|
||||
entity_name: this.entity_name,
|
||||
message_html: this.message_html,
|
||||
message: this.message,
|
||||
message_stripped: this.message_stripped,
|
||||
sender: this.sender,
|
||||
receiver: this.receiver,
|
||||
triggered_by: this.triggered_by,
|
||||
triggered_by_details: this.triggered_by_details,
|
||||
read_at: this.read_at,
|
||||
archived_at: this.archived_at,
|
||||
snoozed_till: this.snoozed_till,
|
||||
is_inbox_issue: this.is_inbox_issue,
|
||||
is_mentioned_notification: this.is_mentioned_notification,
|
||||
workspace: this.workspace,
|
||||
project: this.project,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
created_by: this.created_by,
|
||||
updated_by: this.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
// computed functions
|
||||
|
||||
// helper functions
|
||||
mutateNotification = (notification: Partial<TNotification>) => {
|
||||
Object.entries(notification).forEach(([key, value]) => {
|
||||
if (key in this) {
|
||||
set(this, key, value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description update notification
|
||||
* @param { string } workspaceSlug
|
||||
* @param { Partial<TNotification> } payload
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
updateNotification = async (
|
||||
workspaceSlug: string,
|
||||
payload: Partial<TNotification>
|
||||
): Promise<TNotification | undefined> => {
|
||||
try {
|
||||
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
|
||||
if (notification) {
|
||||
runInAction(() => this.mutateNotification(notification));
|
||||
}
|
||||
return notification;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description mark notification as read
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
markNotificationAsRead = async (workspaceSlug: string): Promise<TNotification | undefined> => {
|
||||
const currentNotificationReadAt = this.read_at;
|
||||
try {
|
||||
const payload: Partial<TNotification> = {
|
||||
read_at: new Date().toISOString(),
|
||||
};
|
||||
this.store.workspaceNotification.setUnreadNotificationsCount("decrement");
|
||||
runInAction(() => this.mutateNotification(payload));
|
||||
const notification = await workspaceNotificationService.markNotificationAsRead(workspaceSlug, this.id);
|
||||
if (notification) {
|
||||
runInAction(() => this.mutateNotification(notification));
|
||||
}
|
||||
return notification;
|
||||
} catch (error) {
|
||||
runInAction(() => this.mutateNotification({ read_at: currentNotificationReadAt }));
|
||||
this.store.workspaceNotification.setUnreadNotificationsCount("increment");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description mark notification as unread
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
markNotificationAsUnRead = async (workspaceSlug: string): Promise<TNotification | undefined> => {
|
||||
const currentNotificationReadAt = this.read_at;
|
||||
try {
|
||||
const payload: Partial<TNotification> = {
|
||||
read_at: undefined,
|
||||
};
|
||||
this.store.workspaceNotification.setUnreadNotificationsCount("increment");
|
||||
runInAction(() => this.mutateNotification(payload));
|
||||
const notification = await workspaceNotificationService.markNotificationAsUnread(workspaceSlug, this.id);
|
||||
if (notification) {
|
||||
runInAction(() => this.mutateNotification(notification));
|
||||
}
|
||||
return notification;
|
||||
} catch (error) {
|
||||
this.store.workspaceNotification.setUnreadNotificationsCount("decrement");
|
||||
runInAction(() => this.mutateNotification({ read_at: currentNotificationReadAt }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description archive notification
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
archiveNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
|
||||
const currentNotificationArchivedAt = this.archived_at;
|
||||
try {
|
||||
const payload: Partial<TNotification> = {
|
||||
archived_at: new Date().toISOString(),
|
||||
};
|
||||
runInAction(() => this.mutateNotification(payload));
|
||||
const notification = await workspaceNotificationService.markNotificationAsArchived(workspaceSlug, this.id);
|
||||
if (notification) {
|
||||
runInAction(() => this.mutateNotification(notification));
|
||||
}
|
||||
return notification;
|
||||
} catch (error) {
|
||||
runInAction(() => this.mutateNotification({ archived_at: currentNotificationArchivedAt }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description unarchive notification
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
unArchiveNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
|
||||
const currentNotificationArchivedAt = this.archived_at;
|
||||
try {
|
||||
const payload: Partial<TNotification> = {
|
||||
archived_at: undefined,
|
||||
};
|
||||
runInAction(() => this.mutateNotification(payload));
|
||||
const notification = await workspaceNotificationService.markNotificationAsUnArchived(workspaceSlug, this.id);
|
||||
if (notification) {
|
||||
runInAction(() => this.mutateNotification(notification));
|
||||
}
|
||||
return notification;
|
||||
} catch (error) {
|
||||
runInAction(() => this.mutateNotification({ archived_at: currentNotificationArchivedAt }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description snooze notification
|
||||
* @param { string } workspaceSlug
|
||||
* @param { Date } snoozeTill
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
snoozeNotification = async (workspaceSlug: string, snoozeTill: Date): Promise<TNotification | undefined> => {
|
||||
const currentNotificationSnoozeTill = this.snoozed_till;
|
||||
try {
|
||||
const payload: Partial<TNotification> = {
|
||||
snoozed_till: snoozeTill.toISOString(),
|
||||
};
|
||||
runInAction(() => this.mutateNotification(payload));
|
||||
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
|
||||
return notification;
|
||||
} catch (error) {
|
||||
runInAction(() => this.mutateNotification({ snoozed_till: currentNotificationSnoozeTill }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description un snooze notification
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
unSnoozeNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
|
||||
const currentNotificationSnoozeTill = this.snoozed_till;
|
||||
try {
|
||||
const payload: Partial<TNotification> = {
|
||||
snoozed_till: undefined,
|
||||
};
|
||||
runInAction(() => this.mutateNotification(payload));
|
||||
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
|
||||
return notification;
|
||||
} catch (error) {
|
||||
runInAction(() => this.mutateNotification({ snoozed_till: currentNotificationSnoozeTill }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { orderBy, isEmpty, update, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TNotificationTab } from "@plane/constants";
|
||||
import { ENotificationTab, ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
|
||||
import type {
|
||||
TNotification,
|
||||
TNotificationFilter,
|
||||
TNotificationLite,
|
||||
TNotificationPaginatedInfo,
|
||||
TNotificationPaginatedInfoQueryParams,
|
||||
TUnreadNotificationsCount,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { convertToEpoch } from "@plane/utils";
|
||||
// services
|
||||
import workspaceNotificationService from "@/services/workspace-notification.service";
|
||||
// store
|
||||
import type { INotification } from "@/store/notifications/notification";
|
||||
import { Notification } from "@/store/notifications/notification";
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
type TNotificationLoader = ENotificationLoader | undefined;
|
||||
type TNotificationQueryParamType = ENotificationQueryParamType;
|
||||
|
||||
export interface IWorkspaceNotificationStore {
|
||||
// observables
|
||||
loader: TNotificationLoader;
|
||||
unreadNotificationsCount: TUnreadNotificationsCount;
|
||||
notifications: Record<string, INotification>; // notification_id -> notification
|
||||
currentNotificationTab: TNotificationTab;
|
||||
currentSelectedNotificationId: string | undefined;
|
||||
paginationInfo: Omit<TNotificationPaginatedInfo, "results"> | undefined;
|
||||
filters: TNotificationFilter;
|
||||
// computed
|
||||
// computed functions
|
||||
notificationIdsByWorkspaceId: (workspaceId: string) => string[] | undefined;
|
||||
notificationLiteByNotificationId: (notificationId: string | undefined) => TNotificationLite;
|
||||
// helper actions
|
||||
mutateNotifications: (notifications: TNotification[]) => void;
|
||||
updateFilters: <T extends keyof TNotificationFilter>(key: T, value: TNotificationFilter[T]) => void;
|
||||
updateBulkFilters: (filters: Partial<TNotificationFilter>) => void;
|
||||
// actions
|
||||
setCurrentNotificationTab: (tab: TNotificationTab) => void;
|
||||
setCurrentSelectedNotificationId: (notificationId: string | undefined) => void;
|
||||
setUnreadNotificationsCount: (type: "increment" | "decrement", newCount?: number) => void;
|
||||
getUnreadNotificationsCount: (workspaceSlug: string) => Promise<TUnreadNotificationsCount | undefined>;
|
||||
getNotifications: (
|
||||
workspaceSlug: string,
|
||||
loader?: TNotificationLoader,
|
||||
queryCursorType?: TNotificationQueryParamType
|
||||
) => Promise<TNotificationPaginatedInfo | undefined>;
|
||||
markAllNotificationsAsRead: (workspaceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
|
||||
// constants
|
||||
paginatedCount = 300;
|
||||
// observables
|
||||
loader: TNotificationLoader = undefined;
|
||||
unreadNotificationsCount: TUnreadNotificationsCount = {
|
||||
total_unread_notifications_count: 0,
|
||||
mention_unread_notifications_count: 0,
|
||||
};
|
||||
notifications: Record<string, INotification> = {};
|
||||
currentNotificationTab: TNotificationTab = ENotificationTab.ALL;
|
||||
currentSelectedNotificationId: string | undefined = undefined;
|
||||
paginationInfo: Omit<TNotificationPaginatedInfo, "results"> | undefined = undefined;
|
||||
filters: TNotificationFilter = {
|
||||
type: {
|
||||
assigned: false,
|
||||
created: false,
|
||||
subscribed: false,
|
||||
},
|
||||
snoozed: false,
|
||||
archived: false,
|
||||
read: false,
|
||||
};
|
||||
|
||||
constructor(protected store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
unreadNotificationsCount: observable,
|
||||
notifications: observable,
|
||||
currentNotificationTab: observable.ref,
|
||||
currentSelectedNotificationId: observable,
|
||||
paginationInfo: observable,
|
||||
filters: observable,
|
||||
// computed
|
||||
// helper actions
|
||||
setCurrentNotificationTab: action,
|
||||
setCurrentSelectedNotificationId: action,
|
||||
setUnreadNotificationsCount: action,
|
||||
mutateNotifications: action,
|
||||
updateFilters: action,
|
||||
updateBulkFilters: action,
|
||||
// actions
|
||||
getUnreadNotificationsCount: action,
|
||||
getNotifications: action,
|
||||
markAllNotificationsAsRead: action,
|
||||
});
|
||||
}
|
||||
|
||||
// computed
|
||||
|
||||
// computed functions
|
||||
/**
|
||||
* @description get notification ids by workspace id
|
||||
* @param { string } workspaceId
|
||||
*/
|
||||
notificationIdsByWorkspaceId = computedFn((workspaceId: string) => {
|
||||
if (!workspaceId || isEmpty(this.notifications)) return undefined;
|
||||
const workspaceNotifications = orderBy(
|
||||
Object.values(this.notifications || []),
|
||||
(n) => convertToEpoch(n.created_at),
|
||||
["desc"]
|
||||
);
|
||||
const workspaceNotificationIds = workspaceNotifications
|
||||
.filter((n) => n.workspace === workspaceId)
|
||||
.filter((n) =>
|
||||
this.currentNotificationTab === ENotificationTab.MENTIONS
|
||||
? n.is_mentioned_notification
|
||||
: !n.is_mentioned_notification
|
||||
)
|
||||
.filter((n) => {
|
||||
if (!this.filters.archived && !this.filters.snoozed) {
|
||||
if (n.archived_at) {
|
||||
return false;
|
||||
} else if (n.snoozed_till) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (this.filters.snoozed) {
|
||||
return n.snoozed_till ? true : false;
|
||||
} else if (this.filters.archived) {
|
||||
return n.archived_at ? true : false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
})
|
||||
// .filter((n) => (this.filters.read ? (n.read_at ? true : false) : n.read_at ? false : true))
|
||||
.map((n) => n.id) as string[];
|
||||
return workspaceNotificationIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get notification lite by notification id
|
||||
* @param { string } notificationId
|
||||
*/
|
||||
notificationLiteByNotificationId = computedFn((notificationId: string | undefined) => {
|
||||
if (!notificationId) return {} as TNotificationLite;
|
||||
const { workspaceSlug } = this.store.router;
|
||||
const notification = this.notifications[notificationId];
|
||||
if (!notification || !workspaceSlug) return {} as TNotificationLite;
|
||||
return {
|
||||
workspace_slug: workspaceSlug,
|
||||
project_id: notification.project,
|
||||
notification_id: notification.id,
|
||||
issue_id: notification.data?.issue?.id,
|
||||
is_inbox_issue: notification.is_inbox_issue || false,
|
||||
};
|
||||
});
|
||||
|
||||
// helper functions
|
||||
/**
|
||||
* @description generate notification query params
|
||||
* @returns { object }
|
||||
*/
|
||||
generateNotificationQueryParams = (paramType: TNotificationQueryParamType): TNotificationPaginatedInfoQueryParams => {
|
||||
const queryParamsType =
|
||||
Object.entries(this.filters.type)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key)
|
||||
.join(",") || undefined;
|
||||
|
||||
const queryCursorNext =
|
||||
paramType === ENotificationQueryParamType.INIT
|
||||
? `${this.paginatedCount}:0:0`
|
||||
: paramType === ENotificationQueryParamType.CURRENT
|
||||
? `${this.paginatedCount}:${0}:0`
|
||||
: paramType === ENotificationQueryParamType.NEXT && this.paginationInfo
|
||||
? this.paginationInfo?.next_cursor
|
||||
: `${this.paginatedCount}:${0}:0`;
|
||||
|
||||
const queryParams: TNotificationPaginatedInfoQueryParams = {
|
||||
type: queryParamsType,
|
||||
snoozed: this.filters.snoozed || false,
|
||||
archived: this.filters.archived || false,
|
||||
read: undefined,
|
||||
per_page: this.paginatedCount,
|
||||
cursor: queryCursorNext,
|
||||
};
|
||||
|
||||
// NOTE: This validation is required to show all the read and unread notifications in a single place it may change in future.
|
||||
queryParams.read = this.filters.read === true ? false : undefined;
|
||||
|
||||
if (this.currentNotificationTab === ENotificationTab.MENTIONS) queryParams.mentioned = true;
|
||||
|
||||
return queryParams;
|
||||
};
|
||||
|
||||
// helper actions
|
||||
/**
|
||||
* @description mutate and validate current existing and new notifications
|
||||
* @param { TNotification[] } notifications
|
||||
*/
|
||||
mutateNotifications = (notifications: TNotification[]) => {
|
||||
(notifications || []).forEach((notification) => {
|
||||
if (!notification.id) return;
|
||||
if (this.notifications[notification.id]) {
|
||||
this.notifications[notification.id].mutateNotification(notification);
|
||||
} else {
|
||||
set(this.notifications, notification.id, new Notification(this.store, notification));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update filters
|
||||
* @param { T extends keyof TNotificationFilter } key
|
||||
* @param { TNotificationFilter[T] } value
|
||||
*/
|
||||
updateFilters = <T extends keyof TNotificationFilter>(key: T, value: TNotificationFilter[T]) => {
|
||||
set(this.filters, key, value);
|
||||
const { workspaceSlug } = this.store.router;
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
set(this, "notifications", {});
|
||||
this.getNotifications(workspaceSlug, ENotificationLoader.INIT_LOADER, ENotificationQueryParamType.INIT);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update bulk filters
|
||||
* @param { Partial<TNotificationFilter> } filters
|
||||
*/
|
||||
updateBulkFilters = (filters: Partial<TNotificationFilter>) => {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
set(this.filters, key, value);
|
||||
});
|
||||
|
||||
const { workspaceSlug } = this.store.router;
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
set(this, "notifications", {});
|
||||
this.getNotifications(workspaceSlug, ENotificationLoader.INIT_LOADER, ENotificationQueryParamType.INIT);
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description set notification tab
|
||||
* @returns { void }
|
||||
*/
|
||||
setCurrentNotificationTab = (tab: TNotificationTab): void => {
|
||||
set(this, "currentNotificationTab", tab);
|
||||
|
||||
const { workspaceSlug } = this.store.router;
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
set(this, "notifications", {});
|
||||
this.getNotifications(workspaceSlug, ENotificationLoader.INIT_LOADER, ENotificationQueryParamType.INIT);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description set current selected notification
|
||||
* @param { string | undefined } notificationId
|
||||
* @returns { void }
|
||||
*/
|
||||
setCurrentSelectedNotificationId = (notificationId: string | undefined): void => {
|
||||
set(this, "currentSelectedNotificationId", notificationId);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description set unread notifications count
|
||||
* @param { "increment" | "decrement" } type
|
||||
* @returns { void }
|
||||
*/
|
||||
setUnreadNotificationsCount = (type: "increment" | "decrement", newCount: number = 1): void => {
|
||||
const validCount = Math.max(0, Math.abs(newCount));
|
||||
|
||||
switch (this.currentNotificationTab) {
|
||||
case ENotificationTab.ALL:
|
||||
update(
|
||||
this.unreadNotificationsCount,
|
||||
"total_unread_notifications_count",
|
||||
(count: number) => +Math.max(0, type === "increment" ? count + validCount : count - validCount)
|
||||
);
|
||||
break;
|
||||
case ENotificationTab.MENTIONS:
|
||||
update(
|
||||
this.unreadNotificationsCount,
|
||||
"mention_unread_notifications_count",
|
||||
(count: number) => +Math.max(0, type === "increment" ? count + validCount : count - validCount)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description get unread notifications count
|
||||
* @param { string } workspaceSlug,
|
||||
* @param { TNotificationQueryParamType } queryCursorType,
|
||||
* @returns { number | undefined }
|
||||
*/
|
||||
getUnreadNotificationsCount = async (workspaceSlug: string): Promise<TUnreadNotificationsCount | undefined> => {
|
||||
try {
|
||||
const unreadNotificationCount = await workspaceNotificationService.fetchUnreadNotificationsCount(workspaceSlug);
|
||||
if (unreadNotificationCount)
|
||||
runInAction(() => {
|
||||
set(this, "unreadNotificationsCount", unreadNotificationCount);
|
||||
});
|
||||
return unreadNotificationCount || undefined;
|
||||
} catch (error) {
|
||||
console.error("WorkspaceNotificationStore -> getUnreadNotificationsCount -> error", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description get all workspace notification
|
||||
* @param { string } workspaceSlug,
|
||||
* @param { TNotificationLoader } loader,
|
||||
* @returns { TNotification | undefined }
|
||||
*/
|
||||
getNotifications = async (
|
||||
workspaceSlug: string,
|
||||
loader: TNotificationLoader = ENotificationLoader.INIT_LOADER,
|
||||
queryParamType: TNotificationQueryParamType = ENotificationQueryParamType.INIT
|
||||
): Promise<TNotificationPaginatedInfo | undefined> => {
|
||||
this.loader = loader;
|
||||
try {
|
||||
const queryParams = this.generateNotificationQueryParams(queryParamType);
|
||||
await this.getUnreadNotificationsCount(workspaceSlug);
|
||||
const notificationResponse = await workspaceNotificationService.fetchNotifications(workspaceSlug, queryParams);
|
||||
if (notificationResponse) {
|
||||
const { results, ...paginationInfo } = notificationResponse;
|
||||
runInAction(() => {
|
||||
if (results) {
|
||||
this.mutateNotifications(results);
|
||||
}
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
});
|
||||
}
|
||||
return notificationResponse;
|
||||
} catch (error) {
|
||||
console.error("WorkspaceNotificationStore -> getNotifications -> error", error);
|
||||
throw error;
|
||||
} finally {
|
||||
runInAction(() => (this.loader = undefined));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description mark all notifications as read
|
||||
* @param { string } workspaceSlug,
|
||||
* @returns { void }
|
||||
*/
|
||||
markAllNotificationsAsRead = async (workspaceSlug: string): Promise<void> => {
|
||||
try {
|
||||
this.loader = ENotificationLoader.MARK_ALL_AS_READY;
|
||||
const queryParams = this.generateNotificationQueryParams(ENotificationQueryParamType.INIT);
|
||||
const params = {
|
||||
type: queryParams.type,
|
||||
snoozed: queryParams.snoozed,
|
||||
archived: queryParams.archived,
|
||||
read: queryParams.read,
|
||||
};
|
||||
await workspaceNotificationService.markAllNotificationsAsRead(workspaceSlug, params);
|
||||
runInAction(() => {
|
||||
update(
|
||||
this.unreadNotificationsCount,
|
||||
this.currentNotificationTab === ENotificationTab.ALL
|
||||
? "total_unread_notifications_count"
|
||||
: "mention_unread_notifications_count",
|
||||
() => 0
|
||||
);
|
||||
Object.values(this.notifications).forEach((notification) =>
|
||||
notification.mutateNotification({
|
||||
read_at: new Date().toUTCString(),
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("WorkspaceNotificationStore -> markAllNotificationsAsRead -> error", error);
|
||||
throw error;
|
||||
} finally {
|
||||
runInAction(() => (this.loader = undefined));
|
||||
}
|
||||
};
|
||||
}
|
||||
539
apps/web/core/store/pages/base-page.ts
Normal file
539
apps/web/core/store/pages/base-page.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
import type { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types";
|
||||
import type { TChangeHandlerProps } from "@plane/ui";
|
||||
import { convertHexEmojiToDecimal } from "@plane/utils";
|
||||
// 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: convertHexEmojiToDecimal(value.value.unified),
|
||||
url: value.value.imageUrl,
|
||||
};
|
||||
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}`;
|
||||
});
|
||||
}
|
||||
301
apps/web/core/store/project-view.store.ts
Normal file
301
apps/web/core/store/project-view.store.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { set } from "lodash-es";
|
||||
import { observable, action, makeObservable, runInAction, computed } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { IProjectView, TViewFilters } from "@plane/types";
|
||||
// constants
|
||||
// helpers
|
||||
import { getValidatedViewFilters, getViewName, orderViews, shouldFilterView } from "@plane/utils";
|
||||
// services
|
||||
import { ViewService } from "@/plane-web/services";
|
||||
// store
|
||||
import type { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IProjectViewStore {
|
||||
//Loaders
|
||||
loader: boolean;
|
||||
fetchedMap: Record<string, boolean>;
|
||||
// observables
|
||||
viewMap: Record<string, IProjectView>;
|
||||
filters: TViewFilters;
|
||||
// computed
|
||||
projectViewIds: string[] | null;
|
||||
// computed actions
|
||||
getProjectViews: (projectId: string) => IProjectView[] | undefined;
|
||||
getFilteredProjectViews: (projectId: string) => IProjectView[] | undefined;
|
||||
getViewById: (viewId: string) => IProjectView;
|
||||
// fetch actions
|
||||
fetchViews: (workspaceSlug: string, projectId: string) => Promise<undefined | IProjectView[]>;
|
||||
fetchViewDetails: (workspaceSlug: string, projectId: string, viewId: string) => Promise<IProjectView>;
|
||||
// CRUD actions
|
||||
createView: (workspaceSlug: string, projectId: string, data: Partial<IProjectView>) => Promise<IProjectView>;
|
||||
updateView: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<IProjectView>
|
||||
) => Promise<IProjectView>;
|
||||
deleteView: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
updateFilters: <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => void;
|
||||
clearAllFilters: () => void;
|
||||
// favorites actions
|
||||
addViewToFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
removeViewFromFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export class ProjectViewStore implements IProjectViewStore {
|
||||
// observables
|
||||
loader: boolean = false;
|
||||
viewMap: Record<string, IProjectView> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
filters: TViewFilters = { searchQuery: "", sortBy: "desc", sortKey: "updated_at" };
|
||||
// root store
|
||||
rootStore;
|
||||
// services
|
||||
viewService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
viewMap: observable,
|
||||
fetchedMap: observable,
|
||||
filters: observable,
|
||||
// computed
|
||||
projectViewIds: computed,
|
||||
// fetch actions
|
||||
fetchViews: action,
|
||||
fetchViewDetails: action,
|
||||
// CRUD actions
|
||||
createView: action,
|
||||
updateView: action,
|
||||
deleteView: action,
|
||||
// actions
|
||||
updateFilters: action,
|
||||
clearAllFilters: action,
|
||||
// favorites actions
|
||||
addViewToFavorites: action,
|
||||
removeViewFromFavorites: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.viewService = new ViewService();
|
||||
|
||||
this.createView = this.createView.bind(this);
|
||||
this.updateView = this.updateView.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of view ids for current project
|
||||
*/
|
||||
get projectViewIds() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId || !this.fetchedMap[projectId]) return null;
|
||||
const viewIds = Object.keys(this.viewMap ?? {})?.filter((viewId) => this.viewMap?.[viewId]?.project === projectId);
|
||||
return viewIds;
|
||||
}
|
||||
|
||||
getProjectViews = computedFn((projectId: string) => {
|
||||
if (!this.fetchedMap[projectId]) return undefined;
|
||||
|
||||
const ViewsList = Object.values(this.viewMap ?? {});
|
||||
// helps to filter views based on the projectId
|
||||
let filteredViews = ViewsList.filter((view) => view?.project === projectId);
|
||||
filteredViews = orderViews(filteredViews, this.filters.sortKey, this.filters.sortBy);
|
||||
|
||||
return filteredViews ?? undefined;
|
||||
});
|
||||
/**
|
||||
* returns viewsIds of issues
|
||||
*/
|
||||
getFilteredProjectViews = computedFn((projectId: string) => {
|
||||
if (!this.fetchedMap[projectId]) return undefined;
|
||||
|
||||
const ViewsList = Object.values(this.viewMap ?? {});
|
||||
// helps to filter views based on the projectId, searchQuery and filters
|
||||
let filteredViews = ViewsList.filter(
|
||||
(view) =>
|
||||
view?.project === projectId &&
|
||||
getViewName(view.name).toLowerCase().includes(this.filters.searchQuery.toLowerCase()) &&
|
||||
shouldFilterView(view, this.filters.filters)
|
||||
);
|
||||
filteredViews = orderViews(filteredViews, this.filters.sortKey, this.filters.sortBy);
|
||||
|
||||
return filteredViews ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns view details by id
|
||||
*/
|
||||
getViewById = computedFn((viewId: string) => this.viewMap?.[viewId] ?? null);
|
||||
|
||||
/**
|
||||
* Updates the filter
|
||||
* @param filterKey
|
||||
* @param filterValue
|
||||
*/
|
||||
updateFilters = <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => {
|
||||
runInAction(() => {
|
||||
set(this.filters, [filterKey], filterValue);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description clears all the filters
|
||||
*/
|
||||
clearAllFilters = () =>
|
||||
runInAction(() => {
|
||||
set(this.filters, ["filters"], {});
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches views for current project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<IProjectView[]>
|
||||
*/
|
||||
fetchViews = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
await this.viewService.getViews(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((view) => {
|
||||
set(this.viewMap, [view.id], view);
|
||||
});
|
||||
set(this.fetchedMap, projectId, true);
|
||||
this.loader = false;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches view details for a specific view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns Promise<IProjectView>
|
||||
*/
|
||||
fetchViewDetails = async (workspaceSlug: string, projectId: string, viewId: string): Promise<IProjectView> =>
|
||||
await this.viewService.getViewDetails(workspaceSlug, projectId, viewId).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId], response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new view for a specific project and adds it to the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns Promise<IProjectView>
|
||||
*/
|
||||
async createView(workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<IProjectView> {
|
||||
const response = await this.viewService.createView(workspaceSlug, projectId, getValidatedViewFilters(data));
|
||||
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [response.id], response);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a view details of specific view and updates it in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @param data
|
||||
* @returns Promise<IProjectView>
|
||||
*/
|
||||
async updateView(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<IProjectView>
|
||||
): Promise<IProjectView> {
|
||||
const currentView = this.getViewById(viewId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId], { ...currentView, ...data });
|
||||
});
|
||||
|
||||
const response = await this.viewService.patchView(workspaceSlug, projectId, viewId, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a view and removes it from the viewMap object
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
deleteView = async (workspaceSlug: string, projectId: string, viewId: string): Promise<any> => {
|
||||
await this.viewService.deleteView(workspaceSlug, projectId, viewId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.viewMap[viewId];
|
||||
if (this.rootStore.favorite.entityMap[viewId]) this.rootStore.favorite.removeFavoriteFromStore(viewId);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a view to favorites
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
addViewToFavorites = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const currentView = this.getViewById(viewId);
|
||||
if (currentView?.is_favorite) return;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_favorite"], true);
|
||||
});
|
||||
await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "view",
|
||||
entity_identifier: viewId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.viewMap[viewId].name || "" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add view to favorites in view store", error);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_favorite"], false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a view from favorites
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
removeViewFromFavorites = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const currentView = this.getViewById(viewId);
|
||||
if (!currentView?.is_favorite) return;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_favorite"], false);
|
||||
});
|
||||
await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, viewId);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove view from favorites in view store", error);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_favorite"], true);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
25
apps/web/core/store/project/index.ts
Normal file
25
apps/web/core/store/project/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
import type { IProjectPublishStore } from "./project-publish.store";
|
||||
import { ProjectPublishStore } from "./project-publish.store";
|
||||
import type { IProjectStore } from "./project.store";
|
||||
import { ProjectStore } from "./project.store";
|
||||
import type { IProjectFilterStore } from "./project_filter.store";
|
||||
import { ProjectFilterStore } from "./project_filter.store";
|
||||
|
||||
export interface IProjectRootStore {
|
||||
project: IProjectStore;
|
||||
projectFilter: IProjectFilterStore;
|
||||
publish: IProjectPublishStore;
|
||||
}
|
||||
|
||||
export class ProjectRootStore {
|
||||
project: IProjectStore;
|
||||
projectFilter: IProjectFilterStore;
|
||||
publish: IProjectPublishStore;
|
||||
|
||||
constructor(_root: CoreRootStore) {
|
||||
this.project = new ProjectStore(_root);
|
||||
this.projectFilter = new ProjectFilterStore(_root);
|
||||
this.publish = new ProjectPublishStore(this);
|
||||
}
|
||||
}
|
||||
188
apps/web/core/store/project/project-publish.store.ts
Normal file
188
apps/web/core/store/project/project-publish.store.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { unset, set } from "lodash-es";
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import type { TProjectPublishSettings } from "@plane/types";
|
||||
// services
|
||||
import { ProjectPublishService } from "@/services/project";
|
||||
// store
|
||||
import type { ProjectRootStore } from "@/store/project";
|
||||
|
||||
export interface IProjectPublishStore {
|
||||
// states
|
||||
generalLoader: boolean;
|
||||
fetchSettingsLoader: boolean;
|
||||
// observables
|
||||
publishSettingsMap: Record<string, TProjectPublishSettings>; // projectID => TProjectPublishSettings
|
||||
// helpers
|
||||
getPublishSettingsByProjectID: (projectID: string) => TProjectPublishSettings | undefined;
|
||||
// actions
|
||||
fetchPublishSettings: (workspaceSlug: string, projectID: string) => Promise<TProjectPublishSettings>;
|
||||
updatePublishSettings: (
|
||||
workspaceSlug: string,
|
||||
projectID: string,
|
||||
projectPublishId: string,
|
||||
data: Partial<TProjectPublishSettings>
|
||||
) => Promise<TProjectPublishSettings>;
|
||||
publishProject: (
|
||||
workspaceSlug: string,
|
||||
projectID: string,
|
||||
data: Partial<TProjectPublishSettings>
|
||||
) => Promise<TProjectPublishSettings>;
|
||||
unPublishProject: (workspaceSlug: string, projectID: string, projectPublishId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectPublishStore implements IProjectPublishStore {
|
||||
// states
|
||||
generalLoader: boolean = false;
|
||||
fetchSettingsLoader: boolean = false;
|
||||
// observables
|
||||
publishSettingsMap: Record<string, TProjectPublishSettings> = {};
|
||||
// root store
|
||||
projectRootStore: ProjectRootStore;
|
||||
// services
|
||||
projectPublishService;
|
||||
|
||||
constructor(_projectRootStore: ProjectRootStore) {
|
||||
makeObservable(this, {
|
||||
// states
|
||||
generalLoader: observable.ref,
|
||||
fetchSettingsLoader: observable.ref,
|
||||
// observables
|
||||
publishSettingsMap: observable,
|
||||
// actions
|
||||
fetchPublishSettings: action,
|
||||
updatePublishSettings: action,
|
||||
publishProject: action,
|
||||
unPublishProject: action,
|
||||
});
|
||||
// root store
|
||||
this.projectRootStore = _projectRootStore;
|
||||
// services
|
||||
this.projectPublishService = new ProjectPublishService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns the publish settings of a particular project
|
||||
* @param {string} projectID
|
||||
* @returns {TProjectPublishSettings | undefined}
|
||||
*/
|
||||
getPublishSettingsByProjectID = (projectID: string): TProjectPublishSettings | undefined =>
|
||||
this.publishSettingsMap?.[projectID] ?? undefined;
|
||||
|
||||
/**
|
||||
* Fetches project publish settings
|
||||
* @param workspaceSlug
|
||||
* @param projectID
|
||||
* @returns
|
||||
*/
|
||||
fetchPublishSettings = async (workspaceSlug: string, projectID: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.fetchSettingsLoader = true;
|
||||
});
|
||||
const response = await this.projectPublishService.fetchPublishSettings(workspaceSlug, projectID);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.publishSettingsMap, [projectID], response);
|
||||
this.fetchSettingsLoader = false;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.fetchSettingsLoader = false;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes project and updates project publish status in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectID
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
publishProject = async (workspaceSlug: string, projectID: string, data: Partial<TProjectPublishSettings>) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.generalLoader = true;
|
||||
});
|
||||
const response = await this.projectPublishService.publishProject(workspaceSlug, projectID, data);
|
||||
runInAction(() => {
|
||||
set(this.publishSettingsMap, [projectID], response);
|
||||
set(this.projectRootStore.project.projectMap, [projectID, "anchor"], response.anchor);
|
||||
this.generalLoader = false;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.generalLoader = false;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates project publish settings
|
||||
* @param workspaceSlug
|
||||
* @param projectID
|
||||
* @param projectPublishId
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
updatePublishSettings = async (
|
||||
workspaceSlug: string,
|
||||
projectID: string,
|
||||
projectPublishId: string,
|
||||
data: Partial<TProjectPublishSettings>
|
||||
) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.generalLoader = true;
|
||||
});
|
||||
const response = await this.projectPublishService.updatePublishSettings(
|
||||
workspaceSlug,
|
||||
projectID,
|
||||
projectPublishId,
|
||||
data
|
||||
);
|
||||
runInAction(() => {
|
||||
set(this.publishSettingsMap, [projectID], response);
|
||||
this.generalLoader = false;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.generalLoader = false;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unpublishes project and updates project publish status in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectID
|
||||
* @param projectPublishId
|
||||
* @returns
|
||||
*/
|
||||
unPublishProject = async (workspaceSlug: string, projectID: string, projectPublishId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.generalLoader = true;
|
||||
});
|
||||
const response = await this.projectPublishService.unpublishProject(workspaceSlug, projectID, projectPublishId);
|
||||
runInAction(() => {
|
||||
unset(this.publishSettingsMap, [projectID]);
|
||||
set(this.projectRootStore.project.projectMap, [projectID, "anchor"], null);
|
||||
this.generalLoader = false;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.generalLoader = false;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
619
apps/web/core/store/project/project.store.ts
Normal file
619
apps/web/core/store/project/project.store.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { sortBy, cloneDeep, update, set } from "lodash-es";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TFetchStatus, TLoader, TProjectAnalyticsCount, TProjectAnalyticsCountParams } from "@plane/types";
|
||||
// helpers
|
||||
import { orderProjects, shouldFilterProject } from "@plane/utils";
|
||||
// services
|
||||
import type { TProject, TPartialProject } from "@/plane-web/types/projects";
|
||||
import { IssueLabelService, IssueService } from "@/services/issue";
|
||||
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
type ProjectOverviewCollapsible = "links" | "attachments" | "milestones";
|
||||
|
||||
export interface IProjectStore {
|
||||
// observables
|
||||
isUpdatingProject: boolean;
|
||||
loader: TLoader;
|
||||
fetchStatus: TFetchStatus;
|
||||
projectMap: Record<string, TProject>; // projectId: project info
|
||||
projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount>; // projectId: project analytics count
|
||||
// computed
|
||||
isInitializingProjects: boolean;
|
||||
filteredProjectIds: string[] | undefined;
|
||||
workspaceProjectIds: string[] | undefined;
|
||||
archivedProjectIds: string[] | undefined;
|
||||
totalProjectIds: string[] | undefined;
|
||||
joinedProjectIds: string[];
|
||||
favoriteProjectIds: string[];
|
||||
currentProjectDetails: TProject | undefined;
|
||||
// actions
|
||||
getProjectById: (projectId: string | undefined | null) => TProject | undefined;
|
||||
getPartialProjectById: (projectId: string | undefined | null) => TPartialProject | undefined;
|
||||
getProjectIdentifierById: (projectId: string | undefined | null) => string;
|
||||
getProjectAnalyticsCountById: (projectId: string | undefined | null) => TProjectAnalyticsCount | undefined;
|
||||
getProjectByIdentifier: (projectIdentifier: string) => TProject | undefined;
|
||||
// collapsible
|
||||
openCollapsibleSection: ProjectOverviewCollapsible[];
|
||||
lastCollapsibleAction: ProjectOverviewCollapsible | null;
|
||||
|
||||
setOpenCollapsibleSection: (section: ProjectOverviewCollapsible[]) => void;
|
||||
setLastCollapsibleAction: (section: ProjectOverviewCollapsible) => void;
|
||||
toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void;
|
||||
|
||||
// helper actions
|
||||
processProjectAfterCreation: (workspaceSlug: string, data: TProject) => void;
|
||||
|
||||
// fetch actions
|
||||
fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>;
|
||||
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
|
||||
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>;
|
||||
fetchProjectAnalyticsCount: (
|
||||
workspaceSlug: string,
|
||||
params?: TProjectAnalyticsCountParams
|
||||
) => Promise<TProjectAnalyticsCount[]>;
|
||||
// favorites actions
|
||||
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
|
||||
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
|
||||
// project-view action
|
||||
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
|
||||
// CRUD actions
|
||||
createProject: (workspaceSlug: string, data: Partial<TProject>) => Promise<TProject>;
|
||||
updateProject: (workspaceSlug: string, projectId: string, data: Partial<TProject>) => Promise<TProject>;
|
||||
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
// archive actions
|
||||
archiveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
restoreProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectStore implements IProjectStore {
|
||||
// observables
|
||||
isUpdatingProject: boolean = false;
|
||||
loader: TLoader = "init-loader";
|
||||
fetchStatus: TFetchStatus = undefined;
|
||||
projectMap: Record<string, TProject> = {};
|
||||
projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount> = {};
|
||||
openCollapsibleSection: ProjectOverviewCollapsible[] = [];
|
||||
lastCollapsibleAction: ProjectOverviewCollapsible | null = null;
|
||||
|
||||
// root store
|
||||
rootStore: CoreRootStore;
|
||||
// service
|
||||
projectService;
|
||||
projectArchiveService;
|
||||
issueLabelService;
|
||||
issueService;
|
||||
stateService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isUpdatingProject: observable,
|
||||
loader: observable.ref,
|
||||
fetchStatus: observable.ref,
|
||||
projectMap: observable,
|
||||
projectAnalyticsCountMap: observable,
|
||||
openCollapsibleSection: observable.ref,
|
||||
lastCollapsibleAction: observable.ref,
|
||||
// computed
|
||||
isInitializingProjects: computed,
|
||||
filteredProjectIds: computed,
|
||||
workspaceProjectIds: computed,
|
||||
archivedProjectIds: computed,
|
||||
totalProjectIds: computed,
|
||||
currentProjectDetails: computed,
|
||||
joinedProjectIds: computed,
|
||||
favoriteProjectIds: computed,
|
||||
// helper actions
|
||||
processProjectAfterCreation: action,
|
||||
// fetch actions
|
||||
fetchPartialProjects: action,
|
||||
fetchProjects: action,
|
||||
fetchProjectDetails: action,
|
||||
fetchProjectAnalyticsCount: action,
|
||||
// favorites actions
|
||||
addProjectToFavorites: action,
|
||||
removeProjectFromFavorites: action,
|
||||
// project-view action
|
||||
updateProjectView: action,
|
||||
// CRUD actions
|
||||
createProject: action,
|
||||
updateProject: action,
|
||||
// collapsible actions
|
||||
setOpenCollapsibleSection: action,
|
||||
setLastCollapsibleAction: action,
|
||||
toggleOpenCollapsibleSection: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.projectService = new ProjectService();
|
||||
this.projectArchiveService = new ProjectArchiveService();
|
||||
this.issueService = new IssueService();
|
||||
this.issueLabelService = new IssueLabelService();
|
||||
this.stateService = new ProjectStateService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if projects are still initializing
|
||||
*/
|
||||
get isInitializingProjects() {
|
||||
return this.loader === "init-loader";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns filtered projects based on filters and search query
|
||||
*/
|
||||
get filteredProjectIds() {
|
||||
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
const {
|
||||
currentWorkspaceDisplayFilters: displayFilters,
|
||||
currentWorkspaceFilters: filters,
|
||||
searchQuery,
|
||||
} = this.rootStore.projectRoot.projectFilter;
|
||||
if (!workspaceDetails || !displayFilters || !filters) return;
|
||||
let workspaceProjects = Object.values(this.projectMap).filter(
|
||||
(p) =>
|
||||
p.workspace === workspaceDetails.id &&
|
||||
(p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.identifier.toLowerCase().includes(searchQuery.toLowerCase())) &&
|
||||
shouldFilterProject(p, displayFilters, filters)
|
||||
);
|
||||
workspaceProjects = orderProjects(workspaceProjects, displayFilters.order_by);
|
||||
return workspaceProjects.map((p) => p.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns project IDs belong to the current workspace
|
||||
*/
|
||||
get workspaceProjectIds() {
|
||||
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!workspaceDetails) return;
|
||||
const workspaceProjects = Object.values(this.projectMap).filter(
|
||||
(p) => p.workspace === workspaceDetails.id && !p.archived_at
|
||||
);
|
||||
const projectIds = workspaceProjects.map((p) => p.id);
|
||||
return projectIds ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns archived project IDs belong to current workspace.
|
||||
*/
|
||||
get archivedProjectIds() {
|
||||
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
let projects = Object.values(this.projectMap ?? {});
|
||||
projects = sortBy(projects, "archived_at");
|
||||
|
||||
const projectIds = projects
|
||||
.filter((project) => project.workspace === currentWorkspace.id && !!project.archived_at)
|
||||
.map((project) => project.id);
|
||||
return projectIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns total project IDs belong to the current workspace
|
||||
*/
|
||||
// workspaceProjectIds + archivedProjectIds
|
||||
get totalProjectIds() {
|
||||
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
const workspaceProjects = this.workspaceProjectIds ?? [];
|
||||
const archivedProjects = this.archivedProjectIds ?? [];
|
||||
return [...workspaceProjects, ...archivedProjects];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current project details
|
||||
*/
|
||||
get currentProjectDetails() {
|
||||
if (!this.rootStore.router.projectId) return;
|
||||
return this.projectMap?.[this.rootStore.router.projectId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns joined project IDs belong to the current workspace
|
||||
*/
|
||||
get joinedProjectIds() {
|
||||
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspace) return [];
|
||||
|
||||
let projects = Object.values(this.projectMap ?? {});
|
||||
projects = sortBy(projects, "sort_order");
|
||||
|
||||
const projectIds = projects
|
||||
.filter((project) => project.workspace === currentWorkspace.id && !!project.member_role && !project.archived_at)
|
||||
.map((project) => project.id);
|
||||
return projectIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns favorite project IDs belong to the current workspace
|
||||
*/
|
||||
get favoriteProjectIds() {
|
||||
const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace;
|
||||
if (!currentWorkspace) return [];
|
||||
|
||||
let projects = Object.values(this.projectMap ?? {});
|
||||
projects = sortBy(projects, "created_at");
|
||||
|
||||
const projectIds = projects
|
||||
.filter(
|
||||
(project) =>
|
||||
project.workspace === currentWorkspace.id &&
|
||||
!!project.member_role &&
|
||||
project.is_favorite &&
|
||||
!project.archived_at
|
||||
)
|
||||
.map((project) => project.id);
|
||||
return projectIds;
|
||||
}
|
||||
|
||||
setOpenCollapsibleSection = (section: ProjectOverviewCollapsible[]) => {
|
||||
this.openCollapsibleSection = section;
|
||||
if (this.lastCollapsibleAction) this.lastCollapsibleAction = null;
|
||||
};
|
||||
|
||||
setLastCollapsibleAction = (section: ProjectOverviewCollapsible) => {
|
||||
this.openCollapsibleSection = [...this.openCollapsibleSection, section];
|
||||
};
|
||||
|
||||
toggleOpenCollapsibleSection = (section: ProjectOverviewCollapsible) => {
|
||||
if (this.openCollapsibleSection && this.openCollapsibleSection.includes(section)) {
|
||||
this.openCollapsibleSection = this.openCollapsibleSection.filter((s) => s !== section);
|
||||
} else {
|
||||
this.openCollapsibleSection = [...this.openCollapsibleSection, section];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description process project after creation
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
processProjectAfterCreation = (workspaceSlug: string, data: TProject) => {
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [data.id], data);
|
||||
// updating the user project role in workspaceProjectsPermissions
|
||||
set(this.rootStore.user.permission.workspaceProjectsPermissions, [workspaceSlug, data.id], data.member_role);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* get Workspace projects partial data using workspace slug
|
||||
* @param workspaceSlug
|
||||
* @returns Promise<TPartialProject[]>
|
||||
*
|
||||
*/
|
||||
fetchPartialProjects = async (workspaceSlug: string) => {
|
||||
try {
|
||||
this.loader = "init-loader";
|
||||
const projectsResponse = await this.projectService.getProjectsLite(workspaceSlug);
|
||||
runInAction(() => {
|
||||
projectsResponse.forEach((project) => {
|
||||
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
|
||||
});
|
||||
this.loader = "loaded";
|
||||
if (!this.fetchStatus) this.fetchStatus = "partial";
|
||||
});
|
||||
return projectsResponse;
|
||||
} catch (error) {
|
||||
console.log("Failed to fetch project from workspace store");
|
||||
this.loader = "loaded";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* get Workspace projects using workspace slug
|
||||
* @param workspaceSlug
|
||||
* @returns Promise<TProject[]>
|
||||
*
|
||||
*/
|
||||
fetchProjects = async (workspaceSlug: string) => {
|
||||
try {
|
||||
if (this.workspaceProjectIds && this.workspaceProjectIds.length > 0) {
|
||||
this.loader = "mutation";
|
||||
} else {
|
||||
this.loader = "init-loader";
|
||||
}
|
||||
const projectsResponse = await this.projectService.getProjects(workspaceSlug);
|
||||
runInAction(() => {
|
||||
projectsResponse.forEach((project) => {
|
||||
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
|
||||
});
|
||||
this.loader = "loaded";
|
||||
this.fetchStatus = "complete";
|
||||
});
|
||||
return projectsResponse;
|
||||
} catch (error) {
|
||||
console.log("Failed to fetch project from workspace store");
|
||||
this.loader = "loaded";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches project details using workspace slug and project id
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<TProject>
|
||||
*/
|
||||
fetchProjectDetails = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
const response = await this.projectService.getProject(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
update(this.projectMap, [projectId], (p) => ({ ...p, ...response }));
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Error while fetching project details", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches project analytics count using workspace slug and project id
|
||||
* @param workspaceSlug
|
||||
* @param params TProjectAnalyticsCountParams
|
||||
* @returns Promise<TProjectAnalyticsCount[]>
|
||||
*/
|
||||
fetchProjectAnalyticsCount = async (
|
||||
workspaceSlug: string,
|
||||
params?: TProjectAnalyticsCountParams
|
||||
): Promise<TProjectAnalyticsCount[]> => {
|
||||
try {
|
||||
const response = await this.projectService.getProjectAnalyticsCount(workspaceSlug, params);
|
||||
runInAction(() => {
|
||||
for (const analyticsData of response) {
|
||||
set(this.projectAnalyticsCountMap, [analyticsData.id], analyticsData);
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to fetch project analytics count", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns project details using project id
|
||||
* @param projectId
|
||||
* @returns TProject | null
|
||||
*/
|
||||
getProjectById = computedFn((projectId: string | undefined | null) => {
|
||||
const projectInfo = this.projectMap[projectId ?? ""] || undefined;
|
||||
return projectInfo;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns project details using project identifier
|
||||
* @param projectIdentifier
|
||||
* @returns TProject | undefined
|
||||
*/
|
||||
getProjectByIdentifier = computedFn((projectIdentifier: string) =>
|
||||
Object.values(this.projectMap).find((project) => project.identifier === projectIdentifier)
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns project lite using project id
|
||||
* This method is used just for type safety
|
||||
* @param projectId
|
||||
* @returns TPartialProject | null
|
||||
*/
|
||||
getPartialProjectById = computedFn((projectId: string | undefined | null) => {
|
||||
const projectInfo = this.projectMap[projectId ?? ""] || undefined;
|
||||
return projectInfo;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns project identifier using project id
|
||||
* @param projectId
|
||||
* @returns string
|
||||
*/
|
||||
getProjectIdentifierById = computedFn((projectId: string | undefined | null) => {
|
||||
const projectInfo = this.projectMap?.[projectId ?? ""];
|
||||
return projectInfo?.identifier;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns project analytics count using project id
|
||||
* @param projectId
|
||||
* @returns TProjectAnalyticsCount[]
|
||||
*/
|
||||
getProjectAnalyticsCountById = computedFn((projectId: string | undefined | null) => {
|
||||
if (!projectId) return undefined;
|
||||
return this.projectAnalyticsCountMap?.[projectId];
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds project to favorites and updates project favorite status in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
addProjectToFavorites = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
const currentProject = this.getProjectById(projectId);
|
||||
if (currentProject.is_favorite) return;
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "is_favorite"], true);
|
||||
});
|
||||
const response = await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "project",
|
||||
entity_identifier: projectId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.projectMap[projectId].name || "" },
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to add project to favorite");
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "is_favorite"], false);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes project from favorites and updates project favorite status in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
removeProjectFromFavorites = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
const currentProject = this.getProjectById(projectId);
|
||||
if (!currentProject.is_favorite) return;
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "is_favorite"], false);
|
||||
});
|
||||
const response = await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug.toString(), projectId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to add project to favorite");
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "is_favorite"], true);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the project view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewProps
|
||||
* @returns
|
||||
*/
|
||||
updateProjectView = async (workspaceSlug: string, projectId: string, viewProps: { sort_order: number }) => {
|
||||
const currentProjectSortOrder = this.getProjectById(projectId)?.sort_order;
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "sort_order"], viewProps?.sort_order);
|
||||
});
|
||||
const response = await this.projectService.setProjectView(workspaceSlug, projectId, viewProps);
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "sort_order"], currentProjectSortOrder);
|
||||
});
|
||||
console.log("Failed to update sort order of the projects");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a project in the workspace and adds it to the store
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
* @returns Promise<TProject>
|
||||
*/
|
||||
createProject = async (workspaceSlug: string, data: any) => {
|
||||
try {
|
||||
const response = await this.projectService.createProject(workspaceSlug, data);
|
||||
this.processProjectAfterCreation(workspaceSlug, response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to create project from project store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a details of a project and updates it in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns Promise<TProject>
|
||||
*/
|
||||
updateProject = async (workspaceSlug: string, projectId: string, data: Partial<TProject>) => {
|
||||
const projectDetails = cloneDeep(this.getProjectById(projectId));
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId], { ...projectDetails, ...data });
|
||||
this.isUpdatingProject = true;
|
||||
});
|
||||
const response = await this.projectService.updateProject(workspaceSlug, projectId, data);
|
||||
runInAction(() => {
|
||||
this.isUpdatingProject = false;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to create project from project store");
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId], projectDetails);
|
||||
this.isUpdatingProject = false;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a project from specific workspace and deletes it from the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
deleteProject = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
if (!this.projectMap?.[projectId]) return;
|
||||
await this.projectService.deleteProject(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
delete this.projectMap[projectId];
|
||||
if (this.rootStore.favorite.entityMap[projectId]) this.rootStore.favorite.removeFavoriteFromStore(projectId);
|
||||
delete this.rootStore.user.permission.workspaceProjectsPermissions[workspaceSlug][projectId];
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Failed to delete project from project store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Archives a project from specific workspace and updates it in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
archiveProject = async (workspaceSlug: string, projectId: string) => {
|
||||
await this.projectArchiveService
|
||||
.archiveProject(workspaceSlug, projectId)
|
||||
.then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "archived_at"], response.archived_at);
|
||||
this.rootStore.favorite.removeFavoriteFromStore(projectId);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Failed to archive project from project store");
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores a project from specific workspace and updates it in the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
restoreProject = async (workspaceSlug: string, projectId: string) => {
|
||||
await this.projectArchiveService
|
||||
.restoreProject(workspaceSlug, projectId)
|
||||
.then(() => {
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "archived_at"], null);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Failed to restore project from project store");
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
180
apps/web/core/store/project/project_filter.store.ts
Normal file
180
apps/web/core/store/project/project_filter.store.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, computed, observable, makeObservable, runInAction, reaction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { TProjectDisplayFilters, TProjectFilters, TProjectAppliedDisplayFilterKeys } from "@plane/types";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
export interface IProjectFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TProjectDisplayFilters>;
|
||||
filters: Record<string, TProjectFilters>;
|
||||
searchQuery: string;
|
||||
// computed
|
||||
currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined;
|
||||
currentWorkspaceAppliedDisplayFilters: TProjectAppliedDisplayFilterKeys[] | undefined;
|
||||
currentWorkspaceFilters: TProjectFilters | undefined;
|
||||
// computed functions
|
||||
getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined;
|
||||
getFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectFilters | undefined;
|
||||
// actions
|
||||
updateDisplayFilters: (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => void;
|
||||
updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
clearAllFilters: (workspaceSlug: string) => void;
|
||||
clearAllAppliedDisplayFilters: (workspaceSlug: string) => void;
|
||||
}
|
||||
|
||||
export class ProjectFilterStore implements IProjectFilterStore {
|
||||
// observables
|
||||
displayFilters: Record<string, TProjectDisplayFilters> = {};
|
||||
filters: Record<string, TProjectFilters> = {};
|
||||
searchQuery: string = "";
|
||||
// root store
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
displayFilters: observable,
|
||||
filters: observable,
|
||||
searchQuery: observable.ref,
|
||||
// computed
|
||||
currentWorkspaceDisplayFilters: computed,
|
||||
currentWorkspaceAppliedDisplayFilters: computed,
|
||||
currentWorkspaceFilters: computed,
|
||||
// actions
|
||||
updateDisplayFilters: action,
|
||||
updateFilters: action,
|
||||
updateSearchQuery: action,
|
||||
clearAllFilters: action,
|
||||
clearAllAppliedDisplayFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// initialize display filters of the current workspace
|
||||
reaction(
|
||||
() => this.rootStore.router.workspaceSlug,
|
||||
(workspaceSlug) => {
|
||||
if (!workspaceSlug) return;
|
||||
this.initWorkspaceFilters(workspaceSlug);
|
||||
this.searchQuery = "";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get display filters of the current workspace
|
||||
*/
|
||||
get currentWorkspaceDisplayFilters() {
|
||||
const workspaceSlug = this.rootStore.router.workspaceSlug;
|
||||
if (!workspaceSlug) return;
|
||||
return this.displayFilters[workspaceSlug];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get project state applied display filter of the current workspace
|
||||
* @returns {TProjectAppliedDisplayFilterKeys[] | undefined} // An array of keys of applied display filters
|
||||
*/
|
||||
// TODO: Figure out a better approach for this
|
||||
get currentWorkspaceAppliedDisplayFilters() {
|
||||
const workspaceSlug = this.rootStore.router.workspaceSlug;
|
||||
if (!workspaceSlug) return;
|
||||
const displayFilters = this.displayFilters[workspaceSlug];
|
||||
return Object.keys(displayFilters).filter(
|
||||
(key): key is TProjectAppliedDisplayFilterKeys =>
|
||||
["my_projects", "archived_projects"].includes(key) && !!displayFilters[key as keyof TProjectDisplayFilters]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get filters of the current workspace
|
||||
*/
|
||||
get currentWorkspaceFilters() {
|
||||
const workspaceSlug = this.rootStore.router.workspaceSlug;
|
||||
if (!workspaceSlug) return;
|
||||
return this.filters[workspaceSlug];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get display filters of a workspace by workspaceSlug
|
||||
* @param {string} workspaceSlug
|
||||
*/
|
||||
getDisplayFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.displayFilters[workspaceSlug]);
|
||||
|
||||
/**
|
||||
* @description get filters of a workspace by workspaceSlug
|
||||
* @param {string} workspaceSlug
|
||||
*/
|
||||
getFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.filters[workspaceSlug]);
|
||||
|
||||
/**
|
||||
* @description initialize display filters and filters of a workspace
|
||||
* @param {string} workspaceSlug
|
||||
*/
|
||||
initWorkspaceFilters = (workspaceSlug: string) => {
|
||||
const displayFilters = this.getDisplayFiltersByWorkspaceSlug(workspaceSlug);
|
||||
runInAction(() => {
|
||||
this.displayFilters[workspaceSlug] = {
|
||||
order_by: displayFilters?.order_by || "created_at",
|
||||
};
|
||||
this.filters[workspaceSlug] = this.filters[workspaceSlug] ?? {};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update display filters of a workspace
|
||||
* @param {string} workspaceSlug
|
||||
* @param {TProjectDisplayFilters} displayFilters
|
||||
*/
|
||||
updateDisplayFilters = (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => {
|
||||
runInAction(() => {
|
||||
Object.keys(displayFilters).forEach((key) => {
|
||||
set(this.displayFilters, [workspaceSlug, key], displayFilters[key as keyof TProjectDisplayFilters]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update filters of a workspace
|
||||
* @param {string} workspaceSlug
|
||||
* @param {TProjectFilters} filters
|
||||
*/
|
||||
updateFilters = (workspaceSlug: string, filters: TProjectFilters) => {
|
||||
runInAction(() => {
|
||||
Object.keys(filters).forEach((key) => {
|
||||
set(this.filters, [workspaceSlug, key], filters[key as keyof TProjectFilters]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update search query
|
||||
* @param {string} query
|
||||
*/
|
||||
updateSearchQuery = (query: string) => (this.searchQuery = query);
|
||||
|
||||
/**
|
||||
* @description clear all filters of a workspace
|
||||
* @param {string} workspaceSlug
|
||||
*/
|
||||
clearAllFilters = (workspaceSlug: string) => {
|
||||
runInAction(() => {
|
||||
this.filters[workspaceSlug] = {};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description clear project display filters of a workspace
|
||||
* @param {string} workspaceSlug
|
||||
*/
|
||||
clearAllAppliedDisplayFilters = (workspaceSlug: string) => {
|
||||
runInAction(() => {
|
||||
if (!this.currentWorkspaceAppliedDisplayFilters) return;
|
||||
this.currentWorkspaceAppliedDisplayFilters.forEach((key) => {
|
||||
set(this.displayFilters, [workspaceSlug, key], false);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
163
apps/web/core/store/root.store.ts
Normal file
163
apps/web/core/store/root.store.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// plane imports
|
||||
import { FALLBACK_LANGUAGE, LANGUAGE_STORAGE_KEY } from "@plane/i18n";
|
||||
import type { IWorkItemFilterStore } from "@plane/shared-state";
|
||||
import { WorkItemFilterStore } from "@plane/shared-state";
|
||||
// plane web store
|
||||
import type { IAnalyticsStore } from "@/plane-web/store/analytics.store";
|
||||
import { AnalyticsStore } from "@/plane-web/store/analytics.store";
|
||||
import type { ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
|
||||
import { CommandPaletteStore } from "@/plane-web/store/command-palette.store";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import type { IStateStore } from "@/plane-web/store/state.store";
|
||||
import { StateStore } from "@/plane-web/store/state.store";
|
||||
// stores
|
||||
import type { ICycleStore } from "./cycle.store";
|
||||
import { CycleStore } from "./cycle.store";
|
||||
import type { ICycleFilterStore } from "./cycle_filter.store";
|
||||
import { CycleFilterStore } from "./cycle_filter.store";
|
||||
import type { IDashboardStore } from "./dashboard.store";
|
||||
import { DashboardStore } from "./dashboard.store";
|
||||
import type { IEditorAssetStore } from "./editor/asset.store";
|
||||
import { EditorAssetStore } from "./editor/asset.store";
|
||||
import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import { ProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import type { IFavoriteStore } from "./favorite.store";
|
||||
import { FavoriteStore } from "./favorite.store";
|
||||
import type { IGlobalViewStore } from "./global-view.store";
|
||||
import { GlobalViewStore } from "./global-view.store";
|
||||
import type { IProjectInboxStore } from "./inbox/project-inbox.store";
|
||||
import { ProjectInboxStore } from "./inbox/project-inbox.store";
|
||||
import type { IInstanceStore } from "./instance.store";
|
||||
import { InstanceStore } from "./instance.store";
|
||||
import type { IIssueRootStore } from "./issue/root.store";
|
||||
import { IssueRootStore } from "./issue/root.store";
|
||||
import type { ILabelStore } from "./label.store";
|
||||
import { LabelStore } from "./label.store";
|
||||
import type { IMemberRootStore } from "./member";
|
||||
import { MemberRootStore } from "./member";
|
||||
import type { IModuleStore } from "./module.store";
|
||||
import { ModulesStore } from "./module.store";
|
||||
import type { IModuleFilterStore } from "./module_filter.store";
|
||||
import { ModuleFilterStore } from "./module_filter.store";
|
||||
import type { IMultipleSelectStore } from "./multiple_select.store";
|
||||
import { MultipleSelectStore } from "./multiple_select.store";
|
||||
import type { IWorkspaceNotificationStore } from "./notifications/workspace-notifications.store";
|
||||
import { WorkspaceNotificationStore } from "./notifications/workspace-notifications.store";
|
||||
import type { IProjectPageStore } from "./pages/project-page.store";
|
||||
import { ProjectPageStore } from "./pages/project-page.store";
|
||||
import type { IProjectRootStore } from "./project";
|
||||
import { ProjectRootStore } from "./project";
|
||||
import type { IProjectViewStore } from "./project-view.store";
|
||||
import { ProjectViewStore } from "./project-view.store";
|
||||
import type { IRouterStore } from "./router.store";
|
||||
import { RouterStore } from "./router.store";
|
||||
import type { IStickyStore } from "./sticky/sticky.store";
|
||||
import { StickyStore } from "./sticky/sticky.store";
|
||||
import type { IThemeStore } from "./theme.store";
|
||||
import { ThemeStore } from "./theme.store";
|
||||
import type { ITransientStore } from "./transient.store";
|
||||
import { TransientStore } from "./transient.store";
|
||||
import type { IUserStore } from "./user";
|
||||
import { UserStore } from "./user";
|
||||
import type { IWorkspaceRootStore } from "./workspace";
|
||||
import { WorkspaceRootStore } from "./workspace";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class CoreRootStore {
|
||||
workspaceRoot: IWorkspaceRootStore;
|
||||
projectRoot: IProjectRootStore;
|
||||
memberRoot: IMemberRootStore;
|
||||
cycle: ICycleStore;
|
||||
cycleFilter: ICycleFilterStore;
|
||||
module: IModuleStore;
|
||||
moduleFilter: IModuleFilterStore;
|
||||
projectView: IProjectViewStore;
|
||||
globalView: IGlobalViewStore;
|
||||
issue: IIssueRootStore;
|
||||
state: IStateStore;
|
||||
label: ILabelStore;
|
||||
dashboard: IDashboardStore;
|
||||
analytics: IAnalyticsStore;
|
||||
projectPages: IProjectPageStore;
|
||||
router: IRouterStore;
|
||||
commandPalette: ICommandPaletteStore;
|
||||
theme: IThemeStore;
|
||||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
projectInbox: IProjectInboxStore;
|
||||
projectEstimate: IProjectEstimateStore;
|
||||
multipleSelect: IMultipleSelectStore;
|
||||
workspaceNotification: IWorkspaceNotificationStore;
|
||||
favorite: IFavoriteStore;
|
||||
transient: ITransientStore;
|
||||
stickyStore: IStickyStore;
|
||||
editorAssetStore: IEditorAssetStore;
|
||||
workItemFilters: IWorkItemFilterStore;
|
||||
|
||||
constructor() {
|
||||
this.router = new RouterStore();
|
||||
this.commandPalette = new CommandPaletteStore();
|
||||
this.instance = new InstanceStore();
|
||||
this.user = new UserStore(this as unknown as RootStore);
|
||||
this.theme = new ThemeStore();
|
||||
this.workspaceRoot = new WorkspaceRootStore(this);
|
||||
this.projectRoot = new ProjectRootStore(this);
|
||||
this.memberRoot = new MemberRootStore(this as unknown as RootStore);
|
||||
this.cycle = new CycleStore(this);
|
||||
this.cycleFilter = new CycleFilterStore(this);
|
||||
this.module = new ModulesStore(this);
|
||||
this.moduleFilter = new ModuleFilterStore(this);
|
||||
this.projectView = new ProjectViewStore(this);
|
||||
this.globalView = new GlobalViewStore(this);
|
||||
this.issue = new IssueRootStore(this as unknown as RootStore);
|
||||
this.state = new StateStore(this as unknown as RootStore);
|
||||
this.label = new LabelStore(this);
|
||||
this.dashboard = new DashboardStore(this);
|
||||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectInbox = new ProjectInboxStore(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);
|
||||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
this.editorAssetStore = new EditorAssetStore();
|
||||
this.analytics = new AnalyticsStore();
|
||||
this.workItemFilters = new WorkItemFilterStore();
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
// handling the system theme when user logged out from the app
|
||||
localStorage.setItem("theme", "system");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, FALLBACK_LANGUAGE);
|
||||
this.router = new RouterStore();
|
||||
this.commandPalette = new CommandPaletteStore();
|
||||
this.instance = new InstanceStore();
|
||||
this.user = new UserStore(this as unknown as RootStore);
|
||||
this.workspaceRoot = new WorkspaceRootStore(this);
|
||||
this.projectRoot = new ProjectRootStore(this);
|
||||
this.memberRoot = new MemberRootStore(this as unknown as RootStore);
|
||||
this.cycle = new CycleStore(this);
|
||||
this.cycleFilter = new CycleFilterStore(this);
|
||||
this.module = new ModulesStore(this);
|
||||
this.moduleFilter = new ModuleFilterStore(this);
|
||||
this.projectView = new ProjectViewStore(this);
|
||||
this.globalView = new GlobalViewStore(this);
|
||||
this.issue = new IssueRootStore(this as unknown as RootStore);
|
||||
this.state = new StateStore(this as unknown as RootStore);
|
||||
this.label = new LabelStore(this);
|
||||
this.dashboard = new DashboardStore(this);
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
|
||||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
this.workspaceNotification = new WorkspaceNotificationStore(this);
|
||||
this.favorite = new FavoriteStore(this);
|
||||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
this.editorAssetStore = new EditorAssetStore();
|
||||
this.workItemFilters = new WorkItemFilterStore();
|
||||
}
|
||||
}
|
||||
176
apps/web/core/store/router.store.ts
Normal file
176
apps/web/core/store/router.store.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { ParsedUrlQuery } from "node:querystring";
|
||||
import { action, makeObservable, observable, computed, runInAction } from "mobx";
|
||||
|
||||
import type { TProfileViews } from "@plane/types";
|
||||
export interface IRouterStore {
|
||||
// observables
|
||||
query: ParsedUrlQuery;
|
||||
// actions
|
||||
setQuery: (query: ParsedUrlQuery) => void;
|
||||
// computed
|
||||
workspaceSlug: string | undefined;
|
||||
teamspaceId: string | undefined;
|
||||
projectId: string | undefined;
|
||||
cycleId: string | undefined;
|
||||
moduleId: string | undefined;
|
||||
viewId: string | undefined;
|
||||
globalViewId: string | undefined;
|
||||
profileViewId: TProfileViews | undefined;
|
||||
userId: string | undefined;
|
||||
peekId: string | undefined;
|
||||
issueId: string | undefined;
|
||||
inboxId: string | undefined;
|
||||
webhookId: string | undefined;
|
||||
epicId: string | undefined;
|
||||
}
|
||||
|
||||
export class RouterStore implements IRouterStore {
|
||||
// observables
|
||||
query: ParsedUrlQuery = {};
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
query: observable,
|
||||
// actions
|
||||
setQuery: action.bound,
|
||||
//computed
|
||||
workspaceSlug: computed,
|
||||
teamspaceId: computed,
|
||||
projectId: computed,
|
||||
cycleId: computed,
|
||||
moduleId: computed,
|
||||
viewId: computed,
|
||||
globalViewId: computed,
|
||||
profileViewId: computed,
|
||||
userId: computed,
|
||||
peekId: computed,
|
||||
issueId: computed,
|
||||
inboxId: computed,
|
||||
webhookId: computed,
|
||||
epicId: computed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the query
|
||||
* @param query
|
||||
*/
|
||||
setQuery = (query: ParsedUrlQuery) => {
|
||||
runInAction(() => {
|
||||
this.query = query;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the workspace slug from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get workspaceSlug() {
|
||||
return this.query?.workspaceSlug?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the teamspace id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get teamspaceId() {
|
||||
return this.query?.teamspaceId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the project id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get projectId() {
|
||||
return this.query?.projectId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the module id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get moduleId() {
|
||||
return this.query?.moduleId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cycle id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get cycleId() {
|
||||
return this.query?.cycleId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the view id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get viewId() {
|
||||
return this.query?.viewId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global view id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get globalViewId() {
|
||||
return this.query?.globalViewId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the profile view id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get profileViewId() {
|
||||
return this.query?.profileViewId?.toString() as TProfileViews;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get userId() {
|
||||
return this.query?.userId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the peek id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get peekId() {
|
||||
return this.query?.peekId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issue id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get issueId() {
|
||||
return this.query?.issueId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the inbox id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get inboxId() {
|
||||
return this.query?.inboxId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the webhook id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get webhookId() {
|
||||
return this.query?.webhookId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the epic id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get epicId() {
|
||||
return this.query?.epicId?.toString();
|
||||
}
|
||||
}
|
||||
330
apps/web/core/store/state.store.ts
Normal file
330
apps/web/core/store/state.store.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { set, groupBy } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import { STATE_GROUPS } from "@plane/constants";
|
||||
import type { IState } from "@plane/types";
|
||||
// helpers
|
||||
import { sortStates } from "@plane/utils";
|
||||
// plane web
|
||||
import { syncIssuesWithDeletedStates } from "@/local-db/utils/load-workspace";
|
||||
import { ProjectStateService } from "@/plane-web/services/project/project-state.service";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
|
||||
export interface IStateStore {
|
||||
//Loaders
|
||||
fetchedMap: Record<string, boolean>;
|
||||
// observables
|
||||
stateMap: Record<string, IState>;
|
||||
// computed
|
||||
workspaceStates: IState[] | undefined;
|
||||
projectStates: IState[] | undefined;
|
||||
groupedProjectStates: Record<string, IState[]> | undefined;
|
||||
// computed actions
|
||||
getStateById: (stateId: string | null | undefined) => IState | undefined;
|
||||
getProjectStates: (projectId: string | null | undefined) => IState[] | undefined;
|
||||
getProjectStateIds: (projectId: string | null | undefined) => string[] | undefined;
|
||||
getProjectDefaultStateId: (projectId: string | null | undefined) => string | undefined;
|
||||
// fetch actions
|
||||
fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<IState[]>;
|
||||
fetchWorkspaceStates: (workspaceSlug: string) => Promise<IState[]>;
|
||||
// crud actions
|
||||
createState: (workspaceSlug: string, projectId: string, data: Partial<IState>) => Promise<IState>;
|
||||
updateState: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
stateId: string,
|
||||
data: Partial<IState>
|
||||
) => Promise<IState | undefined>;
|
||||
deleteState: (workspaceSlug: string, projectId: string, stateId: string) => Promise<void>;
|
||||
markStateAsDefault: (workspaceSlug: string, projectId: string, stateId: string) => Promise<void>;
|
||||
moveStatePosition: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
stateId: string,
|
||||
payload: Partial<IState>
|
||||
) => Promise<void>;
|
||||
|
||||
getStatePercentageInGroup: (stateId: string | null | undefined) => number | undefined;
|
||||
}
|
||||
|
||||
export class StateStore implements IStateStore {
|
||||
stateMap: Record<string, IState> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
rootStore: RootStore;
|
||||
router;
|
||||
stateService: ProjectStateService;
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
stateMap: observable,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
projectStates: computed,
|
||||
groupedProjectStates: computed,
|
||||
// fetch action
|
||||
fetchProjectStates: action,
|
||||
// CRUD actions
|
||||
createState: action,
|
||||
updateState: action,
|
||||
deleteState: action,
|
||||
// state actions
|
||||
markStateAsDefault: action,
|
||||
moveStatePosition: action,
|
||||
});
|
||||
this.stateService = new ProjectStateService();
|
||||
this.router = _rootStore.router;
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stateMap belongs to a specific workspace
|
||||
*/
|
||||
get workspaceStates() {
|
||||
const workspaceSlug = this.router.workspaceSlug || "";
|
||||
if (!workspaceSlug || !this.fetchedMap[workspaceSlug]) return;
|
||||
return sortStates(Object.values(this.stateMap));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stateMap belongs to a specific project
|
||||
*/
|
||||
get projectStates() {
|
||||
const projectId = this.router.projectId;
|
||||
const workspaceSlug = this.router.workspaceSlug || "";
|
||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
||||
return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stateMap belongs to a specific project grouped by group
|
||||
*/
|
||||
get groupedProjectStates() {
|
||||
if (!this.router.projectId) return;
|
||||
|
||||
// First group the existing states
|
||||
const groupedStates = groupBy(this.projectStates, "group") as Record<string, IState[]>;
|
||||
|
||||
// Ensure all STATE_GROUPS are present
|
||||
const allGroups = Object.keys(STATE_GROUPS).reduce(
|
||||
(acc, group) => ({
|
||||
...acc,
|
||||
[group]: groupedStates[group] || [],
|
||||
}),
|
||||
{} as Record<string, IState[]>
|
||||
);
|
||||
|
||||
return allGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns state details using state id
|
||||
* @param stateId
|
||||
*/
|
||||
getStateById = computedFn((stateId: string | null | undefined) => {
|
||||
if (!this.stateMap || !stateId) return;
|
||||
return this.stateMap[stateId] ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the stateMap belongs to a project by projectId
|
||||
* @param projectId
|
||||
* @returns IState[]
|
||||
*/
|
||||
getProjectStates = computedFn((projectId: string | null | undefined) => {
|
||||
const workspaceSlug = this.router.workspaceSlug || "";
|
||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
||||
return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId));
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the state ids for a project by projectId
|
||||
* @param projectId
|
||||
* @returns string[]
|
||||
*/
|
||||
getProjectStateIds = computedFn((projectId: string | null | undefined) => {
|
||||
const workspaceSlug = this.router.workspaceSlug;
|
||||
if (!workspaceSlug || !projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug]))
|
||||
return undefined;
|
||||
const projectStates = this.getProjectStates(projectId);
|
||||
return projectStates?.map((state) => state.id) ?? [];
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the default state id for a project
|
||||
* @param projectId
|
||||
* @returns string | undefined
|
||||
*/
|
||||
getProjectDefaultStateId = computedFn((projectId: string | null | undefined) => {
|
||||
const projectStates = this.getProjectStates(projectId);
|
||||
return projectStates?.find((state) => state.default)?.id;
|
||||
});
|
||||
|
||||
/**
|
||||
* fetches the stateMap of a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @returns
|
||||
*/
|
||||
fetchProjectStates = async (workspaceSlug: string, projectId: string) => {
|
||||
const statesResponse = await this.stateService.getStates(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
statesResponse.forEach((state) => {
|
||||
set(this.stateMap, [state.id], state);
|
||||
});
|
||||
set(this.fetchedMap, projectId, true);
|
||||
});
|
||||
return statesResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetches the stateMap of all the states in workspace
|
||||
* @param workspaceSlug
|
||||
* @returns
|
||||
*/
|
||||
fetchWorkspaceStates = async (workspaceSlug: string) => {
|
||||
const statesResponse = await this.stateService.getWorkspaceStates(workspaceSlug);
|
||||
runInAction(() => {
|
||||
statesResponse.forEach((state) => {
|
||||
set(this.stateMap, [state.id], state);
|
||||
});
|
||||
set(this.fetchedMap, workspaceSlug, true);
|
||||
});
|
||||
return statesResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* creates a new state in a project and adds it to the store
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
createState = async (workspaceSlug: string, projectId: string, data: Partial<IState>) =>
|
||||
await this.stateService.createState(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.stateMap, [response?.id], response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates the state details in the store, in case of failure reverts back to original state
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param stateId
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
updateState = async (workspaceSlug: string, projectId: string, stateId: string, data: Partial<IState>) => {
|
||||
const originalState = this.stateMap[stateId];
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.stateMap, [stateId], { ...this.stateMap?.[stateId], ...data });
|
||||
});
|
||||
const response = await this.stateService.patchState(workspaceSlug, projectId, stateId, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.stateMap = {
|
||||
...this.stateMap,
|
||||
[stateId]: originalState,
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* deletes the state from the store, in case of failure reverts back to original state
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param stateId
|
||||
*/
|
||||
deleteState = async (workspaceSlug: string, projectId: string, stateId: string) => {
|
||||
if (!this.stateMap?.[stateId]) return;
|
||||
await this.stateService.deleteState(workspaceSlug, projectId, stateId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.stateMap[stateId];
|
||||
syncIssuesWithDeletedStates([stateId]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* marks a state as default in a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param stateId
|
||||
*/
|
||||
markStateAsDefault = async (workspaceSlug: string, projectId: string, stateId: string) => {
|
||||
const originalStates = this.stateMap;
|
||||
const currentDefaultState = Object.values(this.stateMap).find(
|
||||
(state) => state.project_id === projectId && state.default
|
||||
);
|
||||
try {
|
||||
runInAction(() => {
|
||||
if (currentDefaultState) set(this.stateMap, [currentDefaultState.id, "default"], false);
|
||||
set(this.stateMap, [stateId, "default"], true);
|
||||
});
|
||||
await this.stateService.markDefault(workspaceSlug, projectId, stateId);
|
||||
} catch (error) {
|
||||
// reverting back to old state group if api fails
|
||||
runInAction(() => {
|
||||
this.stateMap = originalStates;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* updates the sort order of a state and updates the state information using API, in case of failure reverts back to original state
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param stateId
|
||||
* @param direction
|
||||
* @param groupIndex
|
||||
*/
|
||||
moveStatePosition = async (workspaceSlug: string, projectId: string, stateId: string, payload: Partial<IState>) => {
|
||||
const originalStates = this.stateMap;
|
||||
try {
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
runInAction(() => {
|
||||
set(this.stateMap, [stateId, key], value);
|
||||
});
|
||||
});
|
||||
// updating using api
|
||||
await this.stateService.patchState(workspaceSlug, projectId, stateId, payload);
|
||||
} catch {
|
||||
// reverting back to old state group if api fails
|
||||
runInAction(() => {
|
||||
this.stateMap = originalStates;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the percentage position of a state within its group based on sequence
|
||||
* @param stateId The ID of the state to find the percentage for
|
||||
* @returns The percentage position of the state in its group (0-100), or -1 if not found
|
||||
*/
|
||||
getStatePercentageInGroup = computedFn((stateId: string | null | undefined) => {
|
||||
if (!stateId || !this.stateMap[stateId]) return -1;
|
||||
|
||||
const state = this.stateMap[stateId];
|
||||
const group = state.group;
|
||||
|
||||
if (!group || !this.groupedProjectStates || !this.groupedProjectStates[group]) return -1;
|
||||
|
||||
// Get all states in the same group
|
||||
const statesInGroup = this.groupedProjectStates[group];
|
||||
const stateIndex = statesInGroup.findIndex((s) => s.id === stateId);
|
||||
|
||||
if (stateIndex === -1) return undefined;
|
||||
|
||||
// Calculate percentage: ((index + 1) / totalLength) * 100
|
||||
return ((stateIndex + 1) / statesInGroup.length) * 100;
|
||||
});
|
||||
}
|
||||
271
apps/web/core/store/sticky/sticky.store.ts
Normal file
271
apps/web/core/store/sticky/sticky.store.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { orderBy, set } from "lodash-es";
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { STICKIES_PER_PAGE } from "@plane/constants";
|
||||
import type { InstructionType, TLoader, TPaginationInfo, TSticky } from "@plane/types";
|
||||
import { StickyService } from "@/services/sticky.service";
|
||||
|
||||
export interface IStickyStore {
|
||||
creatingSticky: boolean;
|
||||
loader: TLoader;
|
||||
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
|
||||
stickies: Record<string, TSticky>; // stickyId -> sticky
|
||||
searchQuery: string;
|
||||
activeStickyId: string | undefined;
|
||||
recentStickyId: string | undefined;
|
||||
showAddNewSticky: boolean;
|
||||
paginationInfo: TPaginationInfo | undefined;
|
||||
// computed
|
||||
getWorkspaceStickyIds: (workspaceSlug: string) => string[];
|
||||
// actions
|
||||
toggleShowNewSticky: (value: boolean) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
fetchWorkspaceStickies: (workspaceSlug: string) => void;
|
||||
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => Promise<void>;
|
||||
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => Promise<void>;
|
||||
deleteSticky: (workspaceSlug: string, id: string) => Promise<void>;
|
||||
updateActiveStickyId: (id: string | undefined) => void;
|
||||
fetchRecentSticky: (workspaceSlug: string) => Promise<void>;
|
||||
fetchNextWorkspaceStickies: (workspaceSlug: string) => Promise<void>;
|
||||
updateStickyPosition: (
|
||||
workspaceSlug: string,
|
||||
stickyId: string,
|
||||
destinationId: string,
|
||||
edge: InstructionType
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class StickyStore implements IStickyStore {
|
||||
loader: TLoader = "init-loader";
|
||||
creatingSticky = false;
|
||||
workspaceStickies: Record<string, string[]> = {};
|
||||
stickies: Record<string, TSticky> = {};
|
||||
recentStickyId: string | undefined = undefined;
|
||||
searchQuery = "";
|
||||
activeStickyId: string | undefined = undefined;
|
||||
showAddNewSticky = false;
|
||||
paginationInfo: TPaginationInfo | undefined = undefined;
|
||||
|
||||
// services
|
||||
stickyService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
creatingSticky: observable,
|
||||
loader: observable,
|
||||
activeStickyId: observable,
|
||||
showAddNewSticky: observable,
|
||||
recentStickyId: observable,
|
||||
workspaceStickies: observable,
|
||||
stickies: observable,
|
||||
searchQuery: observable,
|
||||
// actions
|
||||
updateSearchQuery: action,
|
||||
updateSticky: action,
|
||||
deleteSticky: action,
|
||||
fetchNextWorkspaceStickies: action,
|
||||
fetchWorkspaceStickies: action,
|
||||
createSticky: action,
|
||||
updateActiveStickyId: action,
|
||||
toggleShowNewSticky: action,
|
||||
fetchRecentSticky: action,
|
||||
updateStickyPosition: action,
|
||||
});
|
||||
this.stickyService = new StickyService();
|
||||
}
|
||||
|
||||
getWorkspaceStickyIds = computedFn((workspaceSlug: string) =>
|
||||
orderBy(
|
||||
(this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]),
|
||||
["sort_order"],
|
||||
["desc"]
|
||||
).map((sticky) => sticky.id)
|
||||
);
|
||||
|
||||
toggleShowNewSticky = (value: boolean) => {
|
||||
this.showAddNewSticky = value;
|
||||
};
|
||||
|
||||
updateSearchQuery = (query: string) => {
|
||||
this.searchQuery = query;
|
||||
};
|
||||
|
||||
updateActiveStickyId = (id: string | undefined) => {
|
||||
this.activeStickyId = id;
|
||||
};
|
||||
|
||||
fetchRecentSticky = async (workspaceSlug: string) => {
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", undefined, 1);
|
||||
runInAction(() => {
|
||||
this.recentStickyId = response.results[0]?.id;
|
||||
this.stickies[response.results[0]?.id] = response.results[0];
|
||||
});
|
||||
};
|
||||
fetchNextWorkspaceStickies = async (workspaceSlug: string) => {
|
||||
try {
|
||||
if (!this.paginationInfo?.next_cursor || !this.paginationInfo.next_page_results || this.loader === "pagination") {
|
||||
return;
|
||||
}
|
||||
this.loader = "pagination";
|
||||
const response = await this.stickyService.getStickies(
|
||||
workspaceSlug,
|
||||
this.paginationInfo.next_cursor,
|
||||
this.searchQuery
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = response;
|
||||
|
||||
// Add new stickies to store
|
||||
results.forEach((sticky) => {
|
||||
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
|
||||
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
|
||||
}
|
||||
this.stickies[sticky.id] = sticky;
|
||||
});
|
||||
|
||||
// Update pagination info directly from backend
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
set(this, "loader", "loaded");
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
runInAction(() => {
|
||||
this.loader = "loaded";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchWorkspaceStickies = async (workspaceSlug: string) => {
|
||||
try {
|
||||
if (this.workspaceStickies[workspaceSlug]) {
|
||||
this.loader = "mutation";
|
||||
} else {
|
||||
this.loader = "init-loader";
|
||||
}
|
||||
|
||||
const response = await this.stickyService.getStickies(
|
||||
workspaceSlug,
|
||||
`${STICKIES_PER_PAGE}:0:0`,
|
||||
this.searchQuery
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = response;
|
||||
|
||||
results.forEach((sticky) => {
|
||||
this.stickies[sticky.id] = sticky;
|
||||
});
|
||||
this.workspaceStickies[workspaceSlug] = results.map((sticky) => sticky.id);
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
this.loader = "loaded";
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
runInAction(() => {
|
||||
this.loader = "loaded";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
createSticky = async (workspaceSlug: string, sticky: Partial<TSticky>) => {
|
||||
if (!this.showAddNewSticky) return;
|
||||
this.showAddNewSticky = false;
|
||||
this.creatingSticky = true;
|
||||
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
|
||||
const response = await this.stickyService.createSticky(workspaceSlug, sticky);
|
||||
runInAction(() => {
|
||||
this.stickies[response.id] = response;
|
||||
this.workspaceStickies[workspaceSlug] = [response.id, ...workspaceStickies];
|
||||
this.activeStickyId = response.id;
|
||||
this.recentStickyId = response.id;
|
||||
this.creatingSticky = false;
|
||||
});
|
||||
};
|
||||
|
||||
updateSticky = async (workspaceSlug: string, id: string, updates: Partial<TSticky>) => {
|
||||
const sticky = this.stickies[id];
|
||||
if (!sticky) return;
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(updates).forEach((key) => {
|
||||
const currentStickyKey = key as keyof TSticky;
|
||||
set(this.stickies[id], key, updates[currentStickyKey] || undefined);
|
||||
});
|
||||
});
|
||||
this.recentStickyId = id;
|
||||
await this.stickyService.updateSticky(workspaceSlug, id, updates);
|
||||
} catch (error) {
|
||||
console.error("Error in updating sticky:", error);
|
||||
this.stickies[id] = sticky;
|
||||
throw new Error();
|
||||
}
|
||||
};
|
||||
|
||||
deleteSticky = async (workspaceSlug: string, id: string) => {
|
||||
const sticky = this.stickies[id];
|
||||
if (!sticky) return;
|
||||
try {
|
||||
this.workspaceStickies[workspaceSlug] = this.workspaceStickies[workspaceSlug].filter(
|
||||
(stickyId) => stickyId !== id
|
||||
);
|
||||
if (this.activeStickyId === id) this.activeStickyId = undefined;
|
||||
delete this.stickies[id];
|
||||
this.recentStickyId = this.workspaceStickies[workspaceSlug][0];
|
||||
await this.stickyService.deleteSticky(workspaceSlug, id);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.stickies[id] = sticky;
|
||||
}
|
||||
};
|
||||
|
||||
updateStickyPosition = async (
|
||||
workspaceSlug: string,
|
||||
stickyId: string,
|
||||
destinationId: string,
|
||||
edge: InstructionType
|
||||
) => {
|
||||
const previousSortOrder = this.stickies[stickyId].sort_order;
|
||||
try {
|
||||
let resultSequence = 10000;
|
||||
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
|
||||
const stickies = workspaceStickies.map((id) => this.stickies[id]);
|
||||
const sortedStickies = orderBy(stickies, "sort_order", "desc").map((sticky) => sticky.id);
|
||||
const destinationSequence = this.stickies[destinationId]?.sort_order || undefined;
|
||||
|
||||
if (destinationSequence) {
|
||||
const destinationIndex = sortedStickies.findIndex((id) => id === destinationId);
|
||||
|
||||
if (edge === "reorder-above") {
|
||||
const prevSequence = this.stickies[sortedStickies[destinationIndex - 1]]?.sort_order || undefined;
|
||||
if (prevSequence) {
|
||||
resultSequence = (destinationSequence + prevSequence) / 2;
|
||||
} else {
|
||||
resultSequence = destinationSequence + resultSequence;
|
||||
}
|
||||
} else {
|
||||
// reorder-below
|
||||
resultSequence = destinationSequence - resultSequence;
|
||||
}
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.stickies[stickyId] = {
|
||||
...this.stickies[stickyId],
|
||||
sort_order: resultSequence,
|
||||
};
|
||||
});
|
||||
|
||||
await this.stickyService.updateSticky(workspaceSlug, stickyId, {
|
||||
sort_order: resultSequence,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move sticky");
|
||||
runInAction(() => {
|
||||
this.stickies[stickyId].sort_order = previousSortOrder;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
192
apps/web/core/store/theme.store.ts
Normal file
192
apps/web/core/store/theme.store.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { action, observable, makeObservable, runInAction } from "mobx";
|
||||
|
||||
export interface IThemeStore {
|
||||
// observables
|
||||
isAnySidebarDropdownOpen: boolean | undefined;
|
||||
sidebarCollapsed: boolean | undefined;
|
||||
sidebarPeek: boolean | undefined;
|
||||
isExtendedSidebarOpened: boolean | undefined;
|
||||
isExtendedProjectSidebarOpened: boolean | undefined;
|
||||
profileSidebarCollapsed: boolean | undefined;
|
||||
workspaceAnalyticsSidebarCollapsed: boolean | undefined;
|
||||
issueDetailSidebarCollapsed: boolean | undefined;
|
||||
epicDetailSidebarCollapsed: boolean | undefined;
|
||||
initiativesSidebarCollapsed: boolean | undefined;
|
||||
projectOverviewSidebarCollapsed: boolean | undefined;
|
||||
// actions
|
||||
toggleAnySidebarDropdown: (open?: boolean) => void;
|
||||
toggleSidebar: (collapsed?: boolean) => void;
|
||||
toggleSidebarPeek: (peek?: boolean) => void;
|
||||
toggleExtendedSidebar: (collapsed?: boolean) => void;
|
||||
toggleExtendedProjectSidebar: (collapsed?: boolean) => void;
|
||||
toggleProfileSidebar: (collapsed?: boolean) => void;
|
||||
toggleWorkspaceAnalyticsSidebar: (collapsed?: boolean) => void;
|
||||
toggleIssueDetailSidebar: (collapsed?: boolean) => void;
|
||||
toggleEpicDetailSidebar: (collapsed?: boolean) => void;
|
||||
toggleInitiativesSidebar: (collapsed?: boolean) => void;
|
||||
toggleProjectOverviewSidebar: (collapsed?: boolean) => void;
|
||||
}
|
||||
|
||||
export class ThemeStore implements IThemeStore {
|
||||
// observables
|
||||
isAnySidebarDropdownOpen: boolean | undefined = undefined;
|
||||
sidebarCollapsed: boolean | undefined = undefined;
|
||||
sidebarPeek: boolean | undefined = undefined;
|
||||
isExtendedSidebarOpened: boolean | undefined = undefined;
|
||||
isExtendedProjectSidebarOpened: boolean | undefined = undefined;
|
||||
profileSidebarCollapsed: boolean | undefined = undefined;
|
||||
workspaceAnalyticsSidebarCollapsed: boolean | undefined = undefined;
|
||||
issueDetailSidebarCollapsed: boolean | undefined = undefined;
|
||||
epicDetailSidebarCollapsed: boolean | undefined = undefined;
|
||||
initiativesSidebarCollapsed: boolean | undefined = undefined;
|
||||
projectOverviewSidebarCollapsed: boolean | undefined = undefined;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isAnySidebarDropdownOpen: observable.ref,
|
||||
sidebarCollapsed: observable.ref,
|
||||
sidebarPeek: observable.ref,
|
||||
isExtendedSidebarOpened: observable.ref,
|
||||
isExtendedProjectSidebarOpened: observable.ref,
|
||||
profileSidebarCollapsed: observable.ref,
|
||||
workspaceAnalyticsSidebarCollapsed: observable.ref,
|
||||
issueDetailSidebarCollapsed: observable.ref,
|
||||
epicDetailSidebarCollapsed: observable.ref,
|
||||
initiativesSidebarCollapsed: observable.ref,
|
||||
projectOverviewSidebarCollapsed: observable.ref,
|
||||
// action
|
||||
toggleAnySidebarDropdown: action,
|
||||
toggleSidebar: action,
|
||||
toggleSidebarPeek: action,
|
||||
toggleExtendedSidebar: action,
|
||||
toggleExtendedProjectSidebar: action,
|
||||
toggleProfileSidebar: action,
|
||||
toggleWorkspaceAnalyticsSidebar: action,
|
||||
toggleIssueDetailSidebar: action,
|
||||
toggleEpicDetailSidebar: action,
|
||||
toggleInitiativesSidebar: action,
|
||||
toggleProjectOverviewSidebar: action,
|
||||
});
|
||||
}
|
||||
|
||||
toggleAnySidebarDropdown = (open?: boolean) => {
|
||||
if (open === undefined) {
|
||||
this.isAnySidebarDropdownOpen = !this.isAnySidebarDropdownOpen;
|
||||
} else {
|
||||
this.isAnySidebarDropdownOpen = open;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the sidebar collapsed state
|
||||
* @param collapsed
|
||||
*/
|
||||
toggleSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
} else {
|
||||
this.sidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("app_sidebar_collapsed", this.sidebarCollapsed.toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the sidebar peek state
|
||||
* @param peek
|
||||
*/
|
||||
toggleSidebarPeek = (peek?: boolean) => {
|
||||
if (peek === undefined) {
|
||||
this.sidebarPeek = !this.sidebarPeek;
|
||||
} else {
|
||||
this.sidebarPeek = peek;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the extended sidebar collapsed state
|
||||
* @param collapsed
|
||||
*/
|
||||
toggleExtendedSidebar = (collapsed?: boolean) => {
|
||||
const updatedState = collapsed ?? !this.isExtendedSidebarOpened;
|
||||
runInAction(() => {
|
||||
this.isExtendedSidebarOpened = updatedState;
|
||||
});
|
||||
localStorage.setItem("extended_sidebar_collapsed", updatedState.toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the extended project sidebar collapsed state
|
||||
* @param collapsed
|
||||
*/
|
||||
toggleExtendedProjectSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.isExtendedProjectSidebarOpened = !this.isExtendedProjectSidebarOpened;
|
||||
} else {
|
||||
this.isExtendedProjectSidebarOpened = collapsed;
|
||||
}
|
||||
localStorage.setItem("extended_project_sidebar_collapsed", this.isExtendedProjectSidebarOpened.toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the profile sidebar collapsed state
|
||||
* @param collapsed
|
||||
*/
|
||||
toggleProfileSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.profileSidebarCollapsed = !this.profileSidebarCollapsed;
|
||||
} else {
|
||||
this.profileSidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("profile_sidebar_collapsed", this.profileSidebarCollapsed.toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the profile sidebar collapsed state
|
||||
* @param collapsed
|
||||
*/
|
||||
toggleWorkspaceAnalyticsSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.workspaceAnalyticsSidebarCollapsed = !this.workspaceAnalyticsSidebarCollapsed;
|
||||
} else {
|
||||
this.workspaceAnalyticsSidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("workspace_analytics_sidebar_collapsed", this.workspaceAnalyticsSidebarCollapsed.toString());
|
||||
};
|
||||
|
||||
toggleIssueDetailSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.issueDetailSidebarCollapsed = !this.issueDetailSidebarCollapsed;
|
||||
} else {
|
||||
this.issueDetailSidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("issue_detail_sidebar_collapsed", this.issueDetailSidebarCollapsed.toString());
|
||||
};
|
||||
|
||||
toggleEpicDetailSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.epicDetailSidebarCollapsed = !this.epicDetailSidebarCollapsed;
|
||||
} else {
|
||||
this.epicDetailSidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("epic_detail_sidebar_collapsed", this.epicDetailSidebarCollapsed.toString());
|
||||
};
|
||||
|
||||
toggleInitiativesSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.initiativesSidebarCollapsed = !this.initiativesSidebarCollapsed;
|
||||
} else {
|
||||
this.initiativesSidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("initiatives_sidebar_collapsed", this.initiativesSidebarCollapsed.toString());
|
||||
};
|
||||
|
||||
toggleProjectOverviewSidebar = (collapsed?: boolean) => {
|
||||
if (collapsed === undefined) {
|
||||
this.projectOverviewSidebarCollapsed = !this.projectOverviewSidebarCollapsed;
|
||||
} else {
|
||||
this.projectOverviewSidebarCollapsed = collapsed;
|
||||
}
|
||||
localStorage.setItem("project_overview_sidebar_collapsed", this.projectOverviewSidebarCollapsed.toString());
|
||||
};
|
||||
}
|
||||
20
apps/web/core/store/timeline/issues-timeline.store.ts
Normal file
20
apps/web/core/store/timeline/issues-timeline.store.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { autorun } from "mobx";
|
||||
// Plane-web
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import type { IBaseTimelineStore } from "@/plane-web/store/timeline/base-timeline.store";
|
||||
import { BaseTimeLineStore } from "@/plane-web/store/timeline/base-timeline.store";
|
||||
|
||||
export interface IIssuesTimeLineStore extends IBaseTimelineStore {
|
||||
isDependencyEnabled: boolean;
|
||||
}
|
||||
|
||||
export class IssuesTimeLineStore extends BaseTimeLineStore implements IIssuesTimeLineStore {
|
||||
constructor(_rootStore: RootStore) {
|
||||
super(_rootStore);
|
||||
|
||||
autorun(() => {
|
||||
const getIssueById = this.rootStore.issue.issues.getIssueById;
|
||||
this.updateBlocks(getIssueById);
|
||||
});
|
||||
}
|
||||
}
|
||||
20
apps/web/core/store/timeline/modules-timeline.store.ts
Normal file
20
apps/web/core/store/timeline/modules-timeline.store.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { autorun } from "mobx";
|
||||
// Store
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import { BaseTimeLineStore } from "@/plane-web/store/timeline/base-timeline.store";
|
||||
import type { IBaseTimelineStore } from "@/plane-web/store/timeline/base-timeline.store";
|
||||
|
||||
export interface IModulesTimeLineStore extends IBaseTimelineStore {
|
||||
isDependencyEnabled: boolean;
|
||||
}
|
||||
|
||||
export class ModulesTimeLineStore extends BaseTimeLineStore implements IModulesTimeLineStore {
|
||||
constructor(_rootStore: RootStore) {
|
||||
super(_rootStore);
|
||||
|
||||
autorun(() => {
|
||||
const getModuleById = this.rootStore.module.getModuleById;
|
||||
this.updateBlocks(getModuleById);
|
||||
});
|
||||
}
|
||||
}
|
||||
28
apps/web/core/store/transient.store.ts
Normal file
28
apps/web/core/store/transient.store.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { action, observable, makeObservable } from "mobx";
|
||||
|
||||
export interface ITransientStore {
|
||||
// observables
|
||||
isIntercomToggle: boolean;
|
||||
// actions
|
||||
toggleIntercom: (intercomToggle: boolean) => void;
|
||||
}
|
||||
|
||||
export class TransientStore implements ITransientStore {
|
||||
// observables
|
||||
isIntercomToggle: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isIntercomToggle: observable.ref,
|
||||
// action
|
||||
toggleIntercom: action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Toggle the intercom collapsed state
|
||||
* @param { boolean } intercomToggle
|
||||
*/
|
||||
toggleIntercom = (intercomToggle: boolean) => (this.isIntercomToggle = intercomToggle);
|
||||
}
|
||||
46
apps/web/core/store/user/account.store.ts
Normal file
46
apps/web/core/store/user/account.store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { set } from "lodash-es";
|
||||
import { makeObservable, observable } from "mobx";
|
||||
// types
|
||||
import type { IUserAccount } from "@plane/types";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
export interface IAccountStore {
|
||||
// observables
|
||||
isLoading: boolean;
|
||||
error: any | undefined;
|
||||
// model observables
|
||||
provider_account_id: string | undefined;
|
||||
provider: string | undefined;
|
||||
}
|
||||
|
||||
export class AccountStore implements IAccountStore {
|
||||
isLoading: boolean = false;
|
||||
error: any | undefined = undefined;
|
||||
// model observables
|
||||
provider_account_id: string | undefined = undefined;
|
||||
provider: string | undefined = undefined;
|
||||
// service
|
||||
userService: UserService;
|
||||
constructor(
|
||||
private store: CoreRootStore,
|
||||
private _account: IUserAccount
|
||||
) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isLoading: observable.ref,
|
||||
error: observable,
|
||||
// model observables
|
||||
provider_account_id: observable.ref,
|
||||
provider: observable.ref,
|
||||
});
|
||||
// service
|
||||
this.userService = new UserService();
|
||||
// set account data
|
||||
Object.entries(this._account).forEach(([key, value]) => {
|
||||
set(this, [key], value ?? undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
335
apps/web/core/store/user/base-permissions.store.ts
Normal file
335
apps/web/core/store/user/base-permissions.store.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { unset, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TUserPermissions, TUserPermissionsLevel } from "@plane/constants";
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
|
||||
} from "@plane/constants";
|
||||
import type { EUserProjectRoles, IUserProjectsRole, IWorkspaceMemberMe, TProjectMembership } from "@plane/types";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// plane web imports
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// services
|
||||
import projectMemberService from "@/services/project/project-member.service";
|
||||
import userService from "@/services/user.service";
|
||||
|
||||
// derived services
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
type ETempUserRole = TUserPermissions | EUserWorkspaceRoles | EUserProjectRoles; // TODO: Remove this once we have migrated user permissions to enums to plane constants package
|
||||
|
||||
export interface IBaseUserPermissionStore {
|
||||
loader: boolean;
|
||||
// observables
|
||||
workspaceUserInfo: Record<string, IWorkspaceMemberMe>; // workspaceSlug -> IWorkspaceMemberMe
|
||||
projectUserInfo: Record<string, Record<string, TProjectMembership>>; // workspaceSlug -> projectId -> TProjectMembership
|
||||
workspaceProjectsPermissions: Record<string, IUserProjectsRole>; // workspaceSlug -> IUserProjectsRole
|
||||
// computed helpers
|
||||
workspaceInfoBySlug: (workspaceSlug: string) => IWorkspaceMemberMe | undefined;
|
||||
getWorkspaceRoleByWorkspaceSlug: (workspaceSlug: string) => TUserPermissions | EUserWorkspaceRoles | undefined;
|
||||
getProjectRolesByWorkspaceSlug: (workspaceSlug: string) => IUserProjectsRole;
|
||||
getProjectRoleByWorkspaceSlugAndProjectId: (workspaceSlug: string, projectId: string) => EUserPermissions | undefined;
|
||||
allowPermissions: (
|
||||
allowPermissions: ETempUserRole[],
|
||||
level: TUserPermissionsLevel,
|
||||
workspaceSlug?: string,
|
||||
projectId?: string,
|
||||
onPermissionAllowed?: () => boolean
|
||||
) => boolean;
|
||||
// actions
|
||||
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
|
||||
leaveWorkspace: (workspaceSlug: string) => Promise<void>;
|
||||
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<TProjectMembership>;
|
||||
fetchUserProjectPermissions: (workspaceSlug: string) => Promise<IUserProjectsRole>;
|
||||
joinProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
leaveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
hasPageAccess: (workspaceSlug: string, key: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This store is used to handle permission layer for the currently logged user.
|
||||
* It manages workspace and project level permissions, roles and access control.
|
||||
*/
|
||||
export abstract class BaseUserPermissionStore implements IBaseUserPermissionStore {
|
||||
loader: boolean = false;
|
||||
// constants
|
||||
workspaceUserInfo: Record<string, IWorkspaceMemberMe> = {};
|
||||
projectUserInfo: Record<string, Record<string, TProjectMembership>> = {};
|
||||
workspaceProjectsPermissions: Record<string, IUserProjectsRole> = {};
|
||||
// observables
|
||||
|
||||
constructor(protected store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
workspaceUserInfo: observable,
|
||||
projectUserInfo: observable,
|
||||
workspaceProjectsPermissions: observable,
|
||||
// computed
|
||||
// actions
|
||||
fetchUserWorkspaceInfo: action,
|
||||
leaveWorkspace: action,
|
||||
fetchUserProjectInfo: action,
|
||||
fetchUserProjectPermissions: action,
|
||||
joinProject: action,
|
||||
leaveProject: action,
|
||||
});
|
||||
}
|
||||
|
||||
// computed helpers
|
||||
/**
|
||||
* @description Returns the current workspace information
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { IWorkspaceMemberMe | undefined }
|
||||
*/
|
||||
workspaceInfoBySlug = computedFn((workspaceSlug: string): IWorkspaceMemberMe | undefined => {
|
||||
if (!workspaceSlug) return undefined;
|
||||
return this.workspaceUserInfo[workspaceSlug] || undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Returns the workspace role by slug
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { TUserPermissions | EUserWorkspaceRoles | undefined }
|
||||
*/
|
||||
getWorkspaceRoleByWorkspaceSlug = computedFn(
|
||||
(workspaceSlug: string): TUserPermissions | EUserWorkspaceRoles | undefined => {
|
||||
if (!workspaceSlug) return undefined;
|
||||
return this.workspaceUserInfo[workspaceSlug]?.role as TUserPermissions | EUserWorkspaceRoles | undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Returns the project membership permission
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { EUserPermissions | undefined }
|
||||
*/
|
||||
protected getProjectRole = computedFn((workspaceSlug: string, projectId: string): EUserPermissions | undefined => {
|
||||
if (!workspaceSlug || !projectId) return undefined;
|
||||
const projectRole = this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId];
|
||||
if (!projectRole) return undefined;
|
||||
const workspaceRole = this.workspaceUserInfo?.[workspaceSlug]?.role;
|
||||
if (workspaceRole === EUserWorkspaceRoles.ADMIN) return EUserPermissions.ADMIN;
|
||||
else return projectRole;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Returns the project permissions by workspace slug
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { IUserProjectsRole }
|
||||
*/
|
||||
getProjectRolesByWorkspaceSlug = computedFn((workspaceSlug: string): IUserProjectsRole => {
|
||||
const projectPermissions = this.workspaceProjectsPermissions[workspaceSlug] || {};
|
||||
return Object.keys(projectPermissions).reduce((acc, projectId) => {
|
||||
const projectRole = this.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
|
||||
if (projectRole) {
|
||||
acc[projectId] = projectRole;
|
||||
}
|
||||
return acc;
|
||||
}, {} as IUserProjectsRole);
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Returns the current project permissions
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { EUserPermissions | undefined }
|
||||
*/
|
||||
abstract getProjectRoleByWorkspaceSlugAndProjectId: (
|
||||
workspaceSlug: string,
|
||||
projectId: string
|
||||
) => EUserPermissions | undefined;
|
||||
|
||||
/**
|
||||
* @description Returns whether the user has the permission to access a page
|
||||
* @param { string } page
|
||||
* @returns { boolean }
|
||||
*/
|
||||
hasPageAccess = computedFn((workspaceSlug: string, key: string): boolean => {
|
||||
if (!workspaceSlug || !key) return false;
|
||||
const settings = WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.find((item) => item.key === key);
|
||||
if (settings) {
|
||||
return this.allowPermissions(settings.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// action helpers
|
||||
/**
|
||||
* @description Returns whether the user has the permission to perform an action
|
||||
* @param { TUserPermissions[] } allowPermissions
|
||||
* @param { TUserPermissionsLevel } level
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { () => boolean } onPermissionAllowed
|
||||
* @returns { boolean }
|
||||
*/
|
||||
allowPermissions = (
|
||||
allowPermissions: ETempUserRole[],
|
||||
level: TUserPermissionsLevel,
|
||||
workspaceSlug?: string,
|
||||
projectId?: string,
|
||||
onPermissionAllowed?: () => boolean
|
||||
): boolean => {
|
||||
const { workspaceSlug: currentWorkspaceSlug, projectId: currentProjectId } = this.store.router;
|
||||
if (!workspaceSlug) workspaceSlug = currentWorkspaceSlug;
|
||||
if (!projectId) projectId = currentProjectId;
|
||||
|
||||
let currentUserRole: TUserPermissions | undefined = undefined;
|
||||
|
||||
if (level === EUserPermissionsLevel.WORKSPACE) {
|
||||
currentUserRole = (workspaceSlug && this.getWorkspaceRoleByWorkspaceSlug(workspaceSlug)) as
|
||||
| EUserPermissions
|
||||
| undefined;
|
||||
}
|
||||
|
||||
if (level === EUserPermissionsLevel.PROJECT) {
|
||||
currentUserRole = (workspaceSlug &&
|
||||
projectId &&
|
||||
this.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId)) as EUserPermissions | undefined;
|
||||
}
|
||||
|
||||
if (typeof currentUserRole === "string") {
|
||||
currentUserRole = parseInt(currentUserRole);
|
||||
}
|
||||
|
||||
if (currentUserRole && typeof currentUserRole === "number" && allowPermissions.includes(currentUserRole)) {
|
||||
if (onPermissionAllowed) {
|
||||
return onPermissionAllowed();
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description Fetches the user's workspace information
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { Promise<IWorkspaceMemberMe | undefined> }
|
||||
*/
|
||||
fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise<IWorkspaceMemberMe> => {
|
||||
try {
|
||||
this.loader = true;
|
||||
const response = await workspaceService.workspaceMemberMe(workspaceSlug);
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
set(this.workspaceUserInfo, [workspaceSlug], response);
|
||||
this.loader = false;
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user workspace information", error);
|
||||
this.loader = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Leaves a workspace
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { Promise<void | undefined> }
|
||||
*/
|
||||
leaveWorkspace = async (workspaceSlug: string): Promise<void> => {
|
||||
try {
|
||||
await userService.leaveWorkspace(workspaceSlug);
|
||||
runInAction(() => {
|
||||
unset(this.workspaceUserInfo, workspaceSlug);
|
||||
unset(this.projectUserInfo, workspaceSlug);
|
||||
unset(this.workspaceProjectsPermissions, workspaceSlug);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error user leaving the workspace", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Fetches the user's project information
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { Promise<TProjectMembership | undefined> }
|
||||
*/
|
||||
fetchUserProjectInfo = async (workspaceSlug: string, projectId: string): Promise<TProjectMembership> => {
|
||||
try {
|
||||
const response = await projectMemberService.projectMemberMe(workspaceSlug, projectId);
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
set(this.projectUserInfo, [workspaceSlug, projectId], response);
|
||||
set(this.workspaceProjectsPermissions, [workspaceSlug, projectId], response.role);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user project information", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Fetches the user's project permissions
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { Promise<IUserProjectsRole | undefined> }
|
||||
*/
|
||||
fetchUserProjectPermissions = async (workspaceSlug: string): Promise<IUserProjectsRole> => {
|
||||
try {
|
||||
const response = await workspaceService.getWorkspaceUserProjectsRole(workspaceSlug);
|
||||
runInAction(() => {
|
||||
set(this.workspaceProjectsPermissions, [workspaceSlug], response);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user project permissions", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Joins a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
joinProject = async (workspaceSlug: string, projectId: string): Promise<void> => {
|
||||
try {
|
||||
const response = await userService.joinProject(workspaceSlug, [projectId]);
|
||||
const projectMemberRole = this.getWorkspaceRoleByWorkspaceSlug(workspaceSlug) ?? EUserPermissions.MEMBER;
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
set(this.workspaceProjectsPermissions, [workspaceSlug, projectId], projectMemberRole);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error user joining the project", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Leaves a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
leaveProject = async (workspaceSlug: string, projectId: string): Promise<void> => {
|
||||
try {
|
||||
await userService.leaveProject(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
unset(this.workspaceProjectsPermissions, [workspaceSlug, projectId]);
|
||||
unset(this.projectUserInfo, [workspaceSlug, projectId]);
|
||||
unset(this.store.projectRoot.project.projectMap, [projectId]);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error user leaving the project", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
303
apps/web/core/store/user/index.ts
Normal file
303
apps/web/core/store/user/index.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { cloneDeep, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction, computed } from "mobx";
|
||||
// plane imports
|
||||
import { EUserPermissions, API_BASE_URL } from "@plane/constants";
|
||||
import type { IUser, TUserPermissions } from "@plane/types";
|
||||
// local
|
||||
import { persistence } from "@/local-db/storage.sqlite";
|
||||
// plane web imports
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import type { IUserPermissionStore } from "@/plane-web/store/user/permission.store";
|
||||
import { UserPermissionStore } from "@/plane-web/store/user/permission.store";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
// stores
|
||||
import type { IAccountStore } from "@/store/user/account.store";
|
||||
import type { IUserProfileStore } from "@/store/user/profile.store";
|
||||
import { ProfileStore } from "@/store/user/profile.store";
|
||||
// local imports
|
||||
import type { IUserSettingsStore } from "./settings.store";
|
||||
import { UserSettingsStore } from "./settings.store";
|
||||
|
||||
type TUserErrorStatus = {
|
||||
status: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface IUserStore {
|
||||
// observables
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: TUserErrorStatus | undefined;
|
||||
data: IUser | undefined;
|
||||
// store observables
|
||||
userProfile: IUserProfileStore;
|
||||
userSettings: IUserSettingsStore;
|
||||
accounts: Record<string, IAccountStore>;
|
||||
permission: IUserPermissionStore;
|
||||
// actions
|
||||
fetchCurrentUser: () => Promise<IUser | undefined>;
|
||||
updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>;
|
||||
handleSetPassword: (csrfToken: string, data: { password: string }) => Promise<IUser | undefined>;
|
||||
deactivateAccount: () => Promise<void>;
|
||||
changePassword: (
|
||||
csrfToken: string,
|
||||
payload: { old_password?: string; new_password: string }
|
||||
) => Promise<IUser | undefined>;
|
||||
reset: () => void;
|
||||
signOut: () => Promise<void>;
|
||||
// computed
|
||||
localDBEnabled: boolean;
|
||||
canPerformAnyCreateAction: boolean;
|
||||
projectsWithCreatePermissions: { [projectId: string]: number } | null;
|
||||
}
|
||||
|
||||
export class UserStore implements IUserStore {
|
||||
// observables
|
||||
isAuthenticated: boolean = false;
|
||||
isLoading: boolean = false;
|
||||
error: TUserErrorStatus | undefined = undefined;
|
||||
data: IUser | undefined = undefined;
|
||||
// store observables
|
||||
userProfile: IUserProfileStore;
|
||||
userSettings: IUserSettingsStore;
|
||||
accounts: Record<string, IAccountStore> = {};
|
||||
permission: IUserPermissionStore;
|
||||
// service
|
||||
userService: UserService;
|
||||
authService: AuthService;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
// stores
|
||||
this.userProfile = new ProfileStore(store);
|
||||
this.userSettings = new UserSettingsStore();
|
||||
this.permission = new UserPermissionStore(store);
|
||||
// service
|
||||
this.userService = new UserService();
|
||||
this.authService = new AuthService();
|
||||
// observables
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isAuthenticated: observable.ref,
|
||||
isLoading: observable.ref,
|
||||
error: observable,
|
||||
// model observables
|
||||
data: observable,
|
||||
userProfile: observable,
|
||||
userSettings: observable,
|
||||
accounts: observable,
|
||||
permission: observable,
|
||||
// actions
|
||||
fetchCurrentUser: action,
|
||||
updateCurrentUser: action,
|
||||
handleSetPassword: action,
|
||||
deactivateAccount: action,
|
||||
changePassword: action,
|
||||
reset: action,
|
||||
signOut: action,
|
||||
// computed
|
||||
canPerformAnyCreateAction: computed,
|
||||
projectsWithCreatePermissions: computed,
|
||||
|
||||
localDBEnabled: computed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description fetches the current user
|
||||
* @returns {Promise<IUser>}
|
||||
*/
|
||||
fetchCurrentUser = async (): Promise<IUser> => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.isLoading = true;
|
||||
this.error = undefined;
|
||||
});
|
||||
const user = await this.userService.currentUser();
|
||||
if (user && user?.id) {
|
||||
await Promise.all([
|
||||
this.userProfile.fetchUserProfile(),
|
||||
this.userSettings.fetchCurrentUserSettings(),
|
||||
this.store.workspaceRoot.fetchWorkspaces(),
|
||||
]);
|
||||
runInAction(() => {
|
||||
this.data = user;
|
||||
this.isLoading = false;
|
||||
this.isAuthenticated = true;
|
||||
});
|
||||
} else
|
||||
runInAction(() => {
|
||||
this.data = user;
|
||||
this.isLoading = false;
|
||||
this.isAuthenticated = false;
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.isAuthenticated = false;
|
||||
this.error = {
|
||||
status: "user-fetch-error",
|
||||
message: "Failed to fetch current user",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the current user
|
||||
* @param data
|
||||
* @returns {Promise<IUser>}
|
||||
*/
|
||||
updateCurrentUser = async (data: Partial<IUser>): Promise<IUser> => {
|
||||
const currentUserData = this.data;
|
||||
try {
|
||||
if (currentUserData) {
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
const userKey: keyof IUser = key as keyof IUser;
|
||||
if (this.data) set(this.data, userKey, data[userKey]);
|
||||
});
|
||||
}
|
||||
const user = await this.userService.updateUser(data);
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (currentUserData) {
|
||||
Object.keys(currentUserData).forEach((key: string) => {
|
||||
const userKey: keyof IUser = key as keyof IUser;
|
||||
if (this.data) set(this.data, userKey, currentUserData[userKey]);
|
||||
});
|
||||
}
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
status: "user-update-error",
|
||||
message: "Failed to update current user",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the user password
|
||||
* @param data
|
||||
* @returns {Promise<IUser>}
|
||||
*/
|
||||
handleSetPassword = async (csrfToken: string, data: { password: string }): Promise<IUser | undefined> => {
|
||||
const currentUserData = cloneDeep(this.data);
|
||||
try {
|
||||
if (currentUserData && currentUserData.is_password_autoset && this.data) {
|
||||
const user = await this.authService.setPassword(csrfToken, { password: data.password });
|
||||
set(this.data, ["is_password_autoset"], false);
|
||||
return user;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (this.data) set(this.data, ["is_password_autoset"], true);
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
status: "user-update-error",
|
||||
message: "Failed to update current user",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
changePassword = async (
|
||||
csrfToken: string,
|
||||
payload: {
|
||||
old_password?: string;
|
||||
new_password: string;
|
||||
}
|
||||
): Promise<IUser | undefined> => {
|
||||
try {
|
||||
const user = await this.userService.changePassword(csrfToken, payload);
|
||||
if (this.data) set(this.data, ["is_password_autoset"], false);
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description deactivates the current user
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deactivateAccount = async (): Promise<void> => {
|
||||
await this.userService.deactivateAccount();
|
||||
this.store.resetOnSignOut();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description resets the user store
|
||||
* @returns {void}
|
||||
*/
|
||||
reset = (): void => {
|
||||
runInAction(() => {
|
||||
this.isAuthenticated = false;
|
||||
this.isLoading = false;
|
||||
this.error = undefined;
|
||||
this.data = undefined;
|
||||
this.userProfile = new ProfileStore(this.store);
|
||||
this.userSettings = new UserSettingsStore();
|
||||
this.permission = new UserPermissionStore(this.store);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description signs out the current user
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
signOut = async (): Promise<void> => {
|
||||
await this.authService.signOut(API_BASE_URL);
|
||||
await persistence.clearStorage(true);
|
||||
this.store.resetOnSignOut();
|
||||
};
|
||||
|
||||
// helper actions
|
||||
/**
|
||||
* @description fetches the projects with write permissions
|
||||
* @returns {{[projectId: string]: number} || null}
|
||||
*/
|
||||
fetchProjectsWithCreatePermissions = (): { [key: string]: TUserPermissions } => {
|
||||
const { workspaceSlug } = this.store.router;
|
||||
|
||||
const allWorkspaceProjectRoles = this.permission.getProjectRolesByWorkspaceSlug(workspaceSlug || "");
|
||||
|
||||
const userPermissions =
|
||||
(allWorkspaceProjectRoles &&
|
||||
Object.keys(allWorkspaceProjectRoles)
|
||||
.filter((key) => allWorkspaceProjectRoles[key] >= EUserPermissions.MEMBER)
|
||||
.reduce(
|
||||
(res: { [projectId: string]: number }, key: string) => ((res[key] = allWorkspaceProjectRoles[key]), res),
|
||||
{}
|
||||
)) ||
|
||||
null;
|
||||
|
||||
return userPermissions;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns projects where user has permissions
|
||||
* @returns {{[projectId: string]: number} || null}
|
||||
*/
|
||||
get projectsWithCreatePermissions() {
|
||||
return this.fetchProjectsWithCreatePermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if user has permissions to write in any project
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformAnyCreateAction() {
|
||||
const filteredProjects = this.fetchProjectsWithCreatePermissions();
|
||||
return filteredProjects ? Object.keys(filteredProjects).length > 0 : false;
|
||||
}
|
||||
|
||||
get localDBEnabled() {
|
||||
return this.userSettings.canUseLocalDB;
|
||||
}
|
||||
}
|
||||
243
apps/web/core/store/user/profile.store.ts
Normal file
243
apps/web/core/store/user/profile.store.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { cloneDeep, set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// types
|
||||
import type { IUserTheme, TUserProfile } from "@plane/types";
|
||||
import { EStartOfTheWeek } from "@plane/types";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
type TError = {
|
||||
status: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface IUserProfileStore {
|
||||
// observables
|
||||
isLoading: boolean;
|
||||
error: TError | undefined;
|
||||
data: TUserProfile;
|
||||
// actions
|
||||
fetchUserProfile: () => Promise<TUserProfile | undefined>;
|
||||
updateUserProfile: (data: Partial<TUserProfile>) => Promise<TUserProfile | undefined>;
|
||||
finishUserOnboarding: () => Promise<void>;
|
||||
updateTourCompleted: () => Promise<TUserProfile | undefined>;
|
||||
updateUserTheme: (data: Partial<IUserTheme>) => Promise<TUserProfile | undefined>;
|
||||
}
|
||||
|
||||
export class ProfileStore implements IUserProfileStore {
|
||||
isLoading: boolean = false;
|
||||
error: TError | undefined = undefined;
|
||||
data: TUserProfile = {
|
||||
id: undefined,
|
||||
user: undefined,
|
||||
role: undefined,
|
||||
last_workspace_id: undefined,
|
||||
theme: {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
sidebarText: undefined,
|
||||
sidebarBackground: undefined,
|
||||
},
|
||||
onboarding_step: {
|
||||
workspace_join: false,
|
||||
profile_complete: false,
|
||||
workspace_create: false,
|
||||
workspace_invite: false,
|
||||
},
|
||||
is_onboarded: false,
|
||||
is_tour_completed: false,
|
||||
use_case: undefined,
|
||||
billing_address_country: undefined,
|
||||
billing_address: undefined,
|
||||
has_billing_address: false,
|
||||
has_marketing_email_consent: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
language: "",
|
||||
start_of_the_week: EStartOfTheWeek.SUNDAY,
|
||||
};
|
||||
|
||||
// services
|
||||
userService: UserService;
|
||||
|
||||
constructor(public store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isLoading: observable.ref,
|
||||
error: observable,
|
||||
data: observable,
|
||||
// actions
|
||||
fetchUserProfile: action,
|
||||
updateUserProfile: action,
|
||||
updateTourCompleted: action,
|
||||
updateUserTheme: action,
|
||||
});
|
||||
// services
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
// helper action
|
||||
mutateUserProfile = (data: Partial<TUserProfile>) => {
|
||||
if (!data) return;
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key in this.data) set(this.data, key, value);
|
||||
});
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description fetches user profile information
|
||||
* @returns {Promise<TUserProfile | undefined>}
|
||||
*/
|
||||
fetchUserProfile = async (): Promise<TUserProfile | undefined> => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.isLoading = true;
|
||||
this.error = undefined;
|
||||
});
|
||||
const userProfile = await this.userService.getCurrentUserProfile();
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.data = userProfile;
|
||||
});
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.error = {
|
||||
status: "user-profile-fetch-error",
|
||||
message: "Failed to fetch user profile",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updated the user profile information
|
||||
* @param {Partial<TUserProfile>} data
|
||||
* @returns {Promise<TUserProfile | undefined>}
|
||||
*/
|
||||
updateUserProfile = async (data: Partial<TUserProfile>): Promise<TUserProfile | undefined> => {
|
||||
const currentUserProfileData = this.data;
|
||||
try {
|
||||
if (currentUserProfileData) {
|
||||
this.mutateUserProfile(data);
|
||||
}
|
||||
const userProfile = await this.userService.updateCurrentUserProfile(data);
|
||||
return userProfile;
|
||||
} catch {
|
||||
if (currentUserProfileData) {
|
||||
this.mutateUserProfile(currentUserProfileData);
|
||||
}
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
status: "user-profile-update-error",
|
||||
message: "Failed to update user profile",
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description finishes the user onboarding
|
||||
* @returns { void }
|
||||
*/
|
||||
finishUserOnboarding = async (): Promise<void> => {
|
||||
try {
|
||||
const firstWorkspace = Object.values(this.store.workspaceRoot.workspaces ?? {})?.[0];
|
||||
const dataToUpdate: Partial<TUserProfile> = {
|
||||
onboarding_step: {
|
||||
profile_complete: true,
|
||||
workspace_join: true,
|
||||
workspace_create: true,
|
||||
workspace_invite: true,
|
||||
},
|
||||
last_workspace_id: firstWorkspace?.id,
|
||||
};
|
||||
|
||||
// update user onboarding steps
|
||||
await this.userService.updateCurrentUserProfile(dataToUpdate);
|
||||
|
||||
// update user onboarding status
|
||||
await this.userService.updateUserOnBoard();
|
||||
|
||||
// Wait for user settings to be refreshed with cache-busting before updating onboarding status
|
||||
await Promise.all([
|
||||
this.fetchUserProfile(),
|
||||
this.store.user.userSettings.fetchCurrentUserSettings(true), // Cache-busting enabled
|
||||
]);
|
||||
|
||||
// Only after settings are refreshed, update the user profile store to mark as onboarded
|
||||
runInAction(() => {
|
||||
this.mutateUserProfile({ ...dataToUpdate, is_onboarded: true });
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
status: "user-profile-onboard-finish-error",
|
||||
message: "Failed to finish user onboarding",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the user tour completed status
|
||||
* @returns @returns {Promise<TUserProfile | undefined>}
|
||||
*/
|
||||
updateTourCompleted = async () => {
|
||||
const isUserProfileTourCompleted = this.data.is_tour_completed || false;
|
||||
try {
|
||||
this.mutateUserProfile({ is_tour_completed: true });
|
||||
const userProfile = await this.userService.updateUserTourCompleted();
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.mutateUserProfile({ is_tour_completed: isUserProfileTourCompleted });
|
||||
this.error = {
|
||||
status: "user-profile-tour-complete-error",
|
||||
message: "Failed to update user profile is_tour_completed",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the user theme
|
||||
* @returns @returns {Promise<TUserProfile | undefined>}
|
||||
*/
|
||||
updateUserTheme = async (data: Partial<IUserTheme>) => {
|
||||
const currentProfileTheme = cloneDeep(this.data.theme);
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
const userKey: keyof IUserTheme = key as keyof IUserTheme;
|
||||
if (this.data.theme) set(this.data.theme, userKey, data[userKey]);
|
||||
});
|
||||
});
|
||||
const userProfile = await this.userService.updateCurrentUserProfile({ theme: this.data.theme });
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
const userKey: keyof IUserTheme = key as keyof IUserTheme;
|
||||
if (currentProfileTheme) set(this.data.theme, userKey, currentProfileTheme[userKey]);
|
||||
});
|
||||
this.error = {
|
||||
status: "user-profile-theme-update-error",
|
||||
message: "Failed to update user profile theme",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
104
apps/web/core/store/user/settings.store.ts
Normal file
104
apps/web/core/store/user/settings.store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import type { IUserSettings } from "@plane/types";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
type TError = {
|
||||
status: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface IUserSettingsStore {
|
||||
// observables
|
||||
isLoading: boolean;
|
||||
error: TError | undefined;
|
||||
data: IUserSettings;
|
||||
canUseLocalDB: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
isScrolled: boolean;
|
||||
// actions
|
||||
fetchCurrentUserSettings: (bustCache?: boolean) => Promise<IUserSettings | undefined>;
|
||||
toggleSidebar: (collapsed?: boolean) => void;
|
||||
toggleIsScrolled: (isScrolled?: boolean) => void;
|
||||
}
|
||||
|
||||
export class UserSettingsStore implements IUserSettingsStore {
|
||||
// observables
|
||||
isLoading: boolean = false;
|
||||
sidebarCollapsed: boolean = true;
|
||||
error: TError | undefined = undefined;
|
||||
isScrolled: boolean = false;
|
||||
data: IUserSettings = {
|
||||
id: undefined,
|
||||
email: undefined,
|
||||
workspace: {
|
||||
last_workspace_id: undefined,
|
||||
last_workspace_slug: undefined,
|
||||
last_workspace_name: undefined,
|
||||
last_workspace_logo: undefined,
|
||||
fallback_workspace_id: undefined,
|
||||
fallback_workspace_slug: undefined,
|
||||
invites: undefined,
|
||||
},
|
||||
};
|
||||
canUseLocalDB: boolean = false;
|
||||
// services
|
||||
userService: UserService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isLoading: observable.ref,
|
||||
error: observable,
|
||||
data: observable,
|
||||
canUseLocalDB: observable.ref,
|
||||
sidebarCollapsed: observable.ref,
|
||||
isScrolled: observable.ref,
|
||||
// actions
|
||||
fetchCurrentUserSettings: action,
|
||||
toggleSidebar: action,
|
||||
toggleIsScrolled: action,
|
||||
});
|
||||
// services
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
// actions
|
||||
toggleSidebar = (collapsed?: boolean) => {
|
||||
this.sidebarCollapsed = collapsed ?? !this.sidebarCollapsed;
|
||||
};
|
||||
|
||||
toggleIsScrolled = (isScrolled?: boolean) => {
|
||||
this.isScrolled = isScrolled ?? !this.isScrolled;
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description fetches user profile information
|
||||
* @returns {Promise<IUserSettings | undefined>}
|
||||
*/
|
||||
fetchCurrentUserSettings = async (bustCache: boolean = false) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.isLoading = true;
|
||||
this.error = undefined;
|
||||
});
|
||||
const userSettings = await this.userService.currentUserSettings(bustCache);
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.data = userSettings;
|
||||
});
|
||||
return userSettings;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Failed to fetch user settings",
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
110
apps/web/core/store/workspace/api-token.store.ts
Normal file
110
apps/web/core/store/workspace/api-token.store.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { action, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { APITokenService } from "@plane/services";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
// services
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
export interface IApiTokenStore {
|
||||
// observables
|
||||
apiTokens: Record<string, IApiToken> | null;
|
||||
// computed actions
|
||||
getApiTokenById: (apiTokenId: string) => IApiToken | null;
|
||||
// fetch actions
|
||||
fetchApiTokens: () => Promise<IApiToken[]>;
|
||||
fetchApiTokenDetails: (tokenId: string) => Promise<IApiToken>;
|
||||
// crud actions
|
||||
createApiToken: (data: Partial<IApiToken>) => Promise<IApiToken>;
|
||||
deleteApiToken: (tokenId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ApiTokenStore implements IApiTokenStore {
|
||||
// observables
|
||||
apiTokens: Record<string, IApiToken> | null = null;
|
||||
// services
|
||||
apiTokenService;
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
apiTokens: observable,
|
||||
// fetch actions
|
||||
fetchApiTokens: action,
|
||||
fetchApiTokenDetails: action,
|
||||
// CRUD actions
|
||||
createApiToken: action,
|
||||
deleteApiToken: action,
|
||||
});
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.apiTokenService = new APITokenService();
|
||||
}
|
||||
|
||||
/**
|
||||
* get API token by id
|
||||
* @param apiTokenId
|
||||
*/
|
||||
getApiTokenById = computedFn((apiTokenId: string) => {
|
||||
if (!this.apiTokens) return null;
|
||||
return this.apiTokens[apiTokenId] || null;
|
||||
});
|
||||
|
||||
/**
|
||||
* fetch all the API tokens
|
||||
*/
|
||||
fetchApiTokens = async () =>
|
||||
await this.apiTokenService.list().then((response) => {
|
||||
const apiTokensObject: { [apiTokenId: string]: IApiToken } = response.reduce((accumulator, currentWebhook) => {
|
||||
if (currentWebhook && currentWebhook.id) {
|
||||
return { ...accumulator, [currentWebhook.id]: currentWebhook };
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
runInAction(() => {
|
||||
this.apiTokens = apiTokensObject;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* fetch API token details using token id
|
||||
* @param tokenId
|
||||
*/
|
||||
fetchApiTokenDetails = async (tokenId: string) =>
|
||||
await this.apiTokenService.retrieve(tokenId).then((response) => {
|
||||
runInAction(() => {
|
||||
this.apiTokens = { ...this.apiTokens, [response.id]: response };
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* create API token using data
|
||||
* @param data
|
||||
*/
|
||||
createApiToken = async (data: Partial<IApiToken>) =>
|
||||
await this.apiTokenService.create(data).then((response) => {
|
||||
runInAction(() => {
|
||||
this.apiTokens = { ...this.apiTokens, [response.id]: response };
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* delete API token using token id
|
||||
* @param tokenId
|
||||
*/
|
||||
deleteApiToken = async (tokenId: string) =>
|
||||
await this.apiTokenService.destroy(tokenId).then(() => {
|
||||
const updatedApiTokens = { ...this.apiTokens };
|
||||
delete updatedApiTokens[tokenId];
|
||||
runInAction(() => {
|
||||
this.apiTokens = updatedApiTokens;
|
||||
});
|
||||
});
|
||||
}
|
||||
147
apps/web/core/store/workspace/home.ts
Normal file
147
apps/web/core/store/workspace/home.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { orderBy, clone, set } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
// plane imports
|
||||
import type { THomeWidgetKeys, TWidgetEntityData } from "@plane/types";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// store
|
||||
import type { IWorkspaceLinkStore } from "./link.store";
|
||||
import { WorkspaceLinkStore } from "./link.store";
|
||||
|
||||
export interface IHomeStore {
|
||||
// observables
|
||||
loading: boolean;
|
||||
showWidgetSettings: boolean;
|
||||
widgetsMap: Record<string, TWidgetEntityData>;
|
||||
widgets: THomeWidgetKeys[];
|
||||
// computed
|
||||
isAnyWidgetEnabled: boolean;
|
||||
orderedWidgets: THomeWidgetKeys[];
|
||||
//stores
|
||||
quickLinks: IWorkspaceLinkStore;
|
||||
// actions
|
||||
toggleWidgetSettings: (value?: boolean) => void;
|
||||
fetchWidgets: (workspaceSlug: string) => Promise<void>;
|
||||
reorderWidget: (
|
||||
workspaceSlug: string,
|
||||
widgetKey: string,
|
||||
destinationId: string,
|
||||
edge: string | undefined
|
||||
) => Promise<void>;
|
||||
toggleWidget: (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export class HomeStore implements IHomeStore {
|
||||
// observables
|
||||
showWidgetSettings = false;
|
||||
loading = false;
|
||||
widgetsMap: Record<string, TWidgetEntityData> = {};
|
||||
widgets: THomeWidgetKeys[] = [];
|
||||
// stores
|
||||
quickLinks: IWorkspaceLinkStore;
|
||||
// services
|
||||
workspaceService: WorkspaceService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loading: observable,
|
||||
showWidgetSettings: observable,
|
||||
widgetsMap: observable,
|
||||
widgets: observable,
|
||||
// computed
|
||||
isAnyWidgetEnabled: computed,
|
||||
orderedWidgets: computed,
|
||||
// actions
|
||||
toggleWidgetSettings: action,
|
||||
fetchWidgets: action,
|
||||
reorderWidget: action,
|
||||
toggleWidget: action,
|
||||
});
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
|
||||
// stores
|
||||
this.quickLinks = new WorkspaceLinkStore();
|
||||
}
|
||||
|
||||
get isAnyWidgetEnabled() {
|
||||
return Object.values(this.widgetsMap).some((widget) => widget.is_enabled);
|
||||
}
|
||||
|
||||
get orderedWidgets() {
|
||||
return orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key);
|
||||
}
|
||||
|
||||
toggleWidgetSettings = (value?: boolean) => {
|
||||
this.showWidgetSettings = value !== undefined ? value : !this.showWidgetSettings;
|
||||
};
|
||||
|
||||
fetchWidgets = async (workspaceSlug: string) => {
|
||||
try {
|
||||
this.loading = true;
|
||||
const widgets = await this.workspaceService.fetchWorkspaceWidgets(workspaceSlug);
|
||||
runInAction(() => {
|
||||
this.widgets = orderBy(Object.values(widgets), "sort_order", "desc").map((widget) => widget.key);
|
||||
widgets.forEach((widget) => {
|
||||
this.widgetsMap[widget.key] = widget;
|
||||
});
|
||||
this.loading = false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch widgets");
|
||||
this.loading = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
toggleWidget = async (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => {
|
||||
try {
|
||||
await this.workspaceService.updateWorkspaceWidget(workspaceSlug, widgetKey, {
|
||||
is_enabled,
|
||||
});
|
||||
runInAction(() => {
|
||||
this.widgetsMap[widgetKey].is_enabled = is_enabled;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle widget");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
reorderWidget = async (workspaceSlug: string, widgetKey: string, destinationId: string, edge: string | undefined) => {
|
||||
const sortOrderBeforeUpdate = clone(this.widgetsMap[widgetKey]?.sort_order);
|
||||
try {
|
||||
let resultSequence = 10000;
|
||||
if (edge) {
|
||||
const sortedIds = orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key);
|
||||
const destinationSequence = this.widgetsMap[destinationId]?.sort_order || undefined;
|
||||
if (destinationSequence) {
|
||||
const destinationIndex = sortedIds.findIndex((id) => id === destinationId);
|
||||
if (edge === "reorder-above") {
|
||||
const prevSequence = this.widgetsMap[sortedIds[destinationIndex - 1]]?.sort_order || undefined;
|
||||
if (prevSequence) {
|
||||
resultSequence = (destinationSequence + prevSequence) / 2;
|
||||
} else {
|
||||
resultSequence = destinationSequence + resultSequence;
|
||||
}
|
||||
} else {
|
||||
resultSequence = destinationSequence - resultSequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
set(this.widgetsMap, [widgetKey, "sort_order"], resultSequence);
|
||||
});
|
||||
await this.workspaceService.updateWorkspaceWidget(workspaceSlug, widgetKey, {
|
||||
sort_order: resultSequence,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move widget");
|
||||
runInAction(() => {
|
||||
set(this.widgetsMap, [widgetKey, "sort_order"], sortOrderBeforeUpdate);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
275
apps/web/core/store/workspace/index.ts
Normal file
275
apps/web/core/store/workspace/index.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { clone, set } from "lodash-es";
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { IWorkspaceSidebarNavigationItem, IWorkspace, IWorkspaceSidebarNavigation } from "@plane/types";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
// sub-stores
|
||||
import type { IApiTokenStore } from "./api-token.store";
|
||||
import { ApiTokenStore } from "./api-token.store";
|
||||
import type { IHomeStore } from "./home";
|
||||
import { HomeStore } from "./home";
|
||||
import type { IWebhookStore } from "./webhook.store";
|
||||
import { WebhookStore } from "./webhook.store";
|
||||
|
||||
export interface IWorkspaceRootStore {
|
||||
loader: boolean;
|
||||
// observables
|
||||
workspaces: Record<string, IWorkspace>;
|
||||
// computed
|
||||
currentWorkspace: IWorkspace | null;
|
||||
workspacesCreatedByCurrentUser: IWorkspace[] | null;
|
||||
navigationPreferencesMap: Record<string, IWorkspaceSidebarNavigation>;
|
||||
getWorkspaceRedirectionUrl: () => string;
|
||||
// computed actions
|
||||
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
|
||||
getWorkspaceById: (workspaceId: string) => IWorkspace | null;
|
||||
// fetch actions
|
||||
fetchWorkspaces: () => Promise<IWorkspace[]>;
|
||||
// crud actions
|
||||
createWorkspace: (data: Partial<IWorkspace>) => Promise<IWorkspace>;
|
||||
updateWorkspace: (workspaceSlug: string, data: Partial<IWorkspace>) => Promise<IWorkspace>;
|
||||
updateWorkspaceLogo: (workspaceSlug: string, logoURL: string) => void;
|
||||
deleteWorkspace: (workspaceSlug: string) => Promise<void>;
|
||||
fetchSidebarNavigationPreferences: (workspaceSlug: string) => Promise<void>;
|
||||
updateSidebarPreference: (
|
||||
workspaceSlug: string,
|
||||
key: string,
|
||||
data: Partial<IWorkspaceSidebarNavigationItem>
|
||||
) => Promise<IWorkspaceSidebarNavigationItem | undefined>;
|
||||
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
|
||||
// sub-stores
|
||||
webhook: IWebhookStore;
|
||||
apiToken: IApiTokenStore;
|
||||
home: IHomeStore;
|
||||
}
|
||||
|
||||
export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
loader: boolean = false;
|
||||
// observables
|
||||
workspaces: Record<string, IWorkspace> = {};
|
||||
navigationPreferencesMap: Record<string, IWorkspaceSidebarNavigation> = {};
|
||||
// services
|
||||
workspaceService;
|
||||
// root store
|
||||
router;
|
||||
user;
|
||||
home;
|
||||
// sub-stores
|
||||
webhook: IWebhookStore;
|
||||
apiToken: IApiTokenStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
loader: observable.ref,
|
||||
// observables
|
||||
workspaces: observable,
|
||||
navigationPreferencesMap: observable,
|
||||
// computed
|
||||
currentWorkspace: computed,
|
||||
workspacesCreatedByCurrentUser: computed,
|
||||
// computed actions
|
||||
getWorkspaceBySlug: action,
|
||||
getWorkspaceById: action,
|
||||
// actions
|
||||
fetchWorkspaces: action,
|
||||
createWorkspace: action,
|
||||
updateWorkspace: action,
|
||||
updateWorkspaceLogo: action,
|
||||
deleteWorkspace: action,
|
||||
fetchSidebarNavigationPreferences: action,
|
||||
updateSidebarPreference: action,
|
||||
});
|
||||
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
// root store
|
||||
this.router = _rootStore.router;
|
||||
this.user = _rootStore.user;
|
||||
this.home = new HomeStore();
|
||||
// sub-stores
|
||||
this.webhook = new WebhookStore(_rootStore);
|
||||
this.apiToken = new ApiTokenStore(_rootStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* get the workspace redirection url based on the last and fallback workspace_slug
|
||||
*/
|
||||
getWorkspaceRedirectionUrl = () => {
|
||||
let redirectionRoute = "/create-workspace";
|
||||
// validate the last and fallback workspace_slug
|
||||
const currentWorkspaceSlug =
|
||||
this.user.userSettings?.data?.workspace?.last_workspace_slug ||
|
||||
this.user.userSettings?.data?.workspace?.fallback_workspace_slug;
|
||||
|
||||
// validate the current workspace_slug is available in the user's workspace list
|
||||
const isCurrentWorkspaceValid = Object.values(this.workspaces || {}).findIndex(
|
||||
(workspace) => workspace.slug === currentWorkspaceSlug
|
||||
);
|
||||
|
||||
if (isCurrentWorkspaceValid >= 0) redirectionRoute = `/${currentWorkspaceSlug}`;
|
||||
return redirectionRoute;
|
||||
};
|
||||
|
||||
/**
|
||||
* computed value of current workspace based on workspace slug saved in the query store
|
||||
*/
|
||||
get currentWorkspace() {
|
||||
const workspaceSlug = this.router.workspaceSlug;
|
||||
if (!workspaceSlug) return null;
|
||||
const workspaceDetails = Object.values(this.workspaces ?? {})?.find((w) => w.slug === workspaceSlug);
|
||||
return workspaceDetails || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* computed value of all the workspaces created by the current logged in user
|
||||
*/
|
||||
get workspacesCreatedByCurrentUser() {
|
||||
if (!this.workspaces) return null;
|
||||
const user = this.user.data;
|
||||
if (!user) return null;
|
||||
const userWorkspaces = Object.values(this.workspaces ?? {})?.filter((w) => w.created_by === user?.id);
|
||||
return userWorkspaces || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* get workspace info from the array of workspaces in the store using workspace slug
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
getWorkspaceBySlug = (workspaceSlug: string) =>
|
||||
Object.values(this.workspaces ?? {})?.find((w) => w.slug == workspaceSlug) || null;
|
||||
|
||||
/**
|
||||
* get workspace info from the array of workspaces in the store using workspace id
|
||||
* @param workspaceId
|
||||
*/
|
||||
getWorkspaceById = (workspaceId: string) => this.workspaces?.[workspaceId] || null; // TODO: use undefined instead of null
|
||||
|
||||
/**
|
||||
* fetch user workspaces from API
|
||||
*/
|
||||
fetchWorkspaces = async () => {
|
||||
this.loader = true;
|
||||
try {
|
||||
const workspaceResponse = await this.workspaceService.userWorkspaces();
|
||||
runInAction(() => {
|
||||
workspaceResponse.forEach((workspace) => {
|
||||
set(this.workspaces, [workspace.id], workspace);
|
||||
});
|
||||
});
|
||||
return workspaceResponse;
|
||||
} finally {
|
||||
this.loader = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* create workspace using the workspace data
|
||||
* @param data
|
||||
*/
|
||||
createWorkspace = async (data: Partial<IWorkspace>) =>
|
||||
await this.workspaceService.createWorkspace(data).then((response) => {
|
||||
runInAction(() => {
|
||||
this.workspaces = set(this.workspaces, response.id, response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* update workspace using the workspace slug and new workspace data
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
updateWorkspace = async (workspaceSlug: string, data: Partial<IWorkspace>) =>
|
||||
await this.workspaceService.updateWorkspace(workspaceSlug, data).then((res) => {
|
||||
if (res && res.id) {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
set(this.workspaces, [res.id, key], data[key as keyof IWorkspace]);
|
||||
});
|
||||
});
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
/**
|
||||
* update workspace using the workspace slug and new workspace data
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} logoURL
|
||||
*/
|
||||
updateWorkspaceLogo = async (workspaceSlug: string, logoURL: string) => {
|
||||
const workspaceId = this.getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
if (!workspaceId) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
runInAction(() => {
|
||||
set(this.workspaces[workspaceId], ["logo_url"], logoURL);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* delete workspace using the workspace slug
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
deleteWorkspace = async (workspaceSlug: string) =>
|
||||
await this.workspaceService.deleteWorkspace(workspaceSlug).then(() => {
|
||||
const updatedWorkspacesList = this.workspaces;
|
||||
const workspaceId = this.getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
delete updatedWorkspacesList[`${workspaceId}`];
|
||||
runInAction(() => {
|
||||
this.workspaces = updatedWorkspacesList;
|
||||
});
|
||||
});
|
||||
|
||||
fetchSidebarNavigationPreferences = async (workspaceSlug: string) => {
|
||||
try {
|
||||
const response = await this.workspaceService.fetchSidebarNavigationPreferences(workspaceSlug);
|
||||
|
||||
runInAction(() => {
|
||||
this.navigationPreferencesMap[workspaceSlug] = response;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sidebar preferences:", error);
|
||||
}
|
||||
};
|
||||
|
||||
updateSidebarPreference = async (
|
||||
workspaceSlug: string,
|
||||
key: string,
|
||||
data: Partial<IWorkspaceSidebarNavigationItem>
|
||||
) => {
|
||||
// Store the data before update to use for reverting if needed
|
||||
const beforeUpdateData = clone(this.navigationPreferencesMap[workspaceSlug]?.[key]);
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.navigationPreferencesMap[workspaceSlug] = {
|
||||
...this.navigationPreferencesMap[workspaceSlug],
|
||||
[key]: {
|
||||
...beforeUpdateData,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const response = await this.workspaceService.updateSidebarPreference(workspaceSlug, key, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Revert to original data if API call fails
|
||||
runInAction(() => {
|
||||
this.navigationPreferencesMap[workspaceSlug] = {
|
||||
...this.navigationPreferencesMap[workspaceSlug],
|
||||
[key]: beforeUpdateData,
|
||||
};
|
||||
});
|
||||
console.error("Failed to update sidebar preference:", error);
|
||||
}
|
||||
};
|
||||
|
||||
getNavigationPreferences = computedFn(
|
||||
(workspaceSlug: string): IWorkspaceSidebarNavigation | undefined => this.navigationPreferencesMap[workspaceSlug]
|
||||
);
|
||||
}
|
||||
127
apps/web/core/store/workspace/link.store.ts
Normal file
127
apps/web/core/store/workspace/link.store.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { set } from "lodash-es";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
// types
|
||||
import type { TLink, TLinkIdMap, TLinkMap } from "@plane/types";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
export interface IWorkspaceLinkStoreActions {
|
||||
addLinks: (projectId: string, links: TLink[]) => void;
|
||||
fetchLinks: (workspaceSlug: string) => Promise<TLink[]>;
|
||||
createLink: (workspaceSlug: string, data: Partial<TLink>) => Promise<TLink>;
|
||||
updateLink: (workspaceSlug: string, linkId: string, data: Partial<TLink>) => Promise<TLink>;
|
||||
removeLink: (workspaceSlug: string, linkId: string) => Promise<void>;
|
||||
setLinkData: (link: TLink | undefined) => void;
|
||||
toggleLinkModal: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface IWorkspaceLinkStore extends IWorkspaceLinkStoreActions {
|
||||
// observables
|
||||
links: TLinkIdMap;
|
||||
linkMap: TLinkMap;
|
||||
linkData: TLink | undefined;
|
||||
isLinkModalOpen: boolean;
|
||||
// helper methods
|
||||
getLinksByWorkspaceId: (projectId: string) => string[] | undefined;
|
||||
getLinkById: (linkId: string) => TLink | undefined;
|
||||
}
|
||||
|
||||
export class WorkspaceLinkStore implements IWorkspaceLinkStore {
|
||||
// observables
|
||||
links: TLinkIdMap = {};
|
||||
linkMap: TLinkMap = {};
|
||||
linkData: TLink | undefined = undefined;
|
||||
isLinkModalOpen = false;
|
||||
// services
|
||||
workspaceService: WorkspaceService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
links: observable,
|
||||
linkMap: observable,
|
||||
linkData: observable,
|
||||
isLinkModalOpen: observable,
|
||||
// actions
|
||||
addLinks: action.bound,
|
||||
fetchLinks: action,
|
||||
createLink: action,
|
||||
updateLink: action,
|
||||
removeLink: action,
|
||||
setLinkData: action,
|
||||
toggleLinkModal: action,
|
||||
});
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
}
|
||||
|
||||
// helper methods
|
||||
getLinksByWorkspaceId = (projectId: string) => {
|
||||
if (!projectId) return undefined;
|
||||
return this.links[projectId] ?? undefined;
|
||||
};
|
||||
|
||||
getLinkById = (linkId: string) => {
|
||||
if (!linkId) return undefined;
|
||||
return this.linkMap[linkId] ?? undefined;
|
||||
};
|
||||
|
||||
// actions
|
||||
setLinkData = (link: TLink | undefined) => {
|
||||
runInAction(() => {
|
||||
this.linkData = link;
|
||||
});
|
||||
};
|
||||
|
||||
toggleLinkModal = (isOpen: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isLinkModalOpen = isOpen;
|
||||
});
|
||||
};
|
||||
|
||||
addLinks = (workspaceSlug: string, links: TLink[]) => {
|
||||
runInAction(() => {
|
||||
this.links[workspaceSlug] = links.map((link) => link.id);
|
||||
links.forEach((link) => set(this.linkMap, link.id, link));
|
||||
});
|
||||
};
|
||||
|
||||
fetchLinks = async (workspaceSlug: string) => {
|
||||
const response = await this.workspaceService.fetchWorkspaceLinks(workspaceSlug);
|
||||
this.addLinks(workspaceSlug, response);
|
||||
return response;
|
||||
};
|
||||
|
||||
createLink = async (workspaceSlug: string, data: Partial<TLink>) => {
|
||||
const response = await this.workspaceService.createWorkspaceLink(workspaceSlug, data);
|
||||
|
||||
runInAction(() => {
|
||||
this.links[workspaceSlug] = [response.id, ...(this.links[workspaceSlug] ?? [])];
|
||||
set(this.linkMap, response.id, response);
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
updateLink = async (workspaceSlug: string, linkId: string, data: Partial<TLink>) => {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
set(this.linkMap, [linkId, key], data[key as keyof TLink]);
|
||||
});
|
||||
});
|
||||
|
||||
const response = await this.workspaceService.updateWorkspaceLink(workspaceSlug, linkId, data);
|
||||
return response;
|
||||
};
|
||||
|
||||
removeLink = async (workspaceSlug: string, linkId: string) => {
|
||||
// const issueLinkCount = this.getLinksByWorkspaceId(projectId)?.length ?? 0;
|
||||
await this.workspaceService.deleteWorkspaceLink(workspaceSlug, linkId);
|
||||
|
||||
const linkIndex = this.links[workspaceSlug].findIndex((link) => link === linkId);
|
||||
if (linkIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.links[workspaceSlug].splice(linkIndex, 1);
|
||||
delete this.linkMap[linkId];
|
||||
});
|
||||
};
|
||||
}
|
||||
196
apps/web/core/store/workspace/webhook.store.ts
Normal file
196
apps/web/core/store/workspace/webhook.store.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// mobx
|
||||
import { action, observable, makeObservable, computed, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import type { IWebhook } from "@plane/types";
|
||||
// services
|
||||
import { WebhookService } from "@/services/webhook.service";
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
export interface IWebhookStore {
|
||||
// observables
|
||||
webhooks: Record<string, IWebhook> | null;
|
||||
webhookSecretKey: string | null;
|
||||
// computed
|
||||
currentWebhook: IWebhook | null;
|
||||
// computed actions
|
||||
getWebhookById: (webhookId: string) => IWebhook | null;
|
||||
// fetch actions
|
||||
fetchWebhooks: (workspaceSlug: string) => Promise<IWebhook[]>;
|
||||
fetchWebhookById: (workspaceSlug: string, webhookId: string) => Promise<IWebhook>;
|
||||
// crud actions
|
||||
createWebhook: (
|
||||
workspaceSlug: string,
|
||||
data: Partial<IWebhook>
|
||||
) => Promise<{ webHook: IWebhook; secretKey: string | null }>;
|
||||
updateWebhook: (workspaceSlug: string, webhookId: string, data: Partial<IWebhook>) => Promise<IWebhook>;
|
||||
removeWebhook: (workspaceSlug: string, webhookId: string) => Promise<void>;
|
||||
// secret key actions
|
||||
regenerateSecretKey: (
|
||||
workspaceSlug: string,
|
||||
webhookId: string
|
||||
) => Promise<{ webHook: IWebhook; secretKey: string | null }>;
|
||||
clearSecretKey: () => void;
|
||||
}
|
||||
|
||||
export class WebhookStore implements IWebhookStore {
|
||||
// observables
|
||||
webhooks: Record<string, IWebhook> | null = null;
|
||||
webhookSecretKey: string | null = null;
|
||||
// services
|
||||
webhookService;
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
webhooks: observable,
|
||||
webhookSecretKey: observable.ref,
|
||||
// computed
|
||||
currentWebhook: computed,
|
||||
// fetch actions
|
||||
fetchWebhooks: action,
|
||||
fetchWebhookById: action,
|
||||
// CRUD actions
|
||||
createWebhook: action,
|
||||
updateWebhook: action,
|
||||
removeWebhook: action,
|
||||
// secret key actions
|
||||
regenerateSecretKey: action,
|
||||
clearSecretKey: action,
|
||||
});
|
||||
|
||||
// services
|
||||
this.webhookService = new WebhookService();
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* computed value of current webhook based on webhook id saved in the query store
|
||||
*/
|
||||
get currentWebhook() {
|
||||
const webhookId = this.rootStore.router.webhookId;
|
||||
if (!webhookId) return null;
|
||||
const currentWebhook = this.webhooks?.[webhookId] ?? null;
|
||||
return currentWebhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* get webhook info from the object of webhooks in the store using webhook id
|
||||
* @param webhookId
|
||||
*/
|
||||
getWebhookById = computedFn((webhookId: string) => this.webhooks?.[webhookId] || null);
|
||||
|
||||
/**
|
||||
* fetch all the webhooks for a workspace
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
fetchWebhooks = async (workspaceSlug: string) =>
|
||||
await this.webhookService.fetchWebhooksList(workspaceSlug).then((response) => {
|
||||
const webHookObject: { [webhookId: string]: IWebhook } = response.reduce((accumulator, currentWebhook) => {
|
||||
if (currentWebhook && currentWebhook.id) {
|
||||
return { ...accumulator, [currentWebhook.id]: currentWebhook };
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
runInAction(() => {
|
||||
this.webhooks = webHookObject;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* fetch webhook info from API using webhook id
|
||||
* @param workspaceSlug
|
||||
* @param webhookId
|
||||
*/
|
||||
fetchWebhookById = async (workspaceSlug: string, webhookId: string) =>
|
||||
await this.webhookService.fetchWebhookDetails(workspaceSlug, webhookId).then((response) => {
|
||||
runInAction(() => {
|
||||
this.webhooks = {
|
||||
...this.webhooks,
|
||||
[response.id]: response,
|
||||
};
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* create a new webhook for a workspace using the data
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
createWebhook = async (workspaceSlug: string, data: Partial<IWebhook>) =>
|
||||
await this.webhookService.createWebhook(workspaceSlug, data).then((response) => {
|
||||
const _secretKey = response?.secret_key ?? null;
|
||||
delete response?.secret_key;
|
||||
const _webhooks = this.webhooks;
|
||||
if (response && response.id && _webhooks) _webhooks[response.id] = response;
|
||||
runInAction(() => {
|
||||
this.webhookSecretKey = _secretKey || null;
|
||||
this.webhooks = _webhooks;
|
||||
});
|
||||
return { webHook: response, secretKey: _secretKey };
|
||||
});
|
||||
|
||||
/**
|
||||
* update a webhook using the data
|
||||
* @param workspaceSlug
|
||||
* @param webhookId
|
||||
* @param data
|
||||
*/
|
||||
updateWebhook = async (workspaceSlug: string, webhookId: string, data: Partial<IWebhook>) =>
|
||||
await this.webhookService.updateWebhook(workspaceSlug, webhookId, data).then((response) => {
|
||||
let _webhooks = this.webhooks;
|
||||
if (webhookId && _webhooks && this.webhooks)
|
||||
_webhooks = { ..._webhooks, [webhookId]: { ...this.webhooks[webhookId], ...data } };
|
||||
runInAction(() => {
|
||||
this.webhooks = _webhooks;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* delete a webhook using webhook id
|
||||
* @param workspaceSlug
|
||||
* @param webhookId
|
||||
*/
|
||||
removeWebhook = async (workspaceSlug: string, webhookId: string) =>
|
||||
await this.webhookService.deleteWebhook(workspaceSlug, webhookId).then(() => {
|
||||
const _webhooks = this.webhooks ?? {};
|
||||
delete _webhooks[webhookId];
|
||||
runInAction(() => {
|
||||
this.webhooks = _webhooks;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* regenerate secret key for a webhook using webhook id
|
||||
* @param workspaceSlug
|
||||
* @param webhookId
|
||||
*/
|
||||
regenerateSecretKey = async (workspaceSlug: string, webhookId: string) =>
|
||||
await this.webhookService.regenerateSecretKey(workspaceSlug, webhookId).then((response) => {
|
||||
const _secretKey = response?.secret_key ?? null;
|
||||
delete response?.secret_key;
|
||||
const _webhooks = this.webhooks;
|
||||
if (_webhooks && response && response.id) {
|
||||
_webhooks[response.id] = response;
|
||||
}
|
||||
runInAction(() => {
|
||||
this.webhookSecretKey = _secretKey || null;
|
||||
this.webhooks = _webhooks;
|
||||
});
|
||||
return { webHook: response, secretKey: _secretKey };
|
||||
});
|
||||
|
||||
/**
|
||||
* clear secret key from the store
|
||||
*/
|
||||
clearSecretKey = () => {
|
||||
this.webhookSecretKey = null;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user