From 1c376aaf0a28878bd9e5dbf7de31baa059b578d5 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 15 Apr 2025 12:24:21 +0530 Subject: [PATCH 1/6] refactor: sub-work items components, hooks and types --- .../types/src/issues/issue_sub_issues.d.ts | 17 + .../issue-detail-widget-modals.tsx | 2 +- .../issue-detail-widgets/relations/helper.tsx | 22 +- .../sub-issues/content.tsx | 36 +- .../sub-issues/{helper.tsx => helper.ts} | 83 +-- .../sub-issues/issues-list/list-item.tsx} | 27 +- .../sub-issues/issues-list}/properties.tsx | 16 +- .../sub-issues/issues-list/root.tsx | 64 ++ .../issue-detail-widgets/sub-issues/root.tsx | 2 +- .../issue-detail-widgets/sub-issues/title.tsx | 8 +- .../issues/relations/issue-list-item.tsx | 2 +- .../components/issues/sub-issues/index.ts | 1 - .../issues/sub-issues/issues-list.tsx | 69 --- .../issues/sub-issues/progressbar.tsx | 25 - .../components/issues/sub-issues/root.tsx | 546 ------------------ 15 files changed, 179 insertions(+), 741 deletions(-) rename web/core/components/issues/issue-detail-widgets/sub-issues/{helper.tsx => helper.ts} (80%) rename web/core/components/issues/{sub-issues/issue-list-item.tsx => issue-detail-widgets/sub-issues/issues-list/list-item.tsx} (94%) rename web/core/components/issues/{sub-issues => issue-detail-widgets/sub-issues/issues-list}/properties.tsx (91%) create mode 100644 web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx delete mode 100644 web/core/components/issues/sub-issues/index.ts delete mode 100644 web/core/components/issues/sub-issues/issues-list.tsx delete mode 100644 web/core/components/issues/sub-issues/progressbar.tsx delete mode 100644 web/core/components/issues/sub-issues/root.tsx diff --git a/packages/types/src/issues/issue_sub_issues.d.ts b/packages/types/src/issues/issue_sub_issues.d.ts index e604761ed02..6e7bbac2302 100644 --- a/packages/types/src/issues/issue_sub_issues.d.ts +++ b/packages/types/src/issues/issue_sub_issues.d.ts @@ -20,3 +20,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; + addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise; + updateSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + issueData: Partial, + oldIssue?: Partial, + fromModal?: boolean + ) => Promise; + removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; + deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; +}; diff --git a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx index a93bbd72fcb..bd9aac8e479 100644 --- a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx +++ b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx @@ -42,7 +42,7 @@ export const IssueDetailWidgetModals: FC = observer((props) => { } = useIssueDetail(); // helper hooks - const subIssueOperations = useSubIssueOperations(); + const subIssueOperations = useSubIssueOperations(issueServiceType); const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId); // handlers diff --git a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx index 3fb88b78f9d..f92a3601fed 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx @@ -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) => Promise; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; }; @@ -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) => { + 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; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx index 26cb370ca1c..c8c69217aa8 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx @@ -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 = observer((props) => { }, }); // store hooks - const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(); const { + toggleCreateIssueModal, + toggleDeleteIssueModal, subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers }, } = useIssueDetail(issueServiceType); @@ -63,20 +64,19 @@ export const SubIssuesCollapsibleContent: FC = 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 = observer((props) => { return ( <> {subIssueHelpers.issue_visibility.includes(parentIssueId) && ( - void; - update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; - remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; -}; -export const useSubIssueOperations = ( - issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES -): TSubIssueOperations => { +export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => { // router const { epicId: epicIdParam } = useParams(); const pathname = usePathname(); + // translation const { t } = useTranslation(); // store hooks const { issue: { getIssueById }, subIssues: { setSubIssueHelpers }, + fetchSubIssues, createSubIssues, updateSubIssue, deleteSubIssue, - } = useIssueDetail(); + removeSubIssue, + } = useIssueDetail(issueServiceType); const { getStateById } = useProjectState(); const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS); // const { updateEpicAnalytics } = useIssueTypes(); const { updateAnalytics } = updateEpicAnalytics(); - const { fetchSubIssues } = useIssueDetail(); - const { removeSubIssue } = useIssueDetail(issueServiceType); const { captureIssueEvent } = useEventTracker(); // derived values @@ -48,9 +40,8 @@ export const useSubIssueOperations = ( 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"), @@ -63,10 +54,10 @@ export const useSubIssueOperations = ( }); }); }, - fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => { + fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => { try { await fetchSubIssues(workspaceSlug, projectId, parentIssueId); - } catch (error) { + } catch { setToast({ type: TOAST_TYPE.ERROR, title: t("toast.error"), @@ -79,7 +70,7 @@ export const useSubIssueOperations = ( }); } }, - addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => { + addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => { try { await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds); setToast({ @@ -92,11 +83,10 @@ export const useSubIssueOperations = ( : t("issue.label", { count: 2 }), }), }); - } catch (error) { + } catch { 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 @@ -107,13 +97,13 @@ export const useSubIssueOperations = ( } }, updateSubIssue: async ( - workspaceSlug: string, - projectId: string, - parentIssueId: string, - issueId: string, - issueData: Partial, - oldIssue: Partial = {}, - fromModal: boolean = false + workspaceSlug, + projectId, + parentIssueId, + issueId, + issueData, + oldIssue = {}, + fromModal = false ) => { try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); @@ -157,7 +147,7 @@ export const useSubIssueOperations = ( message: t("sub_work_item.update.success"), }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); - } catch (error) { + } catch { captureIssueEvent({ eventName: "Sub-issue updated", payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" }, @@ -174,7 +164,7 @@ export const useSubIssueOperations = ( }); } }, - 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); @@ -203,7 +193,7 @@ export const useSubIssueOperations = ( path: pathname, }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); - } catch (error) { + } catch { captureIssueEvent({ eventName: "Sub-issue removed", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, @@ -220,7 +210,7 @@ export const useSubIssueOperations = ( }); } }, - 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(() => { @@ -231,7 +221,7 @@ export const useSubIssueOperations = ( }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); }); - } catch (error) { + } catch { captureIssueEvent({ eventName: "Sub-issue removed", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, @@ -245,7 +235,22 @@ export const useSubIssueOperations = ( } }, }), - [fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers] + [ + captureIssueEvent, + createSubIssues, + deleteSubIssue, + epicId, + fetchSubIssues, + getIssueById, + getStateById, + issueServiceType, + pathname, + removeSubIssue, + setSubIssueHelpers, + t, + updateAnalytics, + updateSubIssue, + ] ); return subIssueOperations; diff --git a/web/core/components/issues/sub-issues/issue-list-item.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx similarity index 94% rename from web/core/components/issues/sub-issues/issue-list-item.tsx rename to web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx index 4d10587fd7b..acd4e8a9675 100644 --- a/web/core/components/issues/sub-issues/issue-list-item.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx @@ -1,12 +1,11 @@ "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 { cn } from "@/helpers/common.helper"; @@ -17,15 +16,11 @@ import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-red import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; -// local components -import { IssueList } from "./issues-list"; -import { IssueProperty } from "./properties"; -// ui -// types -import { TSubIssueOperations } from "./root"; -// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// local imports +import { SubIssuesListItemProperties } from "./properties"; +import { SubIssuesListRoot } from "./root"; -export interface ISubIssues { +type Props = { workspaceSlug: string; projectId: string; parentIssueId: string; @@ -40,9 +35,9 @@ export interface ISubIssues { subIssueOperations: TSubIssueOperations; issueId: string; issueServiceType?: TIssueServiceType; -} +}; -export const IssueListItem: React.FC = observer((props) => { +export const SubIssuesListItem: React.FC = observer((props) => { const { workspaceSlug, projectId, @@ -171,7 +166,7 @@ export const IssueListItem: React.FC = observer((props) => { e.stopPropagation(); }} > - = observer((props) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - subIssueOperations.copyText(workItemLink); + subIssueOperations.copyLink(workItemLink); }} >
@@ -256,7 +251,7 @@ export const IssueListItem: React.FC = observer((props) => { issue.project_id && subIssueCount > 0 && !isCurrentIssueRoot && ( - = (props) => { +export const SubIssuesListItemProperties: React.FC = (props) => { const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props; // hooks const { diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx new file mode 100644 index 00000000000..45a45ed91d0 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx @@ -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 = 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 ( +
+ {subIssueIds?.map((issueId) => ( + + ))} +
+ ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx index a9b0c897519..bba4cd6ffcd 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx @@ -21,7 +21,7 @@ export const SubIssuesCollapsible: FC = 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 ( diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx index 3eb60f5e4a8..ae269545999 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx @@ -1,6 +1,8 @@ "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"; @@ -19,13 +21,13 @@ type Props = { export const SubIssuesCollapsibleTitle: FC = observer((props) => { const { isOpen, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props; + // translation const { t } = useTranslation(); // store hooks const { subIssues: { subIssuesByIssueId, stateDistributionByIssueId }, } = useIssueDetail(issueServiceType); - - // derived data + // derived values const subIssuesDistribution = stateDistributionByIssueId(parentIssueId); const subIssues = subIssuesByIssueId(parentIssueId); diff --git a/web/core/components/issues/relations/issue-list-item.tsx b/web/core/components/issues/relations/issue-list-item.tsx index 6b54371e884..ee22253276f 100644 --- a/web/core/components/issues/relations/issue-list-item.tsx +++ b/web/core/components/issues/relations/issue-list-item.tsx @@ -109,7 +109,7 @@ export const RelationIssueListItem: FC = observer((props) => { const handleCopyIssueLink = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - issueOperations.copyText(workItemLink); + issueOperations.copyLink(workItemLink); }; const handleRemoveRelation = (e: React.MouseEvent) => { diff --git a/web/core/components/issues/sub-issues/index.ts b/web/core/components/issues/sub-issues/index.ts deleted file mode 100644 index 1efe34c51ec..00000000000 --- a/web/core/components/issues/sub-issues/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/web/core/components/issues/sub-issues/issues-list.tsx b/web/core/components/issues/sub-issues/issues-list.tsx deleted file mode 100644 index 9fe1a9ababc..00000000000 --- a/web/core/components/issues/sub-issues/issues-list.tsx +++ /dev/null @@ -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 = 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 ( -
- {subIssueIds && - subIssueIds.length > 0 && - subIssueIds.map((issueId) => ( - - - - ))} -
- ); -}); diff --git a/web/core/components/issues/sub-issues/progressbar.tsx b/web/core/components/issues/sub-issues/progressbar.tsx deleted file mode 100644 index e14eab97fd7..00000000000 --- a/web/core/components/issues/sub-issues/progressbar.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
-
-
{calPercentage(done, total)}% Done
-
- ); -}; diff --git a/web/core/components/issues/sub-issues/root.tsx b/web/core/components/issues/sub-issues/root.tsx deleted file mode 100644 index 509ea713530..00000000000 --- a/web/core/components/issues/sub-issues/root.tsx +++ /dev/null @@ -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; - addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise; - updateSubIssue: ( - workspaceSlug: string, - projectId: string, - parentIssueId: string, - issueId: string, - issueData: Partial, - oldIssue?: Partial, - fromModal?: boolean - ) => Promise; - removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; - deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; -}; - -export const SubIssuesRoot: FC = 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, - oldIssue: Partial = {}, - 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 ( -
- {!subIssues ? ( -
Loading...
- ) : ( - <> - {subIssues && subIssues?.length > 0 ? ( - <> -
-
- -
- - - {subIssuesDistribution?.completed?.length ?? 0}/{subIssues.length} Done - -
-
- - {!disabled && ( - - - Add sub-work item - - } - buttonClassName="whitespace-nowrap" - placement="bottom-end" - noBorder - noChevron - > - { - setTrackElement("Issue detail nested sub-issue"); - handleIssueCrudState("create", parentIssueId, null); - toggleCreateIssueModal(true); - }} - > -
- - Create new -
-
- { - setTrackElement("Issue detail nested sub-issue"); - handleIssueCrudState("existing", parentIssueId, null); - toggleSubIssuesModal(issue.id); - }} - > -
- - Add existing -
-
-
- )} -
- - {subIssueHelpers.issue_visibility.includes(parentIssueId) && ( - - )} - - ) : ( - !disabled && ( -
-
No sub-work items yet
- - - Add sub-work item - - } - buttonClassName="whitespace-nowrap" - placement="bottom-end" - noBorder - noChevron - > - { - setTrackElement("Issue detail nested sub-issue"); - handleIssueCrudState("create", parentIssueId, null); - toggleCreateIssueModal(true); - }} - > -
- - Create new -
-
- { - setTrackElement("Issue detail nested sub-issue"); - handleIssueCrudState("existing", parentIssueId, null); - toggleSubIssuesModal(issue.id); - }} - > -
- - Add existing -
-
-
-
- ) - )} - - {/* issue create, add from existing , update and delete modals */} - {issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && ( - { - 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 && ( - { - 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 && ( - <> - { - 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 && ( - { - 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 - /> - )} - - )} -
- ); -}); From b91ff7faf40712915eb21de2057921b6aba026d1 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Thu, 24 Apr 2025 18:45:31 +0530 Subject: [PATCH 2/6] feat: added orderby and display properties toggle for sub work items --- apiserver/plane/app/views/issue/sub_issue.py | 30 +++ packages/constants/src/issue/common.ts | 10 + packages/constants/src/issue/filter.ts | 177 +++------------ .../types/src/issues/issue_sub_issues.d.ts | 4 +- packages/ui/src/icons/display-properties.tsx | 14 ++ packages/ui/src/icons/index.ts | 1 + .../sub-issues/display-filters.tsx | 70 ++++++ .../issue-detail-widgets/sub-issues/index.ts | 1 + .../sub-issues/issues-list/list-item.tsx | 39 ++-- .../sub-issues/issues-list/properties.tsx | 205 ++++++++++++------ .../issue-detail-widgets/sub-issues/root.tsx | 3 +- .../sub-issues/title-actions.tsx | 69 ++++++ .../issue-detail-widgets/sub-issues/title.tsx | 30 ++- web/core/services/issue/issue.service.ts | 10 +- .../issue/issue-details/sub_issues.store.ts | 51 ++++- .../issue-details/sub_issues_filter.store.ts | 202 +++++++++++++++++ 16 files changed, 669 insertions(+), 247 deletions(-) create mode 100644 packages/ui/src/icons/display-properties.tsx create mode 100644 web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx create mode 100644 web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx create mode 100644 web/core/store/issue/issue-details/sub_issues_filter.store.ts diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index e9199ed04ae..5791281f0ed 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -23,6 +23,7 @@ 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 @@ def get(self, request, slug, project_id, issue_id): .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 @@ def get(self, request, slug, project_id, issue_id): 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, diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 03634337a7d..41d2f735ea6 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -165,6 +165,16 @@ export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = "issue_type", ]; +export const SUB_ISSUES_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [ + "key", + "issue_type", + "assignee", + "start_date", + "due_date", + "priority", + "state", +]; + export const ISSUE_DISPLAY_PROPERTIES: { key: keyof IIssueDisplayProperties; titleTranslationKey: string; diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 687a2bd714c..15952132a07 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -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< diff --git a/packages/types/src/issues/issue_sub_issues.d.ts b/packages/types/src/issues/issue_sub_issues.d.ts index 6e7bbac2302..d78d6950363 100644 --- a/packages/types/src/issues/issue_sub_issues.d.ts +++ b/packages/types/src/issues/issue_sub_issues.d.ts @@ -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; }; diff --git a/packages/ui/src/icons/display-properties.tsx b/packages/ui/src/icons/display-properties.tsx new file mode 100644 index 00000000000..cddc25a19fa --- /dev/null +++ b/packages/ui/src/icons/display-properties.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const DisplayPropertiesIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + +); diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index f274a341424..143c3d79a7c 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -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"; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx new file mode 100644 index 00000000000..fe440fb8980 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx @@ -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) => void; + handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial) => void; + layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; + isEpic?: boolean; +}; + +export const SubIssueDisplayFilters: FC = observer((props) => { + const { + isEpic = false, + displayProperties, + layoutDisplayFiltersOptions, + handleDisplayPropertiesUpdate, + handleDisplayFiltersUpdate, + displayFilters, + } = props; + + return ( + <> + {layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions.display_properties.length > 0 && ( + } + > +
{ + 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 */} +
+ +
+ + {/* order by */} + {!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && ( +
+ + handleDisplayFiltersUpdate({ + order_by: val, + }) + } + orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []} + /> +
+ )} +
+
+ )} + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts b/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts index 78eef976868..5fb3c6334ae 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts @@ -2,3 +2,4 @@ export * from "./content"; export * from "./title"; export * from "./root"; export * from "./quick-action-button"; +export * from "./display-filters"; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx index 21c81971400..91dedae489c 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx @@ -9,6 +9,7 @@ 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 @@ -20,8 +21,6 @@ import { IssueIdentifier } from "@/plane-web/components/issues"; // local components import { SubIssuesListItemProperties } from "./properties"; import { SubIssuesListRoot } from "./root"; -// ui -// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; type Props = { workspaceSlug: string; @@ -56,6 +55,9 @@ export const SubIssuesListItem: React.FC = observer((props) => { const { t } = useTranslation(); const { issue: { getIssueById }, + subIssues: { + filters: { getSubIssueFilters }, + }, } = useIssueDetail(issueServiceType); const { subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers }, @@ -77,6 +79,10 @@ export const SubIssuesListItem: React.FC = 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); @@ -147,17 +153,19 @@ export const SubIssuesListItem: React.FC = observer((props) => { backgroundColor: currentIssueStateDetail?.color ?? "#737373", }} /> -
- {projectDetail && ( - - )} -
+ +
+ {projectDetail && ( + + )} +
+
{issue.name} @@ -175,8 +183,9 @@ export const SubIssuesListItem: React.FC = observer((props) => { parentIssueId={parentIssueId} issueId={issueId} disabled={disabled} - subIssueOperations={subIssueOperations} - issueServiceType={issueServiceType} + updateSubIssue={subIssueOperations.updateSubIssue} + displayProperties={displayProperties} + issue={issue} />
diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx index 927aa79708f..8ab70db4452 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx @@ -1,84 +1,161 @@ // plane imports -import { TIssueServiceType, TSubIssueOperations } from "@plane/types"; +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 } from "@/components/dropdowns"; +import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns"; // hooks -import { useIssueDetail } from "@/hooks/store"; +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; - subIssueOperations: TSubIssueOperations; - issueServiceType?: TIssueServiceType; + updateSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + issueData: Partial, + oldIssue?: Partial + ) => Promise; + displayProperties?: IIssueDisplayProperties; + issue: TIssue; }; -export const SubIssuesListItemProperties: React.FC = (props) => { - const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props; +export const SubIssuesListItemProperties: React.FC = observer((props) => { + const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props; // hooks - const { - issue: { getIssueById }, - } = useIssueDetail(issueServiceType); + const { t } = useTranslation(); - const issue = getIssueById(issueId); + const handleEventPropagation = (e: SyntheticEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; - if (!issue) return <>; + if (!displayProperties) return <>; + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); return (
-
- - issue.project_id && - subIssueOperations.updateSubIssue( - workspaceSlug, - issue.project_id, - parentIssueId, - issueId, - { - state_id: val, - }, - { ...issue } - ) - } - disabled={!disabled} - buttonVariant="border-with-text" - /> -
+ +
+ + 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={} + buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} + optionsClassName="z-30" + disabled={!disabled} + /> +
+
+ + +
+ + 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={} + buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + optionsClassName="z-30" + disabled={!disabled} + /> +
+
-
- - issue.project_id && - subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { - priority: val, - }) - } - disabled={!disabled} - buttonVariant="border-without-text" - buttonClassName="border" - /> -
+ +
+ + issue.project_id && + updateSubIssue( + workspaceSlug, + issue.project_id, + parentIssueId, + issueId, + { + state_id: val, + }, + { ...issue } + ) + } + disabled={!disabled} + buttonVariant="border-with-text" + /> +
+
-
- - 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" : ""} - /> -
+ +
+ + issue.project_id && + updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { + priority: val, + }) + } + disabled={!disabled} + buttonVariant="border-without-text" + buttonClassName="border" + /> +
+
+ + +
+ + 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" : ""} + /> +
+
); -}; +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx index bba4cd6ffcd..adea10b7d86 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx @@ -33,7 +33,8 @@ export const SubIssuesCollapsible: FC = observer((props) => { isOpen={isCollapsibleOpen} parentIssueId={issueId} disabled={disabled} - issueServiceType={issueServiceType} + projectId={projectId} + workspaceSlug={workspaceSlug} /> } buttonClassName="w-full" diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx new file mode 100644 index 00000000000..80f9af6fe50 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx @@ -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 = 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) => { + if (!workspaceSlug || !projectId) return; + updateSubIssueFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId); + }, + [workspaceSlug, projectId, parentId, updateSubIssueFilters] + ); + + const handleDisplayPropertiesUpdate = useCallback( + (updatedDisplayProperties: Partial) => { + if (!workspaceSlug || !projectId) return; + updateSubIssueFilters( + workspaceSlug, + projectId, + EIssueFilterType.DISPLAY_PROPERTIES, + updatedDisplayProperties, + parentId + ); + }, + [workspaceSlug, projectId, parentId, updateSubIssueFilters] + ); + + return ( +
+ + {!disabled && ( + + )} +
+ ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx index ae269545999..ceccf95dd37 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx @@ -7,30 +7,40 @@ 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 = 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 values const subIssuesDistribution = stateDistributionByIssueId(parentIssueId); const subIssues = subIssuesByIssueId(parentIssueId); - // if there are no sub-issues, return null if (!subIssues) return null; @@ -52,9 +62,13 @@ export const SubIssuesCollapsibleTitle: FC = observer((props) => {
} actionItemElement={ - !disabled && ( - - ) + } /> ); diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index ccd240bfab6..ae6a1ed546e 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -267,9 +267,15 @@ export class IssueService extends APIService { }); } - async subIssues(workspaceSlug: string, projectId: string, issueId: string): Promise { + async subIssues( + workspaceSlug: string, + projectId: string, + issueId: string, + queries: Partial> + ): Promise { 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) => { diff --git a/web/core/store/issue/issue-details/sub_issues.store.ts b/web/core/store/issue/issue-details/sub_issues.store.ts index c2c160c3902..d77c42d30eb 100644 --- a/web/core/store/issue/issue-details/sub_issues.store.ts +++ b/web/core/store/issue/issue-details/sub_issues.store.ts @@ -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; @@ -47,11 +51,16 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { // observables subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap; subIssues: TIssueSubIssuesIdMap; + groupedSubIssuesMap: Record; + groupedSubIssuesCount: TGroupedIssueCount; subIssueHelpers: Record; // 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; setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void; @@ -61,7 +70,12 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // observables subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {}; subIssues: TIssueSubIssuesIdMap = {}; + groupedSubIssuesMap: Record = {}; + groupedSubIssuesCount: TGroupedIssueCount = {}; subIssueHelpers: Record = {}; + 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 ); }); diff --git a/web/core/store/issue/issue-details/sub_issues_filter.store.ts b/web/core/store/issue/issue-details/sub_issues_filter.store.ts new file mode 100644 index 00000000000..47edf767c50 --- /dev/null +++ b/web/core/store/issue/issue-details/sub_issues_filter.store.ts @@ -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>; + // helpers methods + updateSubIssueFilters: ( + workspaceSlug: string, + projectId: string, + filterType: EIssueFilterType, + filters: IIssueDisplayFilterOptions | IIssueDisplayProperties, + parentId: string + ) => Promise; + getSubIssueFilters: (parentId: string) => Partial; + computedFilterParams: (parentId: string) => Partial>; + processSubIssueResponse: (issueResponse: TSubIssueResponse) => { + issueList: TIssue[]; + groupedIssues: TIssues; + groupedIssueCount: TGroupedIssueCount; + }; +} + +export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore { + // observables + subIssueFiltersMap: Record> = {}; + + 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> = { + order_by: displayFilters?.order_by || undefined, + group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined, + }; + + const issueFiltersParams: Partial> = {}; + 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; + } + }; +} From 315ba6a84a7758c8c19c9abdf884c784c94c3f11 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Thu, 24 Apr 2025 18:51:02 +0530 Subject: [PATCH 3/6] fix: build errors --- web/core/services/issue/issue.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index ae6a1ed546e..e53492b813f 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -271,7 +271,7 @@ export class IssueService extends APIService { workspaceSlug: string, projectId: string, issueId: string, - queries: Partial> + queries?: Partial> ): Promise { return this.get( `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/${this.serviceType === EIssueServiceType.EPICS ? "issues" : "sub-issues"}/`, From 3773cbee2889d2c20ab68bcc75fc713f7630e145 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Fri, 25 Apr 2025 15:45:31 +0530 Subject: [PATCH 4/6] chore: removed issue type from filters --- packages/constants/src/issue/common.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 41d2f735ea6..39c67505c4c 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -167,7 +167,6 @@ export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = export const SUB_ISSUES_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [ "key", - "issue_type", "assignee", "start_date", "due_date", From 4efbd57d2710b5a670b764f9b242892bbb460895 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Mon, 28 Apr 2025 12:54:37 +0530 Subject: [PATCH 5/6] chore: added null check --- .../issues/issue-detail-widgets/sub-issues/display-filters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx index fe440fb8980..595bed0bcfa 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx @@ -26,7 +26,7 @@ export const SubIssueDisplayFilters: FC = observer return ( <> - {layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions.display_properties.length > 0 && ( + {layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && ( } From 540b73f9c4aa5330a11741932efcfeace1e3eacc Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 29 Apr 2025 14:52:05 +0530 Subject: [PATCH 6/6] fix: added null check --- .../issue-detail-widgets/sub-issues/issues-list/list-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx index 91dedae489c..abb2aaca05b 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx @@ -81,7 +81,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { // derived values const subIssueFilters = getSubIssueFilters(parentIssueId); - const displayProperties = subIssueFilters.displayProperties; + const displayProperties = subIssueFilters.displayProperties ?? {}; // const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);