feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View 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;
});
};
}

View 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;
}
};
}

View 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);
});
};
}

View 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] = {};
});
};
}

View 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;
}
};
}

View 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];
});
}
};
}

View 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;
}
};
}

View 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;
}
};
}

View 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;
}
};
}

View 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];
});
});
}

View 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");
}
};
}

View 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;
}
};
}

View 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;
}
};
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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;
}
};

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
};
}

View 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;
};
}

View File

@@ -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;
}
};
}

View 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;
};
}

View 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);
};
}

View 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;
};
}

View 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");
}
};
}

View 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);
}

View 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
);
}
}
};
}

View File

@@ -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);
};
}

View 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;
}
};
}

View 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;
});
}

View 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;
});
};
}

View 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;
});
};
}

View 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],
};
};
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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);
});
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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);
}
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./issue.store";
export * from "./filter.store";

View 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) => {};
}

View 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;
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./filter.store";
export * from "./issue.store";

View 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;
}

View 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]);
});
};
}

View 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);
}

View 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);
});
});
};
}

View File

@@ -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 };
};
}

View 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
);
};

View File

@@ -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 };
};
}

View 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;
});
}

View 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);
});
};
}

View 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();
};
}

View 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;
});
};
}

View 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;
}
};
}

View File

@@ -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));
}
};
}

View 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);
});
};
}

View 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;
});
};
}

View 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;
}
};
}

View 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}`;
});
}

View 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);
});
}
};
}

View 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);
}
}

View 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;
}
};
}

View 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;
});
};
}

View 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);
});
});
};
}

View 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();
}
}

View 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();
}
}

View 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;
});
}

View 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;
}
};
}

View 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());
};
}

View 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);
});
}
}

View 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);
});
}
}

View 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);
}

View 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);
});
}
}

View 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;
}
};
}

View 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;
}
}

View 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;
}
};
}

View 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;
}
};
}

View 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;
});
});
}

View 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;
}
};
}

View 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]
);
}

View 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];
});
};
}

View 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;
};
}