[WEB-3100 | WEB-3098 | WEB-3089] fix: platform fixes and improvements (#2173)

* fix: initiative and epics detail layout scroll

* chore: initiative and project overview store updated

* chore: initiatve attachment improvements

* chore: initative and project overview attachment improvements

* chore: code clean up

* fix: initiative comment attachment upload

* fix: initiative project list dropdown placement

* fix: epic peek view delete

* fix: initiative empty state cta permission

* fix: initiative permission updated
This commit is contained in:
Anmol Singh Bhatia
2025-01-10 14:32:04 +05:30
committed by GitHub
parent 7a2fba6254
commit 4c8a0066ae
24 changed files with 556 additions and 435 deletions

View File

@@ -11,6 +11,7 @@ export type TInitiativeAttachment = {
name: string;
size: number;
};
asset: string;
asset_url: string;
Initiative_id: string;
// required

View File

@@ -8,7 +8,7 @@ import { InitiativesDetailsHeader } from "./header";
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => (
<>
<AppHeader header={<InitiativesDetailsHeader />} />
<ContentWrapper>{children}</ContentWrapper>
<ContentWrapper className="overflow-hidden">{children}</ContentWrapper>
</>
);

View File

@@ -8,7 +8,7 @@ export default function ProjectEpicDetailsLayout({ children }: { children: React
return (
<>
<AppHeader header={<ProjectEpicDetailsHeader />} />
<ContentWrapper>{children}</ContentWrapper>
<ContentWrapper className="overflow-hidden">{children}</ContentWrapper>
</>
);
}

View File

@@ -91,7 +91,9 @@ export const EpicPeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pro
// store hooks
const {
issue: { getIssueById },
setPeekIssue,
} = useIssueDetail(EIssueServiceType.EPICS);
const { updateIssue, removeIssue } = useIssuesActions(EIssuesStoreType.EPIC);
const { allowPermissions } = useUserPermissions();
const { epicDetailSidebarCollapsed, toggleEpicDetailSidebar } = useAppTheme();
@@ -119,7 +121,7 @@ export const EpicPeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pro
if (issue) {
await removeIssue(issue.project_id, issue.id).then(() => {
// TODO: add toast
router.push(`/${workspaceSlug}/projects/${projectId}/epics`);
setPeekIssue(undefined);
});
}
};

View File

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

View File

@@ -0,0 +1,98 @@
"use client";
import { FC } 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";
// helpers
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useInitiativeAttachments } from "@/plane-web/hooks/store";
type Props = {
attachmentId: string;
disabled?: boolean;
toggleDeleteAttachmentModal: (attachmentId: string | null) => void;
};
export const InitiativeAttachmentsListItem: FC<Props> = observer((props) => {
// props
const { attachmentId, disabled, toggleDeleteAttachmentModal } = props;
// store hooks
const { getUserDetails } = useMember();
const { getAttachmentById } = useInitiativeAttachments();
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.asset ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
>
<div className="group flex items-center justify-between gap-3 h-11 hover:bg-custom-background-90 pl-9 pr-2">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{`${fileName}.${fileExtension}`}</p>
</Tooltip>
<span className="flex size-1.5 bg-custom-background-80 rounded-full" />
<span className="flex-shrink-0 text-custom-text-400">{convertBytesToSize(attachment.attributes.size)}</span>
</div>
<div className="flex items-center gap-3">
{attachment?.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" disabled={disabled}>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDeleteAttachmentModal(attachmentId);
}}
>
<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>
</>
);
});

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useInitiativeAttachments } from "@/plane-web/hooks/store";
// local components
import { InitiativeAttachmentsDetail } from "./attachment-detail";
import { InitiativeAttachmentsUploadDetails } from "./attachment-upload-details";
import { TAttachmentHelpers } from "./use-attachments";
type TInitiativeAttachmentsList = {
initiativeId: string;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
};
export const InitiativeAttachmentsList: FC<TInitiativeAttachmentsList> = observer((props) => {
const { initiativeId, attachmentHelpers, disabled } = props;
// store hooks
const { getAttachmentsByInitiativeId } = useInitiativeAttachments();
// derived values
const { snapshot: attachmentSnapshot } = attachmentHelpers;
const { uploadStatus } = attachmentSnapshot;
const initiativeAttachments = getAttachmentsByInitiativeId(initiativeId);
return (
<>
{uploadStatus?.map((uploadStatus) => (
<InitiativeAttachmentsUploadDetails key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{initiativeAttachments?.map((attachmentId: string) => (
<InitiativeAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
/>
))}
</>
);
});

View File

@@ -1,5 +1 @@
export * from "./attachment-detail";
export * from "./attachment-upload-details";
export * from "./attachments-list";
export * from "./delete-attachment-modal";
export * from "./root";

View File

@@ -1,32 +1,133 @@
"use client";
import { FC } from "react";
import { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
// components
import { InitiativeAttachmentsList } from "./attachments-list";
import { FileRejection, useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// plane web hooks
import { useInitiativeAttachments } from "@/plane-web/hooks/store";
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// local components
import { InitiativeAttachmentsListItem } from "./attachment-list-item";
import { AttachmentsUploadItem } from "./attachment-list-upload-item";
import { InitiativeAttachmentDeleteModal } from "./delete-attachment-modal";
import { useAttachmentOperations } from "./use-attachments";
export type TInitiativeAttachmentRoot = {
type Props = {
workspaceSlug: string;
initiativeId: string;
disabled?: boolean;
};
export const InitiativeAttachmentRoot: FC<TInitiativeAttachmentRoot> = observer((props) => {
// props
const { workspaceSlug, initiativeId, disabled = false } = props;
// hooks
export const InitiativeAttachmentRoot: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, disabled } = props;
// states
const [isLoading, setIsLoading] = useState(false);
const [attachmentDeleteModalId, setAttachmentDeleteModalId] = useState<string | null>(null);
// store hooks
const attachmentHelpers = useAttachmentOperations(workspaceSlug, initiativeId);
const { getAttachmentsByInitiativeId } = useInitiativeAttachments();
// helpers
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
// file size
const { maxFileSize } = useFileSize();
// derived values
const initiativeAttachments = getAttachmentsByInitiativeId(initiativeId);
const { uploadStatus } = attachmentSnapshot;
// handlers
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
attachmentOperations
.create(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "File could not be attached. Try uploading again.",
});
})
.finally(() => {
setIsLoading(false);
});
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
totalAttachedFiles > 1
? "Only one file can be uploaded at a time."
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
});
return;
},
[attachmentOperations, maxFileSize, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
});
const toggleDeleteAttachmentModal = (attachmentId: string | null) => {
setAttachmentDeleteModalId(attachmentId);
};
return (
<div className="relative py-3 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<InitiativeAttachmentsList
initiativeId={initiativeId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
/>
</div>
</div>
<>
{uploadStatus?.map((uploadStatus) => <AttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />)}
{initiativeAttachments && (
<>
{attachmentDeleteModalId && (
<InitiativeAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)}
onClose={() => toggleDeleteAttachmentModal(null)}
attachmentOperations={attachmentOperations}
attachmentId={attachmentDeleteModalId}
/>
)}
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && initiativeAttachments.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>
)}
{initiativeAttachments?.map((attachmentId) => (
<InitiativeAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
toggleDeleteAttachmentModal={toggleDeleteAttachmentModal}
/>
))}
</div>
</>
)}
</>
);
});

View File

@@ -75,6 +75,7 @@ export const ProjectItem = observer((props: Props) => {
</div>
</div>
}
itemClassName="overflow-visible"
isMobile={isMobile}
parentRef={parentRef}
/>

View File

@@ -7,6 +7,7 @@ import { Plus } from "lucide-react";
// plane ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// plane web
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// helpers
import { useAttachmentOperations } from "../collapsible-section/attachment/use-attachments";
@@ -22,6 +23,10 @@ export const InitiativeAttachmentActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, customButton, disabled = false } = props;
// state
const [isLoading, setIsLoading] = useState(false);
// store hooks
const {
initiative: { setLastCollapsibleAction },
} = useInitiatives();
// file size
const { maxFileSize } = useFileSize();
// operations
@@ -38,6 +43,9 @@ export const InitiativeAttachmentActionButton: FC<Props> = observer((props) => {
setIsLoading(true);
attachmentOperations
.create(currentFile)
.then(() => {
setLastCollapsibleAction("attachments");
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
@@ -72,9 +80,16 @@ export const InitiativeAttachmentActionButton: FC<Props> = observer((props) => {
});
return (
<button {...getRootProps()} type="button" disabled={disabled}>
<input {...getInputProps()} />
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
<div
onClick={(e) => {
// TODO: Remove extra div and move event propagation to button
e.stopPropagation();
}}
>
<button {...getRootProps()} type="button" disabled={disabled}>
<input {...getInputProps()} />
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
</div>
);
});

View File

@@ -25,7 +25,10 @@ export const InitiativeDetailRoot = observer((props: Props) => {
// derived values
const initiative = getInitiativeById(initiativeId);
const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
return (
<LayoutRoot

View File

@@ -108,7 +108,7 @@ export const InitiativeSidebarCommentsRoot: FC<Props> = observer((props) => {
},
uploadCommentAsset: async (file, commentId) => {
try {
if (!workspaceSlug || !commentId) throw new Error("Missing fields");
if (!workspaceSlug) throw new Error("Missing fields");
const res = await fileService.uploadWorkspaceAsset(
workspaceSlug,
{

View File

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

View File

@@ -0,0 +1,98 @@
"use client";
import { FC } 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";
// helpers
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { useProjectAttachments } from "@/plane-web/hooks/store/projects/use-project-attachments";
type Props = {
attachmentId: string;
disabled?: boolean;
toggleDeleteAttachmentModal: (attachmentId: string | null) => void;
};
export const ProjectAttachmentsListItem: FC<Props> = observer((props) => {
// props
const { attachmentId, disabled, toggleDeleteAttachmentModal } = props;
// store hooks
const { getUserDetails } = useMember();
const { getAttachmentById } = useProjectAttachments();
const { isMobile } = usePlatformOS();
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.asset ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
const fileURL = getFileURL(attachment?.asset_url ?? "");
if (!attachment) return <></>;
return (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
>
<div className="group flex items-center justify-between gap-3 h-11 hover:bg-custom-background-90 pl-9 pr-2">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{`${fileName}.${fileExtension}`}</p>
</Tooltip>
<span className="flex size-1.5 bg-custom-background-80 rounded-full" />
<span className="flex-shrink-0 text-custom-text-400">{convertBytesToSize(attachment.attributes.size)}</span>
</div>
<div className="flex items-center gap-3">
{attachment?.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" disabled={disabled}>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDeleteAttachmentModal(attachmentId);
}}
>
<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>
</>
);
});

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
// types
// components
import { useProjectAttachments } from "@/plane-web/hooks/store/projects/use-project-attachments";
import { ProjectAttachmentsDetail } from "./attachment-detail";
import { ProjectAttachmentsUploadDetails } from "./attachment-upload-details";
import { TAttachmentHelpers } from "./use-attachments";
type TProjectAttachmentsList = {
projectId: string;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
};
export const ProjectAttachmentsList: FC<TProjectAttachmentsList> = observer((props) => {
const { projectId, attachmentHelpers, disabled } = props;
// store hooks
const { getAttachmentsByProjectId } = useProjectAttachments();
// derived values
const { snapshot: attachmentSnapshot } = attachmentHelpers;
const { uploadStatus } = attachmentSnapshot;
const projectAttachments = getAttachmentsByProjectId(projectId);
return (
<>
{uploadStatus?.map((uploadStatus) => (
<ProjectAttachmentsUploadDetails key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{projectAttachments?.map((attachmentId: string) => (
<ProjectAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
/>
))}
</>
);
});

View File

@@ -1,5 +1 @@
export * from "./attachment-detail";
export * from "./attachment-upload-details";
export * from "./attachments-list";
export * from "./delete-attachment-modal";
export * from "./root";

View File

@@ -1,28 +1,132 @@
"use client";
import { FC } from "react";
import { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
// components
import { ProjectAttachmentsList } from "./attachments-list";
import { FileRejection, useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// plane web hooks
import { useProjectAttachments } from "@/plane-web/hooks/store/projects/use-project-attachments";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// local components
import { ProjectAttachmentsListItem } from "./attachment-list-item";
import { AttachmentsUploadItem } from "./attachment-list-upload-item";
import { ProjectAttachmentDeleteModal } from "./delete-attachment-modal";
import { useAttachmentOperations } from "./use-attachments";
export type TProjectAttachmentRoot = {
type Props = {
workspaceSlug: string;
projectId: string;
disabled?: boolean;
};
export const ProjectAttachmentRoot: FC<TProjectAttachmentRoot> = observer((props) => {
// props
const { workspaceSlug, projectId, disabled = false } = props;
// hooks
export const ProjectAttachmentRoot: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, disabled } = props;
// states
const [isLoading, setIsLoading] = useState(false);
const [attachmentDeleteModalId, setAttachmentDeleteModalId] = useState<string | null>(null);
// store hooks
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId);
const { getAttachmentsByProjectId } = useProjectAttachments();
// helpers
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
// file size
const { maxFileSize } = useFileSize();
// derived values
const projectAttachments = getAttachmentsByProjectId(projectId);
const { uploadStatus } = attachmentSnapshot;
// handlers
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
attachmentOperations
.create(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "File could not be attached. Try uploading again.",
});
})
.finally(() => {
setIsLoading(false);
});
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
totalAttachedFiles > 1
? "Only one file can be uploaded at a time."
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
});
return;
},
[attachmentOperations, maxFileSize, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
});
const toggleDeleteAttachmentModal = (attachmentId: string | null) => {
setAttachmentDeleteModalId(attachmentId);
};
return (
<div className="relative py-3 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProjectAttachmentsList projectId={projectId} disabled={disabled} attachmentHelpers={attachmentHelpers} />
</div>
</div>
<>
{uploadStatus?.map((uploadStatus) => <AttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />)}
{projectAttachments && (
<>
{attachmentDeleteModalId && (
<ProjectAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)}
onClose={() => toggleDeleteAttachmentModal(null)}
attachmentOperations={attachmentOperations}
attachmentId={attachmentDeleteModalId}
/>
)}
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && projectAttachments.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>
)}
{projectAttachments?.map((attachmentId) => (
<ProjectAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
toggleDeleteAttachmentModal={toggleDeleteAttachmentModal}
/>
))}
</div>
</>
)}
</>
);
});

View File

@@ -146,7 +146,7 @@ export class InitiativeStore implements IInitiativeStore {
};
setLastCollapsibleAction = (section: InitiativeCollapsible) => {
this.openCollapsibleSection = [section];
this.openCollapsibleSection = [...this.openCollapsibleSection, section];
};
toggleOpenCollapsibleSection = (section: InitiativeCollapsible) => {

View File

@@ -6,6 +6,7 @@ export type TProjectAttachment = {
name: string;
size: number;
};
asset: string;
asset_url: string;
project_id: string;
// required