[WEB-3838]feat:sub work items sorting (#6967)

* refactor: sub-work items components, hooks and types

* feat: added orderby and display properties toggle for sub work items

* fix: build errors

* chore: removed issue type from filters

* chore: added null check

* fix: added null check

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Vamsi Krishna 2025-04-29 15:23:10 +05:30 committed by GitHub
parent 55340f9f48
commit 14dc6a56bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 843 additions and 995 deletions

View file

@ -23,6 +23,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
from plane.utils.host import base_host
from plane.utils.order_queryset import order_issue_queryset
class SubIssuesEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
@ -102,6 +103,15 @@ class SubIssuesEndpoint(BaseAPIView):
.order_by("-created_at")
)
# Ordering
order_by_param = request.GET.get("order_by", "-created_at")
group_by = request.GET.get("group_by", False)
if order_by_param:
sub_issues, order_by_param = order_issue_queryset(
sub_issues, order_by_param
)
# create's a dict with state group name with their respective issue id's
result = defaultdict(list)
for sub_issue in sub_issues:
@ -138,6 +148,26 @@ class SubIssuesEndpoint(BaseAPIView):
sub_issues = user_timezone_converter(
sub_issues, datetime_fields, request.user.user_timezone
)
# Grouping
if group_by:
result_dict = defaultdict(list)
for issue in sub_issues:
if group_by == "assignees__ids":
if issue["assignee_ids"]:
assignee_ids = issue["assignee_ids"]
for assignee_id in assignee_ids:
result_dict[str(assignee_id)].append(issue)
elif issue["assignee_ids"] == []:
result_dict["None"].append(issue)
elif group_by:
result_dict[str(issue[group_by])].append(issue)
return Response(
{"sub_issues": result_dict, "state_distribution": result},
status=status.HTTP_200_OK,
)
return Response(
{"sub_issues": sub_issues, "state_distribution": result},
status=status.HTTP_200_OK,

View file

@ -165,6 +165,15 @@ export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] =
"issue_type",
];
export const SUB_ISSUES_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [
"key",
"assignee",
"start_date",
"due_date",
"priority",
"state",
];
export const ISSUE_DISPLAY_PROPERTIES: {
key: keyof IIssueDisplayProperties;
titleTranslationKey: string;

View file

@ -6,6 +6,7 @@ import {
TIssueFilterPriorityObject,
ISSUE_DISPLAY_PROPERTIES_KEYS,
EIssuesStoreType,
SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
} from "./common";
import { TIssueLayout } from "./layout";
@ -96,23 +97,11 @@ export type TIssueFiltersToDisplayByPageType = {
export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
profile_issues: {
list: {
filters: [
"priority",
"state_group",
"labels",
"start_date",
"target_date",
],
filters: ["priority", "state_group", "labels", "start_date", "target_date"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "priority", "project", "labels", null],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -121,23 +110,11 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
},
kanban: {
filters: [
"priority",
"state_group",
"labels",
"start_date",
"target_date",
],
filters: ["priority", "state_group", "labels", "start_date", "target_date"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "priority", "project", "labels"],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -173,13 +150,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
"created_by",
null,
],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -190,34 +161,11 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
draft_issues: {
list: {
filters: [
"priority",
"state_group",
"cycle",
"module",
"labels",
"start_date",
"target_date",
"issue_type",
],
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: [
"state_detail.group",
"cycle",
"module",
"priority",
"project",
"labels",
null,
],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -226,33 +174,11 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
},
kanban: {
filters: [
"priority",
"state_group",
"cycle",
"module",
"labels",
"start_date",
"target_date",
"issue_type",
],
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: [
"state_detail.group",
"cycle",
"module",
"priority",
"project",
"labels",
],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels"],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -323,24 +249,8 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: [
"state",
"priority",
"cycle",
"module",
"labels",
"assignees",
"created_by",
null,
],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
"target_date",
],
group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -364,33 +274,9 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: [
"state",
"priority",
"cycle",
"module",
"labels",
"assignees",
"created_by",
],
sub_group_by: [
"state",
"priority",
"cycle",
"module",
"labels",
"assignees",
"created_by",
null,
],
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
"target_date",
],
group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by"],
sub_group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -436,13 +322,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -466,13 +346,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ["key", "issue_type"],
display_filters: {
order_by: [
"sort_order",
"-created_at",
"-updated_at",
"start_date",
"-priority",
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
@ -481,6 +355,19 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
},
},
sub_work_items: {
list: {
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
filters: [],
display_filters: {
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
},
extra_options: {
access: true,
values: ["sub_issue"],
},
},
},
};
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<

View file

@ -10,9 +10,11 @@ export type TSubIssuesStateDistribution = {
export type TIssueSubIssues = {
state_distribution: TSubIssuesStateDistribution;
sub_issues: TIssue[];
sub_issues: TSubIssueResponse;
};
export type TSubIssueResponse = TIssue[] | { [key: string]: TIssue[] };
export type TIssueSubIssuesStateDistributionMap = {
[issue_id: string]: TSubIssuesStateDistribution;
};
@ -20,3 +22,20 @@ export type TIssueSubIssuesStateDistributionMap = {
export type TIssueSubIssuesIdMap = {
[issue_id: string]: string[];
};
export type TSubIssueOperations = {
copyLink: (path: string) => void;
fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise<void>;
addSubIssue: (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>;
};

View file

@ -0,0 +1,14 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const DisplayPropertiesIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" {...rest} className={className}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.93934 0.93934C3.22064 0.658035 3.60218 0.5 4 0.5C4.39782 0.5 4.77936 0.658035 5.06066 0.93934C5.22036 1.09904 5.34033 1.29105 5.41421 1.5H11.5C11.7761 1.5 12 1.72386 12 2C12 2.27614 11.7761 2.5 11.5 2.5H5.41421C5.34033 2.70895 5.22036 2.90096 5.06066 3.06066C4.77936 3.34197 4.39782 3.5 4 3.5C3.60218 3.5 3.22064 3.34197 2.93934 3.06066C2.77964 2.90096 2.65967 2.70895 2.58579 2.5H0.5C0.223858 2.5 0 2.27614 0 2C0 1.72386 0.223858 1.5 0.5 1.5H2.58579C2.65967 1.29105 2.77964 1.09904 2.93934 0.93934ZM4 1.5C3.86739 1.5 3.74021 1.55268 3.64645 1.64645C3.55268 1.74021 3.5 1.86739 3.5 2C3.5 2.13261 3.55268 2.25979 3.64645 2.35355C3.74021 2.44732 3.86739 2.5 4 2.5C4.13261 2.5 4.25979 2.44732 4.35355 2.35355C4.44732 2.25979 4.5 2.13261 4.5 2C4.5 1.86739 4.44732 1.74021 4.35355 1.64645C4.25979 1.55268 4.13261 1.5 4 1.5ZM6.93934 4.93934C7.22064 4.65804 7.60218 4.5 8 4.5C8.39783 4.5 8.77936 4.65804 9.06066 4.93934C9.22036 5.09904 9.34033 5.29105 9.41422 5.5H11.5C11.7761 5.5 12 5.72386 12 6C12 6.27614 11.7761 6.5 11.5 6.5H9.41422C9.34033 6.70895 9.22036 6.90096 9.06066 7.06066C8.77936 7.34196 8.39783 7.5 8 7.5C7.60218 7.5 7.22064 7.34196 6.93934 7.06066C6.77964 6.90096 6.65967 6.70895 6.58579 6.5H0.5C0.223858 6.5 0 6.27614 0 6C0 5.72386 0.223858 5.5 0.5 5.5H6.58579C6.65967 5.29105 6.77964 5.09904 6.93934 4.93934ZM8 5.5C7.86739 5.5 7.74021 5.55268 7.64645 5.64645C7.55268 5.74021 7.5 5.86739 7.5 6C7.5 6.13261 7.55268 6.25979 7.64645 6.35355C7.74021 6.44732 7.86739 6.5 8 6.5C8.13261 6.5 8.25978 6.44732 8.35355 6.35355C8.44732 6.25979 8.5 6.13261 8.5 6C8.5 5.86739 8.44732 5.74022 8.35355 5.64645C8.25978 5.55268 8.13261 5.5 8 5.5ZM2.93934 8.93934C3.22064 8.65804 3.60217 8.5 4 8.5C4.39783 8.5 4.77936 8.65804 5.06066 8.93934C5.22036 9.09904 5.34033 9.29105 5.41421 9.5H11.5C11.7761 9.5 12 9.72386 12 10C12 10.2761 11.7761 10.5 11.5 10.5H5.41421C5.34033 10.709 5.22036 10.901 5.06066 11.0607C4.77936 11.342 4.39783 11.5 4 11.5C3.60217 11.5 3.22064 11.342 2.93934 11.0607C2.77964 10.901 2.65967 10.709 2.58579 10.5H0.5C0.223858 10.5 0 10.2761 0 10C0 9.72386 0.223858 9.5 0.5 9.5H2.58579C2.65967 9.29105 2.77964 9.09904 2.93934 8.93934ZM4 9.5C3.86739 9.5 3.74022 9.55268 3.64645 9.64645C3.55268 9.74022 3.5 9.86739 3.5 10C3.5 10.1326 3.55268 10.2598 3.64645 10.3536C3.74022 10.4473 3.86739 10.5 4 10.5C4.13261 10.5 4.25979 10.4473 4.35355 10.3536C4.44732 10.2598 4.5 10.1326 4.5 10C4.5 9.86739 4.44732 9.74022 4.35355 9.64645C4.25979 9.55268 4.13261 9.5 4 9.5Z"
fill="currentColor"
/>
</svg>
);

View file

@ -51,3 +51,4 @@ export * from "./multiple-sticky";
export * from "./sticky-note-icon";
export * from "./bar-icon";
export * from "./tree-map-icon";
export * from "./display-properties";

View file

@ -1,18 +1,17 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
// plane imports
import { EIssueServiceType, ISSUE_DELETED, ISSUE_UPDATED } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// constants
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
export type TRelationIssueOperations = {
copyText: (text: string) => void;
copyLink: (path: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
@ -29,9 +28,8 @@ export const useRelationOperations = (
const issueOperations: TRelationIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}${text}`).then(() => {
copyLink: (path) => {
copyUrlToClipboard(path).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@ -39,7 +37,7 @@ export const useRelationOperations = (
});
});
},
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
update: async (workspaceSlug, projectId, issueId, data) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
@ -56,7 +54,7 @@ export const useRelationOperations = (
type: TOAST_TYPE.SUCCESS,
message: t("entity.update.success", { entity: entityName }),
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
@ -73,7 +71,7 @@ export const useRelationOperations = (
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
remove: async (workspaceSlug, projectId, issueId) => {
try {
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
captureIssueEvent({
@ -82,7 +80,7 @@ export const useRelationOperations = (
path: pathname,
});
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
@ -91,7 +89,7 @@ export const useRelationOperations = (
}
},
}),
[pathname, removeIssue, updateIssue]
[captureIssueEvent, entityName, pathname, removeIssue, t, updateIssue]
);
return issueOperations;

View file

@ -6,11 +6,11 @@ import { TIssue, TIssueServiceType } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
import { IssueList } from "@/components/issues/sub-issues/issues-list";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
// local imports
import { useSubIssueOperations } from "./helper";
import { SubIssuesListRoot } from "./issues-list/root";
type Props = {
workspaceSlug: string;
@ -53,8 +53,9 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
},
});
// store hooks
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail();
const {
toggleCreateIssueModal,
toggleDeleteIssueModal,
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
} = useIssueDetail(issueServiceType);
@ -63,20 +64,19 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// handler
const handleIssueCrudState = (
key: "create" | "existing" | "update" | "delete",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const handleIssueCrudState = useCallback(
(key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue,
},
});
},
[issueCrudState]
);
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
@ -116,7 +116,7 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
<SubIssuesListRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}

View file

@ -0,0 +1,70 @@
import { FC } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
import { DisplayPropertiesIcon } from "@plane/ui";
import { FilterDisplayProperties, FilterOrderBy, FiltersDropdown } from "@/components/issues";
type TSubIssueDisplayFiltersProps = {
displayProperties: IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
isEpic?: boolean;
};
export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer((props) => {
const {
isEpic = false,
displayProperties,
layoutDisplayFiltersOptions,
handleDisplayPropertiesUpdate,
handleDisplayFiltersUpdate,
displayFilters,
} = props;
return (
<>
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
<FiltersDropdown
placement="bottom-end"
menuButton={<DisplayPropertiesIcon className="h-3.5 w-3.5 text-custom-text-100" />}
>
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left"
>
{/* display properties */}
<div className="py-2">
<FilterDisplayProperties
displayProperties={displayProperties}
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
handleUpdate={handleDisplayPropertiesUpdate}
isEpic={isEpic}
/>
</div>
{/* order by */}
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
<div className="py-2">
<FilterOrderBy
selectedOrderBy={displayFilters?.order_by}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
order_by: val,
})
}
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
/>
</div>
)}
</div>
</FiltersDropdown>
)}
</>
);
});

View file

@ -1,29 +1,23 @@
"use client";
import { useMemo } from "react";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
import { TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { useEventTracker, useIssueDetail, useProjectState } from "@/hooks/store";
// plane-web
// plane web helpers
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
// type
import { TSubIssueOperations } from "../../sub-issues";
export type TRelationIssueOperations = {
copyText: (text: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
// router
const { epicId: epicIdParam } = useParams();
const pathname = usePathname();
// translation
const { t } = useTranslation();
// store hooks
const {
@ -46,9 +40,8 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
copyLink: (path) => {
copyUrlToClipboard(`/${path}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@ -61,7 +54,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
});
},
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch {
@ -77,7 +70,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
@ -94,7 +87,6 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
// message: `Error adding ${issueServiceType === EIssueServiceType.ISSUES ? "sub-issues" : "issues"}`,
message: t("entity.add.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
@ -105,13 +97,13 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
}
},
updateSubIssue: async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue: Partial<TIssue> = {},
fromModal: boolean = false
workspaceSlug,
projectId,
parentIssueId,
issueId,
issueData,
oldIssue = {},
fromModal = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
@ -172,7 +164,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
}
},
removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
removeSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
@ -218,7 +210,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
});
}
},
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
deleteSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
return deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId).then(() => {
@ -244,20 +236,20 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
},
}),
[
fetchSubIssues,
captureIssueEvent,
createSubIssues,
epicId,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
setSubIssueHelpers,
epicId,
fetchSubIssues,
getIssueById,
getStateById,
updateAnalytics,
captureIssueEvent,
pathname,
t,
issueServiceType,
pathname,
removeSubIssue,
setSubIssueHelpers,
t,
updateAnalytics,
updateSubIssue,
]
);

View file

@ -2,3 +2,4 @@ export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
export * from "./display-filters";

View file

@ -1,14 +1,15 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
// ui
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// helpers
import { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper";
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
@ -18,15 +19,10 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { useSubIssueOperations } from "../issue-detail-widgets/sub-issues/helper";
import { IssueList } from "./issues-list";
import { IssueProperty } from "./properties";
// ui
// types
import { TSubIssueOperations } from "./root";
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
import { SubIssuesListItemProperties } from "./properties";
import { SubIssuesListRoot } from "./root";
export interface ISubIssues {
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
@ -41,9 +37,9 @@ export interface ISubIssues {
subIssueOperations: TSubIssueOperations;
issueId: string;
issueServiceType?: TIssueServiceType;
}
};
export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
@ -59,6 +55,9 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
const { t } = useTranslation();
const {
issue: { getIssueById },
subIssues: {
filters: { getSubIssueFilters },
},
} = useIssueDetail(issueServiceType);
const {
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
@ -80,6 +79,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId);
const subIssueCount = issue?.sub_issues_count ?? 0;
// derived values
const subIssueFilters = getSubIssueFilters(parentIssueId);
const displayProperties = subIssueFilters.displayProperties ?? {};
//
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
@ -150,17 +153,19 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
}}
/>
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
</WithDisplayPropertiesHOC>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span className="w-full truncate text-sm text-custom-text-100">{issue.name}</span>
</Tooltip>
@ -173,13 +178,14 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
e.stopPropagation();
}}
>
<IssueProperty
<SubIssuesListItemProperties
workspaceSlug={workspaceSlug}
parentIssueId={parentIssueId}
issueId={issueId}
disabled={disabled}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
updateSubIssue={subIssueOperations.updateSubIssue}
displayProperties={displayProperties}
issue={issue}
/>
</div>
@ -205,7 +211,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
subIssueOperations.copyText(workItemLink);
subIssueOperations.copyLink(workItemLink);
}}
>
<div className="flex items-center gap-2">
@ -258,7 +264,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
issue.project_id &&
subIssueCount > 0 &&
!isCurrentIssueRoot && (
<IssueList
<SubIssuesListRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}

View file

@ -0,0 +1,161 @@
// plane imports
import { SyntheticEvent } from "react";
import { observer } from "mobx-react";
import { CalendarClock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IIssueDisplayProperties, TIssue } from "@plane/types";
// components
import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns";
// hooks
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
type Props = {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
disabled: boolean;
updateSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue?: Partial<TIssue>
) => Promise<void>;
displayProperties?: IIssueDisplayProperties;
issue: TIssue;
};
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props;
// hooks
const { t } = useTranslation();
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
};
if (!displayProperties) return <></>;
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
return (
<div className="relative flex items-center gap-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
start_date: val ? renderFormattedPayloadDate(val) : null,
},
{ ...issue }
)
}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!disabled}
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.target_date ?? null}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
target_date: val ? renderFormattedPayloadDate(val) : null,
},
{ ...issue }
)
}
maxDate={maxDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!disabled}
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!disabled}
buttonVariant="border-with-text"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
assignee_ids: val,
})
}
disabled={!disabled}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</WithDisplayPropertiesHOC>
</div>
);
});

View file

@ -0,0 +1,64 @@
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store";
// local imports
import { SubIssuesListItem } from "./list-item";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
disabled: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
};
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
spacingLeft = 10,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
// store hooks
const {
subIssues: { subIssuesByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const subIssueIds = subIssuesByIssueId(parentIssueId);
return (
<div className="relative">
{subIssueIds?.map((issueId) => (
<SubIssuesListItem
key={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
/>
))}
</div>
);
});

View file

@ -21,7 +21,7 @@ export const SubIssuesCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived state
// derived values
const isCollapsibleOpen = openWidgets.includes("sub-issues");
return (
@ -33,7 +33,8 @@ export const SubIssuesCollapsible: FC<Props> = observer((props) => {
isOpen={isCollapsibleOpen}
parentIssueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
}
buttonClassName="w-full"

View file

@ -0,0 +1,69 @@
import { FC, useCallback } from "react";
import { observer } from "mobx-react";
import { EIssueFilterType, EIssueServiceType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueServiceType } from "@plane/types";
import { useIssueDetail } from "@/hooks/store";
import { SubIssueDisplayFilters } from "./display-filters";
import { SubIssuesActionButton } from "./quick-action-button";
type TSubWorkItemTitleActionsProps = {
disabled: boolean;
issueServiceType?: TIssueServiceType;
parentId: string;
workspaceSlug: string;
projectId: string;
};
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, workspaceSlug, projectId } = props;
// store hooks
const {
subIssues: {
filters: { getSubIssueFilters, updateSubIssueFilters },
},
} = useIssueDetail(issueServiceType);
// derived values
const subIssueFilters = getSubIssueFilters(parentId);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateSubIssueFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
},
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
);
const handleDisplayPropertiesUpdate = useCallback(
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateSubIssueFilters(
workspaceSlug,
projectId,
EIssueFilterType.DISPLAY_PROPERTIES,
updatedDisplayProperties,
parentId
);
},
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
);
return (
<div className="flex items-center gap-2">
<SubIssueDisplayFilters
isEpic={issueServiceType === EIssueServiceType.EPICS}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayProperties={subIssueFilters?.displayProperties ?? {}}
displayFilters={subIssueFilters?.displayFilters ?? {}}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
handleDisplayFiltersUpdate={handleDisplayFilters}
/>
{!disabled && (
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
)}
</div>
);
});

View file

@ -1,34 +1,46 @@
"use client";
import React, { FC } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssueServiceType } from "@plane/types";
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
// components
import { SubIssuesActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { SubWorkItemTitleActions } from "./title-actions";
type Props = {
isOpen: boolean;
parentIssueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
projectId: string;
workspaceSlug: string;
};
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const {
isOpen,
parentIssueId,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
workspaceSlug,
} = props;
// translation
const { t } = useTranslation();
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
subIssues: {
subIssuesByIssueId,
stateDistributionByIssueId,
},
} = useIssueDetail(issueServiceType);
// derived data
// derived values
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
// if there are no sub-issues, return null
if (!subIssues) return null;
@ -50,9 +62,13 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
</div>
}
actionItemElement={
!disabled && (
<SubIssuesActionButton issueId={parentIssueId} disabled={disabled} issueServiceType={issueServiceType} />
)
<SubWorkItemTitleActions
workspaceSlug={workspaceSlug}
projectId={projectId}
parentId={parentIssueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
/>
);

View file

@ -109,7 +109,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
const handleCopyIssueLink = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
issueOperations.copyText(workItemLink);
issueOperations.copyLink(workItemLink);
};
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {

View file

@ -1 +0,0 @@
export * from "./root";

View file

@ -1,69 +0,0 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { EIssueServiceType } from "@plane/constants";
import { TIssue, TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store";
// components
import { IssueListItem } from "./issue-list-item";
// types
import { TSubIssueOperations } from "./root";
export interface IIssueList {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
disabled: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
}
export const IssueList: FC<IIssueList> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
spacingLeft = 10,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
// hooks
const {
subIssues: { subIssuesByIssueId },
} = useIssueDetail(issueServiceType);
const subIssueIds = subIssuesByIssueId(parentIssueId);
return (
<div className="relative">
{subIssueIds &&
subIssueIds.length > 0 &&
subIssueIds.map((issueId) => (
<Fragment key={issueId}>
<IssueListItem
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
/>
</Fragment>
))}
</div>
);
});

View file

@ -1,25 +0,0 @@
export interface IProgressBar {
total: number;
done: number;
}
export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => {
const calPercentage = (doneValue: number, totalValue: number): string => {
if (doneValue === 0 || totalValue === 0) return (0).toFixed(0);
return ((100 * doneValue) / totalValue).toFixed(0);
};
return (
<div className="relative flex items-center gap-2">
<div className="w-full">
<div className="w-full overflow-hidden rounded-full bg-custom-background-80 shadow">
<div
className="h-[6px] rounded-full bg-green-500 transition-all"
style={{ width: `${calPercentage(done, total)}%` }}
/>
</div>
</div>
<div className="flex-shrink-0 text-xs font-medium">{calPercentage(done, total)}% Done</div>
</div>
);
};

View file

@ -1,86 +0,0 @@
import React from "react";
import { TIssueServiceType } from "@plane/types";
// hooks
import { PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
import { useIssueDetail } from "@/hooks/store";
// components
// types
import { TSubIssueOperations } from "./root";
export interface IIssueProperty {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
disabled: boolean;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
}
export const IssueProperty: React.FC<IIssueProperty> = (props) => {
const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
const issue = getIssueById(issueId);
if (!issue) return <></>;
return (
<div className="relative flex items-center gap-2">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
subIssueOperations.updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!disabled}
buttonVariant="border-with-text"
/>
</div>
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
assignee_ids: val,
})
}
disabled={!disabled}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</div>
);
};

View file

@ -1,546 +0,0 @@
"use client";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// icons
import { Plus, ChevronRight, Loader, Pencil } from "lucide-react";
// types
import { IUser, TIssue } from "@plane/types";
// ui
import { CircularProgressIndicator, CustomMenu, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// local components
import useURLHash from "@/hooks/use-url-hash";
import { IssueList } from "./issues-list";
export interface ISubIssuesRoot {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
currentUser: IUser;
disabled: boolean;
}
export type TSubIssueOperations = {
copyText: (text: string) => void;
fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise<void>;
addSubIssue: (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>;
};
export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled = false } = props;
// router
const pathname = usePathname();
const hashValue = useURLHash();
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId, stateDistributionByIssueId, subIssueHelpersByIssueId, setSubIssueHelpers },
fetchSubIssues,
createSubIssues,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
toggleDeleteIssueModal,
} = useIssueDetail();
const { setTrackElement, captureIssueEvent } = useEventTracker();
// state
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
update: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
const scrollToSubIssuesView = useCallback(() => {
if (hashValue === "sub-issues") {
setTimeout(() => {
const subIssueDiv = document.getElementById(`sub-issues`);
if (subIssueDiv)
subIssueDiv.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 200);
}
}, [hashValue]);
useEffect(() => {
if (hashValue) {
scrollToSubIssuesView();
}
}, [hashValue, scrollToSubIssuesView]);
const handleIssueCrudState = (
key: "create" | "existing" | "update" | "delete",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Work item link copied to clipboard.",
});
});
},
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error fetching sub-work items",
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-work items added successfully",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error adding sub-work item",
});
}
},
updateSubIssue: async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue: Partial<TIssue> = {},
fromModal: boolean = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(issueData).join(","),
change_details: Object.values(issueData).join(","),
},
path: pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-work item updated successfully",
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(issueData).join(","),
change_details: Object.values(issueData).join(","),
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error updating sub-work item",
});
}
},
removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-work item removed successfully",
});
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "parent_id",
change_details: parentIssueId,
},
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "parent_id",
change_details: parentIssueId,
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error removing sub-work item",
});
}
},
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
captureIssueEvent({
eventName: "Sub-issue deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error deleting work item",
});
}
},
}),
[fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers]
);
const issue = getIssueById(parentIssueId);
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
}
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
}, [
parentIssueId,
projectId,
setSubIssueHelpers,
subIssueHelpers.issue_visibility,
subIssueOperations,
workspaceSlug,
]);
useEffect(() => {
handleFetchSubIssues();
return () => {
handleFetchSubIssues();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentIssueId]);
if (!issue) return <></>;
return (
<div id="sub-issues" className="h-full w-full space-y-2">
{!subIssues ? (
<div className="py-3 text-center text-sm font-medium text-custom-text-300">Loading...</div>
) : (
<>
{subIssues && subIssues?.length > 0 ? (
<>
<div className="relative flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1 rounded py-1 px-2 transition-all hover:bg-custom-background-80 font-medium"
onClick={handleFetchSubIssues}
>
<div className="flex flex-shrink-0 items-center justify-center">
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
<Loader strokeWidth={2} className="h-3 w-3 animate-spin" />
) : (
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(parentIssueId),
})}
strokeWidth={2}
/>
)}
</div>
<div>Sub-work items</div>
</button>
<div className="flex items-center gap-2 text-custom-text-300">
<CircularProgressIndicator
size={16}
percentage={
subIssuesDistribution?.completed?.length && subIssues.length
? (subIssuesDistribution?.completed?.length / subIssues.length) * 100
: 0
}
strokeWidth={3}
/>
<span>
{subIssuesDistribution?.completed?.length ?? 0}/{subIssues.length} Done
</span>
</div>
</div>
{!disabled && (
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-work item
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={10}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
</>
) : (
!disabled && (
<div className="flex items-center justify-between">
<div className="text-xs italic text-custom-text-300">No sub-work items yet</div>
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-work item
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)
)}
{/* issue create, add from existing , update and delete modals */}
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.create?.toggle}
data={{
parent_id: issueCrudState?.create?.parentIssueId,
}}
onClose={() => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
}}
onSubmit={async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
}
}}
/>
)}
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudState?.existing?.toggle}
handleClose={() => {
handleIssueCrudState("existing", null, null);
toggleSubIssuesModal(null);
}}
searchParams={{ sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId }}
handleOnSubmit={(_issue) =>
subIssueOperations.addSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.map((issue) => issue.id)
)
}
workspaceLevelToggle
/>
)}
{issueCrudState?.update?.toggle && issueCrudState?.update?.issue && (
<>
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.updateSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.id,
_issue,
issueCrudState?.update?.issue,
true
);
}}
/>
</>
)}
{issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.parentIssueId &&
issueCrudState.delete.issue.id && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await subIssueOperations.deleteSubIssue(
workspaceSlug,
projectId,
issueCrudState?.delete?.parentIssueId as string,
issueCrudState?.delete?.issue?.id as string
)
}
isSubIssue
/>
)}
</>
)}
</div>
);
});

View file

@ -267,9 +267,15 @@ export class IssueService extends APIService {
});
}
async subIssues(workspaceSlug: string, projectId: string, issueId: string): Promise<TIssueSubIssues> {
async subIssues(
workspaceSlug: string,
projectId: string,
issueId: string,
queries?: Partial<Record<TIssueParams, string | boolean>>
): Promise<TIssueSubIssues> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/${this.serviceType === EIssueServiceType.EPICS ? "issues" : "sub-issues"}/`
`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/${this.serviceType === EIssueServiceType.EPICS ? "issues" : "sub-issues"}/`,
{ params: queries }
)
.then((response) => response?.data)
.catch((error) => {

View file

@ -13,12 +13,16 @@ import {
TIssueSubIssuesIdMap,
TSubIssuesStateDistribution,
TIssueServiceType,
TLoader,
TGroupedIssues,
TGroupedIssueCount,
} from "@plane/types";
// services
import { updatePersistentLayer } from "@/local-db/utils/utils";
import { IssueService } from "@/services/issue";
// store
import { IIssueDetail } from "./root.store";
import { IWorkItemSubIssueFiltersStore, WorkItemSubIssueFiltersStore } from "./sub_issues_filter.store";
export interface IIssueSubIssuesStoreActions {
fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise<TIssueSubIssues>;
@ -47,11 +51,16 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
// observables
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap;
subIssues: TIssueSubIssuesIdMap;
groupedSubIssuesMap: Record<string, TGroupedIssues>;
groupedSubIssuesCount: TGroupedIssueCount;
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;
groupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined;
// actions
fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void;
@ -61,7 +70,12 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
// observables
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {};
subIssues: TIssueSubIssuesIdMap = {};
groupedSubIssuesMap: Record<string, TGroupedIssues> = {};
groupedSubIssuesCount: TGroupedIssueCount = {};
subIssueHelpers: Record<string, TSubIssueHelpers> = {};
loader: TLoader = undefined;
filters: IWorkItemSubIssueFiltersStore;
// root store
rootIssueDetailStore: IIssueDetail;
// services
@ -74,6 +88,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
subIssuesStateDistribution: observable,
subIssues: observable,
subIssueHelpers: observable,
groupedSubIssuesMap: observable,
loader: observable.ref,
// actions
setSubIssueHelpers: action,
fetchSubIssues: action,
@ -82,7 +98,9 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
removeSubIssue: action,
deleteSubIssue: action,
fetchOtherProjectProperties: action,
groupedSubIssuesByIssueId: action,
});
this.filters = new WorkItemSubIssueFiltersStore(this);
// root store
this.rootIssueDetailStore = rootStore;
// services
@ -101,6 +119,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
return this.subIssues[issueId] ?? undefined;
};
groupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined;
subIssueHelpersByIssueId = (issueId: string) => ({
preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [],
issue_visibility: this.subIssueHelpers?.[issueId]?.issue_visibility || [],
@ -118,20 +138,29 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
};
fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId);
// get filter params
const filterParams = this.filters.computedFilterParams(parentIssueId);
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId, filterParams);
const subIssuesStateDistribution = response?.state_distribution ?? {};
const subIssues = (response.sub_issues ?? []) as TIssue[];
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(subIssues);
// fetch other issues states and members when sub-issues are from different project
if (subIssues && subIssues.length > 0) {
// process sub issues response
const { issueList, groupedIssues } = this.filters.processSubIssueResponse(response.sub_issues);
// set grouped issues count
set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues);
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList);
if (issueList && issueList.length > 0) {
const otherProjectIds = uniq(
subIssues.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
issueList.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
) as string[];
this.fetchOtherProjectProperties(workspaceSlug, otherProjectIds);
}
if (subIssues) {
if (issueList) {
this.rootIssueDetailStore.rootIssueStore.issues.updateIssue(parentIssueId, {
sub_issues_count: subIssues.length,
sub_issues_count: issueList.length,
});
}
runInAction(() => {
@ -139,7 +168,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
set(
this.subIssues,
parentIssueId,
subIssues.map((issue) => issue.id)
issueList.map((issue) => issue.id)
);
});
return response;
@ -282,7 +311,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
set(
this.rootIssueDetailStore.rootIssueStore.issues.issuesMap,
[parentIssueId, "sub_issues_count"],
this.subIssues[parentIssueId].length
this.subIssues[parentIssueId]?.length
);
});
@ -319,7 +348,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
set(
this.rootIssueDetailStore.rootIssueStore.issues.issuesMap,
[parentIssueId, "sub_issues_count"],
this.subIssues[parentIssueId].length
this.subIssues[parentIssueId]?.length
);
});

View file

@ -0,0 +1,202 @@
import set from "lodash/set";
import { action, makeObservable, observable } from "mobx";
import { ALL_ISSUES, EIssueFilterType, EIssueGroupByToServerOptions } from "@plane/constants";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilters,
TGroupedIssueCount,
TGroupedIssues,
TIssue,
TIssueParams,
TIssues,
TSubGroupedIssues,
TSubIssueResponse,
} from "@plane/types";
import { IIssueSubIssuesStore } from "./sub_issues.store";
export interface IWorkItemSubIssueFiltersStore {
subIssueFiltersMap: Record<string, Partial<IIssueFilters>>;
// helpers methods
updateSubIssueFilters: (
workspaceSlug: string,
projectId: string,
filterType: EIssueFilterType,
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
parentId: string
) => Promise<void>;
getSubIssueFilters: (parentId: string) => Partial<IIssueFilters>;
computedFilterParams: (parentId: string) => Partial<Record<TIssueParams, string | boolean>>;
processSubIssueResponse: (issueResponse: TSubIssueResponse) => {
issueList: TIssue[];
groupedIssues: TIssues;
groupedIssueCount: TGroupedIssueCount;
};
}
export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore {
// observables
subIssueFiltersMap: Record<string, Partial<IIssueFilters>> = {};
subIssueStore: IIssueSubIssuesStore;
constructor(subIssueStore: IIssueSubIssuesStore) {
makeObservable(this, {
subIssueFiltersMap: observable,
updateSubIssueFilters: action,
getSubIssueFilters: action,
});
// sub issue store
this.subIssueStore = subIssueStore;
}
/**
* @description This method is used to initialize the sub issue filters
* @param parentId
*/
initSubIssueFilters = (parentId: string) => {
set(this.subIssueFiltersMap, [parentId], {
displayFilters: {},
displayProperties: {
key: true,
issue_type: true,
assignee: true,
start_date: true,
due_date: true,
labels: true,
priority: true,
state: true,
},
});
};
/**
* @description This method is used to process the sub issue response to provide the data to update the store
* @param issueResponse
* @returns issueList, list of issues data
* @returns groupedIssues, grouped issue ids
* @returns groupedIssueCount, object containing issue counts of individual groups
*/
processSubIssueResponse = (
issueResponse: TSubIssueResponse
): {
issueList: TIssue[];
groupedIssues: TIssues;
groupedIssueCount: TGroupedIssueCount;
} => {
const issueResult = issueResponse;
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]: issueResult.length,
},
};
}
const issueList: TIssue[] = [];
const groupedIssues: TGroupedIssues | TSubGroupedIssues = {};
const groupedIssueCount: TGroupedIssueCount = {};
// update total issue count to ALL_ISSUES
set(groupedIssueCount, [ALL_ISSUES], issueResult.length);
// loop through all the groupIds from issue Result
for (const groupId in issueResult) {
const groupIssueResult = issueResult[groupId];
// if groupIssueResult is undefined then continue the loop
if (!groupIssueResult) continue;
// set grouped Issue count of the current groupId
set(groupedIssueCount, [groupId], groupIssueResult.length);
// add the result to issueList
issueList.push(...groupIssueResult);
// set the issue Ids to the groupId path
set(
groupedIssues,
[groupId],
groupIssueResult.map((issue) => issue.id)
);
}
return { issueList, groupedIssues, groupedIssueCount };
};
/**
* @description This method is used to get the sub issue filters
* @param parentId
* @returns IIssueFilters
*/
getSubIssueFilters = (parentId: string) => {
if (!this.subIssueFiltersMap[parentId]) {
this.initSubIssueFilters(parentId);
}
return this.subIssueFiltersMap[parentId];
};
computedFilterParams = (parentId: string) => {
const displayFilters = this.getSubIssueFilters(parentId).displayFilters;
const computedFilters: Partial<Record<TIssueParams, undefined | string[] | boolean | string>> = {
order_by: displayFilters?.order_by || undefined,
group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined,
};
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
Object.keys(computedFilters).forEach((key) => {
const _key = key as TIssueParams;
const _value: string | boolean | string[] | undefined = computedFilters[_key];
const nonEmptyArrayValue = Array.isArray(_value) && _value.length === 0 ? undefined : _value;
if (nonEmptyArrayValue != undefined)
issueFiltersParams[_key] = Array.isArray(nonEmptyArrayValue)
? nonEmptyArrayValue.join(",")
: nonEmptyArrayValue;
});
return issueFiltersParams;
};
/**
* @description This method is used to update the sub issue filters
* @param projectId
* @param filterType
* @param filters
*/
updateSubIssueFilters = async (
workspaceSlug: string,
projectId: string,
filterType: EIssueFilterType,
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
parentId: string
) => {
const _filters = this.getSubIssueFilters(parentId);
switch (filterType) {
case EIssueFilterType.DISPLAY_FILTERS: {
set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters });
this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId);
break;
}
case EIssueFilterType.DISPLAY_PROPERTIES:
set(this.subIssueFiltersMap, [parentId, "displayProperties"], {
..._filters.displayProperties,
...filters,
});
break;
}
};
}