feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TDeDupeIssue, TIssue } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
type Props = {
data?: TIssue | TDeDupeIssue;
dataId?: string | null | undefined;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveIssueModal: React.FC<Props> = (props) => {
const { dataId, data, isOpen, handleClose, onSubmit } = props;
const { t } = useTranslation();
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getProjectById } = useProject();
const { issueMap } = useIssues();
if (!dataId && !data) return null;
const issue = data ? data : issueMap[dataId!];
const projectDetails = getProjectById(issue.project_id);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
if (!onSubmit) return;
setIsArchiving(true);
await onSubmit()
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("issue.archive.success.label"),
message: t("issue.archive.success.message"),
});
onClose();
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.archive.failed.message"),
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-30" 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-30 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-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 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">
{t("issue.archive.label")} {projectDetails?.identifier} {issue.sequence_id}
</h3>
<p className="mt-3 text-sm text-custom-text-200">{t("issue.archive.confirm_message")}</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? t("common.archiving") : t("common.archive")}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,72 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { EHeaderVariant, Header } from "@plane/ui";
// components
import { ArchiveTabsList } from "@/components/archives";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
export const ArchivedIssuesHeader: FC = observer(() => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// store hooks
const { currentProjectDetails } = useProject();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.ARCHIVED);
// i18n
const { t } = useTranslation();
// for archived issues list layout is the only option
const activeLayout = "list";
const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, {
...issueFilters?.displayFilters,
...updatedDisplayFilter,
});
};
const handleDisplayPropertiesUpdate = (property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
};
if (!workspaceSlug || !projectId) return null;
return (
<Header variant={EHeaderVariant.SECONDARY}>
<Header.LeftItem>
<ArchiveTabsList />
</Header.LeftItem>
<Header.RightItem className="items-center">
<WorkItemFiltersToggle entityType={EIssuesStoreType.ARCHIVED} entityId={projectId} />
<FiltersDropdown title={t("common.display")} placement="bottom-end">
<DisplayFiltersSelection
displayFilters={issueFilters?.displayFilters || {}}
displayProperties={issueFilters?.displayProperties || {}}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues.layoutOptions[activeLayout] : undefined
}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,107 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { AlertCircle, X } from "lucide-react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import {
convertBytesToSize,
getFileExtension,
getFileName,
getFileURL,
renderFormattedDate,
truncateText,
} from "@plane/utils";
// icons
//
import { getFileIcon } from "@/components/icons";
// components
import { IssueAttachmentDeleteModal } from "@/components/issues/attachment/delete-attachment-modal";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentHelpers, "create">;
type TIssueAttachmentsDetail = {
attachmentId: string;
attachmentHelpers: TAttachmentOperationsRemoveModal;
disabled?: boolean;
};
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((props) => {
// props
const { attachmentId, attachmentHelpers, disabled } = props;
// store hooks
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
} = useIssueDetail();
// state
const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.asset_url ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
{isDeleteIssueAttachmentModalOpen && (
<IssueAttachmentDeleteModal
isOpen={isDeleteIssueAttachmentModalOpen}
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
attachmentOperations={attachmentHelpers.operations}
attachmentId={attachmentId}
/>
)}
<div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-100 px-4 py-2 text-sm">
<Link href={fileURL ?? ""} target="_blank" rel="noopener noreferrer">
<div className="flex items-center gap-3">
<div className="h-7 w-7">{fileIcon}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<span className="text-sm">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment.updated_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<span>
<AlertCircle className="h-3 w-3" />
</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-custom-text-200">
<span>{fileExtension.toUpperCase()}</span>
<span>{convertBytesToSize(attachment.attributes.size)}</span>
</div>
</div>
</div>
</Link>
{!disabled && (
<button type="button" onClick={() => setIsDeleteIssueAttachmentModalOpen(true)}>
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
</button>
)}
</div>
</>
);
});

View File

@@ -0,0 +1,152 @@
import type { FC } from "react";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsListItem } from "./attachment-list-item";
import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
// types
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
type TIssueAttachmentItemList = {
workspaceSlug: string;
projectId: string;
issueId: string;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
attachmentHelpers,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
const { t } = useTranslation();
// states
const [isUploading, setIsUploading] = useState(false);
// store hooks
const {
attachment: { getAttachmentsByIssueId },
attachmentDeleteModalId,
toggleDeleteAttachmentModal,
fetchActivities,
} = useIssueDetail(issueServiceType);
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
const { create: createAttachment } = attachmentOperations;
const { uploadStatus } = attachmentSnapshot;
// file size
const { maxFileSize } = useFileSize();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId);
// handlers
const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsUploading(true);
createAttachment(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.error"),
});
})
.finally(() => {
handleFetchPropertyActivities();
setIsUploading(false);
});
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message:
totalAttachedFiles > 1
? t("attachment.only_one_file_allowed")
: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
});
return;
},
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isUploading || disabled,
});
return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments && (
<>
{attachmentDeleteModalId && (
<IssueAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)}
onClose={() => toggleDeleteAttachmentModal(null)}
attachmentOperations={attachmentOperations}
attachmentId={attachmentDeleteModalId}
issueServiceType={issueServiceType}
/>
)}
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute flex items-center justify-center left-0 top-0 h-full w-full bg-custom-background-90/75 z-30 ">
<div className="flex items-center justify-center p-1 rounded-md bg-custom-background-100">
<div className="flex flex-col justify-center items-center px-5 py-6 rounded-md border border-dashed border-custom-border-300">
<UploadCloud className="size-7" />
<span className="text-sm text-custom-text-300">{t("attachment.drag_and_drop")}</span>
</div>
</div>
</div>
)}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
))}
</div>
</>
)}
</>
);
});

View File

@@ -0,0 +1,102 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Trash } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
// components
//
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssueAttachmentsListItem = {
attachmentId: string;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer((props) => {
const { t } = useTranslation();
// props
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// store hooks
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
toggleDeleteAttachmentModal,
} = useIssueDetail(issueServiceType);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
>
<div className="group flex items-center justify-between gap-3 h-11 hover:bg-custom-background-90 pl-9 pr-2">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{`${fileName}.${fileExtension}`}</p>
</Tooltip>
<span className="flex size-1.5 bg-custom-background-80 rounded-full" />
<span className="flex-shrink-0 text-custom-text-400">{convertBytesToSize(attachment.attributes.size)}</span>
</div>
<div className="flex items-center gap-3">
{attachment?.created_by && (
<>
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment?.created_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<div className="flex items-center justify-center">
<ButtonAvatars showTooltip userIds={attachment?.created_by} />
</div>
</Tooltip>
</>
)}
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
<CustomMenu.MenuItem
onClick={() => {
toggleDeleteAttachmentModal(attachmentId);
}}
>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.delete")}</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</button>
</>
);
});

View File

@@ -0,0 +1,46 @@
"use client";
import { observer } from "mobx-react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import { CircularProgressIndicator } from "@plane/ui";
// components
import { getFileExtension } from "@plane/utils";
import { getFileIcon } from "@/components/icons";
// helpers
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
type Props = {
uploadStatus: TAttachmentUploadStatus;
};
export const IssueAttachmentsUploadItem: React.FC<Props> = observer((props) => {
// props
const { uploadStatus } = props;
// derived values
const fileName = uploadStatus.name;
const fileExtension = getFileExtension(uploadStatus.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex items-center justify-between gap-3 h-11 bg-custom-background-90 pl-9 pr-2 pointer-events-none">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex-shrink-0">{fileIcon}</div>
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{fileName}</p>
</Tooltip>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span>
<div className="flex-shrink-0 text-sm font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,54 @@
"use client";
import { observer } from "mobx-react";
import { Tooltip } from "@plane/propel/tooltip";
import { CircularProgressIndicator } from "@plane/ui";
import { getFileExtension, truncateText } from "@plane/utils";
// ui
// icons
import { getFileIcon } from "@/components/icons";
// helpers
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
type Props = {
uploadStatus: TAttachmentUploadStatus;
};
export const IssueAttachmentsUploadDetails: React.FC<Props> = observer((props) => {
// props
const { uploadStatus } = props;
// derived values
const fileName = uploadStatus.name;
const fileExtension = getFileExtension(uploadStatus.name ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-90 px-4 py-2 text-sm pointer-events-none">
<div className="flex-shrink-0 flex items-center gap-3">
<div className="h-7 w-7">{fileIcon}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<span className="text-sm">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-custom-text-200">
<span>{fileExtension.toUpperCase()}</span>
</div>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span>
<div className="flex-shrink-0 text-sm font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// types
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
type TAttachmentOperationsModal = Pick<TAttachmentOperations, "create">;
type Props = {
workspaceSlug: string;
disabled?: boolean;
attachmentOperations: TAttachmentOperationsModal;
};
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
const { workspaceSlug, disabled = false, attachmentOperations } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// file size
const { maxFileSize } = useFileSize();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
attachmentOperations.create(currentFile).finally(() => setIsLoading(false));
},
[attachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
});
const fileError =
fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null;
return (
<div
{...getRootProps()}
className={`flex h-[60px] items-center justify-center rounded-md border-2 border-dashed bg-custom-primary/5 px-4 text-xs text-custom-primary ${
isDragActive ? "border-custom-primary bg-custom-primary/10" : "border-custom-border-200"
} ${isDragReject ? "bg-red-100" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
<span className="flex items-center gap-2">
{isDragActive ? (
<p>Drop here...</p>
) : fileError ? (
<p className="text-center text-red-500">{fileError}</p>
) : isLoading ? (
<p className="text-center">Uploading...</p>
) : (
<p className="text-center">Click or drag a file here</p>
)}
</span>
</div>
);
});

View File

@@ -0,0 +1,43 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsDetail } from "./attachment-detail";
import { IssueAttachmentsUploadDetails } from "./attachment-upload-details";
type TIssueAttachmentsList = {
issueId: string;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
};
export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props) => {
const { issueId, attachmentHelpers, disabled } = props;
// store hooks
const {
attachment: { getAttachmentsByIssueId },
} = useIssueDetail();
// derived values
const { snapshot: attachmentSnapshot } = attachmentHelpers;
const { uploadStatus } = attachmentSnapshot;
const issueAttachments = getAttachmentsByIssueId(issueId);
return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadDetails key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
/>
))}
</>
);
});

View File

@@ -0,0 +1,71 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane-i18n
import { useTranslation } from "@plane/i18n";
// types
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// helper
import { getFileName } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
export type TAttachmentOperationsRemoveModal = Pick<TAttachmentOperations, "remove">;
type Props = {
isOpen: boolean;
onClose: () => void;
attachmentId: string;
attachmentOperations: TAttachmentOperationsRemoveModal;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => {
const { t } = useTranslation();
const { isOpen, onClose, attachmentId, attachmentOperations, issueServiceType = EIssueServiceType.ISSUES } = props;
// states
const [loader, setLoader] = useState(false);
// store hooks
const {
attachment: { getAttachmentById },
} = useIssueDetail(issueServiceType);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
// handlers
const handleClose = () => {
onClose();
setLoader(false);
};
const handleDeletion = async (assetId: string) => {
setLoader(true);
attachmentOperations.remove(assetId).finally(() => handleClose());
};
if (!attachment) return <></>;
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={() => handleDeletion(attachment.id)}
isSubmitting={loader}
isOpen={isOpen}
title={t("attachment.delete")}
content={
<>
{/* TODO: Translate here */}
Are you sure you want to delete attachment-{" "}
<span className="font-bold">{getFileName(attachment.attributes.name)}</span>? This attachment will be
permanently removed. This action cannot be undone.
</>
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,37 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentUpload } from "./attachment-upload";
import { IssueAttachmentsList } from "./attachments-list";
export type TIssueAttachmentRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = observer((props) => {
// props
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// hooks
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<div className="relative py-3 space-y-3">
<h3 className="text-lg">Attachments</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<IssueAttachmentUpload
workspaceSlug={workspaceSlug}
disabled={disabled}
attachmentOperations={attachmentHelpers.operations}
/>
<IssueAttachmentsList issueId={issueId} disabled={disabled} attachmentHelpers={attachmentHelpers} />
</div>
</div>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import { MARKETING_PLANE_ONE_PAGE_LINK } from "@plane/constants";
import { getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
type Props = {
className?: string;
};
export const BulkOperationsUpgradeBanner: React.FC<Props> = (props) => {
const { className } = props;
return (
<div className={cn("sticky bottom-0 left-0 h-20 z-[2] px-3.5 grid place-items-center", className)}>
<div className="h-14 w-full bg-custom-primary-100/10 border-[0.5px] border-custom-primary-100/50 py-4 px-3.5 flex items-center justify-between gap-2 rounded-md">
<p className="text-custom-primary-100 font-medium">
Change state, priority, and more for several work items at once. Save three minutes on an average per
operation.
</p>
<a
href={MARKETING_PLANE_ONE_PAGE_LINK}
target="_blank"
rel="noopener noreferrer"
className={cn(getButtonStyling("primary", "sm"), "flex-shrink-0")}
>
Upgrade to One
</a>
</div>
</div>
);
};

View File

@@ -0,0 +1,96 @@
"use client";
import React, { useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/propel/button";
type Props = {
isOpen: boolean;
handleClose: () => void;
onDiscard: () => void;
onConfirm: () => Promise<void>;
};
export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
const { isOpen, handleClose, onDiscard, onConfirm } = props;
const [isLoading, setIsLoading] = useState(false);
const onClose = () => {
handleClose();
setIsLoading(false);
};
const handleDeletion = async () => {
setIsLoading(true);
await onConfirm();
setIsLoading(false);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.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-30 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
<Transition.Child
as={React.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 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem]">
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Save this draft?
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
You can save this work item to Drafts so you can come back to it later.{" "}
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-between gap-2 p-4 sm:px-6">
<div>
<Button variant="neutral-primary" size="sm" onClick={onDiscard}>
Discard
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="sm" onClick={handleDeletion} loading={isLoading}>
{isLoading ? "Saving" : "Save to Drafts"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,83 @@
"use client";
import type { FC } from "react";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
// plane imports
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
type TCreateIssueToastActionItems = {
workspaceSlug: string;
projectId: string;
issueId: string;
isEpic?: boolean;
};
export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = observer((props) => {
const { workspaceSlug, projectId, issueId, isEpic = false } = props;
// state
const [copied, setCopied] = useState(false);
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectIdentifierById } = useProject();
// derived values
const issue = getIssueById(issueId);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
if (!issue) return null;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
});
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
try {
await copyUrlToClipboard(workItemLink);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (error) {
setCopied(false);
}
e.preventDefault();
e.stopPropagation();
};
return (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={workItemLink}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
>
{`View ${isEpic ? "epic" : "work item"}`}
</a>
{copied ? (
<>
<span className="cursor-default px-2 py-1 text-custom-text-200">Copied!</span>
</>
) : (
<>
<button
className="cursor-pointer hidden group-hover:flex px-2 py-1 text-custom-text-300 hover:text-custom-text-200 hover:bg-custom-background-90 rounded"
onClick={copyToClipboard}
>
Copy link
</button>
</>
)}
</div>
);
});

View File

@@ -0,0 +1,128 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { PROJECT_ERROR_MESSAGES, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TDeDupeIssue, TIssue } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// constants
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
// plane-web
type Props = {
isOpen: boolean;
handleClose: () => void;
dataId?: string | null | undefined;
data?: TIssue | TDeDupeIssue;
isSubIssue?: boolean;
onSubmit?: () => Promise<void>;
isEpic?: boolean;
};
export const DeleteIssueModal: React.FC<Props> = observer((props) => {
const { dataId, data, isOpen, handleClose, isSubIssue = false, onSubmit, isEpic = false } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { workspaceSlug } = useParams();
const { issueMap } = useIssues();
const { getProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const { data: currentUser } = useUser();
useEffect(() => {
setIsDeleting(false);
}, [isOpen]);
if (!dataId && !data) return null;
// derived values
const issue = data ? data : issueMap[dataId!];
const projectDetails = getProjectById(issue?.project_id);
const isIssueCreator = issue?.created_by === currentUser?.id;
const canPerformProjectAdminActions = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectDetails?.id
);
const authorized = isIssueCreator || canPerformProjectAdminActions;
const onClose = () => {
setIsDeleting(false);
handleClose();
};
const handleIssueDelete = async () => {
setIsDeleting(true);
if (!authorized) {
setToast({
title: t(PROJECT_ERROR_MESSAGES.permissionError.i18n_title),
type: TOAST_TYPE.ERROR,
message:
PROJECT_ERROR_MESSAGES.permissionError.i18n_message && t(PROJECT_ERROR_MESSAGES.permissionError.i18n_message),
});
onClose();
return;
}
if (onSubmit)
await onSubmit()
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.success"),
message: t("entity.delete.success", {
entity: isSubIssue ? t("common.sub_work_item") : isEpic ? t("common.epic") : t("common.work_item"),
}),
});
onClose();
})
.catch((errors) => {
const isPermissionError =
errors?.error ===
`Only admin or creator can delete the ${isSubIssue ? "sub-work item" : isEpic ? "epic" : "work item"}`;
const currentError = isPermissionError
? PROJECT_ERROR_MESSAGES.permissionError
: PROJECT_ERROR_MESSAGES.issueDeleteError;
setToast({
title: t(currentError.i18n_title),
type: TOAST_TYPE.ERROR,
message: currentError.i18n_message && t(currentError.i18n_message),
});
})
.finally(() => onClose());
};
return (
<AlertModalCore
handleClose={onClose}
handleSubmit={handleIssueDelete}
isSubmitting={isDeleting}
isOpen={isOpen}
title={t("entity.delete.label", { entity: isEpic ? t("common.epic") : t("common.work_item") })}
content={
<>
{/* TODO: Translate here */}
{`Are you sure you want to delete ${isEpic ? "epic" : "work item"} `}
<span className="break-words font-medium text-custom-text-100">
{projectDetails?.identifier}-{issue?.sequence_id}
</span>
{` ? All of the data related to the ${isEpic ? "epic" : "work item"} will be permanently removed. This action cannot be undone.`}
</>
}
/>
);
});

View File

@@ -0,0 +1,199 @@
"use client";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { getDescriptionPlaceholderI18n } from "@plane/utils";
import { RichTextEditor } from "@/components/editor/rich-text";
import type { TIssueOperations } from "@/components/issues/issue-detail";
// helpers
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
export type IssueDescriptionInputProps = {
containerClassName?: string;
editorRef?: React.RefObject<EditorRefApi>;
workspaceSlug: string;
projectId: string;
issueId: string;
initialValue: string | undefined;
disabled?: boolean;
issueOperations: TIssueOperations;
placeholder?: string | ((isFocused: boolean, value: string) => string);
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
swrIssueDescription?: string | null | undefined;
};
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const {
containerClassName,
editorRef,
workspaceSlug,
projectId,
issueId,
disabled,
swrIssueDescription,
initialValue,
issueOperations,
setIsSubmitting,
placeholder,
} = props;
// states
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
// ref to track if there are unsaved changes
const hasUnsavedChanges = useRef(false);
// store hooks
const { uploadEditorAsset } = useEditorAsset();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString();
// form info
const { handleSubmit, reset, control } = useForm<TIssue>({
defaultValues: {
description_html: initialValue,
},
});
// i18n
const { t } = useTranslation();
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
await issueOperations.update(workspaceSlug, projectId, issueId, {
description_html: formData.description_html ?? "<p></p>",
});
},
[workspaceSlug, projectId, issueId, issueOperations]
);
// reset form values
useEffect(() => {
if (!issueId) return;
reset({
id: issueId,
description_html: initialValue === "" ? "<p></p>" : initialValue,
});
setLocalIssueDescription({
id: issueId,
description_html: initialValue === "" ? "<p></p>" : initialValue,
});
// Reset unsaved changes flag when form is reset
hasUnsavedChanges.current = false;
}, [initialValue, issueId, reset]);
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}, 1500),
[handleSubmit, issueId]
);
// Save on unmount if there are unsaved changes
useEffect(
() => () => {
debouncedFormSave.cancel();
if (hasUnsavedChanges.current) {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => {
console.error("Failed to save description on unmount:", error);
})
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}
},
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!workspaceId) return null;
return (
<>
{localIssueDescription.description_html ? (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<RichTextEditor
editable={!disabled}
id={issueId}
initialValue={localIssueDescription.description_html ?? "<p></p>"}
value={swrIssueDescription ?? null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
projectId={projectId}
dragDropEnabled
onChange={(_description: object, description_html: string) => {
setIsSubmitting("submitting");
onChange(description_html);
hasUnsavedChanges.current = true;
debouncedFormSave();
}}
placeholder={
placeholder
? placeholder
: (isFocused, value) => t(`${getDescriptionPlaceholderI18n(isFocused, value)}`)
}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
issue_id: issueId?.toString(),
})
}
containerClassName={containerClassName}
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: issueId,
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading work item asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
ref={editorRef}
/>
)}
/>
) : (
<Loader>
<Loader.Item height="150px" />
</Loader>
)}
</>
);
});

View File

@@ -0,0 +1,141 @@
"use client";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
// plane web imports
import type { TProject } from "@/plane-web/types";
// local imports
import { WorkItemsModal } from "../analytics/work-items/modal";
import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle";
import {
DisplayFiltersSelection,
FiltersDropdown,
LayoutSelection,
MobileLayoutSelection,
} from "./issue-layouts/filters";
type Props = {
currentProjectDetails: TProject | undefined;
projectId: string;
workspaceSlug: string;
canUserCreateIssue: boolean | undefined;
storeType?: EIssuesStoreType.PROJECT | EIssuesStoreType.EPIC;
};
const LAYOUTS = [
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
];
export const HeaderFilters = observer((props: Props) => {
const {
currentProjectDetails,
projectId,
workspaceSlug,
canUserCreateIssue,
storeType = EIssuesStoreType.PROJECT,
} = props;
// i18n
const { t } = useTranslation();
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
},
[workspaceSlug, projectId, updateFilters]
);
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button
className="hidden md:block px-2"
onClick={() => setAnalyticsModal(true)}
variant="neutral-primary"
size="sm"
>
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<ChartNoAxesColumn className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</>
);
});

View File

@@ -0,0 +1,102 @@
"use client";
import type { FC } from "react";
import React from "react";
import { Link, Paperclip, Waypoints } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { ViewsIcon } from "@plane/propel/icons";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// plane web imports
import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons";
// local imports
import { IssueAttachmentActionButton } from "./attachments";
import { IssueLinksActionButton } from "./links";
import { RelationActionButton } from "./relations";
import { SubIssuesActionButton } from "./sub-issues";
import { IssueDetailWidgetButton } from "./widget-button";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
// translation
const { t } = useTranslation();
return (
<div className="flex items-center flex-wrap gap-2">
{!hideWidgets?.includes("sub-work-items") && (
<SubIssuesActionButton
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("issue.add.sub_issue")}
icon={<ViewsIcon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("relations") && (
<RelationActionButton
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("issue.add.relation")}
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("links") && (
<IssueLinksActionButton
customButton={
<IssueDetailWidgetButton
title={t("issue.add.link")}
icon={<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("attachments") && (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("common.attach")}
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
<WorkItemAdditionalWidgetActionButtons
disabled={disabled}
hideWidgets={hideWidgets ?? []}
issueServiceType={issueServiceType}
projectId={projectId}
workItemId={issueId}
workspaceSlug={workspaceSlug}
/>
</div>
);
};

View File

@@ -0,0 +1,33 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// local imports
import { IssueAttachmentItemList } from "../../attachment/attachment-item-list";
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// helper
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId, issueServiceType);
return (
<IssueAttachmentItemList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
issueServiceType={issueServiceType}
/>
);
});

View File

@@ -0,0 +1,104 @@
"use client";
import { useMemo } from "react";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
export type TAttachmentOperations = {
create: (file: File) => Promise<void>;
remove: (attachmentId: string) => Promise<void>;
};
export type TAttachmentSnapshot = {
uploadStatus: TAttachmentUploadStatus[] | undefined;
};
export type TAttachmentHelpers = {
operations: TAttachmentOperations;
snapshot: TAttachmentSnapshot;
};
export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
issueId: string,
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
): TAttachmentHelpers => {
const {
attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId },
} = useIssueDetail(issueServiceType);
const attachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (file) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
},
});
await attachmentUploadPromise;
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
payload: { id: issueId },
error: error as Error,
});
throw error;
}
},
remove: async (attachmentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
setToast({
message: "The attachment has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Attachment removed",
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
payload: { id: issueId },
error: error as Error,
});
setToast({
message: "The Attachment could not be removed",
type: TOAST_TYPE.ERROR,
title: "Attachment not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId);
return {
operations: attachmentOperations,
snapshot: { uploadStatus: attachmentsUploadStatus },
};
};

View File

@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,107 @@
"use client";
import type { FC } from "react";
import React, { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { Plus } from "lucide-react";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// local imports
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false, issueServiceType } = props;
// state
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
// file size
const { maxFileSize } = useFileSize();
// operations
const { operations: attachmentOperations } = useAttachmentOperations(
workspaceSlug,
projectId,
issueId,
issueServiceType
);
// handlers
const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
attachmentOperations
.create(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "File could not be attached. Try uploading again.",
});
})
.finally(() => {
handleFetchPropertyActivities();
setLastWidgetAction("attachments");
setIsLoading(false);
});
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
totalAttachedFiles > 1
? "Only one file can be uploaded at a time."
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
});
return;
},
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
});
return (
<div
onClick={(e) => {
// TODO: Remove extra div and move event propagation to button
e.stopPropagation();
}}
>
<button {...getRootProps()} type="button" disabled={disabled}>
<input {...getInputProps()} />
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
</div>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueAttachmentsCollapsibleContent } from "./content";
import { IssueAttachmentsCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const AttachmentsCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("attachments");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("attachments")}
title={
<IssueAttachmentsCollapsibleTitle
isOpen={isCollapsibleOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
buttonClassName="w-full"
>
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,63 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueAttachmentActionButton } from "./quick-action-button";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
const attachmentCount = issue?.attachment_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
</span>
),
[attachmentCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title={t("common.attachments")}
indicatorElement={indicatorElement}
actionItemElement={
!disabled && (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,98 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { WorkItemAdditionalWidgetCollapsibles } from "@/plane-web/components/issues/issue-detail-widgets/collapsibles";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local imports
import { AttachmentsCollapsible } from "./attachments";
import { LinksCollapsible } from "./links";
import { RelationsCollapsible } from "./relations";
import { SubIssuesCollapsible } from "./sub-issues";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
// store hooks
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId },
relation: { getRelationCountByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
const subIssues = subIssuesByIssueId(issueId);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
// render conditions
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0 && !hideWidgets?.includes("sub-work-items");
const shouldRenderRelations = issueRelationsCount > 0 && !hideWidgets?.includes("relations");
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0 && !hideWidgets?.includes("links");
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
const attachmentsCount = getAttachmentsCountByIssueId(issueId);
const shouldRenderAttachments =
attachmentsCount > 0 ||
(!!attachmentUploads && attachmentUploads.length > 0 && !hideWidgets?.includes("attachments"));
return (
<div className="flex flex-col">
{shouldRenderSubIssues && (
<SubIssuesCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderRelations && (
<RelationsCollapsible
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderLinks && (
<LinksCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderAttachments && (
<AttachmentsCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
<WorkItemAdditionalWidgetCollapsibles
disabled={disabled}
hideWidgets={hideWidgets ?? []}
issueServiceType={issueServiceType}
projectId={projectId}
workItemId={issueId}
workspaceSlug={workspaceSlug}
/>
</div>
);
});

View File

@@ -0,0 +1,205 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web imports
import { WorkItemAdditionalWidgetModals } from "@/plane-web/components/issues/issue-detail-widgets/modals";
// local imports
import { IssueLinkCreateUpdateModal } from "../issue-detail/links/create-update-link-modal";
// helpers
import { CreateUpdateIssueModal } from "../issue-modal/modal";
import { useLinkOperations } from "./links/helper";
import { useSubIssueOperations } from "./sub-issues/helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueServiceType, hideWidgets } = props;
// store hooks
const {
isIssueLinkModalOpen,
toggleIssueLinkModal: toggleIssueLinkModalStore,
setIssueLinkData,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
relationKey,
isRelationModalOpen,
setRelationKey,
setLastWidgetAction,
toggleRelationModal,
createRelation,
issueCrudOperationState,
setIssueCrudOperationState,
} = useIssueDetail(issueServiceType);
// helper hooks
const subIssueOperations = useSubIssueOperations(issueServiceType);
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
// handlers
const handleIssueCrudState = (
key: "create" | "existing",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudOperationState({
...issueCrudOperationState,
[key]: {
toggle: !issueCrudOperationState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const handleExistingIssuesModalClose = () => {
handleIssueCrudState("existing", null, null);
setLastWidgetAction("sub-work-items");
toggleSubIssuesModal(null);
};
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
subIssueOperations.addSubIssue(
workspaceSlug,
projectId,
issueId,
_issue.map((issue) => issue.id)
);
const handleCreateUpdateModalClose = () => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
setLastWidgetAction("sub-work-items");
};
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
}
};
const handleIssueLinkModalOnClose = () => {
toggleIssueLinkModalStore(false);
setLastWidgetAction("links");
setIssueLinkData(null);
};
const handleRelationOnClose = () => {
setRelationKey(null);
toggleRelationModal(null, null);
setLastWidgetAction("relations");
};
const handleExistingIssueModalOnSubmit = async (data: ISearchIssueResponse[]) => {
if (!relationKey) return;
if (data.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one work item.",
});
return;
}
await createRelation(
workspaceSlug,
projectId,
issueId,
relationKey,
data.map((i) => i.id)
);
toggleRelationModal(null, null);
};
// helpers
const createUpdateModalData: Partial<TIssue> = {
parent_id: issueCrudOperationState?.create?.parentIssueId,
project_id: projectId,
};
const existingIssuesModalSearchParams = {
sub_issue: true,
issue_id: issueCrudOperationState?.existing?.parentIssueId,
};
// render conditions
const shouldRenderExistingIssuesModal =
!hideWidgets?.includes("sub-work-items") &&
issueCrudOperationState?.existing?.toggle &&
issueCrudOperationState?.existing?.parentIssueId &&
isSubIssuesModalOpen;
const shouldRenderCreateUpdateModal =
!hideWidgets?.includes("sub-work-items") &&
issueCrudOperationState?.create?.toggle &&
issueCrudOperationState?.create?.parentIssueId &&
isCreateIssueModalOpen;
return (
<>
{!hideWidgets?.includes("links") && (
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModalOpen}
handleOnClose={handleIssueLinkModalOnClose}
linkOperations={handleLinkOperations}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderCreateUpdateModal && (
<CreateUpdateIssueModal
isOpen={issueCrudOperationState?.create?.toggle}
data={createUpdateModalData}
onClose={handleCreateUpdateModalClose}
onSubmit={handleCreateUpdateModalOnSubmit}
isProjectSelectionDisabled
/>
)}
{shouldRenderExistingIssuesModal && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudOperationState?.existing?.toggle}
handleClose={handleExistingIssuesModalClose}
searchParams={existingIssuesModalSearchParams}
handleOnSubmit={handleExistingIssuesModalOnSubmit}
/>
)}
{!hideWidgets?.includes("relations") && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
handleClose={handleRelationOnClose}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={handleExistingIssueModalOnSubmit}
workspaceLevelToggle
/>
)}
<WorkItemAdditionalWidgetModals
hideWidgets={hideWidgets ?? []}
issueServiceType={issueServiceType}
projectId={projectId}
workItemId={issueId}
workspaceSlug={workspaceSlug}
/>
</>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import type { FC } from "react";
import React from "react";
import type { TIssueServiceType } from "@plane/types";
// components
import { LinkList } from "../../issue-detail/links";
// helper
import { useLinkOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType } = props;
// helper
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
return (
<LinkList
issueId={issueId}
linkOperations={handleLinkOperations}
disabled={disabled}
issueServiceType={issueServiceType}
/>
);
};

View File

@@ -0,0 +1,82 @@
"use client";
import { useMemo } from "react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueLink, TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import type { TLinkOperations } from "../../issue-detail/links";
export const useLinkOperations = (
workspaceSlug: string,
projectId: string,
issueId: string,
issueServiceType: TIssueServiceType
): TLinkOperations => {
const { createLink, updateLink, removeLink } = useIssueDetail(issueServiceType);
// i18n
const { t } = useTranslation();
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);
setToast({
message: t("links.toasts.created.message"),
type: TOAST_TYPE.SUCCESS,
title: t("links.toasts.created.title"),
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? t("links.toasts.not_created.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_created.title"),
});
throw error;
}
},
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);
setToast({
message: t("links.toasts.updated.message"),
type: TOAST_TYPE.SUCCESS,
title: t("links.toasts.updated.title"),
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? t("links.toasts.not_updated.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_updated.title"),
});
throw error;
}
},
remove: async (linkId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeLink(workspaceSlug, projectId, issueId, linkId);
setToast({
message: t("links.toasts.removed.message"),
type: TOAST_TYPE.SUCCESS,
title: t("links.toasts.removed.title"),
});
} catch {
setToast({
message: t("links.toasts.not_removed.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_removed.title"),
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, t]
);
return handleLinkOperations;
};

View File

@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,34 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueLinksActionButton: FC<Props> = observer((props) => {
const { customButton, disabled = false, issueServiceType } = props;
// store hooks
const { toggleIssueLinkModal } = useIssueDetail(issueServiceType);
// handlers
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
};
return (
<button type="button" onClick={handleOnClick} disabled={disabled}>
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
);
});

View File

@@ -0,0 +1,52 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinksCollapsibleContent } from "./content";
import { IssueLinksCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const LinksCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("links");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("links")}
title={
<IssueLinksCollapsibleTitle
isOpen={isCollapsibleOpen}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
buttonClassName="w-full"
>
<IssueLinksCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinksActionButton } from "./quick-action-button";
type Props = {
isOpen: boolean;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, issueId, disabled, issueServiceType } = props;
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
const linksCount = issue?.link_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
</span>
),
[linksCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title={t("common.links")}
indicatorElement={indicatorElement}
actionItemElement={
!disabled && <IssueLinksActionButton issueServiceType={issueServiceType} disabled={disabled} />
}
/>
);
});

View File

@@ -0,0 +1,237 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
// helper
import { DeleteIssueModal } from "../../delete-issue-modal";
import { RelationIssueList } from "../../relations/issue-list";
import { useRelationOperations } from "./helper";
type Props = {
workspaceSlug: string;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
export type TRelationObject = {
key: TIssueRelationTypes;
i18n_label: string;
className: string;
icon: (size: number) => React.ReactElement;
placeholder: string;
};
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
// plane hooks
const { t } = useTranslation();
// state
const [issueCrudState, setIssueCrudState] = useState<{
update: TIssueCrudState;
delete: TIssueCrudState;
removeRelation: TIssueCrudState & { relationKey: string | undefined; relationIssueId: string | undefined };
}>({
update: {
toggle: false,
issueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
issueId: undefined,
issue: undefined,
},
removeRelation: {
toggle: false,
issueId: undefined,
issue: undefined,
relationKey: undefined,
relationIssueId: undefined,
},
});
// store hooks
const {
relation: { getRelationsByIssueId, removeRelation },
toggleDeleteIssueModal,
toggleCreateIssueModal,
} = useIssueDetail(issueServiceType);
// helper
const issueOperations = useRelationOperations();
const epicOperations = useRelationOperations(EIssueServiceType.EPICS);
// derived values
const relations = getRelationsByIssueId(issueId);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const handleIssueCrudState = (
key: "update" | "delete" | "removeRelation",
_issueId: string | null,
issue: TIssue | null = null,
relationKey?: TIssueRelationTypes | null,
relationIssueId?: string | null
) => {
setIssueCrudState((prevState) => ({
...prevState,
[key]: {
toggle: !prevState[key].toggle,
issueId: _issueId,
issue: issue,
relationKey: relationKey,
relationIssueId: relationIssueId,
},
}));
};
// if relations are not available, return null
if (!relations) return null;
// map relations to array
const relationsArray = (Object.keys(relations) as TIssueRelationTypes[])
.filter((relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey])
.map((relationKey) => {
const issueIds = relations[relationKey];
const issueRelationOption = ISSUE_RELATION_OPTIONS[relationKey];
return {
relationKey: relationKey,
issueIds: issueIds,
icon: issueRelationOption?.icon,
label: issueRelationOption?.i18n_label ? t(issueRelationOption?.i18n_label) : "",
className: issueRelationOption?.className,
};
});
// filter out relations with no issues
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
const shouldRenderIssueDeleteModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.issueId &&
issueCrudState.delete.issue.id;
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
<div className="flex flex-col gap-">
{filteredRelationsArray.map((relation) => (
<div key={relation.relationKey}>
<Collapsible
buttonClassName="w-full"
title={
<div className={`flex items-center gap-1 px-2.5 py-1 h-9 w-full ${relation.className}`}>
<span>{relation.icon ? relation.icon(14) : null}</span>
<span className="text-sm font-medium leading-5">{relation.label}</span>
</div>
}
defaultOpen
>
<RelationIssueList
workspaceSlug={workspaceSlug}
issueId={issueId}
relationKey={relation.relationKey}
issueIds={relation.issueIds}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
issueServiceType={issueServiceType}
/>
</Collapsible>
</div>
))}
</div>
{shouldRenderIssueDeleteModal && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () => {
if (
issueCrudState.removeRelation.issueId &&
issueCrudState.removeRelation.issue?.project_id &&
issueCrudState.removeRelation.relationKey &&
issueCrudState.removeRelation.relationIssueId
) {
await removeRelation(
workspaceSlug,
issueCrudState.removeRelation.issue.project_id,
issueCrudState.removeRelation.issueId,
issueCrudState.removeRelation.relationKey as TIssueRelationTypes,
issueCrudState.removeRelation.relationIssueId,
true
);
}
if (
issueCrudState.delete.issue &&
issueCrudState.delete.issue.id &&
issueCrudState.delete.issue.project_id
) {
const deleteOperation = !!issueCrudState.delete.issue?.is_epic
? epicOperations.remove
: issueOperations.remove;
await deleteOperation(
workspaceSlug,
issueCrudState.delete.issue?.project_id,
issueCrudState?.delete?.issue?.id
);
}
}}
isEpic={!!issueCrudState.delete.issue?.is_epic}
/>
)}
{shouldRenderIssueUpdateModal && (
<>
{!!issueCrudState?.update?.issue?.is_epic ? (
<CreateUpdateEpicModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
if (!_issue.id || !_issue.project_id) return;
await epicOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
}}
/>
) : (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
if (!_issue.id || !_issue.project_id) return;
await issueOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
}}
/>
)}
</>
)}
</>
);
});

View File

@@ -0,0 +1,85 @@
"use client";
import { useMemo } from "react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
export type TRelationIssueOperations = {
copyLink: (path: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
export const useRelationOperations = (
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
): TRelationIssueOperations => {
const { updateIssue, removeIssue } = useIssueDetail(issueServiceType);
const { t } = useTranslation();
// derived values
const entityName = issueServiceType === EIssueServiceType.ISSUES ? "Work item" : "Epic";
const issueOperations: TRelationIssueOperations = useMemo(
() => ({
copyLink: (path) => {
copyUrlToClipboard(path).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", { entity: entityName }),
});
});
},
update: async (workspaceSlug, projectId, issueId, data) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
setToast({
title: t("toast.success"),
type: TOAST_TYPE.SUCCESS,
message: t("entity.update.success", { entity: entityName }),
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.update.failed", { entity: entityName }),
});
}
},
remove: async (workspaceSlug, projectId, issueId) => {
try {
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
});
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
error: error as Error,
});
}
},
}),
[entityName, removeIssue, t, updateIssue]
);
return issueOperations;
};

View File

@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,67 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
type Props = {
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const RelationActionButton: FC<Props> = observer((props) => {
const { customButton, issueId, disabled = false, issueServiceType } = props;
const { t } = useTranslation();
// store hooks
const { toggleRelationModal, setRelationKey } = useIssueDetail(issueServiceType);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
// handlers
const handleOnClick = (relationKey: TIssueRelationTypes) => {
setRelationKey(relationKey);
toggleRelationModal(issueId, relationKey);
};
// button element
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<CustomMenu
customButton={customButtonElement}
placement="bottom-start"
disabled={disabled}
maxHeight="lg"
closeOnSelect
>
{Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => {
if (!item) return <></>;
return (
<CustomMenu.MenuItem
key={index}
onClick={() => {
handleOnClick(item.key as TIssueRelationTypes);
}}
>
<div className="flex items-center gap-2">
{item.icon(12)}
<span>{t(item.i18n_label)}</span>
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
});

View File

@@ -0,0 +1,50 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { RelationsCollapsibleContent } from "./content";
import { RelationsCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const RelationsCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("relations");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("relations")}
title={
<RelationsCollapsibleTitle
isOpen={isCollapsibleOpen}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
buttonClassName="w-full"
>
<RelationsCollapsibleContent
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local imports
import { RelationActionButton } from "./quick-action-button";
type Props = {
isOpen: boolean;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const { t } = useTranslation();
// store hook
const {
relation: { getRelationCountByIssueId },
} = useIssueDetail(issueServiceType);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
// derived values
const relationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
</span>
),
[relationsCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title={t("common.relations")}
indicatorElement={indicatorElement}
actionItemElement={
!disabled && <RelationActionButton issueId={issueId} disabled={disabled} issueServiceType={issueServiceType} />
}
/>
);
});

View File

@@ -0,0 +1,64 @@
"use client";
import type { FC } from "react";
import React from "react";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// local imports
import { IssueDetailWidgetActionButtons } from "./action-buttons";
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
renderWidgetModals?: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgets: FC<Props> = (props) => {
const {
workspaceSlug,
projectId,
issueId,
disabled,
renderWidgetModals = true,
issueServiceType,
hideWidgets,
} = props;
return (
<>
<div className="flex flex-col gap-5">
<IssueDetailWidgetActionButtons
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
/>
<IssueDetailWidgetCollapsibles
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
/>
</div>
{renderWidgetModals && (
<IssueDetailWidgetModals
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
/>
)}
</>
);
};

View File

@@ -0,0 +1,177 @@
"use client";
import type { FC } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { observer } from "mobx-react";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { CreateUpdateIssueModal } from "../../issue-modal/modal";
import { useSubIssueOperations } from "./helper";
import { SubIssuesListRoot } from "./issues-list/root";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// state
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
update: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
// store hooks
const {
toggleCreateIssueModal,
toggleDeleteIssueModal,
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
} = useIssueDetail(issueServiceType);
// helpers
const subIssueOperations = useSubIssueOperations(issueServiceType);
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// handler
const handleIssueCrudState = useCallback(
(key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue,
},
});
},
[issueCrudState]
);
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
try {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
} catch (error) {
console.error("Error fetching sub-work items:", error);
} finally {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", "");
}
}
}, [
parentIssueId,
projectId,
setSubIssueHelpers,
subIssueHelpers.issue_visibility,
subIssueOperations,
workspaceSlug,
]);
useEffect(() => {
handleFetchSubIssues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentIssueId]);
// render conditions
const shouldRenderDeleteIssueModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.parentIssueId &&
issueCrudState.delete.issue.id;
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<SubIssuesListRoot
storeType={EIssuesStoreType.PROJECT}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={6}
canEdit={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderDeleteIssueModal && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await subIssueOperations.deleteSubIssue(
workspaceSlug,
projectId,
issueCrudState?.delete?.parentIssueId as string,
issueCrudState?.delete?.issue?.id as string
)
}
isSubIssue
/>
)}
{shouldRenderUpdateIssueModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.updateSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.id,
_issue,
issueCrudState?.update?.issue,
true
);
}}
/>
)}
</>
);
});

View File

@@ -0,0 +1,102 @@
import type { FC } from "react";
import { useMemo } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { SlidersHorizontal } from "lucide-react";
// plane imports
import type { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
import { cn } from "@plane/utils";
// components
import {
FilterDisplayProperties,
FilterGroupBy,
FilterOrderBy,
FiltersDropdown,
} from "@/components/issues/issue-layouts/filters";
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
type TSubIssueDisplayFiltersProps = {
displayProperties: IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
isEpic?: boolean;
};
export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer((props) => {
const {
isEpic = false,
displayProperties,
layoutDisplayFiltersOptions,
handleDisplayPropertiesUpdate,
handleDisplayFiltersUpdate,
displayFilters,
} = props;
const isFilterApplied = useMemo(
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
[displayProperties, displayFilters]
);
return (
<>
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
<FiltersDropdown
placement="bottom-end"
menuButton={
<div
className={cn(
"p-1 rounded relative transition-all duration-200",
isFilterApplied && "bg-custom-primary-60/20"
)}
>
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
<SlidersHorizontal className="h-3.5 w-3.5 text-custom-text-100" />
</div>
}
>
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left">
{/* display properties */}
<div className="py-2">
<FilterDisplayProperties
displayProperties={displayProperties}
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
handleUpdate={handleDisplayPropertiesUpdate}
isEpic={isEpic}
/>
</div>
{/* group by */}
<div className="py-2">
<FilterGroupBy
displayFilters={displayFilters}
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
group_by: val,
})
}
ignoreGroupedFilters={[]}
/>
</div>
{/* order by */}
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
<div className="py-2">
<FilterOrderBy
selectedOrderBy={displayFilters?.order_by}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
order_by: val,
})
}
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
/>
</div>
)}
</div>
</FiltersDropdown>
)}
</>
);
});

View File

@@ -0,0 +1,168 @@
import type { FC } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { ListFilter, Search, X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import type { IIssueFilterOptions, IState } from "@plane/types";
import { cn } from "@plane/utils";
import {
FilterAssignees,
FilterDueDate,
FilterPriority,
FilterProjects,
FiltersDropdown,
FilterStartDate,
FilterState,
FilterStateGroup,
} from "@/components/issues/issue-layouts/filters";
import { isFiltersApplied } from "@/components/issues/issue-layouts/utils";
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
type TSubIssueFiltersProps = {
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
filters: IIssueFilterOptions;
memberIds: string[] | undefined;
states?: IState[];
availableFilters: (keyof IIssueFilterOptions)[];
};
export const SubIssueFilters: FC<TSubIssueFiltersProps> = observer((props) => {
const { handleFiltersUpdate, filters, memberIds, states, availableFilters } = props;
// plane hooks
const { t } = useTranslation();
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => !!availableFilters.includes(filter);
const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]);
return (
<>
<FiltersDropdown
placement="bottom-end"
menuButton={
<div
className={cn(
"p-1 rounded relative transition-all duration-200",
isFilterApplied && "bg-custom-primary-60/20"
)}
>
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
<ListFilter className="h-3.5 w-3.5 text-custom-text-100" />
</div>
}
>
<div className="flex max-h-[350px] flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder={t("common.search.label")}
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 text-left">
{/* Priority */}
{isFilterEnabled("priority") && (
<div className="py-2">
<FilterPriority
appliedFilters={filters.priority ?? null}
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state group */}
{isFilterEnabled("state_group") && (
<div className="py-2">
<FilterStateGroup
appliedFilters={filters.state_group ?? null}
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* State */}
{isFilterEnabled("state") && (
<div className="py-2">
<FilterState
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFiltersUpdate("state", val)}
searchQuery={filtersSearchQuery}
states={states}
/>
</div>
)}
{/* Projects */}
{isFilterEnabled("project") && (
<div className="py-2">
<FilterProjects
appliedFilters={filters.project ?? null}
handleUpdate={(val) => handleFiltersUpdate("project", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* work item types */}
{isFilterEnabled("issue_type") && (
<div className="py-2">
<FilterIssueTypes
appliedFilters={filters.issue_type ?? null}
handleUpdate={(val) => handleFiltersUpdate("issue_type", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Assignees */}
{isFilterEnabled("assignees") && (
<div className="py-2">
<FilterAssignees
appliedFilters={filters.assignees ?? null}
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
memberIds={memberIds}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Start Date */}
{isFilterEnabled("start_date") && (
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Target Date */}
{isFilterEnabled("target_date") && (
<div className="py-2">
<FilterDueDate
appliedFilters={filters.target_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
</div>
</div>
</FiltersDropdown>
</>
);
});

View File

@@ -0,0 +1,252 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProjectState } from "@/hooks/store/use-project-state";
// plane web helpers
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
// router
const { epicId: epicIdParam } = useParams();
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
subIssues: { setSubIssueHelpers },
createSubIssues,
fetchSubIssues,
updateSubIssue,
deleteSubIssue,
removeSubIssue,
} = useIssueDetail(issueServiceType);
const { getStateById } = useProjectState();
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
// const { updateEpicAnalytics } = useIssueTypes();
const { updateAnalytics } = updateEpicAnalytics();
// derived values
const epicId = epicIdParam || epicPeekIssue?.issueId;
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyLink: (path) => {
copyUrlToClipboard(path).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items", { count: 1 })
: t("issue.label", { count: 1 }),
}),
});
});
},
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.fetch.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items", { count: 2 })
: t("issue.label", { count: 2 }),
}),
});
}
},
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("entity.add.success", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: issueIds.length }),
}),
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.add.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: issueIds.length }),
}),
});
}
},
updateSubIssue: async (
workspaceSlug,
projectId,
parentIssueId,
issueId,
issueData,
oldIssue = {},
fromModal = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
if (issueServiceType === EIssueServiceType.EPICS) {
const oldState = getStateById(oldIssue?.state_id)?.group;
if (oldState && oldIssue && issueData && epicId) {
// Check if parent_id is changed if yes then decrement the epic analytics count
if (issueData.parent_id && oldIssue?.parent_id && issueData.parent_id !== oldIssue?.parent_id) {
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
decrementStateGroupCount: `${oldState}_issues`,
});
}
// Check if state_id is changed if yes then decrement the old state group count and increment the new state group count
if (issueData.state_id) {
const newState = getStateById(issueData.state_id)?.group;
if (oldState && newState && oldState !== newState) {
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
decrementStateGroupCount: `${oldState}_issues`,
incrementStateGroupCount: `${newState}_issues`,
});
}
}
}
}
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
payload: { id: issueId, parent_id: parentIssueId },
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("sub_work_item.update.success"),
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
payload: { id: issueId, parent_id: parentIssueId },
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("sub_work_item.update.error"),
});
}
},
removeSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
if (issueServiceType === EIssueServiceType.EPICS) {
const issueBeforeRemoval = getIssueById(issueId);
const oldState = getStateById(issueBeforeRemoval?.state_id)?.group;
if (epicId && oldState) {
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
decrementStateGroupCount: `${oldState}_issues`,
});
}
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("entity.remove.success", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: 1 }),
}),
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
payload: { id: issueId, parent_id: parentIssueId },
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
payload: { id: issueId, parent_id: parentIssueId },
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.remove.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: 1 }),
}),
});
}
},
deleteSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
return deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
payload: { id: issueId, parent_id: parentIssueId },
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
payload: { id: issueId, parent_id: parentIssueId },
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.delete.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: 1 }),
}),
});
}
},
}),
[
createSubIssues,
deleteSubIssue,
epicId,
fetchSubIssues,
getIssueById,
getStateById,
issueServiceType,
removeSubIssue,
setSubIssueHelpers,
t,
updateAnalytics,
updateSubIssue,
]
);
return subIssueOperations;
};

View File

@@ -0,0 +1,6 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
export * from "./display-filters";
export * from "./content";

View File

@@ -0,0 +1,97 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { ChevronRight, CircleDashed } from "lucide-react";
import { ALL_ISSUES } from "@plane/constants";
import type { IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { Collapsible } from "@plane/ui";
import { cn } from "@plane/utils";
import { SubIssuesListItem } from "./list-item";
interface TSubIssuesListGroupProps {
workItemIds: string[];
projectId: string;
workspaceSlug: string;
group: IGroupByColumn;
serviceType: TIssueServiceType;
canEdit: boolean;
parentIssueId: string;
rootIssueId: string;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
storeType?: EIssuesStoreType;
spacingLeft?: number;
}
export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props) => {
const {
group,
serviceType,
canEdit,
parentIssueId,
rootIssueId,
projectId,
workspaceSlug,
handleIssueCrudState,
subIssueOperations,
workItemIds,
storeType = EIssuesStoreType.PROJECT,
spacingLeft = 0,
} = props;
const isAllIssues = group.id === ALL_ISSUES;
// states
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(true);
if (!workItemIds.length) return null;
return (
<>
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => setIsCollapsibleOpen(!isCollapsibleOpen)}
title={
!isAllIssues && (
<div className="flex items-center gap-2 p-3">
<ChevronRight
className={cn("size-3.5 transition-all text-custom-text-400", {
"rotate-90": isCollapsibleOpen,
})}
strokeWidth={2.5}
/>
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
{group.icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
</div>
<span className="text-sm text-custom-text-100 font-medium">{group.name}</span>
<span className="text-sm text-custom-text-400">{workItemIds.length}</span>
</div>
)
}
buttonClassName={cn("hidden", !isAllIssues && "block")}
>
{workItemIds?.map((workItemId) => (
<SubIssuesListItem
key={workItemId}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={workItemId}
canEdit={canEdit}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={serviceType}
spacingLeft={spacingLeft}
storeType={storeType}
/>
))}
</Collapsible>
</>
);
});

View File

@@ -0,0 +1,266 @@
"use client";
import { observer } from "mobx-react";
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
import { ControlLink, CustomMenu } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// helpers
import { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper";
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local components
import { SubIssuesListItemProperties } from "./properties";
import { SubIssuesListRoot } from "./root";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
canEdit: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueId: string;
issueServiceType?: TIssueServiceType;
storeType?: EIssuesStoreType;
};
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
issueId,
spacingLeft = 10,
canEdit,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
storeType = EIssuesStoreType.PROJECT,
} = props;
const { t } = useTranslation();
const {
issue: { getIssueById },
subIssues: {
filters: { getSubIssueFilters },
},
} = useIssueDetail(issueServiceType);
const {
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
} = useIssueDetail();
const { fetchSubIssues } = useSubIssueOperations(EIssueServiceType.ISSUES);
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(issueServiceType);
const project = useProject();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
const issue = getIssueById(issueId);
// derived values
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId);
const subIssueCount = issue?.sub_issues_count ?? 0;
// derived values
const subIssueFilters = getSubIssueFilters(parentIssueId);
const displayProperties = subIssueFilters?.displayProperties ?? {};
//
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
if (!issue) return <></>;
// check if current issue is the root issue
const isCurrentIssueRoot = issueId === rootIssueId;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetail?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<div key={issueId}>
<ControlLink
id={`issue-${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
>
{issue && (
<div
className="group relative flex min-h-11 h-full w-full items-center pr-2 py-1 transition-all hover:bg-custom-background-90"
style={{ paddingLeft: `${spacingLeft}px` }}
>
<div className="flex size-5 items-center justify-center flex-shrink-0">
{/* disable the chevron when current issue is also the root issue*/}
{subIssueCount > 0 && !isCurrentIssueRoot && (
<>
{subIssueHelpers.preview_loader.includes(issue.id) ? (
<div className="flex h-full w-full cursor-not-allowed items-center justify-center rounded-sm bg-custom-background-80 transition-all">
<Loader width={14} strokeWidth={2} className="animate-spin" />
</div>
) : (
<div
className="flex h-full w-full cursor-pointer items-center justify-center text-custom-text-400 hover:text-custom-text-300"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!subIssueHelpers.issue_visibility.includes(issueId)) {
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
await fetchSubIssues(workspaceSlug, projectId, issueId);
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
}
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
}}
>
<ChevronRight
className={cn("size-3.5 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
})}
strokeWidth={2.5}
/>
</div>
)}
</>
)}
</div>
<div className="flex w-full truncate cursor-pointer items-center gap-3">
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
</WithDisplayPropertiesHOC>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span className="w-full truncate text-sm text-custom-text-100">{issue.name}</span>
</Tooltip>
</div>
<div
className="flex-shrink-0 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<SubIssuesListItemProperties
workspaceSlug={workspaceSlug}
parentIssueId={parentIssueId}
issueId={issueId}
canEdit={canEdit}
updateSubIssue={subIssueOperations.updateSubIssue}
displayProperties={displayProperties}
issue={issue}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("update", parentIssueId, { ...issue });
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.edit")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
subIssueOperations.copyLink(workItemLink);
}}
>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.copy_link")}</span>
</div>
</CustomMenu.MenuItem>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
if (issue.project_id)
subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
}}
>
<div className="flex items-center gap-2">
<X className="h-3.5 w-3.5" strokeWidth={2} />
{issueServiceType === EIssueServiceType.ISSUES
? t("issue.remove.parent.label")
: t("issue.remove.label")}
</div>
</CustomMenu.MenuItem>
)}
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("delete", parentIssueId, issue);
toggleDeleteIssueModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.delete.label")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</div>
)}
</ControlLink>
{/* should not expand the current issue if it is also the root issue*/}
{subIssueHelpers.issue_visibility.includes(issueId) &&
issue.project_id &&
subIssueCount > 0 &&
!isCurrentIssueRoot && (
<SubIssuesListRoot
storeType={storeType}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}
rootIssueId={rootIssueId}
spacingLeft={spacingLeft + 22}
canEdit={canEdit}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,220 @@
// plane imports
import type { SyntheticEvent } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { CalendarCheck2, CalendarClock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import type { IIssueDisplayProperties, TIssue } from "@plane/types";
import { getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
// components
import { DateDropdown } from "@/components/dropdowns/date";
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
// hooks
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { useProjectState } from "@/hooks/store/use-project-state";
type Props = {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
canEdit: boolean;
updateSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue?: Partial<TIssue>
) => Promise<void>;
displayProperties?: IIssueDisplayProperties;
issue: TIssue;
};
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, parentIssueId, issueId, canEdit, updateSubIssue, displayProperties, issue } = props;
const { t } = useTranslation();
const { getStateById } = useProjectState();
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
};
const handleStartDate = (date: Date | null) => {
if (issue.project_id) {
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
start_date: date ? renderFormattedPayloadDate(date) : null,
});
}
};
const handleTargetDate = (date: Date | null) => {
if (issue.project_id) {
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
target_date: date ? renderFormattedPayloadDate(date) : null,
});
}
};
//derived values
const stateDetails = useMemo(() => getStateById(issue.state_id), [getStateById, issue.state_id]);
const shouldHighlight = useMemo(
() => shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
[issue.target_date, stateDetails?.group]
);
// date range is enabled only when both dates are available and both dates are enabled
const isDateRangeEnabled: boolean = Boolean(
issue.start_date && issue.target_date && displayProperties?.start_date && displayProperties?.due_date
);
if (!displayProperties) return <></>;
const maxDate = getDate(issue.target_date);
const minDate = getDate(issue.start_date);
return (
<div className="relative flex items-center gap-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!canEdit}
buttonVariant="transparent-without-text"
buttonClassName="hover:bg-transparent px-0"
iconSize="size-5"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!canEdit}
buttonVariant="border-without-text"
buttonClassName="border"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* merged dates */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={() => isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
value={{
from: getDate(issue.start_date) || undefined,
to: getDate(issue.target_date) || undefined,
}}
onSelect={(range) => {
handleStartDate(range?.from ?? null);
handleTargetDate(range?.to ?? null);
}}
hideIcon={{
from: false,
}}
isClearable
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
disabled={!canEdit}
showTooltip
customTooltipHeading="Date Range"
renderPlaceholder={false}
/>
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="start_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5">
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!canEdit}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* target/due date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="due_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5">
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
clearIconClassName="text-custom-text-100"
optionsClassName="z-30"
disabled={!canEdit}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
assignee_ids: val,
})
}
disabled={!canEdit}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</WithDisplayPropertiesHOC>
</div>
);
});

View File

@@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { ListFilter } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
// hooks
import { SectionEmptyState } from "@/components/empty-state/section-empty-state-root";
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { SubIssuesListGroup } from "./list-group";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
canEdit: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
storeType: EIssuesStoreType;
};
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
canEdit,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
storeType = EIssuesStoreType.PROJECT,
spacingLeft = 0,
} = props;
const { t } = useTranslation();
// store hooks
const {
subIssues: {
subIssuesByIssueId,
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
},
} = useIssueDetail(issueServiceType);
// derived values
const filters = getSubIssueFilters(rootIssueId);
const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]);
const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null;
const filteredSubWorkItemsCount = (getFilteredSubWorkItems(rootIssueId, filters.filters ?? {}) ?? []).length;
const groups = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
isEpic: issueServiceType === EIssueServiceType.EPICS,
projectId,
});
const getWorkItemIds = useCallback(
(groupId: string) => {
if (isRootLevel) {
const groupedSubIssues = getGroupedSubWorkItems(rootIssueId);
return groupedSubIssues?.[groupId] ?? [];
}
const subIssueIds = subIssuesByIssueId(parentIssueId);
return subIssueIds ?? [];
},
[isRootLevel, subIssuesByIssueId, rootIssueId, getGroupedSubWorkItems, parentIssueId]
);
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
return (
<div className="relative">
{isRootLevel && filteredSubWorkItemsCount === 0 ? (
<SectionEmptyState
title={
!isSubWorkItems
? t("sub_work_item.empty_state.list_filters.title")
: t("sub_work_item.empty_state.sub_list_filters.title")
}
description={
!isSubWorkItems
? t("sub_work_item.empty_state.list_filters.description")
: t("sub_work_item.empty_state.sub_list_filters.description")
}
icon={<ListFilter />}
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
actionElement={
<Button variant="neutral-primary" size="sm" onClick={() => resetFilters(rootIssueId)}>
{t("sub_work_item.empty_state.list_filters.action")}
</Button>
}
/>
) : (
groups?.map((group) => (
<SubIssuesListGroup
key={group.id}
workItemIds={getWorkItemIds(group.id)}
projectId={projectId}
workspaceSlug={workspaceSlug}
group={group}
serviceType={issueServiceType}
canEdit={canEdit}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
storeType={storeType}
spacingLeft={spacingLeft}
/>
))
)}
</div>
);
});

View File

@@ -0,0 +1,103 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { WorkItemsIcon } from "@plane/propel/icons";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const SubIssuesActionButton: FC<Props> = observer((props) => {
const { issueId, customButton, disabled = false, issueServiceType } = props;
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
toggleCreateIssueModal,
toggleSubIssuesModal,
setIssueCrudOperationState,
issueCrudOperationState,
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
// handlers
const handleIssueCrudState = (
key: "create" | "existing",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudOperationState({
...issueCrudOperationState,
[key]: {
toggle: !issueCrudOperationState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const handleCreateNew = () => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.create });
handleIssueCrudState("create", issueId, null);
toggleCreateIssueModal(true);
};
const handleAddExisting = () => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.add_existing });
handleIssueCrudState("existing", issueId, null);
toggleSubIssuesModal(issue.id);
};
// options
const optionItems = [
{
i18n_label: "common.create_new",
icon: <Plus className="h-3 w-3" />,
onClick: handleCreateNew,
},
{
i18n_label: "common.add_existing",
icon: <WorkItemsIcon className="h-3 w-3" />,
onClick: handleAddExisting,
},
];
// button element
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{optionItems.map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={() => {
item.onClick();
}}
>
<div className="flex items-center gap-2">
{item.icon}
<span>{t(item.i18n_label)}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
});

View File

@@ -0,0 +1,53 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { SubIssuesCollapsibleContent } from "./content";
import { SubIssuesCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const SubIssuesCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("sub-work-items");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("sub-work-items")}
title={
<SubIssuesCollapsibleTitle
isOpen={isCollapsibleOpen}
parentIssueId={issueId}
disabled={disabled}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
}
buttonClassName="w-full"
>
<SubIssuesCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,113 @@
import type { FC } from "react";
import { useCallback } from "react";
import { cloneDeep } from "lodash-es";
import { observer } from "mobx-react";
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE,
} from "@plane/constants";
import type {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssueServiceType,
} from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProjectState } from "@/hooks/store/use-project-state";
import { SubIssueDisplayFilters } from "./display-filters";
import { SubIssueFilters } from "./filters";
import { SubIssuesActionButton } from "./quick-action-button";
type TSubWorkItemTitleActionsProps = {
disabled: boolean;
issueServiceType?: TIssueServiceType;
parentId: string;
projectId: string;
};
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, projectId } = props;
// store hooks
const {
subIssues: {
filters: { getSubIssueFilters, updateSubWorkItemFilters },
},
} = useIssueDetail(issueServiceType);
const { getProjectStates } = useProjectState();
const {
project: { getProjectMemberIds },
} = useMember();
// derived values
const projectStates = getProjectStates(projectId);
const projectMemberIds = getProjectMemberIds(projectId, false);
const subIssueFilters = getSubIssueFilters(parentId);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list;
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
},
[updateSubWorkItemFilters, parentId]
);
const handleDisplayPropertiesUpdate = useCallback(
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
},
[updateSubWorkItemFilters, parentId]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
},
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
);
return (
// prevent click everywhere
<div
className="flex gap-2 items-center"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<SubIssueDisplayFilters
isEpic={issueServiceType === EIssueServiceType.EPICS}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayProperties={subIssueFilters?.displayProperties ?? {}}
displayFilters={subIssueFilters?.displayFilters ?? {}}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
handleDisplayFiltersUpdate={handleDisplayFilters}
/>
<SubIssueFilters
handleFiltersUpdate={handleFiltersUpdate}
filters={subIssueFilters?.filters ?? {}}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE}
/>
{!disabled && (
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
)}
</div>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { SubWorkItemTitleActions } from "./title-actions";
type Props = {
isOpen: boolean;
parentIssueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
projectId: string;
workspaceSlug: string;
};
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const {
isOpen,
parentIssueId,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
workspaceSlug,
} = props;
// translation
const { t } = useTranslation();
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
// if there are no sub-issues, return null
if (!subIssues) return null;
// calculate percentage of completed sub-issues
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
const totalCount = subIssues.length;
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
return (
<CollapsibleButton
isOpen={isOpen}
title={`${issueServiceType === EIssueServiceType.EPICS ? t("issue.label", { count: 1 }) : t("common.sub_work_items")}`}
indicatorElement={
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
<span>
{completedCount}/{totalCount} {t("common.done")}
</span>
</div>
}
actionItemElement={
<SubWorkItemTitleActions
projectId={projectId}
parentId={parentIssueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
/>
);
});

View File

@@ -0,0 +1,29 @@
"use client";
import type { FC } from "react";
import React from "react";
// helpers
import { cn } from "@plane/utils";
type Props = {
icon: React.ReactNode;
title: string;
disabled?: boolean;
};
export const IssueDetailWidgetButton: FC<Props> = (props) => {
const { icon, title, disabled = false } = props;
return (
<div
className={cn(
"h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 rounded px-3 py-1.5",
{
"cursor-not-allowed text-custom-text-400 bg-custom-background-90": disabled,
"cursor-pointer text-custom-text-300 hover:bg-custom-background-80": !disabled,
}
)}
>
{icon && icon}
<span className="text-sm font-medium">{title}</span>
</div>
);
};

View File

@@ -0,0 +1,62 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// hooks
// components
import { cn } from "@plane/utils";
import { CycleDropdown } from "@/components/dropdowns/cycle";
// ui
// helpers
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TIssueOperations } from "./root";
type TIssueCycleSelect = {
className?: string;
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled?: boolean;
};
export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) => {
const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
const { t } = useTranslation();
// states
const [isUpdating, setIsUpdating] = useState(false);
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const disableSelect = disabled || isUpdating;
const handleIssueCycleChange = async (cycleId: string | null) => {
if (!issue || issue.cycle_id === cycleId) return;
setIsUpdating(true);
if (cycleId) await issueOperations.addCycleToIssue?.(workspaceSlug, projectId, cycleId, issueId);
else await issueOperations.removeIssueFromCycle?.(workspaceSlug, projectId, issue.cycle_id ?? "", issueId);
setIsUpdating(false);
};
return (
<div className={cn("flex h-full items-center gap-1", className)}>
<CycleDropdown
value={issue?.cycle_id ?? null}
onChange={handleIssueCycleChange}
projectId={projectId}
disabled={disableSelect}
buttonVariant="transparent-with-text"
className="group w-full"
buttonContainerClassName="w-full text-left rounded"
buttonClassName={`text-sm justify-between ${issue?.cycle_id ? "" : "text-custom-text-400"}`}
placeholder={t("cycle.no_cycle")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,103 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import type { E_SORT_ORDER, TActivityFilters } from "@plane/constants";
import { EActivityFilterType, filterActivityOnSelectedFilters } from "@plane/constants";
import type { TCommentsOperations } from "@plane/types";
// components
import { CommentCard } from "@/components/comments/card/root";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web components
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues/issue-details/issue-properties-activity";
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
// local imports
import { IssueActivityItem } from "./activity/activity-list";
import { IssueActivityLoader } from "./loader";
type TIssueActivityCommentRoot = {
workspaceSlug: string;
projectId: string;
isIntakeIssue: boolean;
issueId: string;
selectedFilters: TActivityFilters[];
activityOperations: TCommentsOperations;
showAccessSpecifier?: boolean;
disabled?: boolean;
sortOrder: E_SORT_ORDER;
};
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
const {
workspaceSlug,
isIntakeIssue,
issueId,
selectedFilters,
activityOperations,
showAccessSpecifier,
projectId,
disabled,
sortOrder,
} = props;
// store hooks
const {
activity: { getActivityAndCommentsByIssueId },
comment: { getCommentById },
} = useIssueDetail();
// derived values
const activityAndComments = getActivityAndCommentsByIssueId(issueId, sortOrder);
if (!activityAndComments) return <IssueActivityLoader />;
if (activityAndComments.length <= 0) return null;
const filteredActivityAndComments = filterActivityOnSelectedFilters(activityAndComments, selectedFilters);
const BASE_ACTIVITY_FILTER_TYPES = [
EActivityFilterType.ACTIVITY,
EActivityFilterType.STATE,
EActivityFilterType.ASSIGNEE,
EActivityFilterType.DEFAULT,
];
return (
<div>
{filteredActivityAndComments.map((activityComment, index) => {
const comment = getCommentById(activityComment.id);
return activityComment.activity_type === "COMMENT" ? (
<CommentCard
key={activityComment.id}
workspaceSlug={workspaceSlug}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={!!showAccessSpecifier}
showCopyLinkOption={!isIntakeIssue}
disabled={disabled}
projectId={projectId}
/>
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
<IssueActivityItem
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
<IssueAdditionalPropertiesActivity
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "WORKLOG" ? (
<IssueActivityWorklog
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
activityComment={activityComment}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : (
<></>
);
})}
</div>
);
});

View File

@@ -0,0 +1,68 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Check, ListFilter } from "lucide-react";
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PopoverMenu } from "@plane/ui";
// helper
import { cn } from "@plane/utils";
// constants
type TActivityFilter = {
selectedFilters: TActivityFilters[];
filterOptions: TActivityFilterOption[];
};
export const ActivityFilter: FC<TActivityFilter> = observer((props) => {
const { selectedFilters = [], filterOptions } = props;
// hooks
const { t } = useTranslation();
return (
<PopoverMenu
buttonClassName="outline-none"
button={
<Button
variant="neutral-primary"
size="sm"
prependIcon={<ListFilter className="h-3 w-3" />}
className="relative"
>
<span className="text-custom-text-200">{t("common.filters")}</span>
{selectedFilters.length < filterOptions.length && (
<span className="absolute h-2 w-2 -right-0.5 -top-0.5 bg-custom-primary-100 rounded-full" />
)}
</Button>
}
panelClassName="p-2 rounded-md border border-custom-border-200 bg-custom-background-100"
data={filterOptions}
keyExtractor={(item) => item.key}
render={(item) => (
<div
key={item.key}
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={item.onClick}
>
<div
className={cn(
"flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all bg-custom-background-90",
{
"bg-custom-primary text-white": item.isSelected,
"bg-custom-background-80 text-custom-text-400": item.isSelected && selectedFilters.length === 1,
"bg-custom-background-90": !item.isSelected,
}
)}
>
{item.isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<div className={cn("whitespace-nowrap", item.isSelected ? "text-custom-text-100" : "text-custom-text-200")}>
{t(item.labelTranslationKey)}
</div>
</div>
)}
/>
);
});

View File

@@ -0,0 +1,42 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { RotateCcw } from "lucide-react";
// hooks
import { ArchiveIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// ui
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={
activity.new_value === "restore" ? (
<RotateCcw className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
) : (
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
)
}
activityId={activityId}
ends={ends}
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
>
{activity.new_value === "restore" ? "restored the work item" : "archived the work item"}.
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,43 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// icons
import { Users } from "lucide-react";
// hooks;
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueAssigneeActivity: FC<TIssueAssigneeActivity> = observer((props) => {
const { activityId, ends, showIssue = true } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Users className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
{activity.old_value === "" ? `added a new assignee ` : `removed the assignee `}
<a
href={`/${activity.workspace_detail?.slug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center font-medium text-custom-text-100 hover:underline capitalize"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
</a>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Paperclip } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueAttachmentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueAttachmentActivity: FC<TIssueAttachmentActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Paperclip size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? `uploaded a new attachment` : `removed an attachment`}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { CycleIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// icons
type TIssueCycleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueCycleActivity: FC<TIssueCycleActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CycleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this work item to the cycle </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
</a>
</>
) : activity.verb === "updated" ? (
<>
<span>set the cycle to </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
) : (
<>
<span>removed the work item from the cycle </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,50 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { WorkItemsIcon } from "@plane/propel/icons";
import { EInboxIssueSource } from "@plane/types";
// hooks
import { capitalizeFirstLetter } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueActivityBlockComponent } from "./";
type TIssueDefaultActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueDefaultActivity: FC<TIssueDefaultActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
const source = activity.source_data?.source;
return (
<IssueActivityBlockComponent
activityId={activityId}
icon={<WorkItemsIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />}
ends={ends}
>
<>
{activity.verb === "created" ? (
source && source !== EInboxIssueSource.IN_APP ? (
<span>
created the work item via{" "}
<span className="font-medium">{capitalizeFirstLetter(source.toLowerCase() || "")}</span>.
</span>
) : (
<span> created the work item.</span>
)
) : (
<span> deleted a work item.</span>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { AlignLeft } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueDescriptionActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueDescriptionActivity: FC<TIssueDescriptionActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<AlignLeft size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
updated the description
{showIssue ? ` of ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,36 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Triangle } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueEstimateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Triangle size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the estimate point to ` : `removed the estimate point`}
{activity.new_value ? activity.new_value : activity?.old_value}
{showIssue && (activity.new_value ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,61 @@
"use client";
import type { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { IssueCreatorDisplay } from "@/plane-web/components/issues/issue-details/issue-creator";
// local imports
import { IssueUser } from "../";
type TIssueActivityBlockComponent = {
icon?: ReactNode;
activityId: string;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
const { icon, activityId, ends, children, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
const { isMobile } = usePlatformOS();
if (!activity) return <></>;
return (
<div
className={`relative flex items-center gap-3 text-xs ${
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
}`}
>
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-center z-[4] bg-custom-background-80 text-custom-text-200">
{icon ? icon : <Network className="w-3.5 h-3.5" />}
</div>
<div className="w-full truncate text-custom-text-200">
{!activity?.field && activity?.verb === "created" ? (
<IssueCreatorDisplay activityId={activityId} customUserName={customUserName} />
) : (
<IssueUser activityId={activityId} customUserName={customUserName} />
)}
<span> {children} </span>
<span>
<Tooltip
isMobile={isMobile}
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap text-custom-text-350"> {calculateTimeAgo(activity.created_at)}</span>
</Tooltip>
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
"use client";
import type { FC } from "react";
// hooks
import { Tooltip } from "@plane/propel/tooltip";
import { generateWorkItemLink } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
type TIssueLink = {
activityId: string;
};
export const IssueLink: FC<TIssueLink> = (props) => {
const { activityId } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const { isMobile } = usePlatformOS();
const activity = getActivityById(activityId);
if (!activity) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug: activity.workspace_detail?.slug,
projectId: activity.project,
issueId: activity.issue,
projectIdentifier: activity.project_detail.identifier,
sequenceId: activity.issue_detail.sequence_id,
});
return (
<Tooltip
tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
isMobile={isMobile}
>
<a
aria-disabled={activity.issue === null}
href={`${activity.issue_detail ? workItemLink : "#"}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Work items"}{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
</a>
</Tooltip>
);
};

View File

@@ -0,0 +1,36 @@
import type { FC } from "react";
import Link from "next/link";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type TIssueUser = {
activityId: string;
customUserName?: string;
};
export const IssueUser: FC<TIssueUser> = (props) => {
const { activityId, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<>
{customUserName ? (
<span className="text-custom-text-100 font-medium">{customUserName}</span>
) : (
<Link
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="hover:underline text-custom-text-100 font-medium"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
);
};

View File

@@ -0,0 +1,46 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { IntakeIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// icons
type TIssueInboxActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueInboxActivity: FC<TIssueInboxActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
const getInboxActivityMessage = () => {
switch (activity?.verb) {
case "-1":
return "declined this work item from intake.";
case "0":
return "snoozed this work item.";
case "1":
return "accepted this work item from intake.";
case "2":
return "declined this work item from intake by marking a duplicate work item.";
default:
return "updated intake work item status.";
}
};
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<IntakeIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>{getInboxActivityMessage()}</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,24 @@
export * from "./default";
export * from "./name";
export * from "./description";
export * from "./state";
export * from "./assignee";
export * from "./priority";
export * from "./estimate";
export * from "./parent";
export * from "./relation";
export * from "./start_date";
export * from "./target_date";
export * from "./cycle";
export * from "./module";
export * from "./label";
export * from "./link";
export * from "./attachment";
export * from "./archived-at";
export * from "./inbox";
export * from "./label-activity-chip";
// helpers
export * from "./helpers/activity-block";
export * from "./helpers/issue-user";
export * from "./helpers/issue-link";

View File

@@ -0,0 +1,22 @@
import type { FC } from "react";
import { Tooltip } from "@plane/propel/tooltip";
type TIssueLabelPill = { name?: string; color?: string };
export const LabelActivityChip: FC<TIssueLabelPill> = (props) => {
const { name, color } = props;
return (
<Tooltip tooltipContent={name}>
<span className="inline-flex w-min max-w-32 cursor-default flex-shrink-0 items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: color ?? "#000000",
}}
aria-hidden="true"
/>
<span className="flex-shrink truncate font-medium text-custom-text-100">{name}</span>
</span>
</Tooltip>
);
};

View File

@@ -0,0 +1,42 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Tag } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useLabel } from "@/hooks/store/use-label";
// components
import { IssueActivityBlockComponent, IssueLink, LabelActivityChip } from "./";
type TIssueLabelActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const { getLabelById } = useLabel();
const activity = getActivityById(activityId);
const oldLabelColor = getLabelById(activity?.old_identifier ?? "")?.color;
const newLabelColor = getLabelById(activity?.new_identifier ?? "")?.color;
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Tag size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.old_value === "" ? `added a new label ` : `removed the label `}
<LabelActivityChip
name={activity.old_value === "" ? activity.new_value : activity.old_value}
color={activity.old_value === "" ? newLabelColor : oldLabelColor}
/>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,70 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueLinkActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueLinkActivity: FC<TIssueLinkActivity> = observer((props) => {
const { activityId, showIssue = false, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added </span>
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
</a>
</>
) : activity.verb === "updated" ? (
<>
<span>updated the </span>
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
</a>
</>
) : (
<>
<span>removed this </span>
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
</a>
</>
)}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { ModuleIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// icons
type TIssueModuleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueModuleActivity: FC<TIssueModuleActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ModuleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this work item to the module </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
</a>
</>
) : activity.verb === "updated" ? (
<>
<span>set the module to </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
) : (
<>
<span>removed the work item from the module </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.old_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,30 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Type } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueNameActivity: FC<TIssueNameActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Type size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>set the name to {activity.new_value}.</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { LayoutPanelTop } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueParentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueParentActivity: FC<TIssueParentActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<LayoutPanelTop size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the parent to ` : `removed the parent `}
{activity.new_value ? (
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
) : (
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Signal } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssuePriorityActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssuePriorityActivity: FC<TIssuePriorityActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Signal size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
set the priority to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { getRelationActivityContent, useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
//
import { IssueActivityBlockComponent } from "./";
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueRelationActivity: FC<TIssueRelationActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const activityContent = getRelationActivityContent(activity);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes]?.icon(14) : <></>}
activityId={activityId}
ends={ends}
>
{activityContent}
{activity.old_value === "" ? (
<span className="font-medium text-custom-text-100">{activity.new_value}.</span>
) : (
<span className="font-medium text-custom-text-100">{activity.old_value}.</span>
)}
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,41 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { CalendarDays } from "lucide-react";
// hooks
import { renderFormattedDate } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// helpers
type TIssueStartDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueStartDateActivity: FC<TIssueStartDateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the start date to ` : `removed the start date `}
{activity.new_value && (
<>
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,37 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { DoubleCircleIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// icons
type TIssueStateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueStateActivity: FC<TIssueStateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,41 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { CalendarDays } from "lucide-react";
// hooks
import { renderFormattedDate } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// helpers
type TIssueTargetDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueTargetDateActivity: FC<TIssueTargetDateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the due date to ` : `removed the due date `}
{activity.new_value && (
<>
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,95 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// helpers
import { getValidKeysFromObject } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web components
import { IssueTypeActivity, AdditionalActivityRoot } from "@/plane-web/components/issues/issue-details";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local components
import {
IssueDefaultActivity,
IssueNameActivity,
IssueDescriptionActivity,
IssueStateActivity,
IssueAssigneeActivity,
IssuePriorityActivity,
IssueEstimateActivity,
IssueParentActivity,
IssueRelationActivity,
IssueStartDateActivity,
IssueTargetDateActivity,
IssueCycleActivity,
IssueModuleActivity,
IssueLabelActivity,
IssueLinkActivity,
IssueAttachmentActivity,
IssueArchivedAtActivity,
IssueInboxActivity,
} from "./actions";
type TIssueActivityItem = {
activityId: string;
ends: "top" | "bottom" | undefined;
};
export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
comment: {},
} = useIssueDetail();
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const activityRelations = getValidKeysFromObject(ISSUE_RELATION_OPTIONS);
const componentDefaultProps = { activityId, ends };
const activityField = getActivityById(activityId)?.field;
switch (activityField) {
case null: // default issue creation
return <IssueDefaultActivity {...componentDefaultProps} />;
case "state":
return <IssueStateActivity {...componentDefaultProps} showIssue={false} />;
case "name":
return <IssueNameActivity {...componentDefaultProps} />;
case "description":
return <IssueDescriptionActivity {...componentDefaultProps} showIssue={false} />;
case "assignees":
return <IssueAssigneeActivity {...componentDefaultProps} showIssue={false} />;
case "priority":
return <IssuePriorityActivity {...componentDefaultProps} showIssue={false} />;
case "estimate_points":
case "estimate_categories":
case "estimate_point" /* This case is to handle all the older recorded activities for estimates. Field changed from "estimate_point" -> `estimate_${estimate_type}`*/:
return <IssueEstimateActivity {...componentDefaultProps} showIssue={false} />;
case "parent":
return <IssueParentActivity {...componentDefaultProps} showIssue={false} />;
case activityRelations.find((field) => field === activityField):
return <IssueRelationActivity {...componentDefaultProps} />;
case "start_date":
return <IssueStartDateActivity {...componentDefaultProps} showIssue={false} />;
case "target_date":
return <IssueTargetDateActivity {...componentDefaultProps} showIssue={false} />;
case "cycles":
return <IssueCycleActivity {...componentDefaultProps} />;
case "modules":
return <IssueModuleActivity {...componentDefaultProps} />;
case "labels":
return <IssueLabelActivity {...componentDefaultProps} showIssue={false} />;
case "link":
return <IssueLinkActivity {...componentDefaultProps} showIssue={false} />;
case "attachment":
return <IssueAttachmentActivity {...componentDefaultProps} showIssue={false} />;
case "archived_at":
return <IssueArchivedAtActivity {...componentDefaultProps} />;
case "intake":
case "inbox":
return <IssueInboxActivity {...componentDefaultProps} />;
case "type":
return <IssueTypeActivity {...componentDefaultProps} />;
default:
return <AdditionalActivityRoot {...componentDefaultProps} field={activityField} />;
}
});

View File

@@ -0,0 +1,195 @@
import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { TCommentsOperations } from "@plane/types";
import { copyUrlToClipboard, formatTextList, generateWorkItemLink } from "@plane/utils";
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
export const useCommentOperations = (
workspaceSlug: string | undefined,
projectId: string | undefined,
issueId: string | undefined
): TCommentsOperations => {
// store hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
createComment,
updateComment,
removeComment,
createCommentReaction,
removeCommentReaction,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { getUserDetails } = useMember();
const { uploadEditorAsset } = useEditorAsset();
const { data: currentUser } = useUser();
// derived values
const issueDetails = issueId ? getIssueById(issueId) : undefined;
const projectDetails = projectId ? getProjectById(projectId) : undefined;
// translation
const { t } = useTranslation();
const operations: TCommentsOperations = useMemo(() => {
// Define operations object with all methods
const ops: TCommentsOperations = {
copyCommentLink: (id) => {
if (!workspaceSlug || !issueDetails) return;
try {
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails.project_id,
issueId,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails.sequence_id,
});
const commentLink = `${workItemLink}#comment-${id}`;
copyUrlToClipboard(commentLink).then(() => {
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.copy_link.success"),
});
});
} catch (error) {
console.error("Error in copying comment link:", error);
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.copy_link.error"),
});
}
},
createComment: async (data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
const comment = await createComment(workspaceSlug, projectId, issueId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.create.success"),
});
return comment;
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.create.error"),
});
}
},
updateComment: async (commentId, data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.update.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.update.error"),
});
}
},
removeComment: async (commentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeComment(workspaceSlug, projectId, issueId, commentId);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.remove.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.remove.error"),
});
}
},
uploadCommentAsset: async (blockId, file, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await uploadEditorAsset({
blockId,
data: {
entity_identifier: commentId ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
throw new Error(t("issue.comments.upload.error"));
}
},
addCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction created successfully",
});
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction creation failed",
});
}
},
deleteCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction removed successfully",
});
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction remove failed",
});
}
},
react: async (commentId, reactionEmoji, userReactions) => {
if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji);
else await ops.addCommentReaction(commentId, reactionEmoji);
},
reactionIds: (commentId) => getCommentReactionsByCommentId(commentId),
userReactions: (commentId) =>
currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [],
getReactionUsers: (reaction, reactionIds) => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
},
};
return ops;
}, [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]);
return operations;
};

View File

@@ -0,0 +1,10 @@
export * from "./root";
export * from "./activity-comment-root";
// activity
export * from "./activity/activity-list";
export * from "./activity-filter";
// sort
export * from "./sort-root";

View File

@@ -0,0 +1,31 @@
// plane imports
import { Loader } from "@plane/ui";
export const IssueActivityLoader = () => (
<Loader className="space-y-8">
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="40%" />
<Loader.Item height="8px" width="60%" />
<Loader.Item height="10px" width="80%" />
</div>
</div>
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
</Loader>
);

View File

@@ -0,0 +1,150 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import uniq from "lodash-es/uniq";
import { observer } from "mobx-react";
// plane package imports
import type { TActivityFilters } from "@plane/constants";
import { E_SORT_ORDER, defaultActivityFilters, EUserPermissions } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// i18n
import { useTranslation } from "@plane/i18n";
//types
import type { TFileSignedURLResponse, TIssueComment } from "@plane/types";
// components
import { CommentCreate } from "@/components/comments/comment-create";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
// plane web components
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
import { IssueActivityCommentRoot } from "./activity-comment-root";
import { useCommentOperations } from "./helper";
import { ActivitySortRoot } from "./sort-root";
type TIssueActivity = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
isIntakeIssue?: boolean;
};
export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<TIssueComment>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
};
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
// i18n
const { t } = useTranslation();
// hooks
const { setValue: setFilterValue, storedValue: selectedFilters } = useLocalStorage(
"issue_activity_filters",
defaultActivityFilters
);
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC);
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
const currentUserProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isAdmin = currentUserProjectRole === EUserPermissions.ADMIN;
const isGuest = currentUserProjectRole === EUserPermissions.GUEST;
const isAssigned = issue?.assignee_ids && currentUser?.id ? issue?.assignee_ids.includes(currentUser?.id) : false;
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned);
// toggle filter
const toggleFilter = (filter: TActivityFilters) => {
if (!selectedFilters) return;
let _filters = [];
if (selectedFilters.includes(filter)) {
if (selectedFilters.length === 1) return selectedFilters; // Ensure at least one filter is applied
_filters = selectedFilters.filter((f) => f !== filter);
} else {
_filters = [...selectedFilters, filter];
}
setFilterValue(uniq(_filters));
};
const toggleSortOrder = () => {
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
};
// helper hooks
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
const project = getProjectById(projectId);
const renderCommentCreationBox = useMemo(
() => (
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={issueId}
activityOperations={activityOperations}
showToolbarInitially
projectId={projectId}
/>
),
[workspaceSlug, issueId, activityOperations, projectId]
);
if (!project) return <></>;
return (
<div className="space-y-4 pt-3">
{/* header */}
<div className="flex items-center justify-between">
<div className="text-lg text-custom-text-100">{t("common.activity")}</div>
<div className="flex items-center gap-2">
{isWorklogButtonEnabled && (
<IssueActivityWorklogCreateButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
<ActivitySortRoot sortOrder={sortOrder || E_SORT_ORDER.ASC} toggleSort={toggleSortOrder} />
<ActivityFilterRoot
selectedFilters={selectedFilters || defaultActivityFilters}
toggleFilter={toggleFilter}
isIntakeIssue={isIntakeIssue}
projectId={projectId}
/>
</div>
</div>
{/* rendering activity */}
<div className="space-y-3">
<div className="min-h-[200px]">
<div className="space-y-3">
{!disabled && sortOrder === E_SORT_ORDER.DESC && renderCommentCreationBox}
<IssueActivityCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
isIntakeIssue={isIntakeIssue}
issueId={issueId}
selectedFilters={selectedFilters || defaultActivityFilters}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
disabled={disabled}
sortOrder={sortOrder || E_SORT_ORDER.ASC}
/>
{!disabled && sortOrder === E_SORT_ORDER.ASC && renderCommentCreationBox}
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,36 @@
"use client";
import type { FC } from "react";
import { memo } from "react";
import { ArrowUpWideNarrow, ArrowDownWideNarrow } from "lucide-react";
// plane package imports
import type { E_SORT_ORDER } from "@plane/constants";
import { getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
export type TActivitySortRoot = {
sortOrder: E_SORT_ORDER;
toggleSort: () => void;
className?: string;
iconClassName?: string;
};
export const ActivitySortRoot: FC<TActivitySortRoot> = memo((props) => (
<div
className={cn(
getButtonStyling("neutral-primary", "sm"),
"px-2 text-custom-text-300 cursor-pointer",
props.className
)}
onClick={() => {
props.toggleSort();
}}
>
{props.sortOrder === "asc" ? (
<ArrowUpWideNarrow className={cn("size-4", props.iconClassName)} />
) : (
<ArrowDownWideNarrow className={cn("size-4", props.iconClassName)} />
)}
</div>
));
ActivitySortRoot.displayName = "ActivitySortRoot";

View File

@@ -0,0 +1,180 @@
"use client";
import type { FC } from "react";
import React, { useRef } from "react";
import { observer } from "mobx-react";
import { LinkIcon } from "lucide-react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
import { IssueSubscription } from "./subscription";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
};
export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId } = props;
const { t } = useTranslation();
// ref
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useAppRouter();
// hooks
const { data: currentUser } = useUser();
const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
removeIssue,
archiveIssue,
} = useIssueDetail();
const {
issues: { restoreIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
const {
issues: { removeIssue: removeArchivedIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
const projectIdentifier = getProjectIdentifierById(projectId);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug,
projectId,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
// handlers
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}${workItemLink}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.copied_to_clipboard"),
});
});
};
const handleDeleteIssue = async () => {
try {
const deleteIssue = issue?.archived_at ? removeArchivedIssue : removeIssue;
const redirectionPath = issue?.archived_at
? `/${workspaceSlug}/projects/${projectId}/archives/issues`
: `/${workspaceSlug}/projects/${projectId}/issues`;
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
router.push(redirectionPath);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
});
});
} catch (error) {
setToast({
title: t("toast.error "),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
error: error as Error,
});
}
};
const handleArchiveIssue = async () => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
error: error as Error,
});
}
};
const handleRestore = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("issue.restore.success.title"),
message: t("issue.restore.success.message"),
});
router.push(workItemLink);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("issue.restore.failed.message"),
});
});
};
return (
<>
<div className="flex items-center justify-end flex-shrink-0">
<div className="flex flex-wrap items-center gap-4">
{currentUser && !issue?.archived_at && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<div className="flex flex-wrap items-center gap-2.5 text-custom-text-300">
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<button
type="button"
className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary"
onClick={handleCopyText}
>
<LinkIcon className="h-4 w-4" />
</button>
</Tooltip>
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issue}
handleDelete={handleDeleteIssue}
handleArchive={handleArchiveIssue}
handleRestore={handleRestore}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,166 @@
"use client";
import type { FC } from "react";
import { useState, Fragment, useEffect } from "react";
import { TwitterPicker } from "react-color";
import { Controller, useForm } from "react-hook-form";
import { usePopper } from "react-popper";
import { Plus, X, Loader } from "lucide-react";
import { Popover } from "@headlessui/react";
import type { IIssueLabel } from "@plane/types";
// hooks
import { Input } from "@plane/ui";
// ui
// types
import type { TLabelOperations } from "./root";
type ILabelCreate = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled?: boolean;
};
const defaultValues: Partial<IIssueLabel> = {
name: "",
color: "#ff0000",
};
export const LabelCreate: FC<ILabelCreate> = (props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
// state
const [isCreateToggle, setIsCreateToggle] = useState(false);
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// react hook form
const {
handleSubmit,
formState: { errors, isSubmitting },
reset,
control,
setFocus,
} = useForm<Partial<IIssueLabel>>({
defaultValues,
});
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
useEffect(() => {
if (!isCreateToggle) return;
setFocus("name");
reset();
}, [isCreateToggle, reset, setFocus]);
const handleLabel = async (formData: Partial<IIssueLabel>) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
const currentLabels = [...(values || []), labelResponse.id];
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
handleIsCreateToggle();
reset(defaultValues);
};
return (
<>
<div
className="relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200"
onClick={handleIsCreateToggle}
>
<div className="flex-shrink-0">
{isCreateToggle ? <X className="h-2.5 w-2.5" /> : <Plus className="h-2.5 w-2.5" />}
</div>
<div className="flex-shrink-0">{isCreateToggle ? "Cancel" : "New"}</div>
</div>
{isCreateToggle && (
<form className="relative flex items-center gap-x-2 p-1" onSubmit={handleSubmit(handleLabel)}>
<div>
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<Popover>
<>
<Popover.Button as={Fragment}>
<button type="button" ref={setReferenceElement} className="grid place-items-center outline-none">
{value && value?.trim() !== "" && (
<span
className="h-5 w-5 rounded"
style={{
backgroundColor: value ?? "black",
}}
/>
)}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
<div
className="p-2 max-w-xs sm:px-0"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<TwitterPicker triangle={"hide"} color={value} onChange={(value) => onChange(value.hex)} />
</div>
</Popover.Panel>
</>
</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 text-xs px-1.5 py-1"
disabled={isSubmitting}
/>
)}
/>
<button
type="button"
className="grid place-items-center rounded bg-red-500 p-1"
onClick={() => setIsCreateToggle(false)}
disabled={disabled}
>
<X className="h-3.5 w-3.5 text-white" />
</button>
<button type="submit" className="grid place-items-center rounded bg-green-500 p-1" disabled={isSubmitting}>
{isSubmitting ? (
<Loader className="spin h-3.5 w-3.5 text-white" />
) : (
<Plus className="h-3.5 w-3.5 text-white" />
)}
</button>
</form>
)}
</>
);
};

View File

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

View File

@@ -0,0 +1,55 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// types
import { useLabel } from "@/hooks/store/use-label";
import type { TLabelOperations } from "./root";
type TLabelListItem = {
workspaceSlug: string;
projectId: string;
issueId: string;
labelId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelListItem: FC<TLabelListItem> = observer((props) => {
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
// hooks
const { getLabelById } = useLabel();
const label = getLabelById(labelId);
const handleLabel = async () => {
if (values && !disabled) {
const currentLabels = values.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 truncate border border-custom-border-100 rounded-full text-xs p-0.5 px-1 group ${
!disabled ? "cursor-pointer hover:border-red-500/50 hover:bg-red-500/20" : "cursor-not-allowed"
} `}
onClick={handleLabel}
>
<div
className="rounded-full h-2 w-2 flex-shrink-0"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<div className="truncate">{label.name}</div>
{!disabled && (
<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,38 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// components
import { LabelListItem } from "./label-list-item";
// types
import type { TLabelOperations } from "./root";
type TLabelList = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelList: FC<TLabelList> = observer((props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
const issueLabels = values || undefined;
if (!issueId || !issueLabels) return <></>;
return (
<>
{issueLabels.map((labelId) => (
<LabelListItem
key={labelId}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
labelId={labelId}
values={issueLabels}
labelOperations={labelOperations}
disabled={disabled}
/>
))}
</>
);
});

View File

@@ -0,0 +1,120 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IIssueLabel, TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// components
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
// ui
// types
import { LabelList, IssueLabelSelectRoot } from "./";
// TODO: Fix this import statement, as core should not import from ee
// eslint-disable-next-line import/order
export type TIssueLabel = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
isInboxIssue?: boolean;
onLabelUpdate?: (labelIds: string[]) => void;
issueServiceType?: TIssueServiceType;
};
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,
isInboxIssue = false,
onLabelUpdate,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
const { t } = useTranslation();
// hooks
const { updateIssue } = useIssueDetail(issueServiceType);
const { createLabel } = useLabel();
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
const { getIssueInboxByIssueId } = useProjectInbox();
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
const labelOperations: TLabelOperations = useMemo(
() => ({
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
if (onLabelUpdate) onLabelUpdate(data.label_ids || []);
else await updateIssue(workspaceSlug, projectId, issueId, data);
} catch (error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.update.failed", { entity: t("issue.label", { count: 1 }) }),
});
}
},
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
try {
const labelResponse = await createLabel(workspaceSlug, projectId, data);
if (!isInboxIssue)
setToast({
title: t("toast.success"),
type: TOAST_TYPE.SUCCESS,
message: t("label.create.success"),
});
return labelResponse;
} catch (error) {
let errMessage = t("label.create.failed");
if (error && (error as any).error === "Label with the same name already exists in the project")
errMessage = t("label.create.already_exists");
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: errMessage,
});
throw error;
}
},
}),
[updateIssue, createLabel, onLabelUpdate]
);
return (
<div className="relative flex flex-wrap items-center gap-1">
<LabelList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
disabled={disabled}
/>
{!disabled && (
<IssueLabelSelectRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,219 @@
import { Fragment, useState } from "react";
import { observer } from "mobx-react";
import { usePopper } from "react-popper";
import { Check, Loader, Search, Tag } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane imports
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueLabel } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// helpers
import { getTabIndex } from "@plane/utils";
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useUserPermissions } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
//constants
export interface IIssueLabelSelect {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
onSelect: (_labelIds: string[]) => void;
onAddLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
}
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel } = props;
const { t } = useTranslation();
// store hooks
const { isMobile } = usePlatformOS();
const { fetchProjectLabels, getProjectLabels } = useLabel();
const { allowPermissions } = useUserPermissions();
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [query, setQuery] = useState("");
const [submitting, setSubmitting] = useState<boolean>(false);
const canCreateLabel =
projectId && allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
const projectLabels = getProjectLabels(projectId);
const { baseTabIndex } = getTabIndex(undefined, isMobile);
const fetchLabels = () => {
setIsLoading(true);
if (!projectLabels && workspaceSlug && projectId)
fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false));
};
const options = (projectLabels ?? []).map((label) => ({
value: label.id,
query: label.name,
content: (
<div className="flex items-center justify-start gap-2 overflow-hidden">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
}}
/>
<div className="line-clamp-1 inline-block truncate">{label.name}</div>
</div>
),
}));
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-end",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const issueLabels = values ?? [];
const label = (
<div
className={`relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 py-0.5 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200`}
>
<div className="flex-shrink-0">
<Tag className="h-2.5 w-2.5" />
</div>
<div className="flex-shrink-0">{t("label.select")}</div>
</div>
);
const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
if (query !== "" && e.key === "Enter" && !e.nativeEvent.isComposing && canCreateLabel) {
e.stopPropagation();
e.preventDefault();
await handleAddLabel(query);
}
};
const handleAddLabel = async (labelName: string) => {
setSubmitting(true);
const label = await onAddLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() });
onSelect([...values, label.id]);
setQuery("");
setSubmitting(false);
};
if (!issueId || !values) return <></>;
return (
<>
<Combobox
as="div"
className={`w-auto max-w-full flex-shrink-0 text-left`}
value={issueLabels}
onChange={(value) => onSelect(value)}
multiple
>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className="cursor-pointer rounded"
onClick={() => !projectLabels && fetchLabels()}
>
{label}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="px-2">
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
tabIndex={baseTabIndex}
/>
</div>
</div>
<div className={`vertical-scrollbar scrollbar-sm mt-2 max-h-48 space-y-1 overflow-y-scroll px-2 pr-0`}>
{isLoading ? (
<p className="text-center text-custom-text-200">{t("common.loading")}</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
selected ? "text-custom-text-100" : "text-custom-text-200"
}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && (
<div className="flex-shrink-0">
<Check className={`h-3.5 w-3.5`} />
</div>
)}
</>
)}
</Combobox.Option>
))
) : submitting ? (
<Loader className="spin h-3.5 w-3.5" />
) : canCreateLabel ? (
<Combobox.Option
value={query}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!query.length) return;
handleAddLabel(query);
}}
className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`}
>
{query.length ? (
<>
{/* TODO: Translate here */}+ Add{" "}
<span className="text-custom-text-100">&quot;{query}&quot;</span> to labels
</>
) : (
t("label.create.type")
)}
</Combobox.Option>
) : (
<p className="text-left text-custom-text-200 ">{t("common.search.no_matching_results")}</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
</>
);
});

View File

@@ -0,0 +1,32 @@
import type { FC } from "react";
// components
import type { TLabelOperations } from "../root";
import { IssueLabelSelect } from "./label-select";
// types
type TIssueLabelSelectRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
};
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
const handleLabel = async (_labelIds: string[]) => {
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
};
return (
<IssueLabelSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={values}
onSelect={handleLabel}
onAddLabel={labelOperations.createLabel}
/>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
// plane types
import { Button } from "@plane/propel/button";
import type { TIssueLinkEditableFields, TIssueServiceType } from "@plane/types";
// plane ui
import { Input, ModalCore } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TLinkOperations } from "./root";
export type TLinkOperationsModal = Exclude<TLinkOperations, "remove">;
export type TIssueLinkCreateFormFieldOptions = TIssueLinkEditableFields & {
id?: string;
};
export type TIssueLinkCreateEditModal = {
isModalOpen: boolean;
handleOnClose?: () => void;
linkOperations: TLinkOperationsModal;
issueServiceType: TIssueServiceType;
};
const defaultValues: TIssueLinkCreateFormFieldOptions = {
title: "",
url: "",
};
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
const { isModalOpen, handleOnClose, linkOperations, issueServiceType } = props;
// i18n
const { t } = useTranslation();
// react hook form
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<TIssueLinkCreateFormFieldOptions>({
defaultValues,
});
// store hooks
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail(issueServiceType);
const onClose = () => {
setIssueLinkData(null);
if (handleOnClose) handleOnClose();
};
const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => {
const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`;
try {
if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl });
else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl });
onClose();
} catch (error) {
console.error("error", error);
}
};
useEffect(() => {
if (isModalOpen) reset({ ...defaultValues, ...preloadedData });
}, [preloadedData, reset, isModalOpen]);
return (
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5 p-5">
<h3 className="text-xl font-medium text-custom-text-200">
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
</h3>
<div className="mt-2 space-y-3">
<div>
<label htmlFor="url" className="mb-2 text-custom-text-200">
{t("common.url")}
</label>
<Controller
control={control}
name="url"
rules={{
required: "URL is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="url"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
placeholder={t("common.type_or_paste_a_url")}
className="w-full"
/>
)}
/>
{errors.url && <span className="text-xs text-red-500">{t("common.url_is_invalid")}</span>}
</div>
<div>
<label htmlFor="title" className="mb-2 text-custom-text-200">
{t("common.display_title")}
<span className="text-[10px] block">{t("common.optional")}</span>
</label>
<Controller
control={control}
name="title"
render={({ field: { value, onChange, ref } }) => (
<Input
id="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.title)}
placeholder={t("common.link_title_placeholder")}
className="w-full"
/>
)}
/>
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{`${
preloadedData?.id
? isSubmitting
? t("common.updating")
: t("common.update")
: isSubmitting
? t("common.adding")
: t("common.add")
} ${t("common.link")}`}
</Button>
</div>
</form>
</ModalCore>
);
});

View File

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

View File

@@ -0,0 +1,126 @@
"use client";
import type { FC } from "react";
// hooks
// ui
import { Pencil, Trash2, ExternalLink } from "lucide-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { getIconForLink, copyTextToClipboard, calculateTimeAgo } from "@plane/utils";
// icons
// types
// helpers
//
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
import type { TLinkOperationsModal } from "./create-update-link-modal";
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 },
setIssueLinkData,
} = useIssueDetail();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
const Icon = getIconForLink(linkDetail.url);
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail);
};
const createdByDetails = getUserDetails(linkDetail.created_by_id);
return (
<div key={linkId}>
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div
className="flex w-full cursor-pointer items-start justify-between gap-2"
onClick={() => {
copyTextToClipboard(linkDetail.url);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied!",
message: "Link copied to clipboard",
});
}}
>
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<Icon className="size-3 stroke-2 text-custom-text-350 group-hover:text-custom-text-100 flex-shrink-0" />
</span>
<Tooltip
tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
isMobile={isMobile}
>
<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"
>
<ExternalLink 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 />
{createdByDetails && (
<>
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,121 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Pencil, Trash2, Copy, Link } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import { calculateTimeAgo, copyTextToClipboard } from "@plane/utils";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
import type { TLinkOperationsModal } from "./create-update-link-modal";
type TIssueLinkItem = {
linkId: string;
linkOperations: TLinkOperationsModal;
isNotAllowed: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
// props
const { linkId, linkOperations, isNotAllowed, issueServiceType = EIssueServiceType.ISSUES } = props;
// hooks
const { t } = useTranslation();
const {
toggleIssueLinkModal: toggleIssueLinkModalStore,
setIssueLinkData,
link: { getLinkById },
} = useIssueDetail(issueServiceType);
const { isMobile } = usePlatformOS();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
// const Icon = getIconForLink(linkDetail.url);
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
const linkTitle: string | undefined = linkDetail.metadata?.title;
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail);
};
return (
<>
<div
key={linkId}
className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
>
<div className="flex items-center gap-2.5 truncate flex-grow">
{faviconUrl ? (
<img src={faviconUrl} alt="favicon" className="size-4" />
) : (
<Link className="size-4 text-custom-text-350 group-hover:text-custom-text-100" />
)}
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm cursor-pointer flex-grow flex items-center gap-3"
>
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
{linkTitle && linkTitle !== "" && <span className="text-custom-text-400 text-xs">{linkTitle}</span>}
</a>
</Tooltip>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200">
{calculateTimeAgo(linkDetail.created_at)}
</p>
<span
onClick={() => {
copyTextToClipboard(linkDetail.url);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.link_copied_to_clipboard"),
});
}}
className="relative grid place-items-center rounded p-1 text-custom-text-400 outline-none group-hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
>
<Copy className="h-3.5 w-3.5 stroke-[1.5]" />
</span>
<CustomMenu
ellipsis
buttonClassName="text-custom-text-400 group-hover:text-custom-text-200"
placement="bottom-end"
closeOnSelect
disabled={isNotAllowed}
>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
toggleIssueLinkModal(true);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
{t("common.actions.edit")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
linkOperations.remove(linkDetail.id);
}}
>
<Trash2 className="h-3 w-3" />
{t("common.actions.delete")}
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</>
);
});

Some files were not shown because too many files have changed in this diff Show More