feat: cross-project issue linking (#1612)

* feat: cross project issue linking

* fix: remove parent issue mutation

* fix: build error
This commit is contained in:
Aaryan Khandelwal 2023-07-22 14:53:48 +05:30 committed by GitHub
parent 0e5c0fe31e
commit 6c2600efa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 244 additions and 231 deletions

View File

@ -13,8 +13,9 @@ import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useDebounce from "hooks/use-debounce"; import useDebounce from "hooks/use-debounce";
// ui // ui
import { Loader, PrimaryButton, SecondaryButton } from "components/ui"; import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch, Tooltip } from "components/ui";
// icons // icons
import { LaunchOutlined } from "@mui/icons-material";
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// types // types
@ -45,6 +46,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]); const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
@ -59,6 +61,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
onClose(); onClose();
setSearchTerm(""); setSearchTerm("");
setSelectedIssues([]); setSelectedIssues([]);
setIsWorkspaceLevel(false);
}; };
const onSubmit = async () => { const onSubmit = async () => {
@ -104,10 +107,11 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
.projectIssuesSearch(workspaceSlug as string, projectId as string, { .projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm, search: debouncedSearchTerm,
...searchParams, ...searchParams,
workspace_search: isWorkspaceLevel,
}) })
.then((res) => setIssues(res)) .then((res) => setIssues(res))
.finally(() => setIsSearching(false)); .finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, projectId, searchParams, workspaceSlug]); }, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
return ( return (
<> <>
@ -162,7 +166,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
/> />
</div> </div>
<div className="text-custom-text-200 text-[0.825rem] p-2"> <div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-4 text-custom-text-200 text-[0.825rem] p-2">
{selectedIssues.length > 0 ? ( {selectedIssues.length > 0 ? (
<div className="flex items-center gap-2 flex-wrap mt-1"> <div className="flex items-center gap-2 flex-wrap mt-1">
{selectedIssues.map((issue) => ( {selectedIssues.map((issue) => (
@ -190,6 +194,25 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
No issues selected No issues selected
</div> </div>
)} )}
<Tooltip tooltipContent="Toggle workspace level search">
<div
className={`flex-shrink-0 flex items-center gap-1 text-xs cursor-pointer ${
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
workspace level
</button>
</div>
</Tooltip>
</div> </div>
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto"> <Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
@ -242,22 +265,37 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
htmlFor={`issue-${issue.id}`} htmlFor={`issue-${issue.id}`}
value={issue} value={issue}
className={({ active }) => className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ `group flex items-center justify-between gap-2 w-full cursor-pointer select-none rounded-md px-3 py-2 text-custom-text-200 ${
active ? "bg-custom-background-80 text-custom-text-100" : "" active ? "bg-custom-background-80 text-custom-text-100" : ""
} ${selected ? "text-custom-text-100" : ""}` } ${selected ? "text-custom-text-100" : ""}`
} }
> >
<input type="checkbox" checked={selected} readOnly /> <div className="flex items-center gap-2">
<span <input type="checkbox" checked={selected} readOnly />
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" <span
style={{ className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
backgroundColor: issue.state__color, style={{
}} backgroundColor: issue.state__color,
/> }}
<span className="flex-shrink-0 text-xs"> />
{issue.project__identifier}-{issue.sequence_id} <span className="flex-shrink-0 text-xs">
</span> {issue.project__identifier}-{issue.sequence_id}
{issue.name} </span>
{issue.name}
</div>
<a
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
target="_blank"
className="group-hover:block hidden relative z-1 text-custom-text-200 hover:text-custom-text-100"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<LaunchOutlined
sx={{
fontSize: 16,
}}
/>
</a>
</Combobox.Option> </Combobox.Option>
); );
})} })}

View File

@ -75,6 +75,7 @@ const defaultValues: Partial<IIssue> = {
assignees_list: [], assignees_list: [],
labels: [], labels: [],
labels_list: [], labels_list: [],
target_date: null,
}; };
export interface IssueFormProps { export interface IssueFormProps {
@ -271,7 +272,6 @@ export const IssueForm: FC<IssueFormProps> = ({
</h3> </h3>
</div> </div>
{watch("parent") && {watch("parent") &&
watch("parent") !== "" &&
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
selectedParentIssue && ( selectedParentIssue && (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs"> <div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
@ -476,7 +476,7 @@ export const IssueForm: FC<IssueFormProps> = ({
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<CustomMenu ellipsis> <CustomMenu ellipsis>
{watch("parent") && watch("parent") !== "" ? ( {watch("parent") ? (
<> <>
<CustomMenu.MenuItem <CustomMenu.MenuItem
renderAs="button" renderAs="button"

View File

@ -53,22 +53,26 @@ export const IssueMainContent: React.FC<Props> = ({
) )
: null : null
); );
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
return ( return (
<> <>
<div className="rounded-lg"> <div className="rounded-lg">
{issueDetails?.parent && issueDetails.parent !== "" ? ( {issueDetails?.parent ? (
<div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs"> <div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issueDetails.parent}`}> <Link
href={`/${workspaceSlug}/projects/${issueDetails.parent_detail?.project_detail.id}/issues/${issueDetails.parent}`}
>
<a className="flex items-center gap-2 text-custom-text-200"> <a className="flex items-center gap-2 text-custom-text-200">
<span <span
className="block h-1.5 w-1.5 rounded-full" className="block h-1.5 w-1.5 rounded-full"
style={{ style={{
backgroundColor: issueDetails?.state_detail?.color, backgroundColor: issueDetails.parent_detail?.state_detail.color,
}} }}
/> />
<span className="flex-shrink-0"> <span className="flex-shrink-0">
{issueDetails.project_detail.identifier}-{issueDetails.parent_detail?.sequence_id} {issueDetails.parent_detail?.project_detail.identifier}-
{issueDetails.parent_detail?.sequence_id}
</span> </span>
<span className="truncate"> <span className="truncate">
{issueDetails.parent_detail?.name.substring(0, 50)} {issueDetails.parent_detail?.name.substring(0, 50)}
@ -77,29 +81,28 @@ export const IssueMainContent: React.FC<Props> = ({
</Link> </Link>
<CustomMenu position="left" ellipsis> <CustomMenu position="left" ellipsis>
{siblingIssues && siblingIssues.sub_issues.length > 0 ? ( {siblingIssuesList ? (
<> siblingIssuesList.length > 0 ? (
<h2 className="text-custom-text-200 px-1 mb-2">Sibling issues</h2> <>
{siblingIssues.sub_issues.map((issue) => { <h2 className="text-custom-text-200 px-1 mb-2">Sibling issues</h2>
if (issue.id !== issueDetails.id) {siblingIssuesList.map((issue) => (
return ( <CustomMenu.MenuItem
<CustomMenu.MenuItem key={issue.id}
key={issue.id} renderAs="a"
renderAs="a" href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${ issue.id
issue.id }`}
}`} >
> {issueDetails.project_detail.identifier}-{issue.sequence_id}
{issueDetails.project_detail.identifier}-{issue.sequence_id} </CustomMenu.MenuItem>
</CustomMenu.MenuItem> ))}
); </>
})} ) : (
</> <p className="flex items-center gap-2 whitespace-nowrap px-1 text-left text-xs text-custom-text-200 py-1">
) : ( No sibling issues
<p className="flex items-center gap-2 whitespace-nowrap px-1 text-left text-xs text-custom-text-200 py-1"> </p>
No sibling issues )
</p> ) : null}
)}
<CustomMenu.MenuItem <CustomMenu.MenuItem
renderAs="button" renderAs="button"
onClick={() => submitChanges({ parent: null })} onClick={() => submitChanges({ parent: null })}

View File

@ -11,8 +11,9 @@ import useDebounce from "hooks/use-debounce";
// components // components
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// ui // ui
import { Loader } from "components/ui"; import { Loader, ToggleSwitch, Tooltip } from "components/ui";
// icons // icons
import { LaunchOutlined } from "@mui/icons-material";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types // types
import { ISearchIssueResponse } from "types"; import { ISearchIssueResponse } from "types";
@ -37,6 +38,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]); const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
@ -46,6 +48,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
setSearchTerm(""); setSearchTerm("");
setIsWorkspaceLevel(false);
}; };
useEffect(() => { useEffect(() => {
@ -58,10 +61,11 @@ export const ParentIssuesListModal: React.FC<Props> = ({
search: debouncedSearchTerm, search: debouncedSearchTerm,
parent: true, parent: true,
issue_id: issueId, issue_id: issueId,
workspace_search: isWorkspaceLevel,
}) })
.then((res) => setIssues(res)) .then((res) => setIssues(res))
.finally(() => setIsSearching(false)); .finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, issueId, projectId, workspaceSlug]); }, [debouncedSearchTerm, isOpen, issueId, isWorkspaceLevel, projectId, workspaceSlug]);
return ( return (
<> <>
@ -115,7 +119,29 @@ export const ParentIssuesListModal: React.FC<Props> = ({
displayValue={() => ""} displayValue={() => ""}
/> />
</div> </div>
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto mt-2"> <div className="flex sm:justify-end p-2">
<Tooltip tooltipContent="Toggle workspace level search">
<div
className={`flex-shrink-0 flex items-center gap-1 text-xs cursor-pointer ${
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
label="Workspace level"
/>
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
workspace level
</button>
</div>
</Tooltip>
</div>
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
{searchTerm !== "" && ( {searchTerm !== "" && (
<h5 className="text-[0.825rem] text-custom-text-200 mx-2"> <h5 className="text-[0.825rem] text-custom-text-200 mx-2">
Search results for{" "} Search results for{" "}
@ -158,12 +184,12 @@ export const ParentIssuesListModal: React.FC<Props> = ({
key={issue.id} key={issue.id}
value={issue} value={issue}
className={({ active, selected }) => className={({ active, selected }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ `group flex items-center justify-between gap-2 cursor-pointer select-none rounded-md px-3 py-2 text-custom-text-200 ${
active ? "bg-custom-background-80 text-custom-text-100" : "" active ? "bg-custom-background-80 text-custom-text-100" : ""
} ${selected ? "text-custom-text-100" : ""}` } ${selected ? "text-custom-text-100" : ""}`
} }
> >
<> <div className="flex items-center gap-2">
<span <span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{
@ -174,7 +200,20 @@ export const ParentIssuesListModal: React.FC<Props> = ({
{issue.project__identifier}-{issue.sequence_id} {issue.project__identifier}-{issue.sequence_id}
</span>{" "} </span>{" "}
{issue.name} {issue.name}
</> </div>
<a
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
target="_blank"
className="group-hover:block hidden relative z-1 text-custom-text-200 hover:text-custom-text-100"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<LaunchOutlined
sx={{
fontSize: 16,
}}
/>
</a>
</Combobox.Option> </Combobox.Option>
))} ))}
</ul> </ul>

View File

@ -1,20 +1,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
import { UseFormWatch } from "react-hook-form"; import { UseFormWatch } from "react-hook-form";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components // components
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
// icons // icons
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockedIcon } from "components/icons"; import { BlockedIcon } from "components/icons";
// types // types
import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types"; import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types";
type Props = { type Props = {
issueId?: string; issueId?: string;
@ -34,10 +32,9 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
const handleClose = () => { const handleClose = () => {
setIsBlockedModalOpen(false); setIsBlockedModalOpen(false);
@ -54,11 +51,16 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
return; return;
} }
const selectedIssues: BlockeIssue[] = data.map((i) => ({ const selectedIssues: { blocked_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocked_issue_detail: { blocked_issue_detail: {
id: i.id, id: i.id,
name: i.name, name: i.name,
sequence_id: i.sequence_id, sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
}, },
})); }));
@ -94,14 +96,15 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
key={issue.blocked_issue_detail?.id} key={issue.blocked_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
> >
<Link <a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.blocked_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
> >
<a className="flex items-center gap-1"> <BlockedIcon height={10} width={10} />
<BlockedIcon height={10} width={10} /> {`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`}
{`${projectDetails?.identifier}-${issue.blocked_issue_detail?.sequence_id}`} </a>
</a>
</Link>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"

View File

@ -1,20 +1,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
import { UseFormWatch } from "react-hook-form"; import { UseFormWatch } from "react-hook-form";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components // components
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
// icons // icons
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon } from "components/icons"; import { BlockerIcon } from "components/icons";
// types // types
import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types"; import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types";
type Props = { type Props = {
issueId?: string; issueId?: string;
@ -34,10 +32,9 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
const handleClose = () => { const handleClose = () => {
setIsBlockerModalOpen(false); setIsBlockerModalOpen(false);
@ -54,11 +51,16 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
return; return;
} }
const selectedIssues: BlockeIssue[] = data.map((i) => ({ const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
blocker_issue_detail: { blocker_issue_detail: {
id: i.id, id: i.id,
name: i.name, name: i.name,
sequence_id: i.sequence_id, sequence_id: i.sequence_id,
project_detail: {
id: i.project_id,
identifier: i.project__identifier,
name: i.project__name,
},
}, },
})); }));
@ -94,14 +96,15 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
key={issue.blocker_issue_detail?.id} key={issue.blocker_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<Link <a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.blocker_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
> >
<a className="flex items-center gap-1"> <BlockerIcon height={10} width={10} />
<BlockerIcon height={10} width={10} /> {`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
{`${projectDetails?.identifier}-${issue.blocker_issue_detail?.sequence_id}`} </a>
</a>
</Link>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"

View File

@ -55,10 +55,10 @@ export const SidebarParentSelect: React.FC<Props> = ({
onClick={() => setIsParentModalOpen(true)} onClick={() => setIsParentModalOpen(true)}
disabled={isNotAllowed} disabled={isNotAllowed}
> >
{selectedParentIssue ? ( {selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : issueDetails?.parent ? ( ) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}` `${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : ( ) : (
<span className="text-custom-text-200">Select issue</span> <span className="text-custom-text-200">Select issue</span>
)} )}

View File

@ -18,12 +18,10 @@ import { CreateUpdateIssueModal } from "components/issues";
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
// icons // icons
import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types // types
import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types"; import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; import { SUB_ISSUES } from "constants/fetch-keys";
type Props = { type Props = {
parentIssue: IIssue; parentIssue: IIssue;
@ -38,146 +36,68 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null); const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug } = router.query;
const { memberRole } = useProjectMyMembership(); const { memberRole } = useProjectMyMembership();
const { data: subIssuesResponse } = useSWR<ISubIssueResponse>( const { data: subIssuesResponse } = useSWR(
workspaceSlug && projectId && issueId ? SUB_ISSUES(issueId as string) : null, workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null,
workspaceSlug && projectId && issueId workspaceSlug && parentIssue
? () => ? () => issuesService.subIssues(workspaceSlug as string, parentIssue.project, parentIssue.id)
issuesService.subIssues(workspaceSlug as string, projectId as string, issueId as string)
: null
);
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null : null
); );
const addAsSubIssue = async (data: ISearchIssueResponse[]) => { const addAsSubIssue = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !parentIssue) return;
const payload = { const payload = {
sub_issue_ids: data.map((i) => i.id), sub_issue_ids: data.map((i) => i.id),
}; };
await issuesService await issuesService
.addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", payload) .addSubIssues(workspaceSlug as string, parentIssue.project, parentIssue.id, payload)
.then(() => { .finally(() => mutate(SUB_ISSUES(parentIssue.id)));
mutate<ISubIssueResponse>(
SUB_ISSUES(parentIssue?.id ?? ""),
(prevData) => {
if (!prevData) return prevData;
let newSubIssues = prevData.sub_issues as IIssue[];
const stateDistribution = { ...prevData.state_distribution };
payload.sub_issue_ids.forEach((issueId: string) => {
const issue = issues?.find((i) => i.id === issueId);
if (issue) {
newSubIssues.push(issue);
const issueGroup = issue.state_detail.group;
stateDistribution[issueGroup] = stateDistribution[issueGroup] + 1;
}
});
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
return {
state_distribution: stateDistribution,
sub_issues: newSubIssues,
};
},
false
);
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (payload.sub_issue_ids.includes(p.id))
return {
...p,
parent: parentIssue.id,
};
return p;
}),
false
);
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((err) => {
console.log(err);
});
}; };
const handleSubIssueRemove = (issueId: string) => { const handleSubIssueRemove = (issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !parentIssue) return;
mutate<ISubIssueResponse>( mutate<ISubIssueResponse>(
SUB_ISSUES(parentIssue.id ?? ""), SUB_ISSUES(parentIssue.id),
(prevData) => { (prevData) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const updatedArray = (prevData.sub_issues ?? []).filter((i) => i.id !== issueId);
const stateDistribution = { ...prevData.state_distribution }; const stateDistribution = { ...prevData.state_distribution };
const issueGroup = issues?.find((i) => i.id === issueId)?.state_detail.group ?? "backlog";
const issueGroup = issue.state_detail.group;
stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1; stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1;
return { return {
state_distribution: stateDistribution, state_distribution: stateDistribution,
sub_issues: updatedArray, sub_issues: prevData.sub_issues.filter((i) => i.id !== issue.id),
}; };
}, },
false false
); );
issuesService issuesService
.patchIssue(workspaceSlug.toString(), projectId.toString(), issueId, { parent: null }, user) .patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user)
.then((res) => { .finally(() => mutate(SUB_ISSUES(parentIssue.id)));
mutate(SUB_ISSUES(parentIssue.id ?? ""));
mutate<IIssue[]>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === res.id)
return {
...p,
...res,
};
return p;
}),
false
);
})
.catch((e) => {
console.error(e);
});
}; };
const handleCreateIssueModal = () => { const handleCreateIssueModal = () => {
setCreateIssueModal(true); setCreateIssueModal(true);
setPreloadedData({ setPreloadedData({
parent: parentIssue.id, parent: parentIssue.id,
}); });
}; };
const completedSubIssues = subIssuesResponse const completedSubIssues = subIssuesResponse
? subIssuesResponse?.state_distribution.completed + ? subIssuesResponse.state_distribution.completed +
subIssuesResponse?.state_distribution.cancelled subIssuesResponse.state_distribution.cancelled
: 0; : 0;
const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0;
const totalSubIssues =
subIssuesResponse && subIssuesResponse.sub_issues ? subIssuesResponse?.sub_issues.length : 0;
const completionPercentage = (completedSubIssues / totalSubIssues) * 100; const completionPercentage = (completedSubIssues / totalSubIssues) * 100;
@ -196,54 +116,50 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
searchParams={{ sub_issue: true, issue_id: parentIssue?.id }} searchParams={{ sub_issue: true, issue_id: parentIssue?.id }}
handleOnSubmit={addAsSubIssue} handleOnSubmit={addAsSubIssue}
/> />
{subIssuesResponse && {subIssuesResponse && subIssuesResponse.sub_issues.length > 0 ? (
subIssuesResponse.sub_issues &&
subIssuesResponse.sub_issues.length > 0 ? (
<Disclosure defaultOpen={true}> <Disclosure defaultOpen={true}>
{({ open }) => ( {({ open }) => (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center justify-start gap-3"> <div className="flex items-center justify-start gap-3 text-custom-text-200">
<Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-custom-background-90"> <Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs text-custom-text-100 hover:bg-custom-background-80">
<ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} /> <ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} />
Sub-issues{" "} Sub-issues{" "}
<span className="ml-1 text-custom-text-200"> <span className="ml-1 text-custom-text-200">
{subIssuesResponse.sub_issues.length} {subIssuesResponse.sub_issues.length}
</span> </span>
</Disclosure.Button> </Disclosure.Button>
{subIssuesResponse.state_distribution && ( <div className="flex w-60 items-center gap-2">
<div className="flex w-60 items-center gap-2 text-custom-text-100"> <div className="bar relative h-1.5 w-full rounded bg-custom-background-80">
<div className="bar relative h-1.5 w-full rounded bg-custom-background-80"> <div
<div className="absolute top-0 left-0 h-1.5 rounded bg-green-500 duration-300"
className="absolute top-0 left-0 h-1.5 rounded bg-green-500 duration-300" style={{
style={{ width: `${
width: `${ isNaN(completionPercentage)
isNaN(completionPercentage) ? 0
? 0 : completionPercentage > 100
: completionPercentage > 100 ? 100
? 100 : completionPercentage.toFixed(0)
: completionPercentage.toFixed(0) }%`,
}%`, }}
}} />
/>
</div>
<span className="whitespace-nowrap text-xs">
{isNaN(completionPercentage)
? 0
: completionPercentage > 100
? 100
: completionPercentage.toFixed(0)}
% Done
</span>
</div> </div>
)} <span className="whitespace-nowrap text-xs">
{isNaN(completionPercentage)
? 0
: completionPercentage > 100
? 100
: completionPercentage.toFixed(0)}
% Done
</span>
</div>
</div> </div>
{open && !isNotAllowed ? ( {open && !isNotAllowed ? (
<div className="flex items-center"> <div className="flex items-center">
<button <button
type="button" type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-custom-background-90" className="flex items-center gap-1 rounded px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80"
onClick={handleCreateIssueModal} onClick={handleCreateIssueModal}
> >
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
@ -270,7 +186,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
{subIssuesResponse.sub_issues.map((issue) => ( {subIssuesResponse.sub_issues.map((issue) => (
<Link <Link
key={issue.id} key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
> >
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-90"> <a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-90">
<div className="flex items-center gap-2 rounded text-xs"> <div className="flex items-center gap-2 rounded text-xs">
@ -293,7 +209,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSubIssueRemove(issue.id); handleSubIssueRemove(issue);
}} }}
> >
<XMarkIcon className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" /> <XMarkIcon className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />

View File

@ -50,13 +50,13 @@ const CustomMenu = ({
type="button" type="button"
onClick={menuButtonOnClick} onClick={menuButtonOnClick}
disabled={disabled} disabled={disabled}
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none ${ className={`relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
> >
<MoreHorizOutlined <MoreHorizOutlined
fontSize="small" fontSize="small"
className={`${verticalEllipsis ? "rotate-90" : ""} text-custom-text-200`} className={verticalEllipsis ? "rotate-90" : ""}
/> />
</Menu.Button> </Menu.Button>
) : ( ) : (

View File

@ -17,7 +17,7 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
checked={value} checked={value}
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
className={`relative inline-flex ${ className={`relative flex-shrink-0 inline-flex ${
size === "sm" ? "h-3.5 w-6" : size === "md" ? "h-4 w-7" : "h-6 w-11" size === "sm" ? "h-3.5 w-6" : size === "md" ? "h-4 w-7" : "h-6 w-11"
} flex-shrink-0 cursor-pointer rounded-full border-2 border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ } flex-shrink-0 cursor-pointer rounded-full border-2 border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
value ? "bg-green-500" : "bg-custom-background-80" value ? "bg-green-500" : "bg-custom-background-80"

View File

@ -38,7 +38,7 @@ export const Tooltip: React.FC<Props> = ({
children, children,
disabled = false, disabled = false,
className = "", className = "",
openDelay = 500, openDelay = 200,
closeDelay, closeDelay,
}) => { }) => {
const { theme } = useTheme(); const { theme } = useTheme();

View File

@ -68,7 +68,7 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
> >
<button <button
type="button" type="button"
className={`rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${ className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
sidebarCollapse ? "w-full" : "" sidebarCollapse ? "w-full" : ""
}`} }`}
onClick={() => { onClick={() => {
@ -82,7 +82,7 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
</button> </button>
<button <button
type="button" type="button"
className={`rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${ className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
sidebarCollapse ? "w-full" : "" sidebarCollapse ? "w-full" : ""
}`} }`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)} onClick={() => setIsNeedHelpOpen((prev) => !prev)}
@ -91,14 +91,14 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
</button> </button>
<button <button
type="button" type="button"
className="rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none md:hidden" className="grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none md:hidden"
onClick={() => setSidebarActive(false)} onClick={() => setSidebarActive(false)}
> >
<WestOutlined fontSize="small" /> <WestOutlined fontSize="small" />
</button> </button>
<button <button
type="button" type="button"
className={`hidden md:flex rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${ className={`hidden md:grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
sidebarCollapse ? "w-full" : "" sidebarCollapse ? "w-full" : ""
}`} }`}
onClick={() => toggleCollapsed()} onClick={() => toggleCollapsed()}

View File

@ -7,6 +7,7 @@ import type {
IUserLite, IUserLite,
IProjectLite, IProjectLite,
IWorkspaceLite, IWorkspaceLite,
IStateLite,
} from "types"; } from "types";
export interface IIssueCycle { export interface IIssueCycle {
@ -53,8 +54,10 @@ export interface IIssueParent {
id: string; id: string;
name: string; name: string;
priority: string | null; priority: string | null;
project_detail: IProjectLite;
sequence_id: number; sequence_id: number;
start_date: string | null; start_date: string | null;
state_detail: IStateLite;
target_date: string | null; target_date: string | null;
} }
@ -70,8 +73,8 @@ export interface IIssue {
assignees_list: string[]; assignees_list: string[];
attachment_count: number; attachment_count: number;
attachments: any[]; attachments: any[];
blocked_issues: BlockeIssue[]; blocked_issues: { blocked_issue_detail?: BlockeIssueDetail }[];
blocker_issues: BlockeIssue[]; blocker_issues: { blocker_issue_detail?: BlockeIssueDetail }[];
blockers_list: string[]; blockers_list: string[];
blocks_list: string[]; blocks_list: string[];
bridge_id?: string | null; bridge_id?: string | null;
@ -137,15 +140,11 @@ export interface ISubIssueResponse {
sub_issues: IIssue[]; sub_issues: IIssue[];
} }
export interface BlockeIssue {
blocked_issue_detail?: BlockeIssueDetail;
blocker_issue_detail?: BlockeIssueDetail;
}
export interface BlockeIssueDetail { export interface BlockeIssueDetail {
id: string; id: string;
name: string; name: string;
sequence_id: number; sequence_id: number;
project_detail: IProjectLite;
} }
export interface IIssueComment { export interface IIssueComment {

View File

@ -6,6 +6,7 @@ import type {
TIssueGroupByOptions, TIssueGroupByOptions,
TIssueOrderByOptions, TIssueOrderByOptions,
TIssueViewOptions, TIssueViewOptions,
TStateGroup,
} from "./"; } from "./";
export interface IProject { export interface IProject {
@ -128,6 +129,7 @@ export type TProjectIssuesSearchParams = {
module?: boolean; module?: boolean;
sub_issue?: boolean; sub_issue?: boolean;
issue_id?: string; issue_id?: string;
workspace_search: boolean;
}; };
export interface ISearchIssueResponse { export interface ISearchIssueResponse {
@ -135,9 +137,10 @@ export interface ISearchIssueResponse {
name: string; name: string;
project_id: string; project_id: string;
project__identifier: string; project__identifier: string;
project__name: string;
sequence_id: number; sequence_id: number;
state__color: string; state__color: string;
state__group: string; state__group: TStateGroup;
state__name: string; state__name: string;
workspace__slug: string; workspace__slug: string;
} }

View File

@ -1,5 +1,7 @@
import { IProject, IProjectLite, IWorkspaceLite } from "types"; import { IProject, IProjectLite, IWorkspaceLite } from "types";
export type TStateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export interface IState { export interface IState {
readonly id: string; readonly id: string;
color: string; color: string;
@ -7,7 +9,7 @@ export interface IState {
readonly created_by: string; readonly created_by: string;
default: boolean; default: boolean;
description: string; description: string;
group: "backlog" | "unstarted" | "started" | "completed" | "cancelled"; group: TStateGroup;
name: string; name: string;
project: string; project: string;
readonly project_detail: IProjectLite; readonly project_detail: IProjectLite;
@ -19,6 +21,13 @@ export interface IState {
workspace_detail: IWorkspaceLite; workspace_detail: IWorkspaceLite;
} }
export interface IStateLite {
color: string;
group: TStateGroup;
id: string;
name: string;
}
export interface IStateResponse { export interface IStateResponse {
[key: string]: IState[]; [key: string]: IState[];
} }