mirror of
https://github.com/makeplane/plane.git
synced 2026-04-25 01:28:32 -05:00
[WEB-1679] chore: sub-issues, attachments, and links UI revamp (#5007)
* chore: issue attachment ui revamp * chore: issue link ui revamp * chore: attachment icon improvement * chore: sub-issue ui revamp * chore: open on hover functionality added to custom menu * chore: code refactor
This commit is contained in:
committed by
GitHub
parent
fc15ca5565
commit
1201a4245e
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
@@ -35,9 +35,9 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((pro
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
attachment: { getAttachmentById },
|
||||
isDeleteAttachmentModalOpen,
|
||||
toggleDeleteAttachmentModal,
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false);
|
||||
// derived values
|
||||
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
|
||||
// hooks
|
||||
@@ -47,10 +47,10 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((pro
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDeleteAttachmentModalOpen === attachment.id && (
|
||||
{isDeleteIssueAttachmentModalOpen && (
|
||||
<IssueAttachmentDeleteModal
|
||||
isOpen={!!isDeleteAttachmentModalOpen}
|
||||
onClose={() => toggleDeleteAttachmentModal(null)}
|
||||
isOpen={isDeleteIssueAttachmentModalOpen}
|
||||
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
|
||||
handleAttachmentOperations={handleAttachmentOperations}
|
||||
data={attachment}
|
||||
/>
|
||||
@@ -85,7 +85,7 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((pro
|
||||
</Link>
|
||||
|
||||
{!disabled && (
|
||||
<button type="button" onClick={() => toggleDeleteAttachmentModal(attachment.id)}>
|
||||
<button type="button" onClick={() => setIsDeleteIssueAttachmentModalOpen(true)}>
|
||||
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FC, useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UploadCloud } from "lucide-react";
|
||||
// hooks
|
||||
import { MAX_FILE_SIZE } from "@/constants/common";
|
||||
import { generateFileName } from "@/helpers/attachment.helper";
|
||||
import { useInstance, useIssueDetail } from "@/hooks/store";
|
||||
// components
|
||||
import { IssueAttachmentsListItem } from "./attachment-list-item";
|
||||
// types
|
||||
import { TAttachmentOperations } from "./root";
|
||||
|
||||
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
|
||||
|
||||
type TIssueAttachmentItemList = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((props) => {
|
||||
const { workspaceSlug, issueId, handleAttachmentOperations, disabled } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
const {
|
||||
attachment: { getAttachmentsByIssueId },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueAttachments = getAttachmentsByIssueId(issueId);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const currentFile: File = acceptedFiles[0];
|
||||
if (!currentFile || !workspaceSlug) return;
|
||||
|
||||
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
|
||||
type: currentFile.type,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("asset", uploadedFile);
|
||||
formData.append(
|
||||
"attributes",
|
||||
JSON.stringify({
|
||||
name: uploadedFile.name,
|
||||
size: uploadedFile.size,
|
||||
})
|
||||
);
|
||||
setIsLoading(true);
|
||||
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
|
||||
},
|
||||
[handleAttachmentOperations, workspaceSlug]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
if (!issueAttachments) return <></>;
|
||||
|
||||
return (
|
||||
<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">Drag and drop anywhere to upload</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueAttachments?.map((attachmentId) => (
|
||||
<IssueAttachmentsListItem
|
||||
key={attachmentId}
|
||||
attachmentId={attachmentId}
|
||||
disabled={disabled}
|
||||
handleAttachmentOperations={handleAttachmentOperations}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trash } from "lucide-react";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
import { IssueAttachmentDeleteModal } from "@/components/issues";
|
||||
// helpers
|
||||
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssueDetail, useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import { TAttachmentOperations } from "./root";
|
||||
|
||||
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentOperations, "create">;
|
||||
|
||||
type TIssueAttachmentsListItem = {
|
||||
attachmentId: string;
|
||||
handleAttachmentOperations: TAttachmentOperationsRemoveModal;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer((props) => {
|
||||
// props
|
||||
const { attachmentId, handleAttachmentOperations, 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;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
if (!attachment) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDeleteIssueAttachmentModalOpen && (
|
||||
<IssueAttachmentDeleteModal
|
||||
isOpen={isDeleteIssueAttachmentModalOpen}
|
||||
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
|
||||
handleAttachmentOperations={handleAttachmentOperations}
|
||||
data={attachment}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(attachment.asset, "_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 ">{getFileIcon(getFileExtension(attachment.asset), 18)}</div>
|
||||
<Tooltip
|
||||
tooltipContent={`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<p className="text-custom-text-200 font-medium truncate">{`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}</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?.updated_by && (
|
||||
<>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${
|
||||
getUserDetails(attachment.updated_by)?.display_name ?? ""
|
||||
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<ButtonAvatars showTooltip userIds={attachment?.updated_by} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CustomMenu ellipsis closeOnSelect placement="bottom-end" openOnHover disabled={disabled}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDeleteIssueAttachmentModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./attachment-detail";
|
||||
export * from "./attachment-item-list";
|
||||
export * from "./attachment-list-item";
|
||||
export * from "./attachment-upload";
|
||||
export * from "./attachments-list";
|
||||
export * from "./delete-attachment-modal";
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from "./root";
|
||||
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
export * from "./link-item";
|
||||
export * from "./link-list";
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
|
||||
// ui
|
||||
import { Tooltip, TOAST_TYPE, setToast, CustomMenu } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal";
|
||||
|
||||
type TIssueLinkItem = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkItem: FC<TIssueLinkItem> = (props) => {
|
||||
// props
|
||||
const { linkId, linkOperations, isNotAllowed } = props;
|
||||
// hooks
|
||||
const {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
link: { getLinkById },
|
||||
} = useIssueDetail();
|
||||
|
||||
// state
|
||||
const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false);
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIsIssueLinkModalOpen(modalToggle);
|
||||
};
|
||||
const { isMobile } = usePlatformOS();
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLinkCreateUpdateModal
|
||||
isModalOpen={isIssueLinkModalOpen}
|
||||
handleModal={toggleIssueLinkModal}
|
||||
linkOperations={linkOperations}
|
||||
preloadedData={linkDetail}
|
||||
/>
|
||||
<div
|
||||
key={linkId}
|
||||
className="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-8 flex-shrink-0 px-3 bg-custom-background-90 border-[0.5px] border-custom-border-200 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 truncate">
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="truncate text-xs cursor-pointer"
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied!",
|
||||
message: "Link copied to clipboard",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-300">
|
||||
{calculateTimeAgoShort(linkDetail.created_at)}
|
||||
</p>
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-300 outline-none hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</a>
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
buttonClassName="text-custom-text-300 hover:text-custom-text-200"
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIssueLinkModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
Edit
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkOperations.remove(linkDetail.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// computed
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import { IssueLinkItem } from "./link-item";
|
||||
// hooks
|
||||
import { TLinkOperations } from "./root";
|
||||
|
||||
type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
|
||||
type TLinkList = {
|
||||
issueId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const LinkList: FC<TLinkList> = observer((props) => {
|
||||
// props
|
||||
const { issueId, linkOperations, disabled = false } = props;
|
||||
// hooks
|
||||
const {
|
||||
link: { getLinksByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
const issueLinks = getLinksByIssueId(issueId);
|
||||
|
||||
if (!issueLinks) return <></>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
|
||||
{issueLinks &&
|
||||
issueLinks.length > 0 &&
|
||||
issueLinks.map((linkId) => (
|
||||
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// computed
|
||||
import { useIssueDetail, useUser } from "@/hooks/store";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import { IssueLinkDetail } from "./link-detail";
|
||||
// hooks
|
||||
import { TLinkOperations } from "./root";
|
||||
@@ -11,34 +11,27 @@ export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
export type TIssueLinkList = {
|
||||
issueId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkList: FC<TIssueLinkList> = observer((props) => {
|
||||
// props
|
||||
const { issueId, linkOperations } = props;
|
||||
const { issueId, linkOperations, disabled = false } = props;
|
||||
// hooks
|
||||
const {
|
||||
link: { getLinksByIssueId },
|
||||
} = useIssueDetail();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const issueLinks = getLinksByIssueId(issueId);
|
||||
|
||||
if (!issueLinks) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
|
||||
{issueLinks &&
|
||||
issueLinks.length > 0 &&
|
||||
issueLinks.map((linkId) => (
|
||||
<IssueLinkDetail
|
||||
key={linkId}
|
||||
linkId={linkId}
|
||||
linkOperations={linkOperations}
|
||||
isNotAllowed={currentProjectRole === 5 || currentProjectRole === 10}
|
||||
/>
|
||||
<IssueLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} />
|
||||
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -82,10 +82,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
<div key={issueId}>
|
||||
{issue && (
|
||||
<div
|
||||
className="group relative flex h-full w-full items-center gap-2 border-b border-custom-border-100 px-2 py-1 transition-all hover:bg-custom-background-90"
|
||||
className="group relative flex min-h-11 h-full w-full items-center gap-3 pr-2 py-1 transition-all hover:bg-custom-background-90"
|
||||
style={{ paddingLeft: `${spacingLeft}px` }}
|
||||
>
|
||||
<div className="h-[22px] w-[22px] flex-shrink-0">
|
||||
<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 && (
|
||||
<>
|
||||
@@ -95,7 +95,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer items-center justify-center rounded-sm transition-all hover:bg-custom-background-80"
|
||||
className="flex h-full w-full cursor-pointer items-center justify-center text-custom-text-400 hover:text-custom-text-300"
|
||||
onClick={async () => {
|
||||
if (!subIssueHelpers.issue_visibility.includes(issueId)) {
|
||||
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
|
||||
@@ -106,10 +106,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 transition-all", {
|
||||
className={cn("size-3.5 transition-all", {
|
||||
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
|
||||
})}
|
||||
strokeWidth={2}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -119,9 +119,9 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
|
||||
<div className="flex w-full cursor-pointer items-center gap-2">
|
||||
<div
|
||||
className="h-[6px] w-[6px] flex-shrink-0 rounded-full"
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: currentIssueStateDetail?.color,
|
||||
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
@@ -166,6 +166,33 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
{disabled && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
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} />
|
||||
<span>Remove parent issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<hr className="border-custom-border-300" />
|
||||
|
||||
{disabled && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
@@ -179,55 +206,27 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
{disabled && (
|
||||
<>
|
||||
{subIssueHelpers.issue_loader.includes(issue.id) ? (
|
||||
<div className="flex h-[22px] w-[22px] flex-shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm transition-all">
|
||||
<Loader width={14} strokeWidth={2} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="invisible flex h-[22px] w-[22px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80 group-hover:visible"
|
||||
onClick={() => {
|
||||
issue.project_id &&
|
||||
subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
|
||||
}}
|
||||
>
|
||||
<X width={14} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* should not expand the current issue if it is also the root issue*/}
|
||||
{subIssueHelpers.issue_visibility.includes(issueId) && issue.project_id && subIssueCount > 0 && !isCurrentIssueRoot && (
|
||||
<IssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
parentIssueId={issue.id}
|
||||
rootIssueId={rootIssueId}
|
||||
spacingLeft={spacingLeft + 22}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
)}
|
||||
{subIssueHelpers.issue_visibility.includes(issueId) &&
|
||||
issue.project_id &&
|
||||
subIssueCount > 0 &&
|
||||
!isCurrentIssueRoot && (
|
||||
<IssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
parentIssueId={issue.id}
|
||||
rootIssueId={rootIssueId}
|
||||
spacingLeft={spacingLeft + 22}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,31 +42,24 @@ export const IssueList: FC<IIssueList> = observer((props) => {
|
||||
const subIssueIds = subIssuesByIssueId(parentIssueId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
{subIssueIds &&
|
||||
subIssueIds.length > 0 &&
|
||||
subIssueIds.map((issueId) => (
|
||||
<Fragment key={issueId}>
|
||||
<IssueListItem
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={rootIssueId}
|
||||
issueId={issueId}
|
||||
spacingLeft={spacingLeft}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`absolute bottom-0 top-0 ${spacingLeft > 10 ? `border-l border-custom-border-100` : ``}`}
|
||||
style={{ left: `${spacingLeft - 12}px` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="relative">
|
||||
{subIssueIds &&
|
||||
subIssueIds.length > 0 &&
|
||||
subIssueIds.map((issueId) => (
|
||||
<Fragment key={issueId}>
|
||||
<IssueListItem
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={rootIssueId}
|
||||
issueId={issueId}
|
||||
spacingLeft={spacingLeft}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -395,18 +395,16 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
|
||||
</div>
|
||||
|
||||
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
||||
<div className="border border-b-0 border-custom-border-100">
|
||||
<IssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={parentIssueId}
|
||||
spacingLeft={10}
|
||||
disabled={!disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
</div>
|
||||
<IssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={parentIssueId}
|
||||
spacingLeft={10}
|
||||
disabled={!disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -143,6 +143,43 @@ export const calculateTimeAgo = (time: string | number | Date | null): string =>
|
||||
return distance;
|
||||
};
|
||||
|
||||
export function calculateTimeAgoShort(date: string | number | Date | null): string {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date);
|
||||
const now = new Date();
|
||||
const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000;
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return `${Math.floor(diffInSeconds)}s`;
|
||||
}
|
||||
|
||||
const diffInMinutes = diffInSeconds / 60;
|
||||
if (diffInMinutes < 60) {
|
||||
return `${Math.floor(diffInMinutes)}m`;
|
||||
}
|
||||
|
||||
const diffInHours = diffInMinutes / 60;
|
||||
if (diffInHours < 24) {
|
||||
return `${Math.floor(diffInHours)}h`;
|
||||
}
|
||||
|
||||
const diffInDays = diffInHours / 24;
|
||||
if (diffInDays < 30) {
|
||||
return `${Math.floor(diffInDays)}d`;
|
||||
}
|
||||
|
||||
const diffInMonths = diffInDays / 30;
|
||||
if (diffInMonths < 12) {
|
||||
return `${Math.floor(diffInMonths)}mo`;
|
||||
}
|
||||
|
||||
const diffInYears = diffInMonths / 12;
|
||||
return `${Math.floor(diffInYears)}y`;
|
||||
}
|
||||
|
||||
// Date Validation Helpers
|
||||
/**
|
||||
* @returns {string} boolean value depending on whether the date is greater than today
|
||||
@@ -249,4 +286,4 @@ export const convertToISODateString = (dateString: string | undefined) => {
|
||||
export const getCurrentDateTimeInISO = () => {
|
||||
const date = new Date();
|
||||
return date.toISOString();
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user