From 1201a4245e769bd6e11d323e7e67c003bf29eb8f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 2 Jul 2024 19:06:20 +0530 Subject: [PATCH] [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 --- .../issues/attachment/attachment-detail.tsx | 14 +-- .../attachment/attachment-item-list.tsx | 93 ++++++++++++++ .../attachment/attachment-list-item.tsx | 112 +++++++++++++++++ .../components/issues/attachment/index.ts | 2 + .../issues/issue-detail/links/index.ts | 2 + .../issues/issue-detail/links/link-item.tsx | 116 ++++++++++++++++++ .../issues/issue-detail/links/link-list.tsx | 38 ++++++ .../issues/issue-detail/links/links.tsx | 17 +-- .../issues/issue-detail/links/root.tsx | 2 +- .../issues/sub-issues/issue-list-item.tsx | 99 ++++++++------- .../issues/sub-issues/issues-list.tsx | 45 +++---- .../components/issues/sub-issues/root.tsx | 22 ++-- web/helpers/date-time.helper.ts | 39 +++++- 13 files changed, 492 insertions(+), 109 deletions(-) create mode 100644 web/core/components/issues/attachment/attachment-item-list.tsx create mode 100644 web/core/components/issues/attachment/attachment-list-item.tsx create mode 100644 web/core/components/issues/issue-detail/links/link-item.tsx create mode 100644 web/core/components/issues/issue-detail/links/link-list.tsx diff --git a/web/core/components/issues/attachment/attachment-detail.tsx b/web/core/components/issues/attachment/attachment-detail.tsx index 224ac8ef3..d2c24d355 100644 --- a/web/core/components/issues/attachment/attachment-detail.tsx +++ b/web/core/components/issues/attachment/attachment-detail.tsx @@ -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 = 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 = observer((pro return ( <> - {isDeleteAttachmentModalOpen === attachment.id && ( + {isDeleteIssueAttachmentModalOpen && ( toggleDeleteAttachmentModal(null)} + isOpen={isDeleteIssueAttachmentModalOpen} + onClose={() => setIsDeleteIssueAttachmentModalOpen(false)} handleAttachmentOperations={handleAttachmentOperations} data={attachment} /> @@ -85,7 +85,7 @@ export const IssueAttachmentsDetail: FC = observer((pro {!disabled && ( - )} diff --git a/web/core/components/issues/attachment/attachment-item-list.tsx b/web/core/components/issues/attachment/attachment-item-list.tsx new file mode 100644 index 000000000..f88505ce4 --- /dev/null +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -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; + +type TIssueAttachmentItemList = { + workspaceSlug: string; + issueId: string; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; +}; + +export const IssueAttachmentItemList: FC = 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 ( +
+ + {isDragActive && ( +
+
+
+ + Drag and drop anywhere to upload +
+
+
+ )} + {issueAttachments?.map((attachmentId) => ( + + ))} +
+ ); +}); diff --git a/web/core/components/issues/attachment/attachment-list-item.tsx b/web/core/components/issues/attachment/attachment-list-item.tsx new file mode 100644 index 000000000..6f6543f8c --- /dev/null +++ b/web/core/components/issues/attachment/attachment-list-item.tsx @@ -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; + +type TIssueAttachmentsListItem = { + attachmentId: string; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; +}; + +export const IssueAttachmentsListItem: FC = 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 && ( + setIsDeleteIssueAttachmentModalOpen(false)} + handleAttachmentOperations={handleAttachmentOperations} + data={attachment} + /> + )} + + + ); +}); diff --git a/web/core/components/issues/attachment/index.ts b/web/core/components/issues/attachment/index.ts index 928cd4613..0f1c8a332 100644 --- a/web/core/components/issues/attachment/index.ts +++ b/web/core/components/issues/attachment/index.ts @@ -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"; diff --git a/web/core/components/issues/issue-detail/links/index.ts b/web/core/components/issues/issue-detail/links/index.ts index 4a06c89af..1241167ab 100644 --- a/web/core/components/issues/issue-detail/links/index.ts +++ b/web/core/components/issues/issue-detail/links/index.ts @@ -2,3 +2,5 @@ export * from "./root"; export * from "./links"; export * from "./link-detail"; +export * from "./link-item"; +export * from "./link-list"; diff --git a/web/core/components/issues/issue-detail/links/link-item.tsx b/web/core/components/issues/issue-detail/links/link-item.tsx new file mode 100644 index 000000000..ef2de8ed9 --- /dev/null +++ b/web/core/components/issues/issue-detail/links/link-item.tsx @@ -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 = (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 ( + <> + +
+
+ + + { + copyTextToClipboard(linkDetail.url); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link copied!", + message: "Link copied to clipboard", + }); + }} + > + {linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} + + +
+
+

+ {calculateTimeAgoShort(linkDetail.created_at)} +

+ + + + + { + e.preventDefault(); + e.stopPropagation(); + toggleIssueLinkModal(true); + }} + > + + Edit + + { + e.preventDefault(); + e.stopPropagation(); + linkOperations.remove(linkDetail.id); + }} + > + + Delete + + +
+
+ + ); +}; diff --git a/web/core/components/issues/issue-detail/links/link-list.tsx b/web/core/components/issues/issue-detail/links/link-list.tsx new file mode 100644 index 000000000..ad8424623 --- /dev/null +++ b/web/core/components/issues/issue-detail/links/link-list.tsx @@ -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; + +type TLinkList = { + issueId: string; + linkOperations: TLinkOperationsModal; + disabled?: boolean; +}; + +export const LinkList: FC = observer((props) => { + // props + const { issueId, linkOperations, disabled = false } = props; + // hooks + const { + link: { getLinksByIssueId }, + } = useIssueDetail(); + + const issueLinks = getLinksByIssueId(issueId); + + if (!issueLinks) return <>; + + return ( +
+ {issueLinks && + issueLinks.length > 0 && + issueLinks.map((linkId) => ( + + ))} +
+ ); +}); \ No newline at end of file diff --git a/web/core/components/issues/issue-detail/links/links.tsx b/web/core/components/issues/issue-detail/links/links.tsx index c14b0e97e..cd4fff423 100644 --- a/web/core/components/issues/issue-detail/links/links.tsx +++ b/web/core/components/issues/issue-detail/links/links.tsx @@ -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; export type TIssueLinkList = { issueId: string; linkOperations: TLinkOperationsModal; + disabled?: boolean; }; export const IssueLinkList: FC = 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 ( -
+
{issueLinks && issueLinks.length > 0 && issueLinks.map((linkId) => ( - + ))}
); diff --git a/web/core/components/issues/issue-detail/links/root.tsx b/web/core/components/issues/issue-detail/links/root.tsx index 8d441079c..e50244fd4 100644 --- a/web/core/components/issues/issue-detail/links/root.tsx +++ b/web/core/components/issues/issue-detail/links/root.tsx @@ -126,7 +126,7 @@ export const IssueLinkRoot: FC = (props) => {
- +
diff --git a/web/core/components/issues/sub-issues/issue-list-item.tsx b/web/core/components/issues/sub-issues/issue-list-item.tsx index 0923572f2..881458243 100644 --- a/web/core/components/issues/sub-issues/issue-list-item.tsx +++ b/web/core/components/issues/sub-issues/issue-list-item.tsx @@ -82,10 +82,10 @@ export const IssueListItem: React.FC = observer((props) => {
{issue && (
-
+
{/* disable the chevron when current issue is also the root issue*/} {subIssueCount > 0 && !isCurrentIssueRoot && ( <> @@ -95,7 +95,7 @@ export const IssueListItem: React.FC = observer((props) => {
) : (
{ if (!subIssueHelpers.issue_visibility.includes(issueId)) { setSubIssueHelpers(parentIssueId, "preview_loader", issueId); @@ -106,10 +106,10 @@ export const IssueListItem: React.FC = observer((props) => { }} >
)} @@ -119,9 +119,9 @@ export const IssueListItem: React.FC = observer((props) => {
@@ -166,6 +166,33 @@ export const IssueListItem: React.FC = observer((props) => { )} + + subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`) + } + > +
+ + Copy issue link +
+
+ + {disabled && ( + { + issue.project_id && + subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id); + }} + > +
+ + Remove parent issue +
+
+ )} + +
+ {disabled && ( { @@ -179,55 +206,27 @@ export const IssueListItem: React.FC = observer((props) => {
)} - - - subIssueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`) - } - > -
- - Copy issue link -
-
- - {disabled && ( - <> - {subIssueHelpers.issue_loader.includes(issue.id) ? ( -
- -
- ) : ( -
{ - issue.project_id && - subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id); - }} - > - -
- )} - - )}
)} {/* should not expand the current issue if it is also the root issue*/} - {subIssueHelpers.issue_visibility.includes(issueId) && issue.project_id && subIssueCount > 0 && !isCurrentIssueRoot && ( - - )} + {subIssueHelpers.issue_visibility.includes(issueId) && + issue.project_id && + subIssueCount > 0 && + !isCurrentIssueRoot && ( + + )}
); }); diff --git a/web/core/components/issues/sub-issues/issues-list.tsx b/web/core/components/issues/sub-issues/issues-list.tsx index 389b73b97..2178f086f 100644 --- a/web/core/components/issues/sub-issues/issues-list.tsx +++ b/web/core/components/issues/sub-issues/issues-list.tsx @@ -42,31 +42,24 @@ export const IssueList: FC = observer((props) => { const subIssueIds = subIssuesByIssueId(parentIssueId); return ( - <> -
- {subIssueIds && - subIssueIds.length > 0 && - subIssueIds.map((issueId) => ( - - - - ))} - -
10 ? `border-l border-custom-border-100` : ``}`} - style={{ left: `${spacingLeft - 12}px` }} - /> -
- +
+ {subIssueIds && + subIssueIds.length > 0 && + subIssueIds.map((issueId) => ( + + + + ))} +
); }); diff --git a/web/core/components/issues/sub-issues/root.tsx b/web/core/components/issues/sub-issues/root.tsx index d8cfa3a3c..84db36c4c 100644 --- a/web/core/components/issues/sub-issues/root.tsx +++ b/web/core/components/issues/sub-issues/root.tsx @@ -395,18 +395,16 @@ export const SubIssuesRoot: FC = observer((props) => {
{subIssueHelpers.issue_visibility.includes(parentIssueId) && ( -
- -
+ )} ) : ( diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index a6719ec1e..1eb7fdd70 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -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(); -}; \ No newline at end of file +};