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,42 @@
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { SitesCycleService } from "@plane/services";
import type { TPublicCycle } from "@/types/cycle";
// store
import type { CoreRootStore } from "./root.store";
export interface ICycleStore {
// observables
cycles: TPublicCycle[] | undefined;
// computed actions
getCycleById: (cycleId: string | undefined) => TPublicCycle | undefined;
// fetch actions
fetchCycles: (anchor: string) => Promise<TPublicCycle[]>;
}
export class CycleStore implements ICycleStore {
cycles: TPublicCycle[] | undefined = undefined;
cycleService: SitesCycleService;
rootStore: CoreRootStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
cycles: observable,
// fetch action
fetchCycles: action,
});
this.cycleService = new SitesCycleService();
this.rootStore = _rootStore;
}
getCycleById = (cycleId: string | undefined) => this.cycles?.find((cycle) => cycle.id === cycleId);
fetchCycles = async (anchor: string) => {
const cyclesResponse = await this.cycleService.list(anchor);
runInAction(() => {
this.cycles = cyclesResponse;
});
return cyclesResponse;
};
}

View File

@@ -0,0 +1,515 @@
import { concat, get, set, uniq, update } from "lodash-es";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { ALL_ISSUES } from "@plane/constants";
import { SitesIssueService } from "@plane/services";
import type {
TIssueGroupByOptions,
TGroupedIssues,
TSubGroupedIssues,
TLoader,
IssuePaginationOptions,
TIssues,
TIssuePaginationData,
TGroupedIssueCount,
TPaginationData,
} from "@plane/types";
// types
import type { IIssue, TIssuesResponse } from "@/types/issue";
import type { CoreRootStore } from "../root.store";
// constants
// helpers
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null | "team_project"> | "target_date";
export enum EIssueGroupedAction {
ADD = "ADD",
DELETE = "DELETE",
REORDER = "REORDER",
}
export interface IBaseIssuesStore {
// observable
loader: Record<string, TLoader>;
// actions
addIssue(issues: IIssue[], shouldReplace?: boolean): void;
// helper methods
groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup
groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup
issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup
// helper methods
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;
}
export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof IIssue> = {
project: "project_id",
cycle: "cycle_id",
module: "module_ids",
state: "state_id",
"state_detail.group": "state_group" as keyof IIssue, // state_detail.group is only being used for state_group display,
priority: "priority",
labels: "label_ids",
created_by: "created_by",
assignees: "assignee_ids",
target_date: "target_date",
};
export abstract class BaseIssuesStore implements IBaseIssuesStore {
loader: Record<string, TLoader> = {};
groupedIssueIds: TIssues | undefined = undefined;
issuePaginationData: TIssuePaginationData = {};
groupedIssueCount: TGroupedIssueCount = {};
//
paginationOptions: IssuePaginationOptions | undefined = undefined;
issueService;
// root store
rootIssueStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observable
loader: observable,
groupedIssueIds: observable,
issuePaginationData: observable,
groupedIssueCount: observable,
paginationOptions: observable,
// action
storePreviousPaginationValues: action.bound,
onfetchIssues: action.bound,
onfetchNexIssues: action.bound,
clear: action.bound,
setLoader: action.bound,
});
this.rootIssueStore = _rootStore;
this.issueService = new SitesIssueService();
}
getIssueIds = (groupId?: string, subGroupId?: string) => {
const groupedIssueIds = this.groupedIssueIds;
if (!groupedIssueIds) return undefined;
const allIssues = groupedIssueIds[ALL_ISSUES] ?? [];
if (allIssues && Array.isArray(allIssues)) {
return allIssues as string[];
}
if (groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) {
return (groupedIssueIds[groupId] ?? []) as string[];
}
if (groupId && subGroupId) {
return ((groupedIssueIds as TSubGroupedIssues)[groupId]?.[subGroupId] ?? []) as string[];
}
return undefined;
};
/**
* @description This method will add issues to the issuesMap
* @param {IIssue[]} issues
* @returns {void}
*/
addIssue = (issues: IIssue[], shouldReplace = false) => {
if (issues && issues.length <= 0) return;
runInAction(() => {
issues.forEach((issue) => {
if (!this.rootIssueStore.issueDetail.getIssueById(issue.id) || shouldReplace)
set(this.rootIssueStore.issueDetail.details, issue.id, issue);
});
});
};
/**
* Store the pagination data required for next subsequent issue pagination calls
* @param prevCursor cursor value of previous page
* @param nextCursor cursor value of next page
* @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages
* @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup
* @param subGroupId
*/
setPaginationData(
prevCursor: string,
nextCursor: string,
nextPageResults: boolean,
groupId?: string,
subGroupId?: string
) {
const cursorObject = {
prevCursor,
nextCursor,
nextPageResults,
};
set(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)], cursorObject);
}
/**
* Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined
* @param loaderValue
* @param groupId
* @param subGroupId
*/
setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) {
runInAction(() => {
set(this.loader, this.getGroupKey(groupId, subGroupId), loaderValue);
});
}
/**
* gets the Loader value of particular group/subgroup/ALL_ISSUES
*/
getIssueLoader = (groupId?: string, subGroupId?: string) => get(this.loader, this.getGroupKey(groupId, subGroupId));
/**
* gets the pagination data of particular group/subgroup/ALL_ISSUES
*/
getPaginationData = computedFn(
(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined =>
get(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)])
);
/**
* gets the issue count of particular group/subgroup/ALL_ISSUES
*
* if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds
*/
getGroupIssueCount = computedFn(
(
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
): number | undefined => {
if (isSubGroupCumulative && subGroupId) {
const groupIssuesKeys = Object.keys(this.groupedIssueCount);
let subGroupCumulativeCount = 0;
for (const groupKey of groupIssuesKeys) {
if (groupKey.includes(`_${subGroupId}`)) subGroupCumulativeCount += this.groupedIssueCount[groupKey];
}
return subGroupCumulativeCount;
}
return get(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)]);
}
);
/**
* This Method is called after fetching the first paginated issues
*
* This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined
* If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES
* @param issuesResponse Paginated Response received from the API
* @param options Pagination options
* @param workspaceSlug
* @param projectId
* @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store
*/
onfetchIssues(issuesResponse: TIssuesResponse, options: IssuePaginationOptions) {
// Process the Issue Response to get the following data from it
const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse);
// The Issue list is added to the main Issue Map
this.addIssue(issueList);
// Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts
runInAction(() => {
this.updateGroupedIssueIds(groupedIssues, groupedIssueCount);
this.loader[this.getGroupKey()] = undefined;
});
// store Pagination options for next subsequent calls and data like next cursor etc
this.storePreviousPaginationValues(issuesResponse, options);
}
/**
* This Method is called on the subsequent pagination calls after the first initial call
*
* This method updates the appropriate issue list based on if groupId or subgroupIds are Passed
* @param issuesResponse Paginated Response received from the API
* @param groupId
* @param subGroupId
*/
onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) {
// Process the Issue Response to get the following data from it
const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse);
// The Issue list is added to the main Issue Map
this.addIssue(issueList);
// Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts
runInAction(() => {
this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId);
this.loader[this.getGroupKey(groupId, subGroupId)] = undefined;
});
// store Pagination data like next cursor etc
this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId);
}
/**
* Method called to clear out the current store
*/
clear(shouldClearPaginationOptions = true) {
runInAction(() => {
this.groupedIssueIds = undefined;
this.issuePaginationData = {};
this.groupedIssueCount = {};
if (shouldClearPaginationOptions) {
this.paginationOptions = undefined;
}
});
}
/**
* This method processes the issueResponse to provide data that can be used to update the store
* @param issueResponse
* @returns issueList, list of issue Data
* @returns groupedIssues, grouped issue Ids
* @returns groupedIssueCount, object containing issue counts of individual groups
*/
processIssueResponse(issueResponse: TIssuesResponse): {
issueList: IIssue[];
groupedIssues: TIssues;
groupedIssueCount: TGroupedIssueCount;
} {
const issueResult = issueResponse?.results;
// if undefined return empty objects
if (!issueResult)
return {
issueList: [],
groupedIssues: {},
groupedIssueCount: {},
};
//if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES
if (Array.isArray(issueResult)) {
return {
issueList: issueResult,
groupedIssues: {
[ALL_ISSUES]: issueResult.map((issue) => issue.id),
},
groupedIssueCount: {
[ALL_ISSUES]: issueResponse.total_count,
},
};
}
const issueList: IIssue[] = [];
const groupedIssues: TGroupedIssues | TSubGroupedIssues = {};
const groupedIssueCount: TGroupedIssueCount = {};
// update total issue count to ALL_ISSUES
set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count);
// loop through all the groupIds from issue Result
for (const groupId in issueResult) {
const groupIssuesObject = issueResult[groupId];
const groupIssueResult = groupIssuesObject?.results;
// if groupIssueResult is undefined then continue the loop
if (!groupIssueResult) continue;
// set grouped Issue count of the current groupId
set(groupedIssueCount, [groupId], groupIssuesObject.total_results);
// if groupIssueResult, the it is not subGrouped
if (Array.isArray(groupIssueResult)) {
// add the result to issueList
issueList.push(...groupIssueResult);
// set the issue Ids to the groupId path
set(
groupedIssues,
[groupId],
groupIssueResult.map((issue) => issue.id)
);
continue;
}
// loop through all the subGroupIds from issue Result
for (const subGroupId in groupIssueResult) {
const subGroupIssuesObject = groupIssueResult[subGroupId];
const subGroupIssueResult = subGroupIssuesObject?.results;
// if subGroupIssueResult is undefined then continue the loop
if (!subGroupIssueResult) continue;
// set sub grouped Issue count of the current groupId
set(groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results);
if (Array.isArray(subGroupIssueResult)) {
// add the result to issueList
issueList.push(...subGroupIssueResult);
// set the issue Ids to the [groupId, subGroupId] path
set(
groupedIssues,
[groupId, subGroupId],
subGroupIssueResult.map((issue) => issue.id)
);
continue;
}
}
}
return { issueList, groupedIssues, groupedIssueCount };
}
/**
* This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts
* @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups
* @param groupedIssueCount Object the contains the issue count of each groups
* @param groupId groupId string
* @param subGroupId subGroupId string
* @returns updates the store with the values
*/
updateGroupedIssueIds(
groupedIssues: TIssues,
groupedIssueCount: TGroupedIssueCount,
groupId?: string,
subGroupId?: string
) {
// if groupId exists and groupedIssues has ALL_ISSUES as a group,
// then it's an individual group/subgroup pagination
if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) {
const issueGroup = groupedIssues[ALL_ISSUES];
const issueGroupCount = groupedIssueCount[ALL_ISSUES];
const issuesPath = [groupId];
// issuesPath is the path for the issue List in the Grouped Issue List
// issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination
if (subGroupId) issuesPath.push(subGroupId);
// update the issue Count of the particular group/subGroup
set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueGroupCount);
// update the issue list in the issuePath
this.updateIssueGroup(issueGroup, issuesPath);
return;
}
// if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination
// update total issue count as ALL_ISSUES count in `groupedIssueCount` object
set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]);
// loop through the groups of groupedIssues.
for (const groupId in groupedIssues) {
const issueGroup = groupedIssues[groupId];
const issueGroupCount = groupedIssueCount[groupId];
// update the groupId's issue count
set(this.groupedIssueCount, [groupId], issueGroupCount);
// This updates the group issue list in the store, if the issueGroup is a string
const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]);
// if issueGroup is indeed a string, continue
if (storeUpdated) continue;
// if issueGroup is not a string, loop through the sub group Issues
for (const subGroupId in issueGroup) {
const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId];
const issueSubGroupCount = groupedIssueCount[this.getGroupKey(groupId, subGroupId)];
// update the subGroupId's issue count
set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueSubGroupCount);
// This updates the subgroup issue list in the store
this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]);
}
}
}
/**
* This Method is used to update the issue Id list at the particular issuePath
* @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped
* @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list
* @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath
*/
updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean {
if (!groupedIssueIds) return true;
// if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath
if (groupedIssueIds && Array.isArray(groupedIssueIds)) {
update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) =>
uniq(concat(issueIds, groupedIssueIds as string[]))
);
// return true to indicate the store has been updated
return true;
}
// return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues
return false;
}
/**
* This method is used to update the count of the issues at the path with the increment
* @param path issuePath, corresponding key is to be incremented
* @param increment
*/
updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) {
const updateKeys = Object.keys(accumulatedUpdatesForCount);
for (const updateKey of updateKeys) {
const update = accumulatedUpdatesForCount[updateKey];
if (!update) continue;
const increment = update === EIssueGroupedAction.ADD ? 1 : -1;
// get current count at the key
const issueCount = get(this.groupedIssueCount, updateKey) ?? 0;
// update the count at the key
set(this.groupedIssueCount, updateKey, issueCount + increment);
}
}
/**
* This Method is called to store the pagination options and paginated data from response
* @param issuesResponse issue list response
* @param options pagination options to be stored for next page call
* @param groupId
* @param subGroupId
*/
storePreviousPaginationValues = (
issuesResponse: TIssuesResponse,
options?: IssuePaginationOptions,
groupId?: string,
subGroupId?: string
) => {
if (options) this.paginationOptions = options;
this.setPaginationData(
issuesResponse.prev_cursor,
issuesResponse.next_cursor,
issuesResponse.next_page_results,
groupId,
subGroupId
);
};
/**
* 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
*/
getGroupKey = (groupId?: string, subGroupId?: string) => {
if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`;
if (groupId) return groupId;
return ALL_ISSUES;
};
}

View File

@@ -0,0 +1,73 @@
import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants";
import type { IssuePaginationOptions, TIssueParams } from "@plane/types";
/**
* 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
*/
export const 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 = EIssueGroupByToServerOptions[options.groupedBy];
}
// If group by is specifically sent through options, like that for calendar layout, use that to group
if (options.subGroupedBy) {
paginationParams.sub_group_by = EIssueGroupByToServerOptions[options.subGroupedBy];
}
// If group by is specifically sent through options, like that for calendar layout, use that to group
if (options.orderBy) {
paginationParams.order_by = options.orderBy;
}
// 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,77 @@
import { set } from "lodash-es";
import { observable, action, makeObservable, runInAction } from "mobx";
// plane imports
import { InstanceService } from "@plane/services";
import type { IInstance, IInstanceConfig } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
type TError = {
status: string;
message: string;
data?: {
is_activated: boolean;
is_setup_done: boolean;
};
};
export interface IInstanceStore {
// observables
isLoading: boolean;
instance: IInstance | undefined;
config: IInstanceConfig | undefined;
error: TError | undefined;
// action
fetchInstanceInfo: () => Promise<void>;
hydrate: (data: IInstance) => void;
}
export class InstanceStore implements IInstanceStore {
isLoading: boolean = true;
instance: IInstance | undefined = undefined;
config: IInstanceConfig | undefined = undefined;
error: TError | undefined = undefined;
// services
instanceService;
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
instance: observable,
config: observable,
error: observable,
// actions
fetchInstanceInfo: action,
hydrate: action,
});
// services
this.instanceService = new InstanceService();
}
hydrate = (data: IInstance) => set(this, "instance", data);
/**
* @description fetching instance information
*/
fetchInstanceInfo = async () => {
try {
this.isLoading = true;
this.error = undefined;
const instanceInfo = await this.instanceService.info();
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",
};
});
}
};
}

View File

@@ -0,0 +1,441 @@
import { isEmpty, set } from "lodash-es";
import { makeObservable, observable, action, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// plane imports
import { SitesFileService, SitesIssueService } from "@plane/services";
import type { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types";
import { EFileAssetType } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
// types
import type { IIssue, IPeekMode, IVote } from "@/types/issue";
export interface IIssueDetailStore {
loader: boolean;
error: any;
// observables
peekId: string | null;
peekMode: IPeekMode;
details: {
[key: string]: IIssue;
};
// computed actions
getIsIssuePeeked: (issueID: string) => boolean;
// actions
getIssueById: (issueId: string) => IIssue | undefined;
setPeekId: (issueID: string | null) => void;
setPeekMode: (mode: IPeekMode) => void;
// issue actions
fetchIssueDetails: (anchor: string, issueID: string) => void;
// comment actions
addIssueComment: (anchor: string, issueID: string, data: any) => Promise<TIssuePublicComment>;
updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise<any>;
deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void;
uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise<TFileSignedURLResponse>;
uploadIssueAsset: (file: File, anchor: string, commentID?: string) => Promise<TFileSignedURLResponse>;
addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void;
removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void;
// reaction actions
addIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void;
removeIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void;
// vote actions
addIssueVote: (anchor: string, issueID: string, data: { vote: 1 | -1 }) => Promise<void>;
removeIssueVote: (anchor: string, issueID: string) => Promise<void>;
}
export class IssueDetailStore implements IIssueDetailStore {
loader: boolean = false;
error: any = null;
// observables
peekId: string | null = null;
peekMode: IPeekMode = "side";
details: {
[key: string]: IIssue;
} = {};
// root store
rootStore: CoreRootStore;
// services
issueService: SitesIssueService;
fileService: SitesFileService;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
loader: observable.ref,
error: observable.ref,
// observables
peekId: observable.ref,
peekMode: observable.ref,
details: observable,
// actions
setPeekId: action,
setPeekMode: action,
// issue actions
fetchIssueDetails: action,
// comment actions
addIssueComment: action,
updateIssueComment: action,
deleteIssueComment: action,
uploadCommentAsset: action,
uploadIssueAsset: action,
addCommentReaction: action,
removeCommentReaction: action,
// reaction actions
addIssueReaction: action,
removeIssueReaction: action,
// vote actions
addIssueVote: action,
removeIssueVote: action,
});
this.rootStore = _rootStore;
this.issueService = new SitesIssueService();
this.fileService = new SitesFileService();
}
setPeekId = (issueID: string | null) => {
this.peekId = issueID;
};
setPeekMode = (mode: IPeekMode) => {
this.peekMode = mode;
};
getIsIssuePeeked = (issueID: string) => this.peekId === issueID;
/**
* @description This method will return the issue from the issuesMap
* @param {string} issueId
* @returns {IIssue | undefined}
*/
getIssueById = computedFn((issueId: string) => {
if (!issueId || isEmpty(this.details) || !this.details[issueId]) return undefined;
return this.details[issueId];
});
/**
* Retrieves issue from API
* @param anchorId ]
* @param issueId
* @returns
*/
fetchIssueById = async (anchorId: string, issueId: string) => {
try {
const issueDetails = await this.issueService.retrieve(anchorId, issueId);
runInAction(() => {
set(this.details, [issueId], issueDetails);
});
return issueDetails;
} catch (e) {
console.error(`Error fetching issue details for issueId ${issueId}: `, e);
}
};
/**
* @description fetc
* @param {string} anchor
* @param {string} issueID
*/
fetchIssueDetails = async (anchor: string, issueID: string) => {
try {
this.loader = true;
this.error = null;
const issueDetails = await this.fetchIssueById(anchor, issueID);
const commentsResponse = await this.issueService.listComments(anchor, issueID);
if (issueDetails) {
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...(this.details[issueID] ?? issueDetails),
comments: commentsResponse,
},
};
});
}
} catch (error) {
this.loader = false;
this.error = error;
}
};
addIssueComment = async (anchor: string, issueID: string, data: any) => {
try {
const issueDetails = this.getIssueById(issueID);
const issueCommentResponse = await this.issueService.addComment(anchor, issueID, data);
if (issueDetails) {
runInAction(() => {
set(this.details, [issueID, "comments"], [...(issueDetails?.comments ?? []), issueCommentResponse]);
});
}
return issueCommentResponse;
} catch (error) {
console.log("Failed to add issue comment");
throw error;
}
};
updateIssueComment = async (anchor: string, issueID: string, commentID: string, data: any) => {
try {
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: this.details[issueID].comments.map((c) => ({
...c,
...(c.id === commentID ? data : {}),
})),
},
};
});
await this.issueService.updateComment(anchor, issueID, commentID, data);
} catch (_error) {
const issueComments = await this.issueService.listComments(anchor, issueID);
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: issueComments,
},
};
});
}
};
deleteIssueComment = async (anchor: string, issueID: string, commentID: string) => {
try {
await this.issueService.removeComment(anchor, issueID, commentID);
const remainingComments = this.details[issueID].comments.filter((c) => c.id != commentID);
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: remainingComments,
},
};
});
} catch (_error) {
console.log("Failed to add issue vote");
}
};
uploadCommentAsset = async (file: File, anchor: string, commentID?: string) => {
try {
const res = await this.fileService.uploadAsset(
anchor,
{
entity_identifier: commentID ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file
);
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
};
uploadIssueAsset = async (file: File, anchor: string, commentID?: string) => {
try {
const res = await this.fileService.uploadAsset(
anchor,
{
entity_identifier: commentID ?? "",
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file
);
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
};
addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => {
const newReaction = {
id: uuidv4(),
comment: commentID,
reaction: reactionHex,
actor_detail: this.rootStore.user.currentActor,
};
const newComments = this.details[issueID].comments.map((comment) => ({
...comment,
comment_reactions:
comment.id === commentID ? [...comment.comment_reactions, newReaction] : comment.comment_reactions,
}));
try {
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: [...newComments],
},
};
});
await this.issueService.addCommentReaction(anchor, commentID, {
reaction: reactionHex,
});
} catch (_error) {
const issueComments = await this.issueService.listComments(anchor, issueID);
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: issueComments,
},
};
});
}
};
removeCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => {
try {
const comment = this.details[issueID].comments.find((c) => c.id === commentID);
const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? [];
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: this.details[issueID].comments.map((c) => ({
...c,
comment_reactions: c.id === commentID ? newCommentReactions : c.comment_reactions,
})),
},
};
});
await this.issueService.removeCommentReaction(anchor, commentID, reactionHex);
} catch (_error) {
const issueComments = await this.issueService.listComments(anchor, issueID);
runInAction(() => {
this.details = {
...this.details,
[issueID]: {
...this.details[issueID],
comments: issueComments,
},
};
});
}
};
addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => {
try {
runInAction(() => {
set(
this.details,
[issueID, "reaction_items"],
[
...this.details[issueID].reaction_items,
{
reaction: reactionHex,
actor_details: this.rootStore.user.currentActor,
},
]
);
});
await this.issueService.addReaction(anchor, issueID, {
reaction: reactionHex,
});
} catch (_error) {
console.log("Failed to add issue vote");
const issueReactions = await this.issueService.listReactions(anchor, issueID);
runInAction(() => {
set(this.details, [issueID, "reaction_items"], issueReactions);
});
}
};
removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => {
try {
const newReactions = this.details[issueID].reaction_items.filter(
(_r) => !(_r.reaction === reactionHex && _r.actor_details.id === this.rootStore.user.data?.id)
);
runInAction(() => {
set(this.details, [issueID, "reaction_items"], newReactions);
});
await this.issueService.removeReaction(anchor, issueID, reactionHex);
} catch (_error) {
console.log("Failed to remove issue reaction");
const reactions = await this.issueService.listReactions(anchor, issueID);
runInAction(() => {
set(this.details, [issueID, "reaction_items"], reactions);
});
}
};
addIssueVote = async (anchor: string, issueID: string, data: { vote: 1 | -1 }) => {
const publishSettings = this.rootStore.publishList?.publishMap?.[anchor];
const projectID = publishSettings?.project;
const workspaceSlug = publishSettings?.workspace_detail?.slug;
if (!projectID || !workspaceSlug) throw new Error("Publish settings not found");
const newVote: IVote = {
actor_details: this.rootStore.user.currentActor,
vote: data.vote,
};
const filteredVotes = this.details[issueID].vote_items.filter(
(v) => v.actor_details?.id !== this.rootStore.user.data?.id
);
try {
runInAction(() => {
runInAction(() => {
set(this.details, [issueID, "vote_items"], [...filteredVotes, newVote]);
});
});
await this.issueService.addVote(anchor, issueID, data);
} catch (_error) {
console.log("Failed to add issue vote");
const issueVotes = await this.issueService.listVotes(anchor, issueID);
runInAction(() => {
set(this.details, [issueID, "vote_items"], issueVotes);
});
}
};
removeIssueVote = async (anchor: string, issueID: string) => {
const newVotes = this.details[issueID].vote_items.filter(
(v) => v.actor_details?.id !== this.rootStore.user.data?.id
);
try {
runInAction(() => {
set(this.details, [issueID, "vote_items"], newVotes);
});
await this.issueService.removeVote(anchor, issueID);
} catch (_error) {
console.log("Failed to remove issue vote");
const issueVotes = await this.issueService.listVotes(anchor, issueID);
runInAction(() => {
set(this.details, [issueID, "vote_items"], issueVotes);
});
}
};
}

View File

@@ -0,0 +1,158 @@
import { cloneDeep, isEqual, set } from "lodash-es";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane internal
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants";
import type { IssuePaginationOptions, TIssueParams } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
// types
import type {
TIssueLayoutOptions,
TIssueFilters,
TIssueQueryFilters,
TIssueQueryFiltersParams,
TIssueFilterKeys,
} from "@/types/issue";
import { getPaginationParams } from "./helpers/filter.helpers";
export interface IIssueFilterStore {
// observables
layoutOptions: TIssueLayoutOptions;
filters: { [anchor: string]: TIssueFilters } | undefined;
// computed
isIssueFiltersUpdated: (anchor: string, filters: TIssueFilters) => boolean;
// helpers
getIssueFilters: (anchor: string) => TIssueFilters | undefined;
getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined;
// actions
updateLayoutOptions: (layout: TIssueLayoutOptions) => void;
initIssueFilters: (anchor: string, filters: TIssueFilters, shouldFetchIssues?: boolean) => void;
updateIssueFilters: <K extends keyof TIssueFilters>(
anchor: string,
filterKind: K,
filterKey: keyof TIssueFilters[K],
filters: TIssueFilters[K][typeof filterKey]
) => Promise<void>;
getFilterParams: (
options: IssuePaginationOptions,
anchor: string,
cursor: string | undefined,
groupId: string | undefined,
subGroupId: string | undefined
) => Partial<Record<TIssueParams, string | boolean>>;
}
export class IssueFilterStore implements IIssueFilterStore {
// observables
layoutOptions: TIssueLayoutOptions = {
list: true,
kanban: false,
calendar: false,
gantt: false,
spreadsheet: false,
};
filters: { [anchor: string]: TIssueFilters } | undefined = undefined;
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
layoutOptions: observable,
filters: observable,
// actions
updateLayoutOptions: action,
initIssueFilters: action,
updateIssueFilters: action,
});
}
// helper methods
computedFilter = (filters: TIssueQueryFilters, filteredParams: TIssueFilterKeys[]) => {
const computedFilters: TIssueQueryFiltersParams = {};
Object.keys(filters).map((key) => {
const currentFilterKey = key as TIssueFilterKeys;
const filterValue = filters[currentFilterKey] as any;
if (filterValue !== undefined && filteredParams.includes(currentFilterKey)) {
if (Array.isArray(filterValue)) computedFilters[currentFilterKey] = filterValue.join(",");
else if (typeof filterValue === "string" || typeof filterValue === "boolean")
computedFilters[currentFilterKey] = filterValue.toString();
}
});
return computedFilters;
};
// computed
getIssueFilters = computedFn((anchor: string) => {
const currentFilters = this.filters?.[anchor];
return currentFilters;
});
getAppliedFilters = computedFn((anchor: string) => {
const issueFilters = this.getIssueFilters(anchor);
if (!issueFilters) return undefined;
const currentLayout = issueFilters?.display_filters?.layout;
if (!currentLayout) return undefined;
const currentFilters: TIssueQueryFilters = {
priority: issueFilters?.filters?.priority || undefined,
state: issueFilters?.filters?.state || undefined,
labels: issueFilters?.filters?.labels || undefined,
};
const filteredParams = ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[currentLayout]?.filters || [];
const currentFilterQueryParams: TIssueQueryFiltersParams = this.computedFilter(currentFilters, filteredParams);
return currentFilterQueryParams;
});
isIssueFiltersUpdated = computedFn((anchor: string, userFilters: TIssueFilters) => {
const issueFilters = this.getIssueFilters(anchor);
if (!issueFilters) return false;
const currentUserFilters = cloneDeep(userFilters?.filters || {});
const currentIssueFilters = cloneDeep(issueFilters?.filters || {});
return isEqual(currentUserFilters, currentIssueFilters);
});
// actions
updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options);
initIssueFilters = async (anchor: string, initFilters: TIssueFilters, shouldFetchIssues: boolean = false) => {
if (this.filters === undefined) runInAction(() => (this.filters = {}));
if (this.filters && initFilters) set(this.filters, [anchor], initFilters);
if (shouldFetchIssues) await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation");
};
getFilterParams = computedFn(
(
options: IssuePaginationOptions,
anchor: string,
cursor: string | undefined,
groupId: string | undefined,
subGroupId: string | undefined
) => {
const filterParams = this.getAppliedFilters(anchor);
const paginationParams = getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
return paginationParams;
}
);
updateIssueFilters = async <K extends keyof TIssueFilters>(
anchor: string,
filterKind: K,
filterKey: keyof TIssueFilters[K],
filterValue: TIssueFilters[K][typeof filterKey]
) => {
if (!filterKind || !filterKey || !filterValue) return;
if (this.filters === undefined) runInAction(() => (this.filters = {}));
runInAction(() => {
if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue);
});
if (filterKey !== "layout") await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation");
};
}

View File

@@ -0,0 +1,112 @@
import { action, makeObservable, runInAction } from "mobx";
// plane imports
import { SitesIssueService } from "@plane/services";
import type { IssuePaginationOptions, TLoader } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
// types
import { BaseIssuesStore } from "./helpers/base-issues.store";
import type { IBaseIssuesStore } from "./helpers/base-issues.store";
export interface IIssueStore extends IBaseIssuesStore {
// actions
fetchPublicIssues: (
anchor: string,
loadType: TLoader,
options: IssuePaginationOptions,
isExistingPaginationOptions?: boolean
) => Promise<void>;
fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise<void>;
fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise<void>;
}
export class IssueStore extends BaseIssuesStore implements IIssueStore {
// root store
rootStore: CoreRootStore;
// services
issueService: SitesIssueService;
constructor(_rootStore: CoreRootStore) {
super(_rootStore);
makeObservable(this, {
// actions
fetchPublicIssues: action,
fetchNextPublicIssues: action,
fetchPublicIssuesWithExistingPagination: action,
});
this.rootStore = _rootStore;
this.issueService = new SitesIssueService();
}
/**
* @description fetch issues, states and labels
* @param {string} anchor
* @param params
*/
fetchPublicIssues = async (
anchor: string,
loadType: TLoader = "init-loader",
options: IssuePaginationOptions,
isExistingPaginationOptions: boolean = false
) => {
try {
// set loader and clear store
runInAction(() => {
this.setLoader(loadType);
});
this.clear(!isExistingPaginationOptions);
const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined);
const response = await this.issueService.list(anchor, params);
// after fetching issues, call the base method to process the response further
this.onfetchIssues(response, options);
} catch (error) {
this.setLoader(undefined);
throw error;
}
};
fetchNextPublicIssues = async (anchor: 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.rootStore.issueFilter.getFilterParams(
this.paginationOptions,
anchor,
cursorObject?.nextCursor,
groupId,
subGroupId
);
// call the fetch issues API with the params for next page in issues
const response = await this.issueService.list(anchor, params);
// after the next page of issues are fetched, call the base method to process the response
this.onfetchNexIssues(response, groupId, subGroupId);
} 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
*/
fetchPublicIssuesWithExistingPagination = async (anchor: string, loadType: TLoader = "mutation") => {
if (!this.paginationOptions) return;
return await this.fetchPublicIssues(anchor, loadType, this.paginationOptions, true);
};
}

View File

@@ -0,0 +1,65 @@
import { set } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { SitesLabelService } from "@plane/services";
import type { IIssueLabel } from "@plane/types";
// store
import type { CoreRootStore } from "./root.store";
export interface IIssueLabelStore {
// observables
labels: IIssueLabel[] | undefined;
// computed actions
getLabelById: (labelId: string | undefined) => IIssueLabel | undefined;
getLabelsByIds: (labelIds: string[]) => IIssueLabel[];
// fetch actions
fetchLabels: (anchor: string) => Promise<IIssueLabel[]>;
}
export class LabelStore implements IIssueLabelStore {
labelMap: Record<string, IIssueLabel> = {};
labelService: SitesLabelService;
rootStore: CoreRootStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
labelMap: observable,
// computed
labels: computed,
// fetch action
fetchLabels: action,
});
this.labelService = new SitesLabelService();
this.rootStore = _rootStore;
}
get labels() {
return Object.values(this.labelMap);
}
getLabelById = (labelId: string | undefined) => (labelId ? this.labelMap[labelId] : undefined);
getLabelsByIds = (labelIds: string[]) => {
const currLabels = [];
for (const labelId of labelIds) {
const label = this.getLabelById(labelId);
if (label) {
currLabels.push(label);
}
}
return currLabels;
};
fetchLabels = async (anchor: string) => {
const labelsResponse = await this.labelService.list(anchor);
runInAction(() => {
this.labelMap = {};
for (const label of labelsResponse) {
set(this.labelMap, [label.id], label);
}
});
return labelsResponse;
};
}

View File

@@ -0,0 +1,69 @@
import { set } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { SitesMemberService } from "@plane/services";
import type { TPublicMember } from "@/types/member";
import type { CoreRootStore } from "./root.store";
export interface IIssueMemberStore {
// observables
members: TPublicMember[] | undefined;
// computed actions
getMemberById: (memberId: string | undefined) => TPublicMember | undefined;
getMembersByIds: (memberIds: string[]) => TPublicMember[];
// fetch actions
fetchMembers: (anchor: string) => Promise<TPublicMember[]>;
}
export class MemberStore implements IIssueMemberStore {
memberMap: Record<string, TPublicMember> = {};
memberService: SitesMemberService;
rootStore: CoreRootStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
memberMap: observable,
// computed
members: computed,
// fetch action
fetchMembers: action,
});
this.memberService = new SitesMemberService();
this.rootStore = _rootStore;
}
get members() {
return Object.values(this.memberMap);
}
getMemberById = (memberId: string | undefined) => (memberId ? this.memberMap[memberId] : undefined);
getMembersByIds = (memberIds: string[]) => {
const currMembers = [];
for (const memberId of memberIds) {
const member = this.getMemberById(memberId);
if (member) {
currMembers.push(member);
}
}
return currMembers;
};
fetchMembers = async (anchor: string) => {
try {
const membersResponse = await this.memberService.list(anchor);
runInAction(() => {
this.memberMap = {};
for (const member of membersResponse) {
set(this.memberMap, [member.member], member);
}
});
return membersResponse;
} catch (error) {
console.error("Failed to fetch members:", error);
return [];
}
};
}

View File

@@ -0,0 +1,71 @@
import { set } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { SitesModuleService } from "@plane/services";
// types
import type { TPublicModule } from "@/types/modules";
// root store
import type { CoreRootStore } from "./root.store";
export interface IIssueModuleStore {
// observables
modules: TPublicModule[] | undefined;
// computed actions
getModuleById: (moduleId: string | undefined) => TPublicModule | undefined;
getModulesByIds: (moduleIds: string[]) => TPublicModule[];
// fetch actions
fetchModules: (anchor: string) => Promise<TPublicModule[]>;
}
export class ModuleStore implements IIssueModuleStore {
moduleMap: Record<string, TPublicModule> = {};
moduleService: SitesModuleService;
rootStore: CoreRootStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
moduleMap: observable,
// computed
modules: computed,
// fetch action
fetchModules: action,
});
this.moduleService = new SitesModuleService();
this.rootStore = _rootStore;
}
get modules() {
return Object.values(this.moduleMap);
}
getModuleById = (moduleId: string | undefined) => (moduleId ? this.moduleMap[moduleId] : undefined);
getModulesByIds = (moduleIds: string[]) => {
const currModules = [];
for (const moduleId of moduleIds) {
const issueModule = this.getModuleById(moduleId);
if (issueModule) {
currModules.push(issueModule);
}
}
return currModules;
};
fetchModules = async (anchor: string) => {
try {
const modulesResponse = await this.moduleService.list(anchor);
runInAction(() => {
this.moduleMap = {};
for (const issueModule of modulesResponse) {
set(this.moduleMap, [issueModule.id], issueModule);
}
});
return modulesResponse;
} catch (error) {
console.error("Failed to fetch members:", error);
return [];
}
};
}

View File

@@ -0,0 +1,138 @@
import { set } from "lodash-es";
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { UserService } from "@plane/services";
import type { TUserProfile } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
type TError = {
status: string;
message: string;
};
export interface IProfileStore {
// observables
isLoading: boolean;
error: TError | undefined;
data: TUserProfile;
// actions
fetchUserProfile: () => Promise<TUserProfile | undefined>;
updateUserProfile: (data: Partial<TUserProfile>) => Promise<TUserProfile | undefined>;
}
export class ProfileStore implements IProfileStore {
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,
});
// services
this.userService = new UserService();
}
// actions
/**
* @description fetches user profile information
* @returns {Promise<TUserProfile | undefined>}
*/
fetchUserProfile = async () => {
try {
runInAction(() => {
this.isLoading = true;
this.error = undefined;
});
const userProfile = await this.userService.profile();
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",
};
});
}
};
/**
* @description updated the user profile information
* @param {Partial<TUserProfile>} data
* @returns {Promise<TUserProfile | undefined>}
*/
updateUserProfile = async (data: Partial<TUserProfile>) => {
const currentUserProfileData = this.data;
try {
if (currentUserProfileData) {
Object.keys(data).forEach((key: string) => {
const userKey: keyof TUserProfile = key as keyof TUserProfile;
if (this.data) set(this.data, userKey, data[userKey]);
});
}
const userProfile = await this.userService.updateProfile(data);
return userProfile;
} catch (_error) {
if (currentUserProfileData) {
Object.keys(currentUserProfileData).forEach((key: string) => {
const userKey: keyof TUserProfile = key as keyof TUserProfile;
if (this.data) set(this.data, userKey, currentUserProfileData[userKey]);
});
}
runInAction(() => {
this.error = {
status: "user-profile-update-error",
message: "Failed to update user profile",
};
});
}
};
}

View File

@@ -0,0 +1,117 @@
import { observable, makeObservable, computed } from "mobx";
// types
import type {
IWorkspaceLite,
TProjectDetails,
TPublishEntityType,
TProjectPublishSettings,
TProjectPublishViewProps,
} from "@plane/types";
// store
import type { CoreRootStore } from "../root.store";
export interface IPublishStore extends TProjectPublishSettings {
// computed
workspaceSlug: string | undefined;
canComment: boolean;
canReact: boolean;
canVote: boolean;
}
export class PublishStore implements IPublishStore {
// observables
anchor: string | undefined;
is_comments_enabled: boolean;
created_at: string | undefined;
created_by: string | undefined;
entity_identifier: string | undefined;
entity_name: TPublishEntityType | undefined;
id: string | undefined;
inbox: unknown;
project: string | undefined;
project_details: TProjectDetails | undefined;
is_reactions_enabled: boolean;
updated_at: string | undefined;
updated_by: string | undefined;
view_props: TProjectPublishViewProps | undefined;
is_votes_enabled: boolean;
workspace: string | undefined;
workspace_detail: IWorkspaceLite | undefined;
constructor(
private store: CoreRootStore,
publishSettings: TProjectPublishSettings
) {
this.anchor = publishSettings.anchor;
this.is_comments_enabled = publishSettings.is_comments_enabled;
this.created_at = publishSettings.created_at;
this.created_by = publishSettings.created_by;
this.entity_identifier = publishSettings.entity_identifier;
this.entity_name = publishSettings.entity_name;
this.id = publishSettings.id;
this.inbox = publishSettings.inbox;
this.project = publishSettings.project;
this.project_details = publishSettings.project_details;
this.is_reactions_enabled = publishSettings.is_reactions_enabled;
this.updated_at = publishSettings.updated_at;
this.updated_by = publishSettings.updated_by;
this.view_props = publishSettings.view_props;
this.is_votes_enabled = publishSettings.is_votes_enabled;
this.workspace = publishSettings.workspace;
this.workspace_detail = publishSettings.workspace_detail;
makeObservable(this, {
// observables
anchor: observable.ref,
is_comments_enabled: observable.ref,
created_at: observable.ref,
created_by: observable.ref,
entity_identifier: observable.ref,
entity_name: observable.ref,
id: observable.ref,
inbox: observable,
project: observable.ref,
project_details: observable,
is_reactions_enabled: observable.ref,
updated_at: observable.ref,
updated_by: observable.ref,
view_props: observable,
is_votes_enabled: observable.ref,
workspace: observable.ref,
workspace_detail: observable,
// computed
workspaceSlug: computed,
canComment: computed,
canReact: computed,
canVote: computed,
});
}
/**
* @description returns the workspace slug from the workspace details
*/
get workspaceSlug() {
return this?.workspace_detail?.slug ?? undefined;
}
/**
* @description returns whether commenting is enabled or not
*/
get canComment() {
return !!this.is_comments_enabled;
}
/**
* @description returns whether reacting is enabled or not
*/
get canReact() {
return !!this.is_reactions_enabled;
}
/**
* @description returns whether voting is enabled or not
*/
get canVote() {
return !!this.is_votes_enabled;
}
}

View File

@@ -0,0 +1,47 @@
import { set } from "lodash-es";
import { makeObservable, observable, runInAction, action } from "mobx";
// plane imports
import { SitesProjectPublishService } from "@plane/services";
import type { TProjectPublishSettings } from "@plane/types";
// store
import { PublishStore } from "@/store/publish/publish.store";
import type { CoreRootStore } from "@/store/root.store";
export interface IPublishListStore {
// observables
publishMap: Record<string, PublishStore>; // anchor => PublishStore
// actions
fetchPublishSettings: (pageId: string) => Promise<TProjectPublishSettings>;
}
export class PublishListStore implements IPublishListStore {
// observables
publishMap: Record<string, PublishStore> = {}; // anchor => PublishStore
// service
publishService;
constructor(private rootStore: CoreRootStore) {
makeObservable(this, {
// observables
publishMap: observable,
// actions
fetchPublishSettings: action,
});
// services
this.publishService = new SitesProjectPublishService();
}
/**
* @description fetch publish settings
* @param {string} anchor
*/
fetchPublishSettings = async (anchor: string) => {
const response = await this.publishService.retrieveSettingsByAnchor(anchor);
runInAction(() => {
if (response.anchor) {
set(this.publishMap, [response.anchor], new PublishStore(this.rootStore, response));
}
});
return response;
};
}

View File

@@ -0,0 +1,76 @@
import { enableStaticRendering } from "mobx-react";
// store imports
import type { IInstanceStore } from "@/store/instance.store";
import { InstanceStore } from "@/store/instance.store";
import type { IIssueDetailStore } from "@/store/issue-detail.store";
import { IssueDetailStore } from "@/store/issue-detail.store";
import type { IIssueStore } from "@/store/issue.store";
import { IssueStore } from "@/store/issue.store";
import type { IUserStore } from "@/store/user.store";
import { UserStore } from "@/store/user.store";
import type { ICycleStore } from "./cycle.store";
import { CycleStore } from "./cycle.store";
import type { IIssueFilterStore } from "./issue-filters.store";
import { IssueFilterStore } from "./issue-filters.store";
import type { IIssueLabelStore } from "./label.store";
import { LabelStore } from "./label.store";
import type { IIssueMemberStore } from "./members.store";
import { MemberStore } from "./members.store";
import type { IIssueModuleStore } from "./module.store";
import { ModuleStore } from "./module.store";
import type { IPublishListStore } from "./publish/publish_list.store";
import { PublishListStore } from "./publish/publish_list.store";
import type { IStateStore } from "./state.store";
import { StateStore } from "./state.store";
enableStaticRendering(typeof window === "undefined");
export class CoreRootStore {
instance: IInstanceStore;
user: IUserStore;
issue: IIssueStore;
issueDetail: IIssueDetailStore;
state: IStateStore;
label: IIssueLabelStore;
module: IIssueModuleStore;
member: IIssueMemberStore;
cycle: ICycleStore;
issueFilter: IIssueFilterStore;
publishList: IPublishListStore;
constructor() {
this.instance = new InstanceStore(this);
this.user = new UserStore(this);
this.issue = new IssueStore(this);
this.issueDetail = new IssueDetailStore(this);
this.state = new StateStore(this);
this.label = new LabelStore(this);
this.module = new ModuleStore(this);
this.member = new MemberStore(this);
this.cycle = new CycleStore(this);
this.issueFilter = new IssueFilterStore(this);
this.publishList = new PublishListStore(this);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hydrate = (data: any) => {
if (!data) return;
this.instance.hydrate(data?.instance || undefined);
this.user.hydrate(data?.user || undefined);
};
reset() {
localStorage.setItem("theme", "system");
this.instance = new InstanceStore(this);
this.user = new UserStore(this);
this.issue = new IssueStore(this);
this.issueDetail = new IssueDetailStore(this);
this.state = new StateStore(this);
this.label = new LabelStore(this);
this.module = new ModuleStore(this);
this.member = new MemberStore(this);
this.cycle = new CycleStore(this);
this.issueFilter = new IssueFilterStore(this);
this.publishList = new PublishListStore(this);
}
}

View File

@@ -0,0 +1,54 @@
import { clone } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { SitesStateService } from "@plane/services";
import type { IState } from "@plane/types";
// helpers
import { sortStates } from "@/helpers/state.helper";
// store
import type { CoreRootStore } from "./root.store";
export interface IStateStore {
// observables
states: IState[] | undefined;
//computed
sortedStates: IState[] | undefined;
// computed actions
getStateById: (stateId: string | undefined) => IState | undefined;
// fetch actions
fetchStates: (anchor: string) => Promise<IState[]>;
}
export class StateStore implements IStateStore {
states: IState[] | undefined = undefined;
stateService: SitesStateService;
rootStore: CoreRootStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
states: observable,
// computed
sortedStates: computed,
// fetch action
fetchStates: action,
});
this.stateService = new SitesStateService();
this.rootStore = _rootStore;
}
get sortedStates() {
if (!this.states) return;
return sortStates(clone(this.states));
}
getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId);
fetchStates = async (anchor: string) => {
const statesResponse = await this.stateService.list(anchor);
runInAction(() => {
this.states = statesResponse;
});
return statesResponse;
};
}

View File

@@ -0,0 +1,184 @@
import { AxiosError } from "axios";
import { set } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { UserService } from "@plane/services";
import type { ActorDetail, IUser } from "@plane/types";
// store types
import type { IProfileStore } from "@/store/profile.store";
import { ProfileStore } from "@/store/profile.store";
// store
import type { CoreRootStore } from "@/store/root.store";
type TUserErrorStatus = {
status: string;
message: string;
};
export interface IUserStore {
// observables
isAuthenticated: boolean;
isInitializing: boolean;
error: TUserErrorStatus | undefined;
data: IUser | undefined;
// store observables
profile: IProfileStore;
// computed
currentActor: ActorDetail;
// actions
fetchCurrentUser: () => Promise<IUser | undefined>;
updateCurrentUser: (data: Partial<IUser>) => Promise<IUser | undefined>;
hydrate: (data: IUser | undefined) => void;
reset: () => void;
signOut: () => Promise<void>;
}
export class UserStore implements IUserStore {
// observables
isAuthenticated: boolean = false;
isInitializing: boolean = true;
error: TUserErrorStatus | undefined = undefined;
data: IUser | undefined = undefined;
// store observables
profile: IProfileStore;
// service
userService: UserService;
constructor(private store: CoreRootStore) {
// stores
this.profile = new ProfileStore(store);
// service
this.userService = new UserService();
// observables
makeObservable(this, {
// observables
isAuthenticated: observable.ref,
isInitializing: observable.ref,
error: observable,
// model observables
data: observable,
profile: observable,
// computed
currentActor: computed,
// actions
fetchCurrentUser: action,
updateCurrentUser: action,
reset: action,
signOut: action,
});
}
// computed
get currentActor(): ActorDetail {
return {
id: this.data?.id,
first_name: this.data?.first_name,
last_name: this.data?.last_name,
display_name: this.data?.display_name,
avatar_url: this.data?.avatar_url || undefined,
is_bot: false,
};
}
// actions
/**
* @description fetches the current user
* @returns {Promise<IUser>}
*/
fetchCurrentUser = async (): Promise<IUser> => {
try {
runInAction(() => {
if (this.data === undefined && !this.error) this.isInitializing = true;
this.error = undefined;
});
const user = await this.userService.me();
if (user && user?.id) {
await this.profile.fetchUserProfile();
runInAction(() => {
this.data = user;
this.isInitializing = false;
this.isAuthenticated = true;
});
} else
runInAction(() => {
this.data = user;
this.isInitializing = false;
this.isAuthenticated = false;
});
return user;
} catch (error) {
runInAction(() => {
this.isInitializing = false;
this.isAuthenticated = false;
this.error = {
status: "user-fetch-error",
message: "Failed to fetch current user",
};
if (error instanceof AxiosError && error.status === 401) {
this.data = undefined;
}
});
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.update(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;
}
};
hydrate = (data: IUser | undefined): void => {
if (!data) return;
this.data = { ...this.data, ...data };
};
/**
* @description resets the user store
* @returns {void}
*/
reset = (): void => {
runInAction(() => {
this.isAuthenticated = false;
this.isInitializing = false;
this.error = undefined;
this.data = undefined;
this.profile = new ProfileStore(this.store);
});
};
/**
* @description signs out the current user
* @returns {Promise<void>}
*/
signOut = async (): Promise<void> => {
this.store.reset();
};
}