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