feat: issue attachments feature (#717)
* chore: issue attachment services added * feat: attachment icons added * chore: fetch-key and icons export * feat: issue attachment upload section added * feat: issue attachment list section added * feat: date helper function added * style: responsiveness added * feat: attachment info added * style: attachment overflow fix * style: cursor pointer added * chore: issue attachment interface * style: uploading state added * feat: delete issue attachment modal * style: style improvement and refactor * style: consistent icon added , chore: refactor the code * fix: js icon import fix * fix: build fix * chore: refactor code
@ -1,19 +1,46 @@
|
||||
import React from "react";
|
||||
import {
|
||||
CssIcon,
|
||||
CsvIcon,
|
||||
DocIcon,
|
||||
FigmaIcon,
|
||||
HtmlIcon,
|
||||
JavaScriptIcon,
|
||||
JpgIcon,
|
||||
PdfIcon,
|
||||
PngIcon,
|
||||
SheetIcon,
|
||||
SvgIcon,
|
||||
TxtIcon,
|
||||
} from "components/icons";
|
||||
|
||||
import type { Props } from "./types";
|
||||
export const getFileIcon = (fileType: string) => {
|
||||
switch (fileType) {
|
||||
case "pdf":
|
||||
return <PdfIcon height={28} width={28} />;
|
||||
case "csv":
|
||||
return <CsvIcon height={28} width={28} />;
|
||||
case "xlsx":
|
||||
return <SheetIcon height={28} width={28} />;
|
||||
case "css":
|
||||
return <CssIcon height={28} width={28} />;
|
||||
case "doc":
|
||||
return <DocIcon height={28} width={28} />;
|
||||
case "fig":
|
||||
return <FigmaIcon height={28} width={28} />;
|
||||
case "html":
|
||||
return <HtmlIcon height={28} width={28} />;
|
||||
case "png":
|
||||
return <PngIcon height={28} width={28} />;
|
||||
case "jpg":
|
||||
return <JpgIcon height={28} width={28} />;
|
||||
case "js":
|
||||
return <JavaScriptIcon height={28} width={28} />;
|
||||
case "txt":
|
||||
return <TxtIcon height={28} width={28} />;
|
||||
case "svg":
|
||||
return <SvgIcon height={28} width={28} />;
|
||||
|
||||
export const AttachmentIcon: React.FC<Props> = ({ width, height, className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.75 20C18.4 20 18.1042 19.8792 17.8625 19.6375C17.6208 19.3958 17.5 19.1 17.5 18.75V5.25C17.5 4.9 17.6208 4.60417 17.8625 4.3625C18.1042 4.12083 18.4 4 18.75 4C19.1 4 19.3958 4.12083 19.6375 4.3625C19.8792 4.60417 20 4.9 20 5.25V18.75C20 19.1 19.8792 19.3958 19.6375 19.6375C19.3958 19.8792 19.1 20 18.75 20ZM6.275 20C6.09167 20 5.92083 19.9667 5.7625 19.9C5.60417 19.8333 5.47083 19.7458 5.3625 19.6375C5.25417 19.5292 5.16667 19.3958 5.1 19.2375C5.03333 19.0792 5 18.9167 5 18.75V15.25C5 14.9 5.12083 14.6042 5.3625 14.3625C5.60417 14.1208 5.9 14 6.25 14C6.6 14 6.89583 14.1208 7.1375 14.3625C7.37917 14.6042 7.5 14.9 7.5 15.25V18.75C7.5 18.9167 7.46667 19.0792 7.4 19.2375C7.33333 19.3958 7.24583 19.5292 7.1375 19.6375C7.02917 19.7458 6.9 19.8333 6.75 19.9C6.6 19.9667 6.44167 20 6.275 20ZM12.5 20C12.15 20 11.8542 19.8792 11.6125 19.6375C11.3708 19.3958 11.25 19.1 11.25 18.75V10.25C11.25 9.9 11.3708 9.60417 11.6125 9.3625C11.8542 9.12083 12.15 9 12.5 9C12.85 9 13.1458 9.12083 13.3875 9.3625C13.6292 9.60417 13.75 9.9 13.75 10.25V18.75C13.75 19.1 13.6292 19.3958 13.3875 19.6375C13.1458 19.8792 12.85 20 12.5 20Z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return <DocIcon height={28} width={28}/>;
|
||||
}
|
||||
};
|
||||
|
9
apps/app/components/icons/css-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import CssFileIcon from "public/attachment/css-icon.png";
|
||||
|
||||
export const CssIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={CssFileIcon} height={height} width={width} alt="CssFileIcon" />
|
||||
);
|
9
apps/app/components/icons/csv-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import CSVFileIcon from "public/attachment/csv-icon.png";
|
||||
|
||||
export const CsvIcon: React.FC<Props> = ({ width , height }) => (
|
||||
<Image src={CSVFileIcon} height={height} width={width} alt="CSVFileIcon" />
|
||||
);
|
9
apps/app/components/icons/doc-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import DocFileIcon from "public/attachment/doc-icon.png";
|
||||
|
||||
export const DocIcon: React.FC<Props> = ({ width , height }) => (
|
||||
<Image src={DocFileIcon} height={height} width={width} alt="DocFileIcon" />
|
||||
);
|
9
apps/app/components/icons/figma-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import FigmaFileIcon from "public/attachment/figma-icon.png";
|
||||
|
||||
export const FigmaIcon: React.FC<Props> = ({ width , height }) => (
|
||||
<Image src={FigmaFileIcon} height={height} width={width} alt="FigmaFileIcon" />
|
||||
);
|
9
apps/app/components/icons/html-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import HtmlFileIcon from "public/attachment/html-icon.png";
|
||||
|
||||
export const HtmlIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={HtmlFileIcon} height={height} width={width} alt="HtmlFileIcon" />
|
||||
);
|
9
apps/app/components/icons/img-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import ImgFileIcon from "public/attachment/img-icon.png";
|
||||
|
||||
export const ImgIcon: React.FC<Props> = ({ width , height }) => (
|
||||
<Image src={ImgFileIcon} height={height} width={width} alt="ImgFileIcon" />
|
||||
);
|
@ -56,4 +56,17 @@ export * from "./users";
|
||||
export * from "./import-layers";
|
||||
export * from "./check";
|
||||
export * from "./water-drop-icon";
|
||||
export * from "./transfer-icon";
|
||||
export * from "./transfer-icon";
|
||||
export * from "./pdf-file-icon";
|
||||
export * from "./csv-file-icon";
|
||||
export * from "./sheet-file-icon";
|
||||
export * from "./doc-file-icon";
|
||||
export * from "./html-file-icon";
|
||||
export * from "./css-file-icon";
|
||||
export * from "./js-file-icon";
|
||||
export * from "./figma-file-icon";
|
||||
export * from "./img-file-icon";
|
||||
export * from "./png-file-icon";
|
||||
export * from "./jpg-file-icon";
|
||||
export * from "./svg-file-icon";
|
||||
export * from "./txt-file-icon";
|
9
apps/app/components/icons/jpg-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import JpgFileIcon from "public/attachment/jpg-icon.png";
|
||||
|
||||
export const JpgIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={JpgFileIcon} height={height} width={width} alt="JpgFileIcon" />
|
||||
);
|
9
apps/app/components/icons/js-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import JsFileIcon from "public/attachment/js-icon.png";
|
||||
|
||||
export const JavaScriptIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={JsFileIcon} height={height} width={width} alt="JsFileIcon" />
|
||||
);
|
9
apps/app/components/icons/pdf-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import PDFFileIcon from "public/attachment/pdf-icon.png";
|
||||
|
||||
export const PdfIcon: React.FC<Props> = ({ width , height }) => (
|
||||
<Image src={PDFFileIcon} height={height} width={width} alt="PDFFileIcon" />
|
||||
);
|
9
apps/app/components/icons/png-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import PngFileIcon from "public/attachment/png-icon.png";
|
||||
|
||||
export const PngIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={PngFileIcon} height={height} width={width} alt="PngFileIcon" />
|
||||
);
|
9
apps/app/components/icons/sheet-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import SheetFileIcon from "public/attachment/excel-icon.png";
|
||||
|
||||
export const SheetIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={SheetFileIcon} height={height} width={width} alt="SheetFileIcon" />
|
||||
);
|
9
apps/app/components/icons/svg-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import SvgFileIcon from "public/attachment/svg-icon.png";
|
||||
|
||||
export const SvgIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={SvgFileIcon} height={height} width={width} alt="SvgFileIcon" />
|
||||
);
|
9
apps/app/components/icons/txt-file-icon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import type { Props } from "./types";
|
||||
import TxtFileIcon from "public/attachment/txt-icon.png";
|
||||
|
||||
export const TxtIcon: React.FC<Props> = ({ width, height }) => (
|
||||
<Image src={TxtFileIcon} height={height} width={width} alt="TxtFileIcon" />
|
||||
);
|
100
apps/app/components/issues/attachment-upload.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-dropzone
|
||||
import { useDropzone } from "react-dropzone";
|
||||
// toast
|
||||
import useToast from "hooks/use-toast";
|
||||
// fetch key
|
||||
import { ISSUE_ATTACHMENTS } from "constants/fetch-keys";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// type
|
||||
import { IIssueAttachment } from "types";
|
||||
|
||||
const maxFileSize = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export const IssueAttachmentUpload = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setIsLoading(true);
|
||||
if (!acceptedFiles[0] || !workspaceSlug) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", acceptedFiles[0]);
|
||||
formData.append(
|
||||
"attributes",
|
||||
JSON.stringify({
|
||||
name: acceptedFiles[0].name,
|
||||
size: acceptedFiles[0].size,
|
||||
})
|
||||
);
|
||||
|
||||
issuesService
|
||||
.uploadIssueAttachment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
formData
|
||||
)
|
||||
.then((res) => {
|
||||
mutate<IIssueAttachment[]>(ISSUE_ATTACHMENTS(issueId as string));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "File added successfully.",
|
||||
});
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: "Something went wrong. please check file type & size (max 5 MB)",
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const fileError =
|
||||
fileRejections.length > 0
|
||||
? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`flex items-center justify-center h-[60px] cursor-pointer border-2 border-dashed border-theme text-blue-500 bg-blue-500/5 text-sm rounded-md px-4 ${
|
||||
isDragActive ? "bg-theme/10" : ""
|
||||
} ${isDragReject ? "bg-red-100" : ""}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<span className="flex items-center gap-2">
|
||||
{isDragActive ? (
|
||||
<p>Drop here...</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-center">Uploading....</p>
|
||||
) : (
|
||||
<p className="text-center">Drag & Drop or Click to add new file</p>
|
||||
)}
|
||||
{fileError && <p className="text-red-500 mt-2">{fileError}</p>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
111
apps/app/components/issues/attachments.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
import { DeleteAttachmentModal } from "./delete-attachment-modal";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { ExclamationIcon, getFileIcon } from "components/icons";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// fetch-key
|
||||
import { ISSUE_ATTACHMENTS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// helper
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderLongDateFormat } from "helpers/date-time.helper";
|
||||
import { convertBytesToSize, getFileExtension, getFileName } from "helpers/attachment.helper";
|
||||
// type
|
||||
import { IIssueAttachment } from "types";
|
||||
|
||||
export const IssueAttachments = () => {
|
||||
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
|
||||
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: attachments } = useSWR<IIssueAttachment[]>(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.getIssueAttachment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: people } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteAttachmentModal
|
||||
isOpen={attachmentDeleteModal}
|
||||
setIsOpen={setAttachmentDeleteModal}
|
||||
data={deleteAttachment}
|
||||
/>
|
||||
{attachments &&
|
||||
attachments.length > 0 &&
|
||||
attachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between h-[60px] gap-1 px-4 py-2 text-sm border border-gray-200 bg-white rounded-md"
|
||||
>
|
||||
<Link href={file.asset}>
|
||||
<a target="_blank">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-7 w-7">{getFileIcon(getFileExtension(file.asset))}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip theme="dark" tooltipContent={getFileName(file.attributes.name)}>
|
||||
<span className="text-sm">
|
||||
{truncateText(`${getFileName(file.attributes.name)}`, 10)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
theme="dark"
|
||||
tooltipContent={`${
|
||||
people?.find((person) => person.member.id === file.updated_by)?.member
|
||||
.first_name ?? ""
|
||||
} uploaded on ${renderLongDateFormat(file.updated_at)}`}
|
||||
>
|
||||
<span>
|
||||
<ExclamationIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-gray-500 text-xs">
|
||||
<span>{getFileExtension(file.asset).toUpperCase()}</span>
|
||||
<span>{convertBytesToSize(file.attributes.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteAttachment(file);
|
||||
setAttachmentDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-500 hover:text-gray-800" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
144
apps/app/components/issues/delete-attachment-modal.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// helper
|
||||
import { getFileName } from "helpers/attachment.helper";
|
||||
// types
|
||||
import type { IIssueAttachment } from "types";
|
||||
// fetch-keys
|
||||
import { ISSUE_ATTACHMENTS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
data: IIssueAttachment | null;
|
||||
};
|
||||
|
||||
export const DeleteAttachmentModal: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async (assetId: string) => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
if (!workspaceSlug || !projectId || !data) return;
|
||||
|
||||
mutate<IIssueAttachment[]>(ISSUE_ATTACHMENTS(issueId as string));
|
||||
|
||||
await issuesService
|
||||
.deleteIssueAttachment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
assetId as string
|
||||
)
|
||||
.then((res) => {
|
||||
mutate(ISSUE_ATTACHMENTS(issueId as string));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "File removed successfully.",
|
||||
});
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: "Something went wrong please try again.",
|
||||
});
|
||||
setIsDeleteLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
data && (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" 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-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 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={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-white text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-6 w-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
Delete Attachment
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Are you sure you want to delete attachment-{" "}
|
||||
<span className="font-bold">{getFileName(data.attributes.name)}</span>?
|
||||
This attachment will be permanently removed. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 bg-gray-50 p-4 sm:px-6">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={() => handleDeletion(data.id)} loading={isDeleteLoading}>
|
||||
{isDeleteLoading ? "Deleting..." : "Delete"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
);
|
||||
};
|
@ -9,3 +9,6 @@ export * from "./my-issues-list-item";
|
||||
export * from "./parent-issues-list-modal";
|
||||
export * from "./sidebar";
|
||||
export * from "./sub-issues-list";
|
||||
export * from "./attachment-upload";
|
||||
export * from "./attachments";
|
||||
export * from "./delete-attachment-modal";
|
||||
|
@ -115,6 +115,7 @@ export const VIEW_DETAILS = (viewId: string) => `VIEW_DETAILS_${viewId.toUpperCa
|
||||
// Issues
|
||||
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
|
||||
export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`;
|
||||
export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`;
|
||||
|
||||
// integrations
|
||||
export const APP_INTEGRATIONS = "APP_INTEGRATIONS";
|
||||
|
22
apps/app/helpers/attachment.helper.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const getFileExtension = (filename: string) =>
|
||||
filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
|
||||
|
||||
export const getFileName = (fileName: string) => {
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
|
||||
const nameWithoutExtension = fileName.substring(0, dotIndex);
|
||||
|
||||
return nameWithoutExtension;
|
||||
};
|
||||
|
||||
export const convertBytesToSize = (bytes: number) => {
|
||||
let size;
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
size = Math.round(bytes / 1024) + " KB";
|
||||
} else {
|
||||
size = Math.round(bytes / (1024 * 1024)) + " MB";
|
||||
}
|
||||
|
||||
return size;
|
||||
};
|
@ -89,7 +89,10 @@ export const timeAgo = (time: any) => {
|
||||
return time;
|
||||
};
|
||||
|
||||
export const getDateRangeStatus = (startDate: string | null | undefined, endDate: string | null | undefined) => {
|
||||
export const getDateRangeStatus = (
|
||||
startDate: string | null | undefined,
|
||||
endDate: string | null | undefined
|
||||
) => {
|
||||
if (!startDate || !endDate) return "draft";
|
||||
|
||||
const today = renderDateFormat(new Date());
|
||||
@ -173,3 +176,37 @@ export const renderShortTime = (date: string | Date) => {
|
||||
|
||||
export const isDateRangeValid = (startDate: string, endDate: string) =>
|
||||
new Date(startDate) < new Date(endDate);
|
||||
|
||||
export const renderLongDateFormat = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.getDate();
|
||||
const year = date.getFullYear();
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
const monthIndex = date.getMonth();
|
||||
const monthName = monthNames[monthIndex];
|
||||
const suffixes = ["st", "nd", "rd", "th"];
|
||||
let suffix = "";
|
||||
if (day === 1 || day === 21 || day === 31) {
|
||||
suffix = suffixes[0];
|
||||
} else if (day === 2 || day === 22) {
|
||||
suffix = suffixes[1];
|
||||
} else if (day === 3 || day === 23) {
|
||||
suffix = suffixes[2];
|
||||
} else {
|
||||
suffix = suffixes[3];
|
||||
}
|
||||
return `${day}${suffix} ${monthName} ${year}`;
|
||||
};
|
||||
|
@ -20,6 +20,8 @@ import {
|
||||
IssueDetailsSidebar,
|
||||
IssueActivitySection,
|
||||
AddComment,
|
||||
IssueAttachmentUpload,
|
||||
IssueAttachments,
|
||||
} from "components/issues";
|
||||
// ui
|
||||
import { Loader, CustomMenu } from "components/ui";
|
||||
@ -198,6 +200,13 @@ const IssueDetailsPage: NextPage<UserAuth> = (props) => {
|
||||
<SubIssuesList parentIssue={issueDetails} userAuth={props} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<h3 className="text-lg">Attachments</h3>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<IssueAttachmentUpload/>
|
||||
<IssueAttachments />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 bg-secondary pt-3">
|
||||
<h3 className="text-lg">Comments/Activity</h3>
|
||||
<IssueActivitySection />
|
||||
|
BIN
apps/app/public/attachment/css-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
apps/app/public/attachment/csv-icon.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
BIN
apps/app/public/attachment/doc-icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
apps/app/public/attachment/excel-icon.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
apps/app/public/attachment/figma-icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
apps/app/public/attachment/html-icon.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
apps/app/public/attachment/img-icon.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
apps/app/public/attachment/jpg-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
apps/app/public/attachment/js-icon.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
apps/app/public/attachment/pdf-icon.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
apps/app/public/attachment/png-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
apps/app/public/attachment/svg-icon.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
apps/app/public/attachment/txt-icon.png
Normal file
After Width: | Height: | Size: 11 KiB |
@ -440,6 +440,51 @@ class ProjectIssuesServices extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadIssueAttachment(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
file: FormData
|
||||
): Promise<any> {
|
||||
return this.mediaUpload(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/`,
|
||||
file
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getIssueAttachment(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string
|
||||
): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteIssueAttachment(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
assetId: string
|
||||
): Promise<any> {
|
||||
return this.delete(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/${assetId}/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ProjectIssuesServices();
|
||||
|
16
apps/app/types/issues.d.ts
vendored
@ -256,3 +256,19 @@ export interface IIssueViewOptions {
|
||||
filters: IIssueFilterOptions;
|
||||
target_date: string;
|
||||
}
|
||||
|
||||
export interface IIssueAttachment {
|
||||
asset: string;
|
||||
attributes: {
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
id: string;
|
||||
issue: string;
|
||||
project: string;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
}
|