From 9bac7cb03627a04d47ea2fbbc15bbfce9ae2b69f Mon Sep 17 00:00:00 2001 From: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Date: Wed, 13 Sep 2023 19:41:11 +0530 Subject: [PATCH] feat: issue link to create relation between issues (#2171) * feat: issue linking * fix: search params to filter out selected issue * style: changed icons * fix: build error on web-view * fix: build error * fix: build error on web-view component --- web/components/icons/index.ts | 1 + web/components/icons/related-icon.tsx | 41 ++++ .../issues/sidebar-select/blocked.tsx | 64 ++++-- .../issues/sidebar-select/blocker.tsx | 71 +++++-- .../issues/sidebar-select/duplicate.tsx | 172 ++++++++++++++++ web/components/issues/sidebar-select/index.ts | 2 + .../issues/sidebar-select/relates-to.tsx | 172 ++++++++++++++++ web/components/issues/sidebar.tsx | 72 ++++++- .../web-view/issue-properties-detail.tsx | 192 +++++++++++++----- web/components/web-view/select-blocked.tsx | 2 +- web/components/web-view/select-blocker.tsx | 2 +- web/pages/[workspaceSlug]/editor.tsx | 4 +- .../projects/[projectId]/issues/[issueId].tsx | 4 +- .../projects/[projectId]/issues/[issueId].tsx | 4 +- web/services/issues.service.ts | 46 +++++ web/services/track-event.service.ts | 16 ++ web/types/issues.d.ts | 27 ++- web/types/projects.d.ts | 2 +- 18 files changed, 793 insertions(+), 101 deletions(-) create mode 100644 web/components/icons/related-icon.tsx create mode 100644 web/components/issues/sidebar-select/duplicate.tsx create mode 100644 web/components/issues/sidebar-select/relates-to.tsx diff --git a/web/components/icons/index.ts b/web/components/icons/index.ts index d3be7f2a8..ab661a092 100644 --- a/web/components/icons/index.ts +++ b/web/components/icons/index.ts @@ -83,3 +83,4 @@ export * from "./archive-icon"; export * from "./clock-icon"; export * from "./bell-icon"; export * from "./single-comment-icon"; +export * from "./related-icon"; diff --git a/web/components/icons/related-icon.tsx b/web/components/icons/related-icon.tsx new file mode 100644 index 000000000..3abb4b1c3 --- /dev/null +++ b/web/components/icons/related-icon.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const RelatedIcon: React.FC = ({ + width = "24", + height = "24", + color = "rgb(var(--color-text-200))", + className, +}) => ( + + + + + +); diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index 02cfd3b16..9554a83ba 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -1,11 +1,13 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; - // react-hook-form import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// services +import issuesService from "services/issues.service"; // components import { ExistingIssuesListModal } from "components/core"; // icons @@ -29,10 +31,11 @@ export const SidebarBlockedSelect: React.FC = ({ }) => { const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); + const { user } = useUser(); const { setToastAlert } = useToast(); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const handleClose = () => { setIsBlockedModalOpen(false); @@ -62,21 +65,39 @@ export const SidebarBlockedSelect: React.FC = ({ }, })); - const newBlocked = [...watch("blocked_issues"), ...selectedIssues]; + if (!user) return; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + relation_type: "blocked_by" as const, + related_issue_detail: issue.blocked_issue_detail, + related_issue: issue.blocked_issue_detail.id, + })), + ], + }) + .then((response) => { + submitChanges({ + related_issues: [ + ...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"), + ...response, + ], + }); + }); - submitChanges({ - blocked_issues: newBlocked, - blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""), - }); handleClose(); }; + const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by"); + return ( <> setIsBlockedModalOpen(false)} - searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + searchParams={{ issue_relation: true, issue_id: issueId }} handleOnSubmit={onSubmit} workspaceLevelToggle /> @@ -87,33 +108,42 @@ export const SidebarBlockedSelect: React.FC = ({
- {watch("blocked_issues") && watch("blocked_issues").length > 0 - ? watch("blocked_issues").map((issue) => ( + {blockedByIssue && blockedByIssue.length > 0 + ? blockedByIssue.map((relation) => (
- {`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`} + {`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
- {watch("blocker_issues") && watch("blocker_issues").length > 0 - ? watch("blocker_issues").map((issue) => ( + {blockerIssue && blockerIssue.length > 0 + ? blockerIssue.map((relation) => (
- {`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`} + {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`} +
+ )) + : null} +
+ +
+
+ + ); +}; diff --git a/web/components/issues/sidebar-select/index.ts b/web/components/issues/sidebar-select/index.ts index 5035325fd..8b083841e 100644 --- a/web/components/issues/sidebar-select/index.ts +++ b/web/components/issues/sidebar-select/index.ts @@ -8,3 +8,5 @@ export * from "./module"; export * from "./parent"; export * from "./priority"; export * from "./state"; +export * from "./duplicate"; +export * from "./relates-to"; diff --git a/web/components/issues/sidebar-select/relates-to.tsx b/web/components/issues/sidebar-select/relates-to.tsx new file mode 100644 index 000000000..fb878daee --- /dev/null +++ b/web/components/issues/sidebar-select/relates-to.tsx @@ -0,0 +1,172 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; +// react-hook-form +import { UseFormWatch } from "react-hook-form"; +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// icons +import { X } from "lucide-react"; +import { BlockerIcon, RelatedIcon } from "components/icons"; +// components +import { ExistingIssuesListModal } from "components/core"; +// services +import issuesService from "services/issues.service"; +// types +import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types"; + +type Props = { + issueId?: string; + submitChanges: (formData: Partial) => void; + watch: UseFormWatch; + disabled?: boolean; +}; + +export const SidebarRelatesSelect: React.FC = (props) => { + const { issueId, submitChanges, watch, disabled = false } = props; + + const [isRelatesToModalOpen, setIsRelatesToModalOpen] = useState(false); + + const { user } = useUser(); + const { setToastAlert } = useToast(); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const handleClose = () => { + setIsRelatesToModalOpen(false); + }; + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one issue.", + }); + + return; + } + + const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + if (!user) return; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + related_issue_detail: issue.blocker_issue_detail, + related_issue: issue.blocker_issue_detail.id, + relation_type: "relates_to" as const, + })), + ], + }) + .then((response) => { + submitChanges({ + related_issues: [...watch("related_issues"), ...(response ?? [])], + }); + }); + + handleClose(); + }; + + const relatedToIssueRelation = [ + ...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []), + ...(watch("issue_relations") ?? []) + ?.filter((i) => i.relation_type === "relates_to") + .map((i) => ({ + ...i, + related_issue_detail: i.issue_detail, + related_issue: i.issue_detail?.id, + })), + ]; + + return ( + <> + setIsRelatesToModalOpen(false)} + searchParams={{ issue_relation: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + workspaceLevelToggle + /> +
+
+ +

Relates to

+
+
+
+ {relatedToIssueRelation && relatedToIssueRelation.length > 0 + ? relatedToIssueRelation.map((relation) => ( +
+ + + {`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`} + + +
+ )) + : null} +
+ +
+
+ + ); +}; diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index a33d17705..1f48f7307 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -30,6 +30,8 @@ import { SidebarStateSelect, SidebarEstimateSelect, SidebarLabelSelect, + SidebarDuplicateSelect, + SidebarRelatesSelect, } from "components/issues"; // ui import { CustomDatePicker, Icon } from "components/ui"; @@ -76,6 +78,8 @@ type Props = { | "delete" | "all" | "subscribe" + | "duplicate" + | "relates_to" )[]; uneditable?: boolean; }; @@ -464,7 +468,19 @@ export const IssueDetailsSidebar: React.FC = ({ {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> @@ -472,7 +488,59 @@ export const IssueDetailsSidebar: React.FC = ({ {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} + watch={watchIssue} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && ( + { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} + watch={watchIssue} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && ( + { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> diff --git a/web/components/web-view/issue-properties-detail.tsx b/web/components/web-view/issue-properties-detail.tsx index 2fe0356f5..089f8950f 100644 --- a/web/components/web-view/issue-properties-detail.tsx +++ b/web/components/web-view/issue-properties-detail.tsx @@ -4,9 +4,21 @@ import React, { useState } from "react"; // next import { useRouter } from "next/router"; +// swr +import { mutate } from "swr"; + // react hook forms import { Control, Controller, useWatch } from "react-hook-form"; +// services +import issuesService from "services/issues.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { ISSUE_DETAILS } from "constants/fetch-keys"; + // icons import { BlockedIcon, BlockerIcon } from "components/icons"; import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react"; @@ -26,6 +38,7 @@ import { EstimateSelect, ParentSelect, BlockerSelect, + BlockedSelect, } from "components/web-view"; // types @@ -39,15 +52,16 @@ type Props = { export const IssuePropertiesDetail: React.FC = (props) => { const { control, submitChanges } = props; - const blockerIssue = useWatch({ - control, - name: "blocker_issues", - }); + const blockerIssue = + useWatch({ + control, + name: "issue_relations", + })?.filter((i) => i.relation_type === "blocked_by") || []; const blockedIssue = useWatch({ control, - name: "blocked_issues", - }); + name: "related_issues", + })?.filter((i) => i.relation_type === "blocked_by"); const startDate = useWatch({ control, @@ -55,12 +69,28 @@ export const IssuePropertiesDetail: React.FC = (props) => { }); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId, issueId } = router.query; + + const { user } = useUser(); const [isViewAllOpen, setIsViewAllOpen] = useState(false); const { isEstimateActive } = useEstimateOption(); + const handleMutation = (data: any) => { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }; + return (
@@ -188,51 +218,80 @@ export const IssuePropertiesDetail: React.FC = (props) => { Blocking
- ( - - submitChanges({ - blocker_issues: val, - blockers_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""), - }) - } - /> - )} + { + if (!user || !workspaceSlug || !projectId || !issueId) return; + + issuesService + .createIssueRelation( + workspaceSlug as string, + projectId as string, + issueId as string, + user, + { + related_list: [ + ...val.map((issue: any) => ({ + issue: issue.blocker_issue_detail.id, + relation_type: "blocked_by" as const, + related_issue: issueId as string, + related_issue_detail: issue.blocker_issue_detail, + })), + ], + } + ) + .then((response) => { + handleMutation({ + issue_relations: [ + ...blockerIssue, + ...(response ?? []).map((i: any) => ({ + id: i.id, + relation_type: i.relation_type, + issue_detail: i.related_issue_detail, + issue: i.related_issue, + })), + ], + }); + }); + }} />
{blockerIssue && blockerIssue.map((issue) => (
- {`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`} + {`${issue.issue_detail?.project_detail.identifier}-${issue.issue_detail?.sequence_id}`}
- ( - - submitChanges({ - blocked_issues: val, - blocks_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""), - }) - } - /> - )} + { + if (!user || !workspaceSlug || !projectId || !issueId) return; + + issuesService + .createIssueRelation( + workspaceSlug as string, + projectId as string, + issueId as string, + user, + { + related_list: [ + ...val.map((issue: any) => ({ + issue: issue.blocked_issue_detail.id, + relation_type: "blocked_by" as const, + related_issue: issueId as string, + related_issue_detail: issue.blocked_issue_detail, + })), + ], + } + ) + .then((response) => { + handleMutation({ + related_issues: [ + ...blockedIssue, + ...(response ?? []).map((i: any) => ({ + id: i.id, + relation_type: i.relation_type, + issue_detail: i.related_issue_detail, + issue: i.related_issue, + })), + ], + }); + }); + }} />
{blockedIssue && blockedIssue.map((issue) => (
- {`${issue?.blocked_issue_detail?.project_detail?.identifier}-${issue?.blocked_issue_detail?.sequence_id}`} + {`${issue?.related_issue_detail?.project_detail?.identifier}-${issue?.related_issue_detail?.sequence_id}`}