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..39c67505c4c 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -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; 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 e604761ed02..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; }; @@ -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; + 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/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/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; + 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/helper.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts similarity index 85% rename from web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx rename to web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts index 2e7744f2b7a..6a3b6c4ed3a 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts @@ -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) => Promise; - remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; -}; 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, - oldIssue: Partial = {}, - 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, + issueServiceType, pathname, + removeSubIssue, + setSubIssueHelpers, t, - issueServiceType, + updateAnalytics, + updateSubIssue, ] ); 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/sub-issues/issue-list-item.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx similarity index 85% 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 837c392e113..abb2aaca05b 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,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 = observer((props) => { +export const SubIssuesListItem: React.FC = observer((props) => { const { workspaceSlug, projectId, @@ -59,6 +55,9 @@ export const IssueListItem: React.FC = 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 = 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 = observer((props) => { backgroundColor: currentIssueStateDetail?.color ?? "#737373", }} /> -
- {projectDetail && ( - - )} -
+ +
+ {projectDetail && ( + + )} +
+
{issue.name} @@ -173,13 +178,14 @@ export const IssueListItem: React.FC = observer((props) => { e.stopPropagation(); }} > - @@ -205,7 +211,7 @@ export const IssueListItem: React.FC = observer((props) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - subIssueOperations.copyText(workItemLink); + subIssueOperations.copyLink(workItemLink); }} >
@@ -258,7 +264,7 @@ export const IssueListItem: React.FC = observer((props) => { issue.project_id && subIssueCount > 0 && !isCurrentIssueRoot && ( - , + oldIssue?: Partial + ) => Promise; + displayProperties?: IIssueDisplayProperties; + issue: TIssue; +}; + +export const SubIssuesListItemProperties: React.FC = observer((props) => { + const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props; + // hooks + const { t } = useTranslation(); + + const handleEventPropagation = (e: SyntheticEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + if (!displayProperties) return <>; + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); + return ( +
+ +
+ + 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 && + 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, { + 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/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..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 @@ -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 ( @@ -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 3eb60f5e4a8..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 @@ -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 = 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 = observer((props) => {
} actionItemElement={ - !disabled && ( - - ) + } /> ); 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/properties.tsx b/web/core/components/issues/sub-issues/properties.tsx deleted file mode 100644 index 73148766875..00000000000 --- a/web/core/components/issues/sub-issues/properties.tsx +++ /dev/null @@ -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 = (props) => { - const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props; - // hooks - const { - issue: { getIssueById }, - } = useIssueDetail(issueServiceType); - - const issue = getIssueById(issueId); - - if (!issue) return <>; - 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 && - subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { - priority: val, - }) - } - disabled={!disabled} - buttonVariant="border-without-text" - buttonClassName="border" - /> -
- -
- - 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" : ""} - /> -
-
- ); -}; 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 - /> - )} - - )} -
- ); -}); diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index ccd240bfab6..e53492b813f 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; + } + }; +}