chore: refactored and resolved build issues on the issues and issue detail page (#3340)

* fix: handled undefined issue_id in list layout

* dev: issue detail store and optimization

* dev: issue filter and list operations

* fix: typo on labels update

* dev: Handled all issues in the list layout in project issues

* dev: handled kanban and auick add issue in swimlanes

* chore: fixed peekoverview in kanban

* chore: fixed peekoverview in calendar

* chore: fixed peekoverview in gantt

* chore: updated quick add in the gantt chart

* chore: handled issue detail properties and resolved build issues

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
guru_sainath 2024-01-10 20:09:45 +05:30 committed by sriram veeraghanta
parent e6b31e2550
commit 4611ec0b83
112 changed files with 3303 additions and 2560 deletions

View file

@ -0,0 +1,103 @@
import React, { ReactNode, useState } from "react";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// hooks
import { useCycle, useIssueDetail } from "hooks/store";
// ui
import { ContrastIcon, CustomSearchSelect, Spinner, Tooltip } from "@plane/ui";
// types
import type { TIssueOperations } from "./root";
type TIssueCycleSelect = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled?: boolean;
};
export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
// hooks
const { getCycleById, currentProjectIncompleteCycleIds, fetchAllCycles } = useCycle();
const {
issue: { getIssueById },
} = useIssueDetail();
// state
const [isUpdating, setIsUpdating] = useState(false);
useSWR(workspaceSlug && projectId ? `PROJECT_${projectId}_ISSUE_${issueId}_CYCLES` : null, async () => {
if (workspaceSlug && projectId) await fetchAllCycles(workspaceSlug, projectId);
});
const issue = getIssueById(issueId);
const projectCycleIds = currentProjectIncompleteCycleIds;
const issueCycle = (issue && issue.cycle_id && getCycleById(issue.cycle_id)) || undefined;
const disableSelect = disabled || isUpdating;
const handleIssueCycleChange = async (cycleId: string) => {
if (!cycleId) return;
setIsUpdating(true);
if (issue && issue.cycle_id === cycleId)
await issueOperations.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
else await issueOperations.addIssueToCycle(workspaceSlug, projectId, cycleId, [issueId]);
setIsUpdating(false);
};
type TDropdownOptions = { value: string; query: string; content: ReactNode }[];
const options: TDropdownOptions | undefined = projectCycleIds
? (projectCycleIds
.map((cycleId) => {
const cycle = getCycleById(cycleId) || undefined;
if (!cycle) return undefined;
return {
value: cycle.id,
query: cycle.name,
content: (
<div className="flex items-center gap-1.5 truncate">
<span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<ContrastIcon />
</span>
<span className="flex-grow truncate">{cycle.name}</span>
</div>
) as ReactNode,
};
})
.filter((cycle) => cycle !== undefined) as TDropdownOptions)
: undefined;
return (
<div className="flex items-center gap-1">
<CustomSearchSelect
value={issue?.cycle_id}
onChange={(value: any) => handleIssueCycleChange(value)}
options={options}
customButton={
<div>
<Tooltip position="left" tooltipContent={`${issueCycle ? issueCycle?.name : "No cycle"}`}>
<button
type="button"
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
disableSelect ? "cursor-not-allowed" : ""
} max-w-[10rem]`}
>
<span
className={`flex items-center gap-1.5 truncate ${
issueCycle ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span className="flex-shrink-0">{issueCycle && <ContrastIcon className="h-3.5 w-3.5" />}</span>
<span className="truncate">{issueCycle ? issueCycle?.name : "No cycle"}</span>
</span>
</button>
</Tooltip>
</div>
}
width="max-w-[10rem]"
noChevron
disabled={disableSelect}
/>
{isUpdating && <Spinner className="h-4 w-4" />}
</div>
);
});

View file

@ -0,0 +1,14 @@
export * from "./root";
export * from "./main-content";
export * from "./sidebar";
// select
export * from "./cycle-select";
export * from "./module-select";
export * from "./parent-select";
export * from "./relation-select";
export * from "./parent";
export * from "./label";
export * from "./subscription";
export * from "./links";

View file

@ -0,0 +1,163 @@
import { FC, useState, Fragment, useEffect } from "react";
import { Plus, X } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { TwitterPicker } from "react-color";
import { Popover, Transition } from "@headlessui/react";
// hooks
import { useIssueDetail } from "hooks/store";
import useToast from "hooks/use-toast";
// ui
import { Input } from "@plane/ui";
// types
import { TLabelOperations } from "./root";
import { IIssueLabel } from "@plane/types";
type ILabelCreate = {
workspaceSlug: string;
projectId: string;
issueId: string;
labelOperations: TLabelOperations;
disabled?: boolean;
};
const defaultValues: Partial<IIssueLabel> = {
name: "",
color: "#ff0000",
};
export const LabelCreate: FC<ILabelCreate> = (props) => {
const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props;
// hooks
const { setToastAlert } = useToast();
const {
issue: { getIssueById },
} = useIssueDetail();
// state
const [isCreateToggle, setIsCreateToggle] = useState(false);
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
// react hook form
const {
handleSubmit,
formState: { errors, isSubmitting },
reset,
control,
setFocus,
} = useForm<Partial<IIssueLabel>>({
defaultValues,
});
useEffect(() => {
if (!isCreateToggle) return;
setFocus("name");
reset();
}, [isCreateToggle, reset, setFocus]);
const handleLabel = async (formData: Partial<IIssueLabel>) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
try {
const issue = getIssueById(issueId);
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
const currentLabels = [...(issue?.label_ids || []), labelResponse.id];
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
reset(defaultValues);
} catch (error) {
setToastAlert({
title: "Label creation failed",
type: "error",
message: "Label creation failed. Please try again sometime later.",
});
}
};
return (
<>
<div
className="flex-shrink-0 transition-all relative flex items-center gap-1 cursor-pointer border border-custom-border-100 rounded-full text-xs p-0.5 px-2 group hover:border-red-500/50 hover:bg-red-500/20"
onClick={handleIsCreateToggle}
>
<div className="flex-shrink-0">
{isCreateToggle ? (
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
) : (
<Plus className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
)}
</div>
<div className="flex-shrink-0">{isCreateToggle ? "Cancel" : "New"}</div>
</div>
{isCreateToggle && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleLabel)}>
<div>
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<Popover className="relative">
<>
<Popover.Button className="grid place-items-center outline-none">
{value && value?.trim() !== "" && (
<span
className="h-6 w-6 rounded"
style={{
backgroundColor: value ?? "black",
}}
/>
)}
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute z-10 mt-1.5 max-w-xs px-2 sm:px-0">
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
</Popover.Panel>
</Transition>
</>
</Popover>
)}
/>
</div>
<Controller
control={control}
name="name"
rules={{
required: "This is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value ?? ""}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full"
/>
)}
/>
<button
type="button"
className="grid place-items-center rounded bg-red-500 p-1.5"
onClick={() => setIsCreateToggle(false)}
disabled={disabled}
>
<X className="h-4 w-4 text-white" />
</button>
<button type="submit" className="grid place-items-center rounded bg-green-500 p-1.5" disabled={isSubmitting}>
<Plus className="h-4 w-4 text-white" />
</button>
</form>
)}
</>
);
};

View file

@ -0,0 +1,5 @@
export * from "./root";
export * from "./label-list";
export * from "./label-list-item";
export * from "./create-label";

View file

@ -0,0 +1,52 @@
import { FC } from "react";
import { X } from "lucide-react";
// types
import { TLabelOperations } from "./root";
import { useIssueDetail, useLabel } from "hooks/store";
type TLabelListItem = {
workspaceSlug: string;
projectId: string;
issueId: string;
labelId: string;
labelOperations: TLabelOperations;
};
export const LabelListItem: FC<TLabelListItem> = (props) => {
const { workspaceSlug, projectId, issueId, labelId, labelOperations } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getLabelById } = useLabel();
const issue = getIssueById(issueId);
const label = getLabelById(labelId);
const handleLabel = async () => {
if (issue) {
const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId);
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
}
};
if (!label) return <></>;
return (
<div
key={labelId}
className="transition-all relative flex items-center gap-1 cursor-pointer border border-custom-border-100 rounded-full text-xs p-0.5 px-1 group hover:border-red-500/50 hover:bg-red-500/20"
onClick={handleLabel}
>
<div
className="rounded-full h-2 w-2 flex-shrink-0"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<div className="flex-shrink-0">{label.name}</div>
<div className="flex-shrink-0">
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
</div>
</div>
);
};

View file

@ -0,0 +1,40 @@
import { FC } from "react";
// components
import { LabelListItem } from "./label-list-item";
// hooks
import { useIssueDetail } from "hooks/store";
// types
import { TLabelOperations } from "./root";
type TLabelList = {
workspaceSlug: string;
projectId: string;
issueId: string;
labelOperations: TLabelOperations;
};
export const LabelList: FC<TLabelList> = (props) => {
const { workspaceSlug, projectId, issueId, labelOperations } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
const issueLabels = issue?.label_ids || undefined;
if (!issue || !issueLabels) return <></>;
return (
<>
{issueLabels.map((labelId) => (
<LabelListItem
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
labelId={labelId}
labelOperations={labelOperations}
/>
))}
</>
);
};

View file

@ -0,0 +1,92 @@
import { FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
// components
import { LabelList, LabelCreate } from "./";
// hooks
import { useIssueDetail, useLabel } from "hooks/store";
// types
import { IIssueLabel, TIssue } from "@plane/types";
import useToast from "hooks/use-toast";
export type TIssueLabel = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export type TLabelOperations = {
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
createLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
};
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// hooks
const { updateIssue } = useIssueDetail();
const {
project: { createLabel },
} = useLabel();
const { setToastAlert } = useToast();
const labelOperations: TLabelOperations = useMemo(
() => ({
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
setToastAlert({
title: "Issue updated successfully",
type: "success",
message: "Issue updated successfully",
});
} catch (error) {
setToastAlert({
title: "Issue update failed",
type: "error",
message: "Issue update failed",
});
}
},
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
try {
const labelResponse = await createLabel(workspaceSlug, projectId, data);
setToastAlert({
title: "Label created successfully",
type: "success",
message: "Label created successfully",
});
return labelResponse;
} catch (error) {
setToastAlert({
title: "Label creation failed",
type: "error",
message: "Label creation failed",
});
return error;
}
},
}),
[updateIssue, createLabel, setToastAlert]
);
return (
<div className="relative flex flex-wrap items-center gap-1">
<LabelList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
labelOperations={labelOperations}
/>
{/* <div>select existing labels</div> */}
<LabelCreate
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
labelOperations={labelOperations}
/>
</div>
);
});

View file

@ -0,0 +1,9 @@
import { FC } from "react";
type TLabelExistingSelect = {};
export const LabelExistingSelect: FC<TLabelExistingSelect> = (props) => {
const {} = props;
return <></>;
};

View file

@ -0,0 +1,167 @@
import { FC, useEffect, Fragment } from "react";
import { Controller, useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, Input } from "@plane/ui";
// types
import type { TIssueLinkEditableFields } from "@plane/types";
import { TLinkOperations } from "./root";
export type TLinkOperationsModal = Exclude<TLinkOperations, "remove">;
export type TIssueLinkCreateFormFieldOptions = TIssueLinkEditableFields & {
id?: string;
};
export type TIssueLinkCreateEditModal = {
isModalOpen: boolean;
handleModal: (modalToggle: boolean) => void;
linkOperations: TLinkOperationsModal;
preloadedData?: TIssueLinkCreateFormFieldOptions | null;
};
const defaultValues: TIssueLinkCreateFormFieldOptions = {
title: "",
url: "",
};
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = (props) => {
// props
const { isModalOpen, handleModal, linkOperations, preloadedData } = props;
// react hook form
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<TIssueLinkCreateFormFieldOptions>({
defaultValues,
});
const onClose = () => {
handleModal(false);
const timeout = setTimeout(() => {
reset(preloadedData ? preloadedData : defaultValues);
clearTimeout(timeout);
}, 500);
};
const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => {
if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: formData.url });
else await linkOperations.update(formData.id as string, { title: formData.title, url: formData.url });
onClose();
};
useEffect(() => {
reset({ ...defaultValues, ...preloadedData });
}, [preloadedData, reset]);
return (
<Transition.Root show={isModalOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
{preloadedData?.id ? "Update Link" : "Add Link"}
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<label htmlFor="url" className="mb-2 text-custom-text-200">
URL
</label>
<Controller
control={control}
name="url"
rules={{
required: "URL is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
placeholder="https://..."
pattern="^(https?://).*"
className="w-full"
/>
)}
/>
</div>
<div>
<label htmlFor="title" className="mb-2 text-custom-text-200">
{`Title (optional)`}
</label>
<Controller
control={control}
name="title"
render={({ field: { value, onChange, ref } }) => (
<Input
id="title"
name="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.title)}
placeholder="Enter title"
className="w-full"
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{preloadedData?.id
? isSubmitting
? "Updating Link..."
: "Update Link"
: isSubmitting
? "Adding Link..."
: "Add Link"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -0,0 +1,4 @@
export * from "./root";
export * from "./links";
export * from "./link-detail";

View file

@ -0,0 +1,122 @@
import { FC, useState } from "react";
// hooks
import useToast from "hooks/use-toast";
import { useIssueDetail } from "hooks/store";
// ui
import { ExternalLinkIcon, Tooltip } from "@plane/ui";
// icons
import { Pencil, Trash2, LinkIcon } from "lucide-react";
// types
import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal";
// helpers
import { calculateTimeAgo } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
export type TIssueLinkDetail = {
linkId: string;
linkOperations: TLinkOperationsModal;
isNotAllowed: boolean;
};
export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
// props
const { linkId, linkOperations, isNotAllowed } = props;
// hooks
const {
toggleIssueLinkModal: toggleIssueLinkModalStore,
link: { getLinkById },
} = useIssueDetail();
const { setToastAlert } = useToast();
// state
const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false);
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModalOpen(modalToggle);
};
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
return (
<div key={linkId}>
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModalOpen}
handleModal={toggleIssueLinkModal}
linkOperations={linkOperations}
preloadedData={linkDetail}
/>
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div
className="flex w-full items-start justify-between gap-2 cursor-pointer"
onClick={() => {
copyTextToClipboard(linkDetail.url);
setToastAlert({
type: "success",
title: "Link copied!",
message: "Link copied to clipboard",
});
}}
>
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
<Tooltip tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}>
<span className="truncate text-xs">
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
</span>
</Tooltip>
</div>
{!isNotAllowed && (
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</button>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
linkOperations.remove(linkDetail.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(linkDetail.created_at)}
<br />
by{" "}
{linkDetail.created_by_detail.is_bot
? linkDetail.created_by_detail.first_name + " Bot"
: linkDetail.created_by_detail.display_name}
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,39 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// computed
import { IssueLinkDetail } from "./link-detail";
// hooks
import { useIssueDetail, useUser } from "hooks/store";
import { TLinkOperations } from "./root";
export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
export type TIssueLinkList = {
linkOperations: TLinkOperationsModal;
};
export const IssueLinkList: FC<TIssueLinkList> = observer((props) => {
// props
const { linkOperations } = props;
// hooks
const {
link: { issueLinks },
} = useIssueDetail();
const {
membership: { currentProjectRole },
} = useUser();
return (
<div className="space-y-2">
{issueLinks &&
issueLinks.length > 0 &&
issueLinks.map((linkId) => (
<IssueLinkDetail
linkId={linkId}
linkOperations={linkOperations}
isNotAllowed={currentProjectRole === 5 || currentProjectRole === 10}
/>
))}
</div>
);
});

View file

@ -0,0 +1,134 @@
import { FC, useCallback, useMemo, useState } from "react";
import { Plus } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { IssueLinkCreateUpdateModal } from "./create-update-link-modal";
import { IssueLinkList } from "./links";
// types
import { TIssueLink } from "@plane/types";
export type TLinkOperations = {
create: (data: Partial<TIssueLink>) => Promise<void>;
update: (linkId: string, data: Partial<TIssueLink>) => Promise<void>;
remove: (linkId: string) => Promise<void>;
};
export type TIssueLinkRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
is_editable: boolean;
is_archived: boolean;
};
export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
// props
const { workspaceSlug, projectId, issueId, is_editable, is_archived } = props;
// hooks
const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail();
// state
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
const toggleIssueLinkModal = useCallback(
(modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModal(modalToggle);
},
[toggleIssueLinkModalStore]
);
const { setToastAlert } = useToast();
const handleLinkOperations: TLinkOperations = useMemo(
() => ({
create: async (data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await createLink(workspaceSlug, projectId, issueId, data);
setToastAlert({
message: "The link has been successfully created",
type: "success",
title: "Link created",
});
toggleIssueLinkModal(false);
} catch (error) {
setToastAlert({
message: "The link could not be created",
type: "error",
title: "Link not created",
});
}
},
update: async (linkId: string, data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
setToastAlert({
message: "The link has been successfully updated",
type: "success",
title: "Link updated",
});
toggleIssueLinkModal(false);
} catch (error) {
setToastAlert({
message: "The link could not be updated",
type: "error",
title: "Link not updated",
});
}
},
remove: async (linkId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeLink(workspaceSlug, projectId, issueId, linkId);
setToastAlert({
message: "The link has been successfully removed",
type: "success",
title: "Link removed",
});
toggleIssueLinkModal(false);
} catch (error) {
setToastAlert({
message: "The link could not be removed",
type: "error",
title: "Link not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert, toggleIssueLinkModal]
);
return (
<>
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModal}
handleModal={toggleIssueLinkModal}
linkOperations={handleLinkOperations}
/>
<div className={`py-1 text-xs ${is_archived ? "opacity-60" : ""}`}>
<div className="flex items-center justify-between gap-2">
<h4>Links</h4>
{is_editable && (
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
is_archived ? "cursor-not-allowed" : "cursor-pointer"
}`}
onClick={() => toggleIssueLinkModal(true)}
disabled={is_archived}
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
<div>
<IssueLinkList linkOperations={handleLinkOperations} />
</div>
</div>
</>
);
};

View file

@ -0,0 +1,130 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
// components
import { IssueDescriptionForm, IssueAttachmentRoot, IssueUpdateStatus } from "components/issues";
import { IssueParentDetail } from "./parent";
import { IssueReaction } from "./reactions";
import { SubIssuesRoot } from "../sub-issues";
// ui
import { StateGroupIcon } from "@plane/ui";
// types
import { TIssueOperations } from "./root";
// constants
import { EUserProjectRoles } from "constants/project";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
is_archived: boolean;
is_editable: boolean;
};
export const IssueMainContent: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// hooks
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const { getProjectById } = useProject();
const { projectStates } = useProjectState();
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
if (!issue) return <></>;
const projectDetails = projectId ? getProjectById(projectId) : null;
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return (
<>
<div className="rounded-lg space-y-4">
{issue.parent_id && (
<IssueParentDetail
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issue={issue}
issueOperations={issueOperations}
/>
)}
<div className="mb-2.5 flex items-center">
{currentIssueState && (
<StateGroupIcon
className="mr-3 h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
</div>
<IssueDescriptionForm
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
issue={issue}
issueOperations={issueOperations}
isAllowed={isAllowed || !is_editable}
/>
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
/>
)}
{currentUser && (
<SubIssuesRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
is_archived={is_archived}
is_editable={is_editable}
/>
)}
</div>
{/* issue attachments */}
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
is_archived={is_archived}
is_editable={is_editable}
/>
{/* <div className="space-y-5 pt-3">
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
<IssueActivitySection
activity={issueActivity}
handleCommentUpdate={handleCommentUpdate}
handleCommentDelete={handleCommentDelete}
showAccessSpecifier={Boolean(projectDetails && projectDetails.is_deployed)}
/>
<AddComment
onSubmit={handleAddComment}
disabled={is_editable}
showAccessSpecifier={Boolean(projectDetails && projectDetails.is_deployed)}
/>
</div> */}
</>
);
});

View file

@ -0,0 +1,103 @@
import React, { ReactNode, useState } from "react";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// hooks
import { useModule, useIssueDetail } from "hooks/store";
// ui
import { CustomSearchSelect, DiceIcon, Spinner, Tooltip } from "@plane/ui";
// types
import type { TIssueOperations } from "./root";
type TIssueModuleSelect = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled?: boolean;
};
export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
// hooks
const { getModuleById, projectModuleIds, fetchModules } = useModule();
const {
issue: { getIssueById },
} = useIssueDetail();
// state
const [isUpdating, setIsUpdating] = useState(false);
useSWR(workspaceSlug && projectId ? `PROJECT_${projectId}_ISSUE_${issueId}_MODULES` : null, async () => {
if (workspaceSlug && projectId) await fetchModules(workspaceSlug, projectId);
});
const issue = getIssueById(issueId);
const issueModule = (issue && issue.module_id && getModuleById(issue.module_id)) || undefined;
const disableSelect = disabled || isUpdating;
const handleIssueModuleChange = async (moduleId: string) => {
if (!moduleId) return;
setIsUpdating(true);
if (issue && issue.module_id === moduleId)
await issueOperations.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
else await issueOperations.addIssueToModule(workspaceSlug, projectId, moduleId, [issueId]);
setIsUpdating(false);
};
type TDropdownOptions = { value: string; query: string; content: ReactNode }[];
const options: TDropdownOptions | undefined = projectModuleIds
? (projectModuleIds
.map((moduleId) => {
const _module = getModuleById(moduleId);
if (!_module) return undefined;
return {
value: _module.id,
query: _module.name,
content: (
<div className="flex items-center gap-1.5 truncate">
<span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<DiceIcon />
</span>
<span className="flex-grow truncate">{_module.name}</span>
</div>
) as ReactNode,
};
})
.filter((_module) => _module !== undefined) as TDropdownOptions)
: undefined;
return (
<div className="flex items-center gap-1">
<CustomSearchSelect
value={issue?.module_id}
onChange={(value: any) => handleIssueModuleChange(value)}
options={options}
customButton={
<div>
<Tooltip position="left" tooltipContent={`${issueModule?.name ?? "No module"}`}>
<button
type="button"
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
disableSelect ? "cursor-not-allowed" : ""
} max-w-[10rem]`}
>
<span
className={`flex items-center gap-1.5 truncate ${
issueModule ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span className="flex-shrink-0">{issueModule && <DiceIcon className="h-3.5 w-3.5" />}</span>
<span className="truncate">{issueModule?.name ?? "No module"}</span>
</span>
</button>
</Tooltip>
</div>
}
width="max-w-[10rem]"
noChevron
disabled={disableSelect}
/>
{isUpdating && <Spinner className="h-4 w-4" />}
</div>
);
});

View file

@ -0,0 +1,82 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// hooks
import { useIssueDetail, useProject } from "hooks/store";
import { Spinner } from "@plane/ui";
// components
import { ParentIssuesListModal } from "components/issues";
import { TIssueOperations } from "./root";
type TIssueParentSelect = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled?: boolean;
};
export const IssueParentSelect: React.FC<TIssueParentSelect> = observer(
({ workspaceSlug, projectId, issueId, issueOperations, disabled = false }) => {
// hooks
const { getProjectById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// state
const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail();
const [updating, setUpdating] = useState(false);
const issue = getIssueById(issueId);
const parentIssue = issue && issue.parent_id ? getIssueById(issue.parent_id) : undefined;
const parentIssueProjectDetails =
parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined;
const handleParentIssue = async (_issueId: string | null = null) => {
setUpdating(true);
await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }).finally(() => {
toggleParentIssueModal(false);
setUpdating(false);
});
};
if (!issue) return <></>;
return (
<div className="relative flex items-center gap-2">
<ParentIssuesListModal
projectId={projectId}
issueId={issueId}
isOpen={isParentIssueModalOpen}
handleClose={() => toggleParentIssueModal(false)}
onChange={(issue: any) => handleParentIssue(issue?.id)}
/>
<button
className={`flex items-center gap-2 rounded bg-custom-background-80 px-2.5 py-0.5 text-xs w-max max-w-max" ${
disabled ? "cursor-not-allowed" : "cursor-pointer "
}`}
disabled={disabled}
>
<div onClick={() => toggleParentIssueModal(true)}>
{parentIssue ? (
`${parentIssueProjectDetails?.identifier}-${parentIssue.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
)}
</div>
{parentIssue && (
<div onClick={() => handleParentIssue(null)}>
<X className="h-2.5 w-2.5" />
</div>
)}
</button>
{updating && <Spinner className="h-4 w-4" />}
</div>
);
}
);

View file

@ -0,0 +1,4 @@
export * from "./root";
export * from "./siblings";
export * from "./sibling-item";

View file

@ -0,0 +1,72 @@
import { FC } from "react";
import Link from "next/link";
import { MinusCircle } from "lucide-react";
// component
import { IssueParentSiblings } from "./siblings";
// ui
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail, useIssues, useProject, useProjectState } from "hooks/store";
// types
import { TIssueOperations } from "../root";
import { TIssue } from "@plane/types";
export type TIssueParentDetail = {
workspaceSlug: string;
projectId: string;
issueId: string;
issue: TIssue;
issueOperations: TIssueOperations;
};
export const IssueParentDetail: FC<TIssueParentDetail> = (props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
// hooks
const { issueMap } = useIssues();
const { peekIssue } = useIssueDetail();
const { getProjectById } = useProject();
const { getProjectStates } = useProjectState();
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
const issueParentState = getProjectStates(parentIssue?.project_id)?.find(
(state) => state?.id === parentIssue?.state_id
);
const stateColor = issueParentState?.color || undefined;
if (!parentIssue) return <></>;
return (
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<Link href={`/${peekIssue?.workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
<span className="flex-shrink-0 text-custom-text-200">
{getProjectById(parentIssue.project_id)?.identifier}-{parentIssue?.sequence_id}
</span>
</div>
<span className="truncate text-custom-text-100">{(parentIssue?.name ?? "").substring(0, 50)}</span>
</div>
</Link>
<CustomMenu ellipsis optionsClassName="p-1.5">
<div className="border-b border-custom-border-300 text-xs font-medium text-custom-text-200">
Sibling issues
</div>
<IssueParentSiblings currentIssue={issue} parentIssue={parentIssue} />
<CustomMenu.MenuItem
onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })}
className="flex items-center gap-2 py-2 text-red-500"
>
<MinusCircle className="h-4 w-4" />
<span> Remove Parent Issue</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</>
);
};

View file

@ -0,0 +1,39 @@
import { FC } from "react";
import Link from "next/link";
// ui
import { CustomMenu, LayersIcon } from "@plane/ui";
// hooks
import { useIssueDetail, useProject } from "hooks/store";
type TIssueParentSiblingItem = {
issueId: string;
};
export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = (props) => {
const { issueId } = props;
// hooks
const { getProjectById } = useProject();
const {
peekIssue,
issue: { getIssueById },
} = useIssueDetail();
const issueDetail = (issueId && getIssueById(issueId)) || undefined;
if (!issueDetail) return <></>;
const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined;
return (
<>
<CustomMenu.MenuItem key={issueDetail.id}>
<Link
href={`/${peekIssue?.workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`}
className="flex items-center gap-2 py-2"
>
<LayersIcon className="h-4 w-4" />
{projectDetails?.identifier}-{issueDetail.sequence_id}
</Link>
</CustomMenu.MenuItem>
</>
);
};

View file

@ -0,0 +1,51 @@
import { FC } from "react";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// components
import { IssueParentSiblingItem } from "./sibling-item";
// hooks
import { useIssueDetail } from "hooks/store";
// types
import { TIssue } from "@plane/types";
export type TIssueParentSiblings = {
currentIssue: TIssue;
parentIssue: TIssue;
};
export const IssueParentSiblings: FC<TIssueParentSiblings> = (props) => {
const { currentIssue, parentIssue } = props;
// hooks
const {
peekIssue,
fetchSubIssues,
subIssues: { subIssuesByIssueId },
} = useIssueDetail();
const { isLoading } = useSWR(
peekIssue && parentIssue
? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
: null,
peekIssue && parentIssue
? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id)
: null
);
const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined;
return (
<div>
{isLoading ? (
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
Loading
</div>
) : subIssueIds && subIssueIds.length > 0 ? (
subIssueIds.map((issueId) => currentIssue.id != issueId && <IssueParentSiblingItem issueId={issueId} />)
) : (
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
No sibling issues
</div>
)}
</div>
);
};

View file

@ -0,0 +1,4 @@
export * from "./reaction-selector";
export * from "./issue";
// export * from "./issue-comment";

View file

@ -0,0 +1,103 @@
import { FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
// components
import { ReactionSelector } from "./reaction-selector";
// hooks
import { useIssueDetail } from "hooks/store";
import useToast from "hooks/use-toast";
// types
import { IUser } from "@plane/types";
import { renderEmoji } from "helpers/emoji.helper";
export type TIssueReaction = {
workspaceSlug: string;
projectId: string;
issueId: string;
currentUser: IUser;
};
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUser } = props;
// hooks
const {
reaction: { getReactionsByIssueId, reactionsByUser },
createReaction,
removeReaction,
} = useIssueDetail();
const { setToastAlert } = useToast();
const reactionIds = getReactionsByIssueId(issueId);
const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction);
const issueReactionOperations = useMemo(
() => ({
create: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await createReaction(workspaceSlug, projectId, issueId, reaction);
setToastAlert({
title: "Reaction created successfully",
type: "success",
message: "Reaction created successfully",
});
} catch (error) {
setToastAlert({
title: "Reaction creation failed",
type: "error",
message: "Reaction creation failed",
});
}
},
remove: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id);
setToastAlert({
title: "Reaction removed successfully",
type: "success",
message: "Reaction removed successfully",
});
} catch (error) {
setToastAlert({
title: "Reaction remove failed",
type: "error",
message: "Reaction remove failed",
});
}
},
react: async (reaction: string) => {
if (userReactions.includes(reaction)) await issueReactionOperations.remove(reaction);
else await issueReactionOperations.create(reaction);
},
}),
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, setToastAlert, userReactions]
);
return (
<div className="mt-4 relative flex items-center gap-1.5">
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<button
type="button"
onClick={() => issueReactionOperations.react(reaction)}
key={reaction}
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</>
)
)}
</div>
);
});

View file

@ -0,0 +1,74 @@
import { Fragment } from "react";
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "helpers/emoji.helper";
// icons
import { SmilePlus } from "lucide-react";
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
value?: string | string[] | null;
onSelect: (emoji: string) => void;
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex items-center justify-center rounded-md px-2 ${
size === "sm" ? "h-6 w-6" : size === "md" ? "h-7 w-7" : "h-8 w-8"
}`}
>
<SmilePlus className="h-3.5 w-3.5 text-custom-text-100" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`absolute -left-2 z-10 bg-custom-sidebar-background-100 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="rounded-md border border-custom-border-200 bg-custom-sidebar-background-100 p-1 shadow-custom-shadow-sm">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex select-none items-center justify-between rounded-md p-1 text-sm hover:bg-custom-sidebar-background-90"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View file

@ -0,0 +1,165 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { X, CopyPlus } from "lucide-react";
// hooks
import { useIssueDetail, useIssues, useProject, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { ExistingIssuesListModal } from "components/core";
// icons
import { BlockerIcon, BlockedIcon, RelatedIcon } from "@plane/ui";
// types
import { TIssueRelationTypes, ISearchIssueResponse } from "@plane/types";
export type TRelationObject = { name: string; icon: (size: number) => any; className: string };
const issueRelationObject: Record<TIssueRelationTypes, TRelationObject> = {
blocking: {
name: "Blocking",
icon: (size: number = 16) => <BlockerIcon height={size} width={size} />,
className: "text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20",
},
blocked_by: {
name: "Blocked by",
icon: (size: number = 16) => <BlockedIcon height={size} width={size} />,
className: "border-custom-border-200 text-red-500 hover:border-red-500/20 hover:bg-red-500/20",
},
duplicate: {
name: "Duplicate",
icon: (size: number = 16) => <CopyPlus height={size} width={size} />,
className: "border-custom-border-200",
},
relates_to: {
name: "Relates to",
icon: (size: number = 16) => <RelatedIcon height={size} width={size} />,
className: "border-custom-border-200",
},
};
type TIssueRelationSelect = {
workspaceSlug: string;
projectId: string;
issueId: string;
relationKey: TIssueRelationTypes;
disabled?: boolean;
};
export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, relationKey, disabled = false } = props;
// hooks
const { currentUser } = useUser();
const { getProjectById } = useProject();
const {
createRelation,
removeRelation,
relation: { getRelationByIssueIdRelationType },
} = useIssueDetail();
const { issueMap } = useIssues();
// states
const [isRelationModalOpen, setIsRelationModalOpen] = useState(false);
// toast alert
const { setToastAlert } = useToast();
const relationIssueIds = getRelationByIssueIdRelationType(issueId as string, relationKey);
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (data.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
await createRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relationKey,
data.map((i) => i.id)
);
setIsRelationModalOpen(false);
};
return (
<>
<ExistingIssuesListModal
isOpen={isRelationModalOpen}
handleClose={() => setIsRelationModalOpen(false)}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
<div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
{relationKey && issueRelationObject[relationKey] && (
<>
{issueRelationObject[relationKey].icon(16)}
<p>{issueRelationObject[relationKey].name}</p>
</>
)}
</div>
<div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1">
{relationIssueIds && relationIssueIds.length > 0
? relationIssueIds.map((relationIssueId: any) => {
const currentIssue = issueMap[relationIssueId];
if (!currentIssue) return;
const projectDetails = getProjectById(currentIssue.project_id);
return (
<div
key={relationIssueId}
className={`group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs duration-300 ${issueRelationObject[relationKey].className}`}
>
<a
href={`/${workspaceSlug}/projects/${projectDetails?.id}/issues/${relationIssueId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
{issueRelationObject[relationKey].icon(10)}
{`${projectDetails?.identifier}-${currentIssue?.sequence_id}`}
</a>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
if (!currentUser) return;
removeRelation(
workspaceSlug as string,
projectId as string,
issueId,
relationKey,
relationIssueId
);
}}
>
<X className="h-2 w-2" />
</button>
</div>
);
})
: null}
</div>
<button
type="button"
className={`rounded bg-custom-background-80 px-2.5 py-0.5 text-xs text-custom-text-200 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
}`}
onClick={() => setIsRelationModalOpen(true)}
disabled={disabled}
>
Select issues
</button>
</div>
</div>
</>
);
});

View file

@ -0,0 +1,199 @@
import { FC, useMemo } from "react";
import { useRouter } from "next/router";
// components
import { IssueMainContent } from "./main-content";
import { IssueDetailsSidebar } from "./sidebar";
// ui
import { EmptyState } from "components/common";
// images
import emptyIssue from "public/empty-state/issue.svg";
// hooks
import { useIssueDetail } from "hooks/store";
import useToast from "hooks/use-toast";
// types
import { TIssue } from "@plane/types";
export type TIssueOperations = {
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
};
export type TIssueDetailRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
is_archived?: boolean;
is_editable?: boolean;
};
export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
const { workspaceSlug, projectId, issueId, is_archived = false, is_editable = true } = props;
// router
const router = useRouter();
// hooks
const {
issue: { getIssueById },
updateIssue,
removeIssue,
addIssueToCycle,
removeIssueFromCycle,
addIssueToModule,
removeIssueFromModule,
} = useIssueDetail();
const { setToastAlert } = useToast();
const issueOperations: TIssueOperations = useMemo(
() => ({
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
setToastAlert({
title: "Issue updated successfully",
type: "success",
message: "Issue updated successfully",
});
} catch (error) {
setToastAlert({
title: "Issue update failed",
type: "error",
message: "Issue update failed",
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await removeIssue(workspaceSlug, projectId, issueId);
setToastAlert({
title: "Issue deleted successfully",
type: "success",
message: "Issue deleted successfully",
});
} catch (error) {
setToastAlert({
title: "Issue delete failed",
type: "error",
message: "Issue delete failed",
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
setToastAlert({
title: "Cycle added to issue successfully",
type: "success",
message: "Issue added to issue successfully",
});
} catch (error) {
setToastAlert({
title: "Cycle add to issue failed",
type: "error",
message: "Cycle add to issue failed",
});
}
},
removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
try {
await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
setToastAlert({
title: "Cycle removed from issue successfully",
type: "success",
message: "Cycle removed from issue successfully",
});
} catch (error) {
setToastAlert({
title: "Cycle remove from issue failed",
type: "error",
message: "Cycle remove from issue failed",
});
}
},
addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
try {
await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds);
setToastAlert({
title: "Module added to issue successfully",
type: "success",
message: "Module added to issue successfully",
});
} catch (error) {
setToastAlert({
title: "Module add to issue failed",
type: "error",
message: "Module add to issue failed",
});
}
},
removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
try {
await removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
setToastAlert({
title: "Module removed from issue successfully",
type: "success",
message: "Module removed from issue successfully",
});
} catch (error) {
setToastAlert({
title: "Module remove from issue failed",
type: "error",
message: "Module remove from issue failed",
});
}
},
}),
[
updateIssue,
removeIssue,
addIssueToCycle,
removeIssueFromCycle,
addIssueToModule,
removeIssueFromModule,
setToastAlert,
]
);
const issue = getIssueById(issueId);
return (
<>
{!issue ? (
<EmptyState
image={emptyIssue}
title="Issue does not exist"
description="The issue you are looking for does not exist, has been archived, or has been deleted."
primaryButton={{
text: "View other issues",
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
}}
/>
) : (
<div className="flex h-full overflow-hidden">
<div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5">
<IssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
is_archived={is_archived}
is_editable={is_editable}
/>
</div>
<div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5">
<IssueDetailsSidebar
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
is_archived={is_archived}
is_editable={true}
/>
</div>
</div>
)}
</>
);
};

View file

@ -0,0 +1,461 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { CalendarDays, LinkIcon, Signal, Tag, Trash2, Triangle, LayoutPanelTop } from "lucide-react";
// hooks
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import {
DeleteIssueModal,
IssueLinkRoot,
IssueRelationSelect,
IssueCycleSelect,
IssueModuleSelect,
IssueParentSelect,
IssueLabel,
} from "components/issues";
import { IssueSubscription } from "./subscription";
import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
// ui
import { CustomDatePicker } from "components/ui";
// icons
import { ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import type { TIssueOperations } from "./root";
// fetch-keys
import { EUserProjectRoles } from "constants/project";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
is_archived: boolean;
is_editable: boolean;
fieldsToShow?: (
| "state"
| "assignee"
| "priority"
| "estimate"
| "parent"
| "blocker"
| "blocked"
| "startDate"
| "dueDate"
| "cycle"
| "module"
| "label"
| "link"
| "delete"
| "all"
| "subscribe"
| "duplicate"
| "relates_to"
)[];
};
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
issueOperations,
is_archived,
is_editable,
fieldsToShow = ["all"],
} = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
// store hooks
const { getProjectById } = useProject();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const { projectStates } = useProjectState();
const { areEstimatesEnabledForCurrentProject } = useEstimate();
const { setToastAlert } = useToast();
const {
issue: { getIssueById },
} = useIssueDetail();
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const issue = getIssueById(issueId);
if (!issue) return <></>;
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const projectDetails = issue ? getProjectById(issue.project_id) : null;
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 minDate = issue.start_date ? new Date(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
return (
<>
{workspaceSlug && projectId && issue && (
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issue}
onSubmit={async () => {
await issueOperations.remove(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
}}
/>
)}
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="flex items-center justify-between px-5 pb-3">
<div className="flex items-center gap-x-2">
{currentIssueState ? (
<StateGroupIcon
className="h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
) : inboxIssueId ? (
<StateGroupIcon className="h-4 w-4" stateGroup="backlog" color="#ff7700" />
) : null}
<h4 className="text-lg font-medium text-custom-text-300">
{projectDetails?.identifier}-{issue?.sequence_id}
</h4>
</div>
<div className="flex flex-wrap items-center gap-2">
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
<IssueSubscription
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUserId={currentUser?.id}
disabled={!isAllowed || !is_editable}
/>
)}
{/* {(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>
)} */}
{/* {isAllowed && (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 overflow-y-auto px-5">
<div className={`divide-y-2 divide-custom-border-200 ${is_editable ? "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:w-1/2">
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<p>State</p>
</div>
<div className="h-5 sm:w-1/2">
<StateDropdown
value={issue?.state_id ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!isAllowed || !is_editable}
buttonVariant="background-with-text"
/>
</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:w-1/2">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Assignees</p>
</div>
<div className="h-5 sm:w-1/2">
<ProjectMemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })
}
disabled={!isAllowed || !is_editable}
projectId={projectId?.toString() ?? ""}
placeholder="Assignees"
multiple
buttonVariant={
issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "background-with-text"
}
buttonClassName={issue?.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</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:w-1/2">
<Signal className="h-4 w-4 flex-shrink-0" />
<p>Priority</p>
</div>
<div className="h-5 sm:w-1/2">
<PriorityDropdown
value={issue?.priority || undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!isAllowed || !is_editable}
buttonVariant="background-with-text"
/>
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
areEstimatesEnabledForCurrentProject && (
<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">
<Triangle className="h-4 w-4 flex-shrink-0 " />
<p>Estimate</p>
</div>
<div className="h-5 sm:w-1/2">
<EstimateDropdown
value={issue?.estimate_point || null}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
}
projectId={projectId}
disabled={!isAllowed || !is_editable}
buttonVariant="background-with-text"
/>
</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">
<LayoutPanelTop className="h-4 w-4 flex-shrink-0" />
<p>Parent</p>
</div>
<div className="sm:basis-1/2">
<IssueParentSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isAllowed || !is_editable}
/>
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
<IssueRelationSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="blocking"
disabled={!isAllowed || !is_editable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
<IssueRelationSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="blocked_by"
disabled={!isAllowed || !is_editable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && (
<IssueRelationSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="duplicate"
disabled={!isAllowed || !is_editable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && (
<IssueRelationSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey="relates_to"
disabled={!isAllowed || !is_editable}
/>
)}
{(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">
<CustomDatePicker
placeholder="Start date"
value={issue.start_date || undefined}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, { start_date: val })
}
className="border-none bg-custom-background-80"
maxDate={maxDate ?? undefined}
disabled={!isAllowed || !is_editable}
/>
</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">
<CustomDatePicker
placeholder="Due date"
value={issue.target_date || undefined}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, { target_date: val })
}
className="border-none bg-custom-background-80"
minDate={minDate ?? undefined}
disabled={!isAllowed || !is_editable}
/>
</div>
</div>
)}
</div>
)}
{showThirdSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && projectDetails?.cycle_view && (
<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">
<IssueCycleSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isAllowed || !is_editable}
/>
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && projectDetails?.module_view && (
<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">
<IssueModuleSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isAllowed || !is_editable}
/>
</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">
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isAllowed || !is_editable}
/>
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
<IssueLinkRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
is_editable={is_editable}
is_archived={is_archived}
/>
)}
</div>
</div>
</>
);
});

View file

@ -0,0 +1,54 @@
import { FC, useState } from "react";
import { Bell } from "lucide-react";
import { observer } from "mobx-react-lite";
// UI
import { Button } from "@plane/ui";
// hooks
import { useIssueDetail } from "hooks/store";
export type TIssueSubscription = {
workspaceSlug: string;
projectId: string;
issueId: string;
currentUserId: string;
disabled?: boolean;
};
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUserId, disabled } = props;
// hooks
const {
issue: { getIssueById },
subscription: { getSubscriptionByIssueId },
createSubscription,
removeSubscription,
} = useIssueDetail();
// state
const [loading, setLoading] = useState(false);
const issue = getIssueById(issueId);
const subscription = getSubscriptionByIssueId(issueId);
const handleSubscription = () => {
setLoading(true);
if (subscription?.subscribed) removeSubscription(workspaceSlug, projectId, issueId);
else createSubscription(workspaceSlug, projectId, issueId);
};
if (issue?.created_by === currentUserId || issue?.assignee_ids.includes(currentUserId)) return <></>;
return (
<div>
<Button
size="sm"
prependIcon={<Bell className="h-3 w-3" />}
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={handleSubscription}
disabled={disabled}
>
{loading ? "Loading..." : subscription?.subscribed ? "Unsubscribe" : "Subscribe"}
</Button>
</div>
);
});