[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:
Anmol Singh Bhatia
2024-07-02 19:06:20 +05:30
committed by GitHub
parent fc15ca5565
commit 1201a4245e
13 changed files with 492 additions and 109 deletions
@@ -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>
);
});
+10 -12
View File
@@ -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}
/>
)}
</>
) : (
+38 -1
View File
@@ -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();
};
};