forked from github/plane
734f27122b
* style: update notification Read status toast alert description. * style: update issue subscribe button design. * fix: remove group_by `none` display filter from the kanban view in profile and draft issues. * style: design improvement in members settings. * style: add display name for all user role. * style: remove email for user roles other than admin. * style: fix border color as per designs.
653 lines
25 KiB
TypeScript
653 lines
25 KiB
TypeScript
import React, { useCallback, useState } from "react";
|
|
import { useRouter } from "next/router";
|
|
import { observer } from "mobx-react-lite";
|
|
import { mutate } from "swr";
|
|
import { Controller, UseFormWatch } from "react-hook-form";
|
|
// mobx store
|
|
import { useMobxStore } from "lib/mobx/store-provider";
|
|
// hooks
|
|
import useToast from "hooks/use-toast";
|
|
import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription";
|
|
import useEstimateOption from "hooks/use-estimate-option";
|
|
// services
|
|
import { IssueService } from "services/issue";
|
|
import { ModuleService } from "services/module.service";
|
|
// components
|
|
import { LinkModal, LinksList } from "components/core";
|
|
import {
|
|
DeleteIssueModal,
|
|
SidebarAssigneeSelect,
|
|
SidebarBlockedSelect,
|
|
SidebarBlockerSelect,
|
|
SidebarCycleSelect,
|
|
SidebarModuleSelect,
|
|
SidebarParentSelect,
|
|
SidebarPrioritySelect,
|
|
SidebarStateSelect,
|
|
SidebarEstimateSelect,
|
|
SidebarLabelSelect,
|
|
SidebarDuplicateSelect,
|
|
SidebarRelatesSelect,
|
|
} from "components/issues";
|
|
// ui
|
|
import { CustomDatePicker } from "components/ui";
|
|
// icons
|
|
import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react";
|
|
import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
|
|
// helpers
|
|
import { copyTextToClipboard } from "helpers/string.helper";
|
|
// types
|
|
import type { IIssue, IIssueLink, linkDetails } from "types";
|
|
// fetch-keys
|
|
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
|
|
|
type Props = {
|
|
control: any;
|
|
submitChanges: (formData: any) => void;
|
|
issueDetail: IIssue | undefined;
|
|
watch: UseFormWatch<IIssue>;
|
|
fieldsToShow?: (
|
|
| "state"
|
|
| "assignee"
|
|
| "priority"
|
|
| "estimate"
|
|
| "parent"
|
|
| "blocker"
|
|
| "blocked"
|
|
| "startDate"
|
|
| "dueDate"
|
|
| "cycle"
|
|
| "module"
|
|
| "label"
|
|
| "link"
|
|
| "delete"
|
|
| "all"
|
|
| "subscribe"
|
|
| "duplicate"
|
|
| "relates_to"
|
|
)[];
|
|
uneditable?: boolean;
|
|
};
|
|
|
|
const issueService = new IssueService();
|
|
const moduleService = new ModuleService();
|
|
|
|
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|
const { control, submitChanges, issueDetail, watch: watchIssue, fieldsToShow = ["all"], uneditable = false } = props;
|
|
|
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
|
const [linkModal, setLinkModal] = useState(false);
|
|
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
|
|
|
const { user: userStore } = useMobxStore();
|
|
const user = userStore.currentUser;
|
|
const userRole = userStore.currentProjectRole;
|
|
|
|
const router = useRouter();
|
|
const { workspaceSlug, projectId, issueId } = router.query;
|
|
|
|
const { isEstimateActive } = useEstimateOption();
|
|
|
|
const { loading, handleSubscribe, handleUnsubscribe, subscribed } = useUserIssueNotificationSubscription(
|
|
workspaceSlug,
|
|
projectId,
|
|
issueId
|
|
);
|
|
|
|
const { setToastAlert } = useToast();
|
|
|
|
const handleCycleChange = useCallback(
|
|
(cycleId: string) => {
|
|
if (!workspaceSlug || !projectId || !issueDetail || !user) return;
|
|
|
|
issueService
|
|
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleId, {
|
|
issues: [issueDetail.id],
|
|
})
|
|
.then(() => {
|
|
mutate(ISSUE_DETAILS(issueId as string));
|
|
});
|
|
},
|
|
[workspaceSlug, projectId, issueId, issueDetail, user]
|
|
);
|
|
|
|
const handleModuleChange = useCallback(
|
|
(moduleId: string) => {
|
|
if (!workspaceSlug || !projectId || !issueDetail || !user) return;
|
|
|
|
moduleService
|
|
.addIssuesToModule(workspaceSlug as string, projectId as string, moduleId, {
|
|
issues: [issueDetail.id],
|
|
})
|
|
.then(() => {
|
|
mutate(ISSUE_DETAILS(issueId as string));
|
|
});
|
|
},
|
|
[workspaceSlug, projectId, issueId, issueDetail, user]
|
|
);
|
|
|
|
const handleCreateLink = async (formData: IIssueLink) => {
|
|
if (!workspaceSlug || !projectId || !issueDetail) return;
|
|
|
|
const payload = { metadata: {}, ...formData };
|
|
|
|
await issueService
|
|
.createIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, payload)
|
|
.then(() => mutate(ISSUE_DETAILS(issueDetail.id)))
|
|
.catch((err) => {
|
|
if (err.status === 400)
|
|
setToastAlert({
|
|
type: "error",
|
|
title: "Error!",
|
|
message: "This URL already exists for this issue.",
|
|
});
|
|
else
|
|
setToastAlert({
|
|
type: "error",
|
|
title: "Error!",
|
|
message: "Something went wrong. Please try again.",
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleUpdateLink = async (formData: IIssueLink, linkId: string) => {
|
|
if (!workspaceSlug || !projectId || !issueDetail) return;
|
|
|
|
const payload = { metadata: {}, ...formData };
|
|
|
|
const updatedLinks = issueDetail.issue_link.map((l) =>
|
|
l.id === linkId
|
|
? {
|
|
...l,
|
|
title: formData.title,
|
|
url: formData.url,
|
|
}
|
|
: l
|
|
);
|
|
|
|
mutate<IIssue>(
|
|
ISSUE_DETAILS(issueDetail.id),
|
|
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
|
|
false
|
|
);
|
|
|
|
await issueService
|
|
.updateIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, linkId, payload)
|
|
.then(() => {
|
|
mutate(ISSUE_DETAILS(issueDetail.id));
|
|
})
|
|
.catch((err) => {
|
|
console.log(err);
|
|
});
|
|
};
|
|
|
|
const handleDeleteLink = async (linkId: string) => {
|
|
if (!workspaceSlug || !projectId || !issueDetail) return;
|
|
|
|
const updatedLinks = issueDetail.issue_link.filter((l) => l.id !== linkId);
|
|
|
|
mutate<IIssue>(
|
|
ISSUE_DETAILS(issueDetail.id),
|
|
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
|
|
false
|
|
);
|
|
|
|
await issueService
|
|
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, linkId)
|
|
.then(() => {
|
|
mutate(ISSUE_DETAILS(issueDetail.id));
|
|
})
|
|
.catch((err) => {
|
|
console.log(err);
|
|
});
|
|
};
|
|
|
|
const handleCopyText = () => {
|
|
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
|
|
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issueDetail?.id}`).then(() => {
|
|
setToastAlert({
|
|
type: "success",
|
|
title: "Link Copied!",
|
|
message: "Issue link copied to clipboard.",
|
|
});
|
|
});
|
|
};
|
|
|
|
const showFirstSection =
|
|
fieldsToShow.includes("all") ||
|
|
fieldsToShow.includes("state") ||
|
|
fieldsToShow.includes("assignee") ||
|
|
fieldsToShow.includes("priority") ||
|
|
fieldsToShow.includes("estimate");
|
|
|
|
const showSecondSection =
|
|
fieldsToShow.includes("all") ||
|
|
fieldsToShow.includes("parent") ||
|
|
fieldsToShow.includes("blocker") ||
|
|
fieldsToShow.includes("blocked") ||
|
|
fieldsToShow.includes("dueDate");
|
|
|
|
const showThirdSection =
|
|
fieldsToShow.includes("all") || fieldsToShow.includes("cycle") || fieldsToShow.includes("module");
|
|
|
|
const startDate = watchIssue("start_date");
|
|
const targetDate = watchIssue("target_date");
|
|
|
|
const minDate = startDate ? new Date(startDate) : null;
|
|
minDate?.setDate(minDate.getDate());
|
|
|
|
const maxDate = targetDate ? new Date(targetDate) : null;
|
|
maxDate?.setDate(maxDate.getDate());
|
|
|
|
const handleEditLink = (link: linkDetails) => {
|
|
setSelectedLinkToUpdate(link);
|
|
setLinkModal(true);
|
|
};
|
|
|
|
const isNotAllowed = userRole === 5 || userRole === 10;
|
|
|
|
return (
|
|
<>
|
|
<LinkModal
|
|
isOpen={linkModal}
|
|
handleClose={() => {
|
|
setLinkModal(false);
|
|
setSelectedLinkToUpdate(null);
|
|
}}
|
|
data={selectedLinkToUpdate}
|
|
status={selectedLinkToUpdate ? true : false}
|
|
createIssueLink={handleCreateLink}
|
|
updateIssueLink={handleUpdateLink}
|
|
/>
|
|
{issueDetail && (
|
|
<DeleteIssueModal handleClose={() => setDeleteIssueModal(false)} isOpen={deleteIssueModal} data={issueDetail} />
|
|
)}
|
|
<div className="h-full w-full flex flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
|
|
<div className="flex items-center justify-between px-5 pb-3">
|
|
<h4 className="text-sm font-medium">
|
|
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
|
|
</h4>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{issueDetail?.created_by !== user?.id &&
|
|
!issueDetail?.assignees.includes(user?.id ?? "") &&
|
|
!router.pathname.includes("[archivedIssueId]") &&
|
|
(fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
|
<Button
|
|
size="sm"
|
|
prependIcon={<Bell className="h-3 w-3" />}
|
|
variant="outline-primary"
|
|
className="hover:!bg-custom-primary-100/20"
|
|
onClick={() => {
|
|
if (subscribed) handleUnsubscribe();
|
|
else handleSubscribe();
|
|
}}
|
|
>
|
|
{loading ? "Loading..." : subscribed ? "Unsubscribe" : "Subscribe"}
|
|
</Button>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
|
<button
|
|
type="button"
|
|
className="rounded-md border border-custom-border-200 p-2 shadow-sm duration-300 hover:bg-custom-background-90 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary"
|
|
onClick={handleCopyText}
|
|
>
|
|
<LinkIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
|
|
<button
|
|
type="button"
|
|
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
|
|
onClick={() => setDeleteIssueModal(true)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-full w-full px-5 overflow-y-auto">
|
|
<div className={`divide-y-2 divide-custom-border-200 ${uneditable ? "opacity-60" : ""}`}>
|
|
{showFirstSection && (
|
|
<div className="py-1">
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
|
<p>State</p>
|
|
</div>
|
|
<div>
|
|
<Controller
|
|
control={control}
|
|
name="state"
|
|
render={({ field: { value } }) => (
|
|
<SidebarStateSelect
|
|
value={value}
|
|
onChange={(val: string) => submitChanges({ state: val })}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
|
<p>Assignees</p>
|
|
</div>
|
|
<div>
|
|
<Controller
|
|
control={control}
|
|
name="assignees"
|
|
render={({ field: { value } }) => (
|
|
<SidebarAssigneeSelect
|
|
value={value}
|
|
onChange={(val: string[]) => submitChanges({ assignees: val })}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<Signal className="h-4 w-4 flex-shrink-0" />
|
|
<p>Priority</p>
|
|
</div>
|
|
<div>
|
|
<Controller
|
|
control={control}
|
|
name="priority"
|
|
render={({ field: { value } }) => (
|
|
<SidebarPrioritySelect
|
|
value={value}
|
|
onChange={(val) => submitChanges({ priority: val })}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && isEstimateActive && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<Triangle className="h-4 w-4 flex-shrink-0 " />
|
|
<p>Estimate</p>
|
|
</div>
|
|
<div className="sm:basis-1/2">
|
|
<Controller
|
|
control={control}
|
|
name="estimate_point"
|
|
render={({ field: { value } }) => (
|
|
<SidebarEstimateSelect
|
|
value={value}
|
|
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showSecondSection && (
|
|
<div className="py-1">
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<User2 className="h-4 w-4 flex-shrink-0" />
|
|
<p>Parent</p>
|
|
</div>
|
|
<div className="sm:basis-1/2">
|
|
<Controller
|
|
control={control}
|
|
name="parent"
|
|
render={({ field: { onChange } }) => (
|
|
<SidebarParentSelect
|
|
onChange={(val: string) => {
|
|
submitChanges({ parent: val });
|
|
onChange(val);
|
|
}}
|
|
issueDetails={issueDetail}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
|
<SidebarBlockerSelect
|
|
issueId={issueId as string}
|
|
submitChanges={(data: any) => {
|
|
mutate<IIssue>(
|
|
ISSUE_DETAILS(issueId as string),
|
|
(prevData) => {
|
|
if (!prevData) return prevData;
|
|
return {
|
|
...prevData,
|
|
...data,
|
|
};
|
|
},
|
|
false
|
|
);
|
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
|
}}
|
|
watch={watchIssue}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
|
<SidebarBlockedSelect
|
|
issueId={issueId as string}
|
|
submitChanges={(data: any) => {
|
|
mutate<IIssue>(
|
|
ISSUE_DETAILS(issueId as string),
|
|
(prevData) => {
|
|
if (!prevData) return prevData;
|
|
return {
|
|
...prevData,
|
|
...data,
|
|
};
|
|
},
|
|
false
|
|
);
|
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
|
}}
|
|
watch={watchIssue}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && (
|
|
<SidebarDuplicateSelect
|
|
issueId={issueId as string}
|
|
submitChanges={(data: any) => {
|
|
if (!data) return mutate(ISSUE_DETAILS(issueId as string));
|
|
mutate<IIssue>(ISSUE_DETAILS(issueId as string), (prevData) => {
|
|
if (!prevData) return prevData;
|
|
return {
|
|
...prevData,
|
|
...data,
|
|
};
|
|
});
|
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
|
}}
|
|
watch={watchIssue}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && (
|
|
<SidebarRelatesSelect
|
|
issueId={issueId as string}
|
|
submitChanges={(data: any) => {
|
|
if (!data) return mutate(ISSUE_DETAILS(issueId as string));
|
|
mutate<IIssue>(ISSUE_DETAILS(issueId as string), (prevData) => {
|
|
if (!prevData) return prevData;
|
|
return {
|
|
...prevData,
|
|
...data,
|
|
};
|
|
});
|
|
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
|
}}
|
|
watch={watchIssue}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<CalendarDays className="h-4 w-4 flex-shrink-0" />
|
|
<p>Start date</p>
|
|
</div>
|
|
<div className="sm:basis-1/2">
|
|
<Controller
|
|
control={control}
|
|
name="start_date"
|
|
render={({ field: { value } }) => (
|
|
<CustomDatePicker
|
|
placeholder="Start date"
|
|
value={value}
|
|
onChange={(val) =>
|
|
submitChanges({
|
|
start_date: val,
|
|
})
|
|
}
|
|
className="bg-custom-background-80 border-none"
|
|
maxDate={maxDate ?? undefined}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
|
<CalendarDays className="h-4 w-4 flex-shrink-0" />
|
|
<p>Due date</p>
|
|
</div>
|
|
<div className="sm:basis-1/2">
|
|
<Controller
|
|
control={control}
|
|
name="target_date"
|
|
render={({ field: { value } }) => (
|
|
<CustomDatePicker
|
|
placeholder="Due date"
|
|
value={value}
|
|
onChange={(val) =>
|
|
submitChanges({
|
|
target_date: val,
|
|
})
|
|
}
|
|
className="bg-custom-background-80 border-none"
|
|
minDate={minDate ?? undefined}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showThirdSection && (
|
|
<div className="py-1">
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
|
|
<ContrastIcon className="h-4 w-4 flex-shrink-0" />
|
|
<p>Cycle</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<SidebarCycleSelect
|
|
issueDetail={issueDetail}
|
|
handleCycleChange={handleCycleChange}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
|
|
<div className="flex flex-wrap items-center py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
|
|
<DiceIcon className="h-4 w-4 flex-shrink-0" />
|
|
<p>Module</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<SidebarModuleSelect
|
|
issueDetail={issueDetail}
|
|
handleModuleChange={handleModuleChange}
|
|
disabled={isNotAllowed || uneditable}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
|
<div className="flex flex-wrap items-start py-2">
|
|
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
|
|
<Tag className="h-4 w-4 flex-shrink-0" />
|
|
<p>Label</p>
|
|
</div>
|
|
<div className="space-y-1 sm:w-1/2">
|
|
<SidebarLabelSelect
|
|
issueDetails={issueDetail}
|
|
labelList={issueDetail?.labels ?? []}
|
|
submitChanges={submitChanges}
|
|
isNotAllowed={isNotAllowed}
|
|
uneditable={uneditable ?? false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
|
<div className={`min-h-[116px] py-1 text-xs ${uneditable ? "opacity-60" : ""}`}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h4>Links</h4>
|
|
{!isNotAllowed && (
|
|
<button
|
|
type="button"
|
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
|
|
uneditable ? "cursor-not-allowed" : "cursor-pointer"
|
|
}`}
|
|
onClick={() => setLinkModal(true)}
|
|
disabled={uneditable}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="mt-2 space-y-2">
|
|
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
|
|
<LinksList
|
|
links={issueDetail.issue_link}
|
|
handleDeleteLink={handleDeleteLink}
|
|
handleEditLink={handleEditLink}
|
|
userAuth={{
|
|
isGuest: userRole === 5,
|
|
isViewer: userRole === 10,
|
|
isMember: userRole === 15,
|
|
isOwner: userRole === 20,
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
});
|