plane/web/store/issue/helpers/base-issues.store.ts
rahulramesha 666d35afb9
feat: Issue pagination (#4109)
* dev: separate order by of issue queryset to separate utilty function

* dev: pagination for spreadhseet and gantt

* dev: group pagination

* dev: paginate single entities

* dev: refactor pagination

* dev: paginating issue apis

* dev: grouped pagination for empty groups

* dev: ungrouped list

* dev: fix paginator for single groups

* dev: fix paginating true list

* dev: state__group pagination

* fix: imports

* dev: fix grouping on taget date and project_id

* dev: remove unused imports

* dev: add ruff in dependencies

* make store changes for pagination

* fix some build errors due to type changes

* dev: add total pages key

* chore: paginator changes

* implement pagination for spreadsheet, list, kanban and calendar

* fix: order by grouped pagination

* dev: sub group paginator

* dev: grouped paginator

* dev: sub grouping paginator

* restructure gantt layout charts

* dev: fix pagination count

* dev: date filtering for issues

* dev: group by counts

* implement new logic for pagination layouts

* fix: label id and assignee id interchange

* dev: fix priority ordering

* fix group by bugs

* dev: grouping for priority

* fix reeordering while update

* dev: fix order by for pagination

* fix: total results for sub group pagination

* dev: add comments and fix ordering

* fix orderby priority for spreadsheet

* fix subGroupCount

* Fix logic for load more in Kanban

* fix issue quick add

* dev: fix issue creation

* dev: add sorting

* fix order by for modules and cycles

* fix non render of Issues

* fix subGroupKey generation when subGroupId is null

* dev: fix cycle and module issue

* dev: fix sub grouping

* fix: imports

* fix minor build errors

* fix major build errors

* fix priority order by

* grouped pagination cursor logic changes

* fix calendar pagination

* active cycle issues pagination

* dev: fix lint errors

* fix Kanban subgroup dnd

* fix empty subgroup kanbans

* fix updation from an empty field with groupBy

* fix issue count of groups

* fix issue sorting on first page fetch

* dev: remove pagination from list endpoint add ordering for sub grouping and handle error for empty issues

* refactor module and cycle issues

* fix quick add refactor

* refactor gantt roots

* fix empty states

* fix filter params

* fix group by module

* minor UX changes

* fix sub grouping in Kanban

* remove unnecessary sorting logic in backend (Nikhil's changes)

* dev: add error handling when using without on results

* calendar layout loader improvement

* list per page count logic change

* spreadsheet loader improvement

* Added loader for issues load more pagination

* fix quick add in gantt

* dev: add profile issue pagination

* fix all issue and profile issues logic

* remove empty state from calendar layout

* use useEffect instead of swr to fetch issues to have quick switching between views cycles etc

* dev: add aggregation for multi fields

* fix priority sorting for workspace issues

* fix move from draft for draft issues

* fix pagination loader for spreadsheet

* fetch project, module and cycle stats on update, create and delete of issues

* increase horizontal margin

* change load more pagination to on scroll pagination for active cycle issues

* fix linting error

* dev: fix ordering when order by m2m

* dev: fix null paginations

* dev: commenting

* 0add comments to the issue stores methods

* fix order by for array properties

* fix: priority ordering

* perform optimistic updates while adding or removing cycles or modules

* fix build errors

* dev: add default values when iterating through sub group

* Move code from EE to CE repo

* chore: folder structure updates

* Move sortabla and radio input to packages/ui

* chore: updated empty and loading screens

* chore: delete an estimate point

* chore: estimate point response change

* chore: updated create estimate and handled the build error

* chore: migration fixes

* chore: updated create estimate

* [WEB-1322] dev: conflict free pages collaboration (#4463)

* chore: pages realtime

* chore: empty binary response

* chore: added a ypy package

* feat: pages collaboration

* chore: update fetching logic

* chore: degrade ypy version

* chore: replace useEffect fetch logic with useSWR

* chore: move all the update logic to the page store

* refactor: remove react-hook-form

* chore: save description_html as well

* chore: migrate old data logic

* fix: added description_binary as field name

* fix: code cleanup

* refactor: create separate hook to handle page description

* fix: build errors

* chore: combine updates instead of using the whole document

* chore: removed ypy package

* chore: added conflict resolving logic to the client side

* chore: add a save changes button

* chore: add read-only validation

* chore: remove saving state information

* chore: added permission class

* chore: removed the migration file

* chore: corrected the model field

* chore: rename pageStore to page

* chore: update collaboration provider

* chore: add try catch to handle error

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* chore: create estimate workflow update

* chore: editing and deleting the existing estimate updates

* chore: updating the new estinates in update modal

* chore: ui changed

* chore: response changes of get and post

* chore: new field added in estimates

* chore: individual endpoint for estimate points

* chore: typo changes

* chore: create estimate point

* chore: integrated new endpoints

* chore: update key value pair

* chore: update sorting in the estimates

* Add custom option in the estimate templates

* chore: handled current project active estimate

* chore: handle estimate update worklfow

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: handled estimates switch

* chore: handled estimate edit

* chore: handled close button in estimate edit

* chore: updated ceate estimare workflow

* chore: updated switch estimate

* fix minor bugs in base issues store

* single column scroll pagination

* UI changes for load more button

* chore: UI and typos

* chore: resolved build error

* [WEB-1184] feat: issue bulk operations (#4530)

* chore: bulk operations

* chore: archive bulk issues

* chore: bulk ops keys changed

* chore: bulk delete and archive confirmation modals

* style: list layout spacing

* chore: create hoc for multi-select groups

* chore: update multiple select components

* chore: archive, target and start date error messsage

* chore: edge case handling

* chore: bulk ops in spreadsheet layout

* chore: update UI

* chore: scroll element into view

* fix: shift + arrow navigation

* chore: implement bulk ops in the gantt layout

* fix: ui bugs

* chore: move selection logic to store

* fix: group selection

* refactor: multiple select store

* style: dropdowns UI

* fix: bulk assignee and label update mutation

* chore: removed migrations

* refactor: entities grouping logic

* fix performance issue is selection of bulk ops

* fix: shift keyboard navigation

* fix: group click action

* chore: start and target date validation

* chore: remove optimistic updates, check archivability in frontend

* chore: code optimisation

* chore: add store comments

* refactor: component fragmentation

* style: issue active state

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>

* fix a performance issue when there are too many groups

* chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point

* [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583)

* chore: added logo_props

* chore: logo props in cycles, views and modules

* chore: emoji icon picker types updated

* chore: info icon added to plane ui package

* chore: icon color adjust helper function added

* style: icon picker ui improvement and default color options updated

* chore: update page logo action added in store

* chore: emoji code to unicode helper function added

* chore: common logo renderer component added

* chore: app header project logo updated

* chore: project logo updated across platform

* chore: page logo picker added

* chore: control link component improvement

* chore: list item improvement

* chore: emoji picker component updated

* chore: space app and package logo prop type updated

* chore: migration

* chore: logo added to project view

* chore: page logo picker added in create modal and breadcrumbs

* chore: view logo picker added in create modal and updated breadcrumbs

* fix: build error

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: merge conflict

* chore: lucide react added to planu ui package

* chore: new emoji picker component added with lucid icon and code refactor

* chore: logo component updated

* chore: emoji picker updated for pages and views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: handled inline errors in the estimate switch

* fix module and cycle drag and drop

* Fix issue count bug for accumulated actions

* chore: handled active and availability vadilation

* chore: handled create and update components in projecr estimates

* chore: added migration

* Add category specific values for custom template

* chore: estimate dropdown handled in issues

* chore: estimate alerts

* fix bulk updates

* chore: updated alerts

* add optional chaining

* Extract the list row actions

* change color of load more to match new Issues

* list group collapsible

* fix: updated and handled the estimate points

* fix: upgrader ee banner

* Fix issues with sortable

* Fix sortable spacing issue in create estimate modal

* fix: updated the issue create sorting

* chore: removed radio button from ui and updated in the estimates

* chore: resolved import error in packaged ui

* chore: handled props in create modal

* chore: removed ee files

* chore: changed default analytics

* fix: pagination ordering for grouped and subgrouped

* chore: removed the migration file

* chore: estimate point value in graph

* chore: estimate point key change

* chore: squashed migration (#4634)

* chore: squashed migration

* chore: removed instance migraion

* chore: key changes

* chore: issue activity back migration

* dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue

* chore: estimate point value field

* chore: estimate point activity

* chore: removed the unused function

* chore: resolved merge conflicts

* chore: deploy board keys changed

* chore: yarn lock file change

* chore: resolved frontend build

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>

* [WEB-1516] refactor: space app routing and layouts (#4705)

* dev: change layout

* chore: replace workspace slug and project id with anchor

* chore: migration fixes

* chore: update filtering logic

* chore: endpoint changes

* chore: update endpoint

* chore: changed url pratterns

* chore: use client side for layout and page

* chore: issue vote changes

* chore: project deploy board response change

* refactor: publish project store and components

* fix: update layout options after fetching settings

* chore: remove unnecessary types

* style: peek overview

* refactor: components folder structure

* fix: redirect from old path

* chore: make the whole issue block clickable

* chore: removed the migration file

* chore: add server side redirection for old routes

* chore: is enabled key change

* chore: update types

* chore: removed the migration file

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* Merge develop into revamp-estimates-ce

* chore: removed migration file and updated the estimate system order and removed ee banner

* chore: initial radio select in create estimate

* chore: space key changes

* Fix sortable component as the sort order was broken.

* fix: formatting and linting errors

* fix Alignment for load more

* add logic to approuter

* fix approuter changes and fix build

* chore: removed the linting issue

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
2024-06-10 20:15:03 +05:30

1820 lines
66 KiB
TypeScript

import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import update from "lodash/update";
import uniq from "lodash/uniq";
import concat from "lodash/concat";
import pull from "lodash/pull";
import orderBy from "lodash/orderBy";
import clone from "lodash/clone";
import indexOf from "lodash/indexOf";
import set from "lodash/set";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import isNil from "lodash/isNil";
// types
import {
TIssue,
TIssueGroupByOptions,
TIssueOrderByOptions,
TGroupedIssues,
TSubGroupedIssues,
TLoader,
IssuePaginationOptions,
TIssuesResponse,
TIssues,
TIssuePaginationData,
TGroupedIssueCount,
TPaginationData,
TBulkOperationsPayload,
} from "@plane/types";
import { IIssueRootStore } from "../root.store";
import { IBaseIssueFilterStore } from "./issue-filter-helper.store";
// constants
import { ALL_ISSUES, EIssueLayoutTypes, ISSUE_PRIORITIES } from "@/constants/issue";
// helpers
// services
import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue";
import { ModuleService } from "@/services/module.service";
import { CycleService } from "@/services/cycle.service";
import {
getDifference,
getGroupIssueKeyActions,
getGroupKey,
getIssueIds,
getSortOrderToFilterEmptyValues,
getSubGroupIssueKeyActions,
} from "./base-issues-utils";
import { convertToISODateString } from "@/helpers/date-time.helper";
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null> | "target_date";
export enum EIssueGroupedAction {
ADD = "ADD",
DELETE = "DELETE",
REORDER = "REORDER",
}
export interface IBaseIssuesStore {
// observable
loader: Record<string, TLoader>;
groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup
groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup
issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup
//actions
removeIssue(workspaceSlug: string, projectId: string, issueId: string): Promise<void>;
// helper methods
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined;
issuesSortWithOrderBy(issueIds: string[], key: Partial<TIssueOrderByOptions>): string[];
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;
addIssueToCycle: (
workspaceSlug: string,
projectId: string,
cycleId: string,
issueIds: string[],
fetchAddedIssues?: boolean
) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssuesToModule: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueIds: string[],
fetchAddedIssues?: boolean
) => 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>;
}
// This constant maps the group by keys to the respective issue property that the key relies on
const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
project: "project_id",
state: "state_id",
"state_detail.group": "state_id" as keyof TIssue, // state_detail.group is only being used for state_group display,
priority: "priority",
labels: "label_ids",
created_by: "created_by",
assignees: "assignee_ids",
target_date: "target_date",
cycle: "cycle_id",
module: "module_ids",
};
export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
project: "project_id",
cycle: "cycle_id",
module: "module_ids",
state: "state_id",
"state_detail.group": "state_group" as keyof TIssue, // state_detail.group is only being used for state_group display,
priority: "priority",
labels: "label_ids",
created_by: "created_by",
assignees: "assignee_ids",
target_date: "target_date",
};
// This constant maps the order by keys to the respective issue property that the key relies on
const ISSUE_ORDERBY_KEY: Record<TIssueOrderByOptions, keyof TIssue> = {
created_at: "created_at",
"-created_at": "created_at",
updated_at: "updated_at",
"-updated_at": "updated_at",
priority: "priority",
"-priority": "priority",
sort_order: "sort_order",
state__name: "state_id",
"-state__name": "state_id",
assignees__first_name: "assignee_ids",
"-assignees__first_name": "assignee_ids",
labels__name: "label_ids",
"-labels__name": "label_ids",
issue_module__module__name: "module_ids",
"-issue_module__module__name": "module_ids",
issue_cycle__cycle__name: "cycle_id",
"-issue_cycle__cycle__name": "cycle_id",
target_date: "target_date",
"-target_date": "target_date",
estimate_point: "estimate_point",
"-estimate_point": "estimate_point",
start_date: "start_date",
"-start_date": "start_date",
link_count: "link_count",
"-link_count": "link_count",
attachment_count: "attachment_count",
"-attachment_count": "attachment_count",
sub_issues_count: "sub_issues_count",
"-sub_issues_count": "sub_issues_count",
};
export abstract class BaseIssuesStore implements IBaseIssuesStore {
loader: Record<string, TLoader> = {};
groupedIssueIds: TIssues | undefined = undefined;
issuePaginationData: TIssuePaginationData = {};
groupedIssueCount: TGroupedIssueCount = {};
//
paginationOptions: IssuePaginationOptions | undefined = undefined;
isArchived: boolean;
// services
issueService;
issueArchiveService;
issueDraftService;
moduleService;
cycleService;
// root store
rootIssueStore;
issueFilterStore;
constructor(_rootStore: IIssueRootStore, issueFilterStore: IBaseIssueFilterStore, isArchived = false) {
makeObservable(this, {
// observable
loader: observable,
groupedIssueIds: observable,
issuePaginationData: observable,
groupedIssueCount: observable,
paginationOptions: observable,
// computed
moduleId: computed,
cycleId: computed,
orderBy: computed,
groupBy: computed,
subGroupBy: computed,
orderByKey: computed,
issueGroupKey: computed,
issueSubGroupKey: computed,
// action
storePreviousPaginationValues: action.bound,
onfetchIssues: action.bound,
onfetchNexIssues: action.bound,
clear: action.bound,
setLoader: action.bound,
addIssue: action.bound,
removeIssueFromList: action.bound,
createIssue: action,
updateIssue: action,
createDraftIssue: action,
updateDraftIssue: action,
issueQuickAdd: action.bound,
removeIssue: action.bound,
archiveIssue: action.bound,
removeBulkIssues: action.bound,
bulkArchiveIssues: action.bound,
bulkUpdateProperties: action.bound,
addIssueToCycle: action.bound,
removeIssueFromCycle: action.bound,
addCycleToIssue: action.bound,
removeCycleFromIssue: action.bound,
addIssuesToModule: action.bound,
removeIssuesFromModule: action.bound,
changeModulesInIssue: action.bound,
});
this.rootIssueStore = _rootStore;
this.issueFilterStore = issueFilterStore;
this.isArchived = isArchived;
this.issueService = new IssueService();
this.issueArchiveService = new IssueArchiveService();
this.issueDraftService = new IssueDraftService();
this.moduleService = new ModuleService();
this.cycleService = new CycleService();
}
// Abstract class to be implemented to fetch parent stats such as project, module or cycle details
abstract fetchParentStats: (workspaceSlug: string, projectId?: string, id?: string) => void;
// current Module Id from url
get moduleId() {
return this.rootIssueStore.moduleId;
}
// current Cycle Id from url
get cycleId() {
return this.rootIssueStore.cycleId;
}
// current Order by value
get orderBy() {
const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters;
if (!displayFilters) return;
return displayFilters?.order_by;
}
// current Group by value
get groupBy() {
const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters;
if (!displayFilters || !displayFilters?.layout) return;
const layout = displayFilters?.layout;
return layout === EIssueLayoutTypes.CALENDAR
? "target_date"
: [EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]?.includes(layout)
? displayFilters?.group_by
: undefined;
}
// current Sub group by value
get subGroupBy() {
const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters;
if (!displayFilters || displayFilters.group_by === displayFilters.sub_group_by) return;
return displayFilters?.layout === "kanban" ? displayFilters?.sub_group_by : undefined;
}
getIssueIds = (groupId?: string, subGroupId?: string) => {
const groupedIssueIds = this.groupedIssueIds;
const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters;
if (!displayFilters || !groupedIssueIds) return undefined;
const subGroupBy = displayFilters?.sub_group_by;
const groupBy = displayFilters?.group_by;
if (!groupBy && !subGroupBy && Array.isArray(groupedIssueIds)) {
return groupedIssueIds as string[];
}
if (groupBy && groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) {
return groupedIssueIds[groupId] as string[];
}
if (groupBy && subGroupBy && groupId && subGroupId) {
return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[];
}
return undefined;
};
// The Issue Property corresponding to the order by value
get orderByKey() {
const orderBy = this.orderBy;
if (!orderBy) return;
return ISSUE_ORDERBY_KEY[orderBy];
}
// The Issue Property corresponding to the group by value
get issueGroupKey() {
const groupBy = this.groupBy;
if (!groupBy) return;
return ISSUE_GROUP_BY_KEY[groupBy];
}
// The Issue Property corresponding to the sub group by value
get issueSubGroupKey() {
const subGroupBy = this.subGroupBy;
if (!subGroupBy) return;
return ISSUE_GROUP_BY_KEY[subGroupBy];
}
/**
* Store the pagination data required for next subsequent issue pagination calls
* @param prevCursor cursor value of previous page
* @param nextCursor cursor value of next page
* @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages
* @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup
* @param subGroupId
*/
setPaginationData(
prevCursor: string,
nextCursor: string,
nextPageResults: boolean,
groupId?: string,
subGroupId?: string
) {
const cursorObject = {
prevCursor,
nextCursor,
nextPageResults,
};
set(this.issuePaginationData, [getGroupKey(groupId, subGroupId)], cursorObject);
}
/**
* Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined
* @param loaderValue
* @param groupId
* @param subGroupId
*/
setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) {
runInAction(() => {
set(this.loader, getGroupKey(groupId, subGroupId), loaderValue);
});
}
/**
* gets the Loader value of particular group/subgroup/ALL_ISSUES
*/
getIssueLoader = (groupId?: string, subGroupId?: string) => {
return get(this.loader, getGroupKey(groupId, subGroupId));
};
/**
* gets the pagination data of particular group/subgroup/ALL_ISSUES
*/
getPaginationData = computedFn(
(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined => {
return get(this.issuePaginationData, [getGroupKey(groupId, subGroupId)]);
}
);
/**
* gets the issue count of particular group/subgroup/ALL_ISSUES
*
* if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds
*/
getGroupIssueCount = computedFn(
(
groupId: string | undefined,
subGroupId: string | undefined,
isSubGroupCumulative: boolean
): number | undefined => {
if (isSubGroupCumulative && subGroupId) {
const groupIssuesKeys = Object.keys(this.groupedIssueCount);
let subGroupCumulativeCount = 0;
for (const groupKey of groupIssuesKeys) {
if (groupKey.includes(subGroupId)) subGroupCumulativeCount += this.groupedIssueCount[groupKey];
}
return subGroupCumulativeCount;
}
return get(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)]);
}
);
/**
* This Method is called after fetching the first paginated issues
*
* This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined
* If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES
* @param issuesResponse Paginated Response received from the API
* @param options Pagination options
* @param workspaceSlug
* @param projectId
* @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store
*/
onfetchIssues(
issuesResponse: TIssuesResponse,
options: IssuePaginationOptions,
workspaceSlug: string,
projectId?: string,
id?: string
) {
// Process the Issue Response to get the following data from it
const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse);
// The Issue list is added to the main Issue Map
this.rootIssueStore.issues.addIssue(issueList);
// Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts
runInAction(() => {
this.updateGroupedIssueIds(groupedIssues, groupedIssueCount);
this.loader[getGroupKey()] = undefined;
});
// fetch parent stats if required, to be handled in the Implemented class
this.fetchParentStats(workspaceSlug, projectId, id);
// store Pagination options for next subsequent calls and data like next cursor etc
this.storePreviousPaginationValues(issuesResponse, options);
}
/**
* This Method is called on the subsequent pagination calls after the first initial call
*
* This method updates the appropriate issue list based on if groupId or subgroupIds are Passed
* @param issuesResponse Paginated Response received from the API
* @param groupId
* @param subGroupId
*/
onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) {
// Process the Issue Response to get the following data from it
const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse);
// The Issue list is added to the main Issue Map
this.rootIssueStore.issues.addIssue(issueList);
// Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts
runInAction(() => {
this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId);
this.loader[getGroupKey(groupId, subGroupId)] = undefined;
});
// store Pagination data like next cursor etc
this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId);
}
/**
* Method to create Issue. This method updates the store and calls the API to create an issue
* @param workspaceSlug
* @param projectId
* @param data Default Issue Data
* @param id optional id like moduleId and cycleId, not used here but required in overridden the Module or cycle issues methods
* @param shouldUpdateList If false, then it would not update the Issue Id list but only makes an API call and adds to the main Issue Map
* @returns
*/
async createIssue(
workspaceSlug: string,
projectId: string,
data: Partial<TIssue>,
id?: string,
shouldUpdateList = true
) {
try {
// perform an API call
const response = await this.issueService.createIssue(workspaceSlug, projectId, data);
// add Issue to Store
this.addIssue(response, shouldUpdateList);
// If shouldUpdateList is true, call fetchParentStats
shouldUpdateList && this.fetchParentStats(workspaceSlug, projectId);
return response;
} catch (error) {
throw error;
}
}
/**
* Updates the Issue, by calling the API and also updating the store
* @param workspaceSlug
* @param projectId
* @param issueId
* @param data Partial Issue Data to be updated
* @param shouldSync If False then only issue is to be updated in the store not call API to update
* @returns
*/
async updateIssue(
workspaceSlug: string,
projectId: string,
issueId: string,
data: Partial<TIssue>,
shouldSync = true
) {
// Store Before state of the issue
const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId));
try {
// Update the Respective Stores
this.rootIssueStore.issues.updateIssue(issueId, data);
this.updateIssueList({ ...issueBeforeUpdate, ...data } as TIssue, issueBeforeUpdate);
// Check if should Sync
if (!shouldSync) return;
// call API to update the issue
await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data);
// call fetch Parent Stats
this.fetchParentStats(workspaceSlug, projectId);
} catch (error) {
// If errored out update store again to revert the change
this.rootIssueStore.issues.updateIssue(issueId, issueBeforeUpdate ?? {});
this.updateIssueList(issueBeforeUpdate, { ...issueBeforeUpdate, ...data } as TIssue);
throw error;
}
}
/**
* Similar to Create Issue but for creating Draft issues
* @param workspaceSlug
* @param projectId
* @param data draft issue data
* @returns
*/
async createDraftIssue(workspaceSlug: string, projectId: string, data: Partial<TIssue>) {
try {
// call API to create a Draft issue
const response = await this.issueDraftService.createDraftIssue(workspaceSlug, projectId, data);
// call Fetch parent stats
this.fetchParentStats(workspaceSlug, projectId);
// Add issue to store
this.addIssue(response);
return response;
} catch (error) {
throw error;
}
}
/**
* Similar to update issue but for draft issues.
* @param workspaceSlug
* @param projectId
* @param issueId
* @param data Partial Issue Data to be updated
*/
async updateDraftIssue(workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) {
// Store Before state of the issue
const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId));
try {
// Update the Respective Stores
this.rootIssueStore.issues.updateIssue(issueId, data);
this.updateIssueList({ ...issueBeforeUpdate, ...data } as TIssue, issueBeforeUpdate);
// call API to update the issue
await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data);
// call Fetch parent stats
this.fetchParentStats(workspaceSlug, projectId);
// If the issue is updated to not a draft issue anymore remove from the store list
if (!isNil(data.is_draft) && !data.is_draft) this.removeIssueFromList(issueId);
} catch (error) {
// If errored out update store again to revert the change
this.rootIssueStore.issues.updateIssue(issueId, issueBeforeUpdate ?? {});
this.updateIssueList(issueBeforeUpdate, { ...issueBeforeUpdate, ...data } as TIssue);
throw error;
}
}
/**
* This method is called to delete an issue
* @param workspaceSlug
* @param projectId
* @param issueId
*/
async removeIssue(workspaceSlug: string, projectId: string, issueId: string) {
try {
// Male API call
await this.issueService.deleteIssue(workspaceSlug, projectId, issueId);
// Remove from Respective issue Id list
runInAction(() => {
this.removeIssueFromList(issueId);
});
// call fetch Parent stats
this.fetchParentStats(workspaceSlug, projectId);
// Remove issue from main issue Map store
this.rootIssueStore.issues.removeIssue(issueId);
} catch (error) {
throw error;
}
}
/**
* This method is called to Archive an issue
* @param workspaceSlug
* @param projectId
* @param issueId
*/
async archiveIssue(workspaceSlug: string, projectId: string, issueId: string) {
try {
// Male API call
const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId);
// call fetch Parent stats
this.fetchParentStats(workspaceSlug, projectId);
runInAction(() => {
// Update the Archived at of the issue from store
this.rootIssueStore.issues.updateIssue(issueId, {
archived_at: response.archived_at,
});
// Since Archived remove the issue Id from the current store
this.removeIssueFromList(issueId);
});
} catch (error) {
throw error;
}
}
/**
* Method to perform Quick add of issues
* @param workspaceSlug
* @param projectId
* @param data
* @returns
*/
async issueQuickAdd(workspaceSlug: string, projectId: string, data: TIssue) {
try {
// Add issue to store with a temporary Id
this.addIssue(data);
// call Create issue method
const response = await this.createIssue(workspaceSlug, projectId, data);
runInAction(() => {
this.removeIssueFromList(data.id);
this.rootIssueStore.issues.removeIssue(data.id);
});
if (data.cycle_id && data.cycle_id !== "" && !this.cycleId) {
await this.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id);
}
if (data.module_ids && data.module_ids.length > 0 && !this.moduleId) {
await this.changeModulesInIssue(workspaceSlug, projectId, response.id, data.module_ids, []);
}
return response;
} catch (error) {
throw error;
}
}
/**
* This is a method to delete issues in bulk
* @param workspaceSlug
* @param projectId
* @param issueIds
* @returns
*/
async removeBulkIssues(workspaceSlug: string, projectId: string, issueIds: string[]) {
try {
// Make API call to bulk delete issues
const response = await this.issueService.bulkDeleteIssues(workspaceSlug, projectId, { issue_ids: issueIds });
// call fetch parent stats
this.fetchParentStats(workspaceSlug, projectId);
// Remove issues from the store
runInAction(() => {
issueIds.forEach((issueId) => {
this.removeIssueFromList(issueId);
this.rootIssueStore.issues.removeIssue(issueId);
});
});
return response;
} catch (error) {
throw error;
}
}
/**
* Bulk Archive issues
* @param workspaceSlug
* @param projectId
* @param issueIds
*/
bulkArchiveIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => {
try {
const response = await this.issueService.bulkArchiveIssues(workspaceSlug, projectId, { issue_ids: issueIds });
runInAction(() => {
issueIds.forEach((issueId) => {
this.updateIssue(workspaceSlug, projectId, issueId, {
archived_at: response.archived_at,
});
this.removeIssueFromList(issueId);
});
});
} catch (error) {
throw error;
}
};
/**
* @description bulk update properties of selected issues
* @param {TBulkOperationsPayload} data
*/
bulkUpdateProperties = async (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => {
const issueIds = data.issue_ids;
try {
// make request to update issue properties
await this.issueService.bulkOperations(workspaceSlug, projectId, data);
// update issues in the store
runInAction(() => {
issueIds.forEach((issueId) => {
const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId));
if (!issueBeforeUpdate) throw new Error("Issue not found");
Object.keys(data.properties).forEach((key) => {
const property = key as keyof TBulkOperationsPayload["properties"];
const propertyValue = data.properties[property];
// update root issue map properties
if (Array.isArray(propertyValue)) {
// if property value is array, append it to the existing values
const existingValue = issueBeforeUpdate[property];
// convert existing value to an array
const newExistingValue = Array.isArray(existingValue) ? existingValue : [];
this.rootIssueStore.issues.updateIssue(issueId, {
[property]: uniq([newExistingValue, ...propertyValue]),
});
} else {
// if property value is not an array, simply update the value
this.rootIssueStore.issues.updateIssue(issueId, {
[property]: propertyValue,
});
}
});
const issueDetails = this.rootIssueStore.issues.getIssueById(issueId);
this.updateIssueList(issueDetails, issueBeforeUpdate);
});
});
} catch (error) {
throw error;
}
};
/**
* This method is used to add issues to a particular Cycle
* @param workspaceSlug
* @param projectId
* @param cycleId
* @param issueIds
* @param fetchAddedIssues If True we make an additional call to fetch all the issues from their Ids, Since the addIssueToCycle API does not return them
*/
async addIssueToCycle(
workspaceSlug: string,
projectId: string,
cycleId: string,
issueIds: string[],
fetchAddedIssues = true
) {
try {
// Perform an APi call to add issue to cycle
await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, {
issues: issueIds,
});
// if cycle Id is the current Cycle Id then call fetch parent stats
if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId);
// if true, fetch the issue data for all the issueIds
if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds);
// Update issueIds from current store
runInAction(() => {
// If cycle Id is the current cycle Id, then, add issue to list of issueIds
if (this.cycleId === cycleId) issueIds.forEach((issueId) => this.addIssueToList(issueId));
// If cycle Id is not the current cycle Id, then, remove issue to list of issueIds
else if (this.cycleId) issueIds.forEach((issueId) => this.removeIssueFromList(issueId));
});
// For Each issue update cycle Id by calling current store's update Issue, without making an API call
issueIds.forEach((issueId) => {
this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: cycleId }, false);
});
} catch (error) {
throw error;
}
}
/**
* This method is used to remove issue from a cycle
* @param workspaceSlug
* @param projectId
* @param cycleId
* @param issueId
*/
async removeIssueFromCycle(workspaceSlug: string, projectId: string, cycleId: string, issueId: string) {
try {
// Perform an APi call to remove issue from cycle
await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
// if cycle Id is the current Cycle Id then call fetch parent stats
if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId);
runInAction(() => {
// If cycle Id is the current cycle Id, then, remove issue from list of issueIds
this.cycleId === cycleId && this.removeIssueFromList(issueId);
});
// update Issue cycle Id to null by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: null }, false);
} catch (error) {
throw error;
}
}
addCycleToIssue = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id;
try {
// Update issueIds from current store
runInAction(() => {
// If cycle Id is the current cycle Id, then, add issue to list of issueIds
if (this.cycleId === cycleId) this.addIssueToList(issueId);
// For Each issue update cycle Id by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: cycleId }, false);
});
await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, {
issues: [issueId],
});
// if cycle Id is the current Cycle Id then call fetch parent stats
if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId);
} catch (error) {
// remove the new issue ids from the cycle issues map
runInAction(() => {
// If cycle Id is the current cycle Id, then, remove issue to list of issueIds
if (this.cycleId === cycleId) this.removeIssueFromList(issueId);
// For Each issue update cycle Id to previous value by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: issueCycleId }, false);
});
throw error;
}
};
/*
* Remove a cycle from issue
* @param workspaceSlug
* @param projectId
* @param issueId
* @returns
*/
removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id;
if (!issueCycleId) return;
try {
// perform optimistic update, update store
// Update issueIds from current store
runInAction(() => {
// If cycle Id is the current cycle Id, then, add issue to list of issueIds
if (this.cycleId === issueCycleId) this.removeIssueFromList(issueId);
// For Each issue update cycle Id by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: null }, false);
});
// make API call
await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, issueCycleId, issueId);
// if cycle Id is the current Cycle Id then call fetch parent stats
if (this.cycleId === issueCycleId) this.fetchParentStats(workspaceSlug, projectId);
} catch (error) {
// revert back changes if fails
// Update issueIds from current store
runInAction(() => {
// If cycle Id is the current cycle Id, then, add issue to list of issueIds
if (this.cycleId === issueCycleId) this.addIssueToList(issueId);
// For Each issue update cycle Id by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: issueCycleId }, false);
});
throw error;
}
};
/**
* This method is used to add issues to a module
* @param workspaceSlug
* @param projectId
* @param moduleId
* @param issueIds
* @param fetchAddedIssues If True we make an additional call to fetch all the issues from their Ids, Since the addIssuesToModule API does not return them
*/
async addIssuesToModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
issueIds: string[],
fetchAddedIssues = true
) {
try {
// Perform an APi call to add issue to module
await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, {
issues: issueIds,
});
// if true, fetch the issue data for all the issueIds
if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds);
// if module Id is the current Module Id then call fetch parent stats
if (this.moduleId === moduleId) this.fetchParentStats(workspaceSlug, projectId);
runInAction(() => {
// if module Id is the current Module Id, then, add issue to list of issueIds
this.moduleId === moduleId && issueIds.forEach((issueId) => this.addIssueToList(issueId));
});
// For Each issue update module Ids by calling current store's update Issue, without making an API call
issueIds.forEach((issueId) => {
const issueModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? [];
const updatedIssueModuleIds = uniq(concat(issueModuleIds, [moduleId]));
this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: updatedIssueModuleIds }, false);
});
} catch (error) {
throw error;
}
}
/**
* This method is used to remove issue from a module
* @param workspaceSlug
* @param projectId
* @param moduleId
* @param issueIds
* @returns
*/
async removeIssuesFromModule(workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) {
try {
// Perform an APi call to remove issue to module
const response = await this.moduleService.removeIssuesFromModuleBulk(
workspaceSlug,
projectId,
moduleId,
issueIds
);
// if module Id is the current Module Id then call fetch parent stats
if (this.moduleId === moduleId) this.fetchParentStats(workspaceSlug, projectId);
runInAction(() => {
// if module Id is the current Module Id, then remove issue from list of issueIds
this.moduleId === moduleId && issueIds.forEach((issueId) => this.removeIssueFromList(issueId));
});
// For Each issue update module Ids by calling current store's update Issue, without making an API call
runInAction(() => {
issueIds.forEach((issueId) => {
const issueModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? [];
const updatedIssueModuleIds = pull(issueModuleIds, moduleId);
this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: updatedIssueModuleIds }, false);
});
});
return response;
} catch (error) {
throw error;
}
}
/*
* change modules array in issue
* @param workspaceSlug
* @param projectId
* @param issueId
* @param addModuleIds array of modules to be added
* @param removeModuleIds array of modules to be removed
*/
async changeModulesInIssue(
workspaceSlug: string,
projectId: string,
issueId: string,
addModuleIds: string[],
removeModuleIds: string[]
) {
// keep a copy of the original module ids
const originalModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? [];
try {
runInAction(() => {
// get current Module Ids of the issue
let currentModuleIds = [...originalModuleIds];
// remove the new issue id to the module issues
removeModuleIds.forEach((moduleId) => {
// If module Id is equal to current module Id, them remove Issue from List
this.moduleId === moduleId && this.removeIssueFromList(issueId);
currentModuleIds = pull(currentModuleIds, moduleId);
});
// If current Module Id is included in the modules list, then add Issue to List
if (addModuleIds.includes(this.moduleId ?? "")) this.addIssueToList(issueId);
currentModuleIds = uniq(concat([...currentModuleIds], addModuleIds));
// For current Issue, update module Ids by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: currentModuleIds }, false);
});
//Perform API call
await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, {
modules: addModuleIds,
removed_modules: removeModuleIds,
});
if (addModuleIds.includes(this.moduleId || "") || removeModuleIds.includes(this.moduleId || "")) {
this.fetchParentStats(workspaceSlug, projectId);
}
} catch (error) {
// revert the issue back to its original module ids
runInAction(() => {
// If current Module Id is included in the add modules list, then remove Issue from List
if (addModuleIds.includes(this.moduleId ?? "")) this.removeIssueFromList(issueId);
// If current Module Id is included in the removed modules list, then add Issue to List
if (removeModuleIds.includes(this.moduleId ?? "")) this.addIssueToList(issueId);
// For current Issue, update module Ids by calling current store's update Issue, without making an API call
this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: originalModuleIds }, false);
});
throw error;
}
}
/**
* Add issue to the store
* @param issue
* @param shouldUpdateList indicates if the issue Id to be added to the list
*/
addIssue(issue: TIssue, shouldUpdateList = true) {
runInAction(() => {
this.rootIssueStore.issues.addIssue([issue]);
});
// if true, add issue id to the list
if (shouldUpdateList) this.updateIssueList(issue, undefined, EIssueGroupedAction.ADD);
}
/**
* Method called to clear out the current store
*/
clear() {
runInAction(() => {
this.groupedIssueIds = undefined;
this.issuePaginationData = {};
this.groupedIssueCount = {};
this.paginationOptions = undefined;
});
}
/**
* Method called to add issue id to list.
* This will only work if the issue already exists in the main issue map
* @param issueId
*/
addIssueToList(issueId: string) {
const issue = this.rootIssueStore.issues.getIssueById(issueId);
this.updateIssueList(issue, undefined, EIssueGroupedAction.ADD);
}
/**
* Method called to remove issue id from list.
* This will only work if the issue already exists in the main issue map
* @param issueId
*/
removeIssueFromList(issueId: string) {
const issue = this.rootIssueStore.issues.getIssueById(issueId);
this.updateIssueList(issue, undefined, EIssueGroupedAction.DELETE);
}
/**
* Method called to update the issue list,
* If an action is passed, this method would add/remove the issue from list according to the action
* If there is no action, this method compares before and after states of the issue to decide, where to remove the issue id from and where to add it to
* if only issue is passed down then, the method determines where to add the issue Id and updates the list
* @param issue current issue state
* @param issueBeforeUpdate issue state before the update
* @param action specific action can be provided to force the method to that action
* @returns
*/
updateIssueList(
issue?: TIssue,
issueBeforeUpdate?: TIssue,
action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE
) {
if (!issue) return;
// get issueUpdates from another method by passing down the three arguments
// issueUpdates is nothing but an array of objects that contain the path of the issueId list that need updating and also the action that needs to be performed at the path
const issueUpdates = this.getUpdateDetails(issue, issueBeforeUpdate, action);
const accumulatedUpdatesForCount = {};
runInAction(() => {
// The issueUpdates
for (const issueUpdate of issueUpdates) {
//if update is add, add it at a particular path
if (issueUpdate.action === EIssueGroupedAction.ADD) {
// add issue Id at the path
update(this, ["groupedIssueIds", ...issueUpdate.path], (issueIds: string[] = []) => {
return this.issuesSortWithOrderBy(uniq(concat(issueIds, issue.id)), this.orderBy);
});
}
//if update is delete, remove it at a particular path
if (issueUpdate.action === EIssueGroupedAction.DELETE) {
// remove issue Id from the path
update(this, ["groupedIssueIds", ...issueUpdate.path], (issueIds: string[] = []) => {
return pull(issueIds, issue.id);
});
}
// accumulate the updates so that we don't end up updating the count twice for the same issue
this.accumulateIssueUpdates(accumulatedUpdatesForCount, issueUpdate.path, issueUpdate.action);
//if update is reorder, reorder it at a particular path
if (issueUpdate.action === EIssueGroupedAction.REORDER) {
// re-order/re-sort the issue Ids at the path
update(this, ["groupedIssueIds", ...issueUpdate.path], (issueIds: string[] = []) => {
return this.issuesSortWithOrderBy(issueIds, this.orderBy);
});
}
}
// update the respective counts from the accumulation object
this.updateIssueCount(accumulatedUpdatesForCount);
});
}
/**
* This method processes the issueResponse to provide data that can be used to update the store
* @param issueResponse
* @returns issueList, list of issue Data
* @returns groupedIssues, grouped issue Ids
* @returns groupedIssueCount, object containing issue counts of individual groups
*/
processIssueResponse(issueResponse: TIssuesResponse): {
issueList: TIssue[];
groupedIssues: TIssues;
groupedIssueCount: TGroupedIssueCount;
} {
const issueResult = issueResponse?.results;
// if undefined return empty objects
if (!issueResult)
return {
issueList: [],
groupedIssues: {},
groupedIssueCount: {},
};
//if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES
if (Array.isArray(issueResult)) {
return {
issueList: issueResult,
groupedIssues: {
[ALL_ISSUES]: issueResult.map((issue) => issue.id),
},
groupedIssueCount: {
[ALL_ISSUES]: issueResponse.total_count,
},
};
}
const issueList: TIssue[] = [];
const groupedIssues: TGroupedIssues | TSubGroupedIssues = {};
const groupedIssueCount: TGroupedIssueCount = {};
// update total issue count to ALL_ISSUES
set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count);
// loop through all the groupIds from issue Result
for (const groupId in issueResult) {
const groupIssuesObject = issueResult[groupId];
const groupIssueResult = groupIssuesObject?.results;
// if groupIssueResult is undefined then continue the loop
if (!groupIssueResult) continue;
// set grouped Issue count of the current groupId
set(groupedIssueCount, [groupId], groupIssuesObject.total_results);
// if groupIssueResult, the it is not subGrouped
if (Array.isArray(groupIssueResult)) {
// add the result to issueList
issueList.push(...groupIssueResult);
// set the issue Ids to the groupId path
set(
groupedIssues,
[groupId],
groupIssueResult.map((issue) => issue.id)
);
continue;
}
// loop through all the subGroupIds from issue Result
for (const subGroupId in groupIssueResult) {
const subGroupIssuesObject = groupIssueResult[subGroupId];
const subGroupIssueResult = subGroupIssuesObject?.results;
// if subGroupIssueResult is undefined then continue the loop
if (!subGroupIssueResult) continue;
// set sub grouped Issue count of the current groupId
set(groupedIssueCount, [getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results);
if (Array.isArray(subGroupIssueResult)) {
// add the result to issueList
issueList.push(...subGroupIssueResult);
// set the issue Ids to the [groupId, subGroupId] path
set(
groupedIssues,
[groupId, subGroupId],
subGroupIssueResult.map((issue) => issue.id)
);
continue;
}
}
}
return { issueList, groupedIssues, groupedIssueCount };
}
/**
* This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts
* @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups
* @param groupedIssueCount Object the contains the issue count of each groups
* @param groupId groupId string
* @param subGroupId subGroupId string
* @returns updates the store with the values
*/
updateGroupedIssueIds(
groupedIssues: TIssues,
groupedIssueCount: TGroupedIssueCount,
groupId?: string,
subGroupId?: string
) {
// if groupId exists and groupedIssues has ALL_ISSUES as a group,
// then it's an individual group/subgroup pagination
if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) {
const issueGroup = groupedIssues[ALL_ISSUES];
const issueGroupCount = groupedIssueCount[ALL_ISSUES];
const issuesPath = [groupId];
// issuesPath is the path for the issue List in the Grouped Issue List
// issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination
if (subGroupId) issuesPath.push(subGroupId);
// update the issue Count of the particular group/subGroup
set(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)], issueGroupCount);
// update the issue list in the issuePath
this.updateIssueGroup(issueGroup, issuesPath);
return;
}
// if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination
// update total issue count as ALL_ISSUES count in `groupedIssueCount` object
set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]);
// loop through the groups of groupedIssues.
for (const groupId in groupedIssues) {
const issueGroup = groupedIssues[groupId];
const issueGroupCount = groupedIssueCount[groupId];
// update the groupId's issue count
set(this.groupedIssueCount, [groupId], issueGroupCount);
// This updates the group issue list in the store, if the issueGroup is a string
const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]);
// if issueGroup is indeed a string, continue
if (storeUpdated) continue;
// if issueGroup is not a string, loop through the sub group Issues
for (const subGroupId in issueGroup) {
const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId];
const issueSubGroupCount = groupedIssueCount[getGroupKey(groupId, subGroupId)];
// update the subGroupId's issue count
set(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)], issueSubGroupCount);
// This updates the subgroup issue list in the store
this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]);
}
}
}
/**
* This Method is used to update the issue Id list at the particular issuePath
* @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped
* @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list
* @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath
*/
updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean {
if (!groupedIssueIds) return true;
// if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath
if (groupedIssueIds && Array.isArray(groupedIssueIds)) {
update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) => {
return this.issuesSortWithOrderBy(uniq(concat(issueIds, groupedIssueIds as string[])), this.orderBy);
});
// return true to indicate the store has been updated
return true;
}
// return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues
return false;
}
/**
* For Every issue update, accumulate it so that when an single issue is added to two groups, it doesn't increment the total count twice
* @param accumulator
* @param path
* @param action
* @returns
*/
accumulateIssueUpdates(
accumulator: { [key: string]: EIssueGroupedAction },
path: string[],
action: EIssueGroupedAction
) {
const [groupId, subGroupId] = path;
if (action !== EIssueGroupedAction.ADD && action !== EIssueGroupedAction.DELETE) return;
// if both groupId && subGroupId exists update the subgroup key
if (subGroupId && groupId) {
const groupKey = getGroupKey(groupId, subGroupId);
this.updateUpdateAccumulator(accumulator, groupKey, action);
}
// after above, if groupId exists update the group key
if (groupId) {
this.updateUpdateAccumulator(accumulator, groupId, action);
}
// if groupId is not ALL_ISSUES then update the All_ISSUES key
// (if groupId is equal to ALL_ISSUES, it would have updated in the previous condition)
if (groupId !== ALL_ISSUES) {
this.updateUpdateAccumulator(accumulator, ALL_ISSUES, action);
}
}
/**
* This method's job is just to check and update the accumulator key
* @param accumulator accumulator object
* @param key object key like, subgroupKey, Group Key or ALL_ISSUES
* @param action
* @returns
*/
updateUpdateAccumulator(
accumulator: { [key: string]: EIssueGroupedAction },
key: string,
action: EIssueGroupedAction
) {
// if the key for accumulator is undefined, they update it with the action
if (!accumulator[key]) {
accumulator[key] = action;
return;
}
// if the key for accumulator is not the current action,
// Meaning if the key already has an action ADD and the current one is REMOVE,
// The the key is deleted as both the actions cancel each other out
if (accumulator[key] !== action) {
delete accumulator[key];
}
}
/**
* This method is used to update the count of the issues at the path with the increment
* @param path issuePath, corresponding key is to be incremented
* @param increment
*/
updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) {
const updateKeys = Object.keys(accumulatedUpdatesForCount);
for (const updateKey of updateKeys) {
const update = accumulatedUpdatesForCount[updateKey];
if (!update) continue;
const increment = update === EIssueGroupedAction.ADD ? 1 : -1;
// get current count at the key
const issueCount = get(this.groupedIssueCount, updateKey) ?? 0;
// update the count at the key
set(this.groupedIssueCount, updateKey, issueCount + increment);
}
}
/**
* This method is used to get update Details that would be used to update the issue Ids at the path
* @param issue current state of issue
* @param issueBeforeUpdate state of the issue before the update
* @param action optional action, to force the method to return back that action
* @returns an array of object that contains the path at which issue to be updated and the action to be performed at the path
*/
getUpdateDetails = (
issue: Partial<TIssue>,
issueBeforeUpdate?: Partial<TIssue>,
action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE
): { path: string[]; action: EIssueGroupedAction }[] => {
// check the before and after states to return if there needs to be a re-sorting of issueId list if the issue property that orderBy depends on has changed
const orderByUpdates = this.getOrderByUpdateDetails(issue, issueBeforeUpdate);
// if unGrouped, then return the path as ALL_ISSUES along with orderByUpdates
if (!this.issueGroupKey) return action ? [{ path: [ALL_ISSUES], action }, ...orderByUpdates] : orderByUpdates;
// if grouped, the get the Difference between the two issue properties (this.issueGroupKey) on which groupBy is performed
const groupActionsArray = getDifference(
this.getArrayStringArray(issue[this.issueGroupKey], this.groupBy),
this.getArrayStringArray(issueBeforeUpdate?.[this.issueGroupKey], this.groupBy),
action
);
// if not subGrouped, then use the differences to construct an updateDetails Array
if (!this.issueSubGroupKey)
return [
...getGroupIssueKeyActions(
groupActionsArray[EIssueGroupedAction.ADD],
groupActionsArray[EIssueGroupedAction.DELETE]
),
...orderByUpdates,
];
// if subGrouped, the get the Difference between the two issue properties (this.issueGroupKey) on which subGroupBy is performed
const subGroupActionsArray = getDifference(
this.getArrayStringArray(issue[this.issueSubGroupKey], this.subGroupBy),
this.getArrayStringArray(issueBeforeUpdate?.[this.issueSubGroupKey], this.subGroupBy),
action
);
// Use the differences to construct an updateDetails Array
return [
...getSubGroupIssueKeyActions(
groupActionsArray,
subGroupActionsArray,
this.getArrayStringArray(issueBeforeUpdate?.[this.issueGroupKey] ?? issue[this.issueGroupKey], this.groupBy),
this.getArrayStringArray(issue[this.issueGroupKey], this.groupBy),
this.getArrayStringArray(
issueBeforeUpdate?.[this.issueSubGroupKey] ?? issue[this.issueSubGroupKey],
this.subGroupBy
),
this.getArrayStringArray(issue[this.issueSubGroupKey], this.subGroupBy)
),
...orderByUpdates,
];
};
/**
* This method is used to get update Details that would be used to re-order/re-sort the issue Ids at the path
* @param issue current state of issue
* @param issueBeforeUpdate state of the issue before the update
* @returns an array of object that contains the path at which issue to be re-sorted/re-ordered
*/
getOrderByUpdateDetails(
issue: Partial<TIssue> | undefined,
issueBeforeUpdate: Partial<TIssue> | undefined
): { path: string[]; action: EIssueGroupedAction.REORDER }[] {
// if before and after states of the issue prop on which orderBy depends on then return and empty Array
if (
!issue ||
!issueBeforeUpdate ||
!this.orderByKey ||
isEqual(issue[this.orderByKey], issueBeforeUpdate[this.orderByKey])
)
return [];
// if they are not equal and issues are not grouped then, provide path as ALL_ISSUES
if (!this.issueGroupKey) return [{ path: [ALL_ISSUES], action: EIssueGroupedAction.REORDER }];
// if they are grouped then identify the paths based on props on which group by is dependent on
const issueKeyActions: { path: string[]; action: EIssueGroupedAction.REORDER }[] = [];
const groupByValues = this.getArrayStringArray(issue[this.issueGroupKey]);
// if issues are not subGrouped then, provide path from groupByValues
if (!this.issueSubGroupKey) {
for (const groupKey of groupByValues) {
issueKeyActions.push({ path: [groupKey], action: EIssueGroupedAction.REORDER });
}
return issueKeyActions;
}
// if they are grouped then identify the paths based on props on which sub group by is dependent on
const subGroupByValues = this.getArrayStringArray(issue[this.issueSubGroupKey]);
// if issues are subGrouped then, provide path from subGroupByValues
for (const groupKey of groupByValues) {
for (const subGroupKey of subGroupByValues) {
issueKeyActions.push({ path: [groupKey, subGroupKey], action: EIssueGroupedAction.REORDER });
}
}
return issueKeyActions;
}
/**
* get the groupByKey issue property on which actions are to be decided in the form of array
* @param value
* @param groupByKey
* @returns an array of issue property values
*/
getArrayStringArray = (
value: string | string[] | undefined | null,
groupByKey?: TIssueGroupByOptions | undefined
): string[] => {
// if value is not defined, return empty array
if (!value) return [];
// if array return the array
if (Array.isArray(value)) return value;
// if the groupKey is state group then return the group based on state_id
if (groupByKey === "state_detail.group") {
return [this.rootIssueStore.rootStore.state.stateMap?.[value]?.group];
}
return [value];
};
/**
* This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees
* @param dataType what type of data is being sent
* @param dataIds id/ids of the data that is to be populated
* @param order ascending or descending for arrays of data
* @returns string | string[] of sortable fields to be used for sorting
*/
populateIssueDataForSorting(
dataType: "state_id" | "label_ids" | "assignee_ids" | "module_ids" | "cycle_id",
dataIds: string | string[] | null | undefined,
order?: "asc" | "desc"
) {
if (!dataIds) return;
const dataValues: string[] = [];
const isDataIdsArray = Array.isArray(dataIds);
const dataIdsArray = isDataIdsArray ? dataIds : [dataIds];
switch (dataType) {
case "state_id":
const stateMap = this.rootIssueStore?.stateMap;
if (!stateMap) break;
for (const dataId of dataIdsArray) {
const state = stateMap[dataId];
if (state && state.name) dataValues.push(state.name.toLocaleLowerCase());
}
break;
case "label_ids":
const labelMap = this.rootIssueStore?.labelMap;
if (!labelMap) break;
for (const dataId of dataIdsArray) {
const label = labelMap[dataId];
if (label && label.name) dataValues.push(label.name.toLocaleLowerCase());
}
break;
case "assignee_ids":
const memberMap = this.rootIssueStore?.memberMap;
if (!memberMap) break;
for (const dataId of dataIdsArray) {
const member = memberMap[dataId];
if (member && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase());
}
break;
case "module_ids":
const moduleMap = this.rootIssueStore?.moduleMap;
if (!moduleMap) break;
for (const dataId of dataIdsArray) {
const currentModule = moduleMap[dataId];
if (currentModule && currentModule.name) dataValues.push(currentModule.name.toLocaleLowerCase());
}
break;
case "cycle_id":
const cycleMap = this.rootIssueStore?.cycleMap;
if (!cycleMap) break;
for (const dataId of dataIdsArray) {
const cycle = cycleMap[dataId];
if (cycle && cycle.name) dataValues.push(cycle.name.toLocaleLowerCase());
}
break;
}
return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order])[0] : dataValues) : dataValues[0];
}
issuesSortWithOrderBy = (issueIds: string[], key: TIssueOrderByOptions | undefined): string[] => {
const issues = this.rootIssueStore.issues.getIssuesByIds(issueIds, this.isArchived ? "archived" : "un-archived");
const array = orderBy(issues, (issue) => convertToISODateString(issue["created_at"]), ["desc"]);
switch (key) {
case "sort_order":
return getIssueIds(orderBy(array, "sort_order"));
case "state__name":
return getIssueIds(
orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue?.["state_id"]))
);
case "-state__name":
return getIssueIds(
orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue?.["state_id"]), ["desc"])
);
// dates
case "created_at":
return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["created_at"])));
case "-created_at":
return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["created_at"]), ["desc"]));
case "updated_at":
return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["updated_at"])));
case "-updated_at":
return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["updated_at"]), ["desc"]));
case "start_date":
return getIssueIds(orderBy(array, [getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"])); //preferring sorting based on empty values to always keep the empty values below
case "-start_date":
return getIssueIds(
orderBy(
array,
[getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
)
);
case "target_date":
return getIssueIds(orderBy(array, [getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"])); //preferring sorting based on empty values to always keep the empty values below
case "-target_date":
return getIssueIds(
orderBy(
array,
[getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
)
);
// custom
case "priority": {
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
return getIssueIds(orderBy(array, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority)));
}
case "-priority": {
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
return getIssueIds(
orderBy(array, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority), ["desc"])
);
}
// number
case "attachment_count":
return getIssueIds(orderBy(array, "attachment_count"));
case "-attachment_count":
return getIssueIds(orderBy(array, "attachment_count", ["desc"]));
case "estimate_point":
return getIssueIds(
orderBy(array, [getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"])
); //preferring sorting based on empty values to always keep the empty values below
case "-estimate_point":
return getIssueIds(
orderBy(
array,
[getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
)
);
case "link_count":
return getIssueIds(orderBy(array, "link_count"));
case "-link_count":
return getIssueIds(orderBy(array, "link_count", ["desc"]));
case "sub_issues_count":
return getIssueIds(orderBy(array, "sub_issues_count"));
case "-sub_issues_count":
return getIssueIds(orderBy(array, "sub_issues_count", ["desc"]));
// Array
case "labels__name":
return getIssueIds(
orderBy(array, [
getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("label_ids", issue?.["label_ids"], "asc"),
])
);
case "-labels__name":
return getIssueIds(
orderBy(
array,
[
getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("label_ids", issue?.["label_ids"], "asc"),
],
["asc", "desc"]
)
);
case "issue_module__module__name":
return getIssueIds(
orderBy(array, [
getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("module_ids", issue?.["module_ids"], "asc"),
])
);
case "-issue_module__module__name":
return getIssueIds(
orderBy(
array,
[
getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("module_ids", issue?.["module_ids"], "asc"),
],
["asc", "desc"]
)
);
case "issue_cycle__cycle__name":
return getIssueIds(
orderBy(array, [
getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("cycle_id", issue?.["cycle_id"], "asc"),
])
);
case "-issue_cycle__cycle__name":
return getIssueIds(
orderBy(
array,
[
getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("cycle_id", issue?.["cycle_id"], "asc"),
],
["asc", "desc"]
)
);
case "assignees__first_name":
return getIssueIds(
orderBy(array, [
getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("assignee_ids", issue?.["assignee_ids"], "asc"),
])
);
case "-assignees__first_name":
return getIssueIds(
orderBy(
array,
[
getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below
(issue) => this.populateIssueDataForSorting("assignee_ids", issue?.["assignee_ids"], "asc"),
],
["asc", "desc"]
)
);
default:
return getIssueIds(array);
}
};
/**
* This Method is called to store the pagination options and paginated data from response
* @param issuesResponse issue list response
* @param options pagination options to be stored for next page call
* @param groupId
* @param subGroupId
*/
storePreviousPaginationValues = (
issuesResponse: TIssuesResponse,
options?: IssuePaginationOptions,
groupId?: string,
subGroupId?: string
) => {
if (options) this.paginationOptions = options;
this.setPaginationData(
issuesResponse.prev_cursor,
issuesResponse.next_cursor,
issuesResponse.next_page_results,
groupId,
subGroupId
);
};
}