feat: init
This commit is contained in:
113
apps/web/core/components/issues/archive-issue-modal.tsx
Normal file
113
apps/web/core/components/issues/archive-issue-modal.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TDeDupeIssue, TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
type Props = {
|
||||
data?: TIssue | TDeDupeIssue;
|
||||
dataId?: string | null | undefined;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSubmit?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const ArchiveIssueModal: React.FC<Props> = (props) => {
|
||||
const { dataId, data, isOpen, handleClose, onSubmit } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { issueMap } = useIssues();
|
||||
|
||||
if (!dataId && !data) return null;
|
||||
|
||||
const issue = data ? data : issueMap[dataId!];
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const onClose = () => {
|
||||
setIsArchiving(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
if (!onSubmit) return;
|
||||
|
||||
setIsArchiving(true);
|
||||
await onSubmit()
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("issue.archive.success.label"),
|
||||
message: t("issue.archive.success.message"),
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.error.label"),
|
||||
message: t("issue.archive.failed.message"),
|
||||
})
|
||||
)
|
||||
.finally(() => setIsArchiving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={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-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 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={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-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="px-5 py-4">
|
||||
<h3 className="text-xl font-medium 2xl:text-2xl">
|
||||
{t("issue.archive.label")} {projectDetails?.identifier} {issue.sequence_id}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm text-custom-text-200">{t("issue.archive.confirm_message")}</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
|
||||
{isArchiving ? t("common.archiving") : t("common.archive")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
72
apps/web/core/components/issues/archived-issues-header.tsx
Normal file
72
apps/web/core/components/issues/archived-issues-header.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { EHeaderVariant, Header } from "@plane/ui";
|
||||
// components
|
||||
import { ArchiveTabsList } from "@/components/archives";
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
export const ArchivedIssuesHeader: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// for archived issues list layout is the only option
|
||||
const activeLayout = "list";
|
||||
|
||||
const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, {
|
||||
...issueFilters?.displayFilters,
|
||||
...updatedDisplayFilter,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisplayPropertiesUpdate = (property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
return (
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<Header.LeftItem>
|
||||
<ArchiveTabsList />
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
<WorkItemFiltersToggle entityType={EIssuesStoreType.ARCHIVED} entityId={projectId} />
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
displayFilters={issueFilters?.displayFilters || {}}
|
||||
displayProperties={issueFilters?.displayProperties || {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
|
||||
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
107
apps/web/core/components/issues/attachment/attachment-detail.tsx
Normal file
107
apps/web/core/components/issues/attachment/attachment-detail.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
convertBytesToSize,
|
||||
getFileExtension,
|
||||
getFileName,
|
||||
getFileURL,
|
||||
renderFormattedDate,
|
||||
truncateText,
|
||||
} from "@plane/utils";
|
||||
// icons
|
||||
//
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
// components
|
||||
import { IssueAttachmentDeleteModal } from "@/components/issues/attachment/delete-attachment-modal";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
|
||||
|
||||
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentHelpers, "create">;
|
||||
|
||||
type TIssueAttachmentsDetail = {
|
||||
attachmentId: string;
|
||||
attachmentHelpers: TAttachmentOperationsRemoveModal;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((props) => {
|
||||
// props
|
||||
const { attachmentId, attachmentHelpers, 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;
|
||||
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 (
|
||||
<>
|
||||
{isDeleteIssueAttachmentModalOpen && (
|
||||
<IssueAttachmentDeleteModal
|
||||
isOpen={isDeleteIssueAttachmentModalOpen}
|
||||
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
|
||||
attachmentOperations={attachmentHelpers.operations}
|
||||
attachmentId={attachmentId}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 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={() => setIsDeleteIssueAttachmentModalOpen(true)}>
|
||||
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { FileRejection } from "react-dropzone";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UploadCloud } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// types
|
||||
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
|
||||
// components
|
||||
import { IssueAttachmentsListItem } from "./attachment-list-item";
|
||||
import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
|
||||
// types
|
||||
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
|
||||
|
||||
type TIssueAttachmentItemList = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
attachmentHelpers: TAttachmentHelpers;
|
||||
disabled?: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachmentHelpers,
|
||||
disabled,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
attachment: { getAttachmentsByIssueId },
|
||||
attachmentDeleteModalId,
|
||||
toggleDeleteAttachmentModal,
|
||||
fetchActivities,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
|
||||
const { create: createAttachment } = attachmentOperations;
|
||||
const { uploadStatus } = attachmentSnapshot;
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
// derived values
|
||||
const issueAttachments = getAttachmentsByIssueId(issueId);
|
||||
|
||||
// handlers
|
||||
const handleFetchPropertyActivities = useCallback(() => {
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
}, [fetchActivities, workspaceSlug, projectId, issueId]);
|
||||
|
||||
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;
|
||||
|
||||
setIsUploading(true);
|
||||
createAttachment(currentFile)
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("attachment.error"),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
handleFetchPropertyActivities();
|
||||
setIsUploading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message:
|
||||
totalAttachedFiles > 1
|
||||
? t("attachment.only_one_file_allowed")
|
||||
: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
|
||||
});
|
||||
return;
|
||||
},
|
||||
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
multiple: false,
|
||||
disabled: isUploading || disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{uploadStatus?.map((uploadStatus) => (
|
||||
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
|
||||
))}
|
||||
{issueAttachments && (
|
||||
<>
|
||||
{attachmentDeleteModalId && (
|
||||
<IssueAttachmentDeleteModal
|
||||
isOpen={Boolean(attachmentDeleteModalId)}
|
||||
onClose={() => toggleDeleteAttachmentModal(null)}
|
||||
attachmentOperations={attachmentOperations}
|
||||
attachmentId={attachmentDeleteModalId}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
<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">{t("attachment.drag_and_drop")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueAttachments?.map((attachmentId) => (
|
||||
<IssueAttachmentsListItem
|
||||
key={attachmentId}
|
||||
attachmentId={attachmentId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
// components
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type TIssueAttachmentsListItem = {
|
||||
attachmentId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer((props) => {
|
||||
const { t } = useTranslation();
|
||||
// props
|
||||
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
attachment: { getAttachmentById },
|
||||
toggleDeleteAttachmentModal,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
|
||||
const fileName = getFileName(attachment?.attributes.name ?? "");
|
||||
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
|
||||
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?.created_by && (
|
||||
<>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${
|
||||
getUserDetails(attachment?.created_by)?.display_name ?? ""
|
||||
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<ButtonAvatars showTooltip userIds={attachment?.created_by} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleDeleteAttachmentModal(attachmentId);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>{t("common.actions.delete")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { getFileExtension } from "@plane/utils";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
// helpers
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
|
||||
|
||||
type Props = {
|
||||
uploadStatus: TAttachmentUploadStatus;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsUploadItem: 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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CircularProgressIndicator } from "@plane/ui";
|
||||
import { getFileExtension, truncateText } from "@plane/utils";
|
||||
// ui
|
||||
// icons
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
// helpers
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
|
||||
|
||||
type Props = {
|
||||
uploadStatus: TAttachmentUploadStatus;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsUploadDetails: 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-[2px] 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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// types
|
||||
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
|
||||
|
||||
type TAttachmentOperationsModal = Pick<TAttachmentOperations, "create">;
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
disabled?: boolean;
|
||||
attachmentOperations: TAttachmentOperationsModal;
|
||||
};
|
||||
|
||||
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, disabled = false, attachmentOperations } = props;
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const currentFile: File = acceptedFiles[0];
|
||||
if (!currentFile || !workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
attachmentOperations.create(currentFile).finally(() => setIsLoading(false));
|
||||
},
|
||||
[attachmentOperations, workspaceSlug]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
const fileError =
|
||||
fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`flex h-[60px] items-center justify-center rounded-md border-2 border-dashed bg-custom-primary/5 px-4 text-xs text-custom-primary ${
|
||||
isDragActive ? "border-custom-primary bg-custom-primary/10" : "border-custom-border-200"
|
||||
} ${isDragReject ? "bg-red-100" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<span className="flex items-center gap-2">
|
||||
{isDragActive ? (
|
||||
<p>Drop here...</p>
|
||||
) : fileError ? (
|
||||
<p className="text-center text-red-500">{fileError}</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-center">Uploading...</p>
|
||||
) : (
|
||||
<p className="text-center">Click or drag a file here</p>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
|
||||
// components
|
||||
import { IssueAttachmentsDetail } from "./attachment-detail";
|
||||
import { IssueAttachmentsUploadDetails } from "./attachment-upload-details";
|
||||
|
||||
type TIssueAttachmentsList = {
|
||||
issueId: string;
|
||||
attachmentHelpers: TAttachmentHelpers;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props) => {
|
||||
const { issueId, attachmentHelpers, disabled } = props;
|
||||
// store hooks
|
||||
const {
|
||||
attachment: { getAttachmentsByIssueId },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const { snapshot: attachmentSnapshot } = attachmentHelpers;
|
||||
const { uploadStatus } = attachmentSnapshot;
|
||||
const issueAttachments = getAttachmentsByIssueId(issueId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{uploadStatus?.map((uploadStatus) => (
|
||||
<IssueAttachmentsUploadDetails key={uploadStatus.id} uploadStatus={uploadStatus} />
|
||||
))}
|
||||
{issueAttachments?.map((attachmentId) => (
|
||||
<IssueAttachmentsDetail
|
||||
key={attachmentId}
|
||||
attachmentId={attachmentId}
|
||||
disabled={disabled}
|
||||
attachmentHelpers={attachmentHelpers}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane-i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// helper
|
||||
import { getFileName } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
|
||||
|
||||
export type TAttachmentOperationsRemoveModal = Pick<TAttachmentOperations, "remove">;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
attachmentId: string;
|
||||
attachmentOperations: TAttachmentOperationsRemoveModal;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, onClose, attachmentId, attachmentOperations, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// states
|
||||
const [loader, setLoader] = useState(false);
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
attachment: { getAttachmentById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
|
||||
|
||||
// handlers
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setLoader(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async (assetId: string) => {
|
||||
setLoader(true);
|
||||
attachmentOperations.remove(assetId).finally(() => handleClose());
|
||||
};
|
||||
|
||||
if (!attachment) return <></>;
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={() => handleDeletion(attachment.id)}
|
||||
isSubmitting={loader}
|
||||
isOpen={isOpen}
|
||||
title={t("attachment.delete")}
|
||||
content={
|
||||
<>
|
||||
{/* TODO: Translate here */}
|
||||
Are you sure you want to delete attachment-{" "}
|
||||
<span className="font-bold">{getFileName(attachment.attributes.name)}</span>? This attachment will be
|
||||
permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/issues/attachment/index.ts
Normal file
1
apps/web/core/components/issues/attachment/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
37
apps/web/core/components/issues/attachment/root.tsx
Normal file
37
apps/web/core/components/issues/attachment/root.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
|
||||
// components
|
||||
import { IssueAttachmentUpload } from "./attachment-upload";
|
||||
import { IssueAttachmentsList } from "./attachments-list";
|
||||
|
||||
export type TIssueAttachmentRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// hooks
|
||||
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
return (
|
||||
<div className="relative py-3 space-y-3">
|
||||
<h3 className="text-lg">Attachments</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<IssueAttachmentUpload
|
||||
workspaceSlug={workspaceSlug}
|
||||
disabled={disabled}
|
||||
attachmentOperations={attachmentHelpers.operations}
|
||||
/>
|
||||
<IssueAttachmentsList issueId={issueId} disabled={disabled} attachmentHelpers={attachmentHelpers} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { MARKETING_PLANE_ONE_PAGE_LINK } from "@plane/constants";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const BulkOperationsUpgradeBanner: React.FC<Props> = (props) => {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("sticky bottom-0 left-0 h-20 z-[2] px-3.5 grid place-items-center", className)}>
|
||||
<div className="h-14 w-full bg-custom-primary-100/10 border-[0.5px] border-custom-primary-100/50 py-4 px-3.5 flex items-center justify-between gap-2 rounded-md">
|
||||
<p className="text-custom-primary-100 font-medium">
|
||||
Change state, priority, and more for several work items at once. Save three minutes on an average per
|
||||
operation.
|
||||
</p>
|
||||
<a
|
||||
href={MARKETING_PLANE_ONE_PAGE_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(getButtonStyling("primary", "sm"), "flex-shrink-0")}
|
||||
>
|
||||
Upgrade to One
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
apps/web/core/components/issues/confirm-issue-discard.tsx
Normal file
96
apps/web/core/components/issues/confirm-issue-discard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onDiscard: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, onDiscard, onConfirm } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsLoading(true);
|
||||
await onConfirm();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" 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-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
|
||||
<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-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
Save this draft?
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
You can save this work item to Drafts so you can come back to it later.{" "}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 p-4 sm:px-6">
|
||||
<div>
|
||||
<Button variant="neutral-primary" size="sm" onClick={onDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleDeletion} loading={isLoading}>
|
||||
{isLoading ? "Saving" : "Save to Drafts"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||
// plane imports
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
type TCreateIssueToastActionItems = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, isEpic = false } = props;
|
||||
// state
|
||||
const [copied, setCopied] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId,
|
||||
projectIdentifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
isEpic,
|
||||
});
|
||||
|
||||
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
try {
|
||||
await copyUrlToClipboard(workItemLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
} catch (error) {
|
||||
setCopied(false);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-custom-text-200">
|
||||
<a
|
||||
href={workItemLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
|
||||
>
|
||||
{`View ${isEpic ? "epic" : "work item"}`}
|
||||
</a>
|
||||
|
||||
{copied ? (
|
||||
<>
|
||||
<span className="cursor-default px-2 py-1 text-custom-text-200">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="cursor-pointer hidden group-hover:flex px-2 py-1 text-custom-text-300 hover:text-custom-text-200 hover:bg-custom-background-90 rounded"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
128
apps/web/core/components/issues/delete-issue-modal.tsx
Normal file
128
apps/web/core/components/issues/delete-issue-modal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { PROJECT_ERROR_MESSAGES, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TDeDupeIssue, TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// constants
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane-web
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
dataId?: string | null | undefined;
|
||||
data?: TIssue | TDeDupeIssue;
|
||||
isSubIssue?: boolean;
|
||||
onSubmit?: () => Promise<void>;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const DeleteIssueModal: React.FC<Props> = observer((props) => {
|
||||
const { dataId, data, isOpen, handleClose, isSubIssue = false, onSubmit, isEpic = false } = props;
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// store hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const { issueMap } = useIssues();
|
||||
const { getProjectById } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
setIsDeleting(false);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!dataId && !data) return null;
|
||||
|
||||
// derived values
|
||||
const issue = data ? data : issueMap[dataId!];
|
||||
const projectDetails = getProjectById(issue?.project_id);
|
||||
const isIssueCreator = issue?.created_by === currentUser?.id;
|
||||
|
||||
const canPerformProjectAdminActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
projectDetails?.id
|
||||
);
|
||||
|
||||
const authorized = isIssueCreator || canPerformProjectAdminActions;
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeleting(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleIssueDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
|
||||
if (!authorized) {
|
||||
setToast({
|
||||
title: t(PROJECT_ERROR_MESSAGES.permissionError.i18n_title),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message:
|
||||
PROJECT_ERROR_MESSAGES.permissionError.i18n_message && t(PROJECT_ERROR_MESSAGES.permissionError.i18n_message),
|
||||
});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (onSubmit)
|
||||
await onSubmit()
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.success"),
|
||||
message: t("entity.delete.success", {
|
||||
entity: isSubIssue ? t("common.sub_work_item") : isEpic ? t("common.epic") : t("common.work_item"),
|
||||
}),
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch((errors) => {
|
||||
const isPermissionError =
|
||||
errors?.error ===
|
||||
`Only admin or creator can delete the ${isSubIssue ? "sub-work item" : isEpic ? "epic" : "work item"}`;
|
||||
const currentError = isPermissionError
|
||||
? PROJECT_ERROR_MESSAGES.permissionError
|
||||
: PROJECT_ERROR_MESSAGES.issueDeleteError;
|
||||
setToast({
|
||||
title: t(currentError.i18n_title),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: currentError.i18n_message && t(currentError.i18n_message),
|
||||
});
|
||||
})
|
||||
.finally(() => onClose());
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={onClose}
|
||||
handleSubmit={handleIssueDelete}
|
||||
isSubmitting={isDeleting}
|
||||
isOpen={isOpen}
|
||||
title={t("entity.delete.label", { entity: isEpic ? t("common.epic") : t("common.work_item") })}
|
||||
content={
|
||||
<>
|
||||
{/* TODO: Translate here */}
|
||||
{`Are you sure you want to delete ${isEpic ? "epic" : "work item"} `}
|
||||
<span className="break-words font-medium text-custom-text-100">
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
{` ? All of the data related to the ${isEpic ? "epic" : "work item"} will be permanently removed. This action cannot be undone.`}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
199
apps/web/core/components/issues/description-input.tsx
Normal file
199
apps/web/core/components/issues/description-input.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { getDescriptionPlaceholderI18n } from "@plane/utils";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export type IssueDescriptionInputProps = {
|
||||
containerClassName?: string;
|
||||
editorRef?: React.RefObject<EditorRefApi>;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
initialValue: string | undefined;
|
||||
disabled?: boolean;
|
||||
issueOperations: TIssueOperations;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
|
||||
swrIssueDescription?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorRef,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
disabled,
|
||||
swrIssueDescription,
|
||||
initialValue,
|
||||
issueOperations,
|
||||
setIsSubmitting,
|
||||
placeholder,
|
||||
} = props;
|
||||
// states
|
||||
const [localIssueDescription, setLocalIssueDescription] = useState({
|
||||
id: issueId,
|
||||
description_html: initialValue,
|
||||
});
|
||||
// ref to track if there are unsaved changes
|
||||
const hasUnsavedChanges = useRef(false);
|
||||
// store hooks
|
||||
const { uploadEditorAsset } = useEditorAsset();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString();
|
||||
// form info
|
||||
const { handleSubmit, reset, control } = useForm<TIssue>({
|
||||
defaultValues: {
|
||||
description_html: initialValue,
|
||||
},
|
||||
});
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
async (formData: Partial<TIssue>) => {
|
||||
await issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueOperations]
|
||||
);
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!issueId) return;
|
||||
reset({
|
||||
id: issueId,
|
||||
description_html: initialValue === "" ? "<p></p>" : initialValue,
|
||||
});
|
||||
setLocalIssueDescription({
|
||||
id: issueId,
|
||||
description_html: initialValue === "" ? "<p></p>" : initialValue,
|
||||
});
|
||||
// Reset unsaved changes flag when form is reset
|
||||
hasUnsavedChanges.current = false;
|
||||
}, [initialValue, issueId, reset]);
|
||||
|
||||
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
|
||||
// TODO: Verify the exhaustive-deps warning
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedFormSave = useCallback(
|
||||
debounce(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
|
||||
setIsSubmitting("submitted");
|
||||
hasUnsavedChanges.current = false;
|
||||
});
|
||||
}, 1500),
|
||||
[handleSubmit, issueId]
|
||||
);
|
||||
|
||||
// Save on unmount if there are unsaved changes
|
||||
useEffect(
|
||||
() => () => {
|
||||
debouncedFormSave.cancel();
|
||||
|
||||
if (hasUnsavedChanges.current) {
|
||||
handleSubmit(handleDescriptionFormSubmit)()
|
||||
.catch((error) => {
|
||||
console.error("Failed to save description on unmount:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubmitting("submitted");
|
||||
hasUnsavedChanges.current = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
if (!workspaceId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{localIssueDescription.description_html ? (
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<RichTextEditor
|
||||
editable={!disabled}
|
||||
id={issueId}
|
||||
initialValue={localIssueDescription.description_html ?? "<p></p>"}
|
||||
value={swrIssueDescription ?? null}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
dragDropEnabled
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
hasUnsavedChanges.current = true;
|
||||
debouncedFormSave();
|
||||
}}
|
||||
placeholder={
|
||||
placeholder
|
||||
? placeholder
|
||||
: (isFocused, value) => t(`${getDescriptionPlaceholderI18n(isFocused, value)}`)
|
||||
}
|
||||
searchMentionCallback={async (payload) =>
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
issue_id: issueId?.toString(),
|
||||
})
|
||||
}
|
||||
containerClassName={containerClassName}
|
||||
uploadFile={async (blockId, file) => {
|
||||
try {
|
||||
const { asset_id } = await uploadEditorAsset({
|
||||
blockId,
|
||||
data: {
|
||||
entity_identifier: issueId,
|
||||
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
|
||||
},
|
||||
file,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
return asset_id;
|
||||
} catch (error) {
|
||||
console.log("Error in uploading work item asset:", error);
|
||||
throw new Error("Asset upload failed. Please try again later.");
|
||||
}
|
||||
}}
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
141
apps/web/core/components/issues/filters.tsx
Normal file
141
apps/web/core/components/issues/filters.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
// plane web imports
|
||||
import type { TProject } from "@/plane-web/types";
|
||||
// local imports
|
||||
import { WorkItemsModal } from "../analytics/work-items/modal";
|
||||
import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FiltersDropdown,
|
||||
LayoutSelection,
|
||||
MobileLayoutSelection,
|
||||
} from "./issue-layouts/filters";
|
||||
|
||||
type Props = {
|
||||
currentProjectDetails: TProject | undefined;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
canUserCreateIssue: boolean | undefined;
|
||||
storeType?: EIssuesStoreType.PROJECT | EIssuesStoreType.EPIC;
|
||||
};
|
||||
const LAYOUTS = [
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
];
|
||||
|
||||
export const HeaderFilters = observer((props: Props) => {
|
||||
const {
|
||||
currentProjectDetails,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
canUserCreateIssue,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
} = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(storeType);
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
isEpic={storeType === EIssuesStoreType.EPIC}
|
||||
/>
|
||||
<div className="hidden @4xl:flex">
|
||||
<LayoutSelection
|
||||
layouts={LAYOUTS}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex @4xl:hidden">
|
||||
<MobileLayoutSelection
|
||||
layouts={LAYOUTS}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
activeLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
|
||||
<FiltersDropdown
|
||||
miniIcon={<SlidersHorizontal className="size-3.5" />}
|
||||
title={t("common.display")}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
isEpic={storeType === EIssuesStoreType.EPIC}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
{canUserCreateIssue ? (
|
||||
<Button
|
||||
className="hidden md:block px-2"
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
|
||||
<div className="flex @4xl:hidden">
|
||||
<ChartNoAxesColumn className="size-3.5" />
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { Link, Paperclip, Waypoints } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ViewsIcon } from "@plane/propel/icons";
|
||||
// plane imports
|
||||
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// plane web imports
|
||||
import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons";
|
||||
// local imports
|
||||
import { IssueAttachmentActionButton } from "./attachments";
|
||||
import { IssueLinksActionButton } from "./links";
|
||||
import { RelationActionButton } from "./relations";
|
||||
import { SubIssuesActionButton } from "./sub-issues";
|
||||
import { IssueDetailWidgetButton } from "./widget-button";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
{!hideWidgets?.includes("sub-work-items") && (
|
||||
<SubIssuesActionButton
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.sub_issue")}
|
||||
icon={<ViewsIcon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{!hideWidgets?.includes("relations") && (
|
||||
<RelationActionButton
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.relation")}
|
||||
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{!hideWidgets?.includes("links") && (
|
||||
<IssueLinksActionButton
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.link")}
|
||||
icon={<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{!hideWidgets?.includes("attachments") && (
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("common.attach")}
|
||||
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
<WorkItemAdditionalWidgetActionButtons
|
||||
disabled={disabled}
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// local imports
|
||||
import { IssueAttachmentItemList } from "../../attachment/attachment-item-list";
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// helper
|
||||
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
return (
|
||||
<IssueAttachmentItemList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
attachmentHelpers={attachmentHelpers}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
|
||||
|
||||
export type TAttachmentOperations = {
|
||||
create: (file: File) => Promise<void>;
|
||||
remove: (attachmentId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TAttachmentSnapshot = {
|
||||
uploadStatus: TAttachmentUploadStatus[] | undefined;
|
||||
};
|
||||
|
||||
export type TAttachmentHelpers = {
|
||||
operations: TAttachmentOperations;
|
||||
snapshot: TAttachmentSnapshot;
|
||||
};
|
||||
|
||||
export const useAttachmentOperations = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
|
||||
): TAttachmentHelpers => {
|
||||
const {
|
||||
attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
const attachmentOperations: TAttachmentOperations = useMemo(
|
||||
() => ({
|
||||
create: async (file) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
|
||||
setPromiseToast(attachmentUploadPromise, {
|
||||
loading: "Uploading attachment...",
|
||||
success: {
|
||||
title: "Attachment uploaded",
|
||||
message: () => "The attachment has been successfully uploaded",
|
||||
},
|
||||
error: {
|
||||
title: "Attachment not uploaded",
|
||||
message: () => "The attachment could not be uploaded",
|
||||
},
|
||||
});
|
||||
|
||||
await attachmentUploadPromise;
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (attachmentId) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
|
||||
setToast({
|
||||
message: "The attachment has been successfully removed",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Attachment removed",
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
message: "The Attachment could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Attachment not removed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
|
||||
);
|
||||
const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId);
|
||||
|
||||
return {
|
||||
operations: attachmentOperations,
|
||||
snapshot: { uploadStatus: attachmentsUploadStatus },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { FileRejection } from "react-dropzone";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// local imports
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, customButton, disabled = false, issueServiceType } = props;
|
||||
// state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// store hooks
|
||||
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
// operations
|
||||
const { operations: attachmentOperations } = useAttachmentOperations(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
issueServiceType
|
||||
);
|
||||
// handlers
|
||||
const handleFetchPropertyActivities = useCallback(() => {
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
}, [fetchActivities, workspaceSlug, projectId, issueId]);
|
||||
|
||||
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(() => {
|
||||
handleFetchPropertyActivities();
|
||||
setLastWidgetAction("attachments");
|
||||
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, handleFetchPropertyActivities, setLastWidgetAction]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueAttachmentsCollapsibleContent } from "./content";
|
||||
import { IssueAttachmentsCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const AttachmentsCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("attachments");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("attachments")}
|
||||
title={
|
||||
<IssueAttachmentsCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<IssueAttachmentsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueAttachmentActionButton } from "./quick-action-button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const attachmentCount = issue?.attachment_count ?? 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
|
||||
</span>
|
||||
),
|
||||
[attachmentCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={t("common.attachments")}
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
!disabled && (
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { WorkItemAdditionalWidgetCollapsibles } from "@/plane-web/components/issues/issue-detail-widgets/collapsibles";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
// local imports
|
||||
import { AttachmentsCollapsible } from "./attachments";
|
||||
import { LinksCollapsible } from "./links";
|
||||
import { RelationsCollapsible } from "./relations";
|
||||
import { SubIssuesCollapsible } from "./sub-issues";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { subIssuesByIssueId },
|
||||
attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId },
|
||||
relation: { getRelationCountByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const subIssues = subIssuesByIssueId(issueId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
|
||||
// render conditions
|
||||
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0 && !hideWidgets?.includes("sub-work-items");
|
||||
const shouldRenderRelations = issueRelationsCount > 0 && !hideWidgets?.includes("relations");
|
||||
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0 && !hideWidgets?.includes("links");
|
||||
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
|
||||
const attachmentsCount = getAttachmentsCountByIssueId(issueId);
|
||||
const shouldRenderAttachments =
|
||||
attachmentsCount > 0 ||
|
||||
(!!attachmentUploads && attachmentUploads.length > 0 && !hideWidgets?.includes("attachments"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderSubIssues && (
|
||||
<SubIssuesCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderRelations && (
|
||||
<RelationsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderLinks && (
|
||||
<LinksCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderAttachments && (
|
||||
<AttachmentsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
<WorkItemAdditionalWidgetCollapsibles
|
||||
disabled={disabled}
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web imports
|
||||
import { WorkItemAdditionalWidgetModals } from "@/plane-web/components/issues/issue-detail-widgets/modals";
|
||||
// local imports
|
||||
import { IssueLinkCreateUpdateModal } from "../issue-detail/links/create-update-link-modal";
|
||||
// helpers
|
||||
import { CreateUpdateIssueModal } from "../issue-modal/modal";
|
||||
import { useLinkOperations } from "./links/helper";
|
||||
import { useSubIssueOperations } from "./sub-issues/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueServiceType, hideWidgets } = props;
|
||||
// store hooks
|
||||
const {
|
||||
isIssueLinkModalOpen,
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
setIssueLinkData,
|
||||
isCreateIssueModalOpen,
|
||||
toggleCreateIssueModal,
|
||||
isSubIssuesModalOpen,
|
||||
toggleSubIssuesModal,
|
||||
relationKey,
|
||||
isRelationModalOpen,
|
||||
setRelationKey,
|
||||
setLastWidgetAction,
|
||||
toggleRelationModal,
|
||||
createRelation,
|
||||
issueCrudOperationState,
|
||||
setIssueCrudOperationState,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helper hooks
|
||||
const subIssueOperations = useSubIssueOperations(issueServiceType);
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
|
||||
// handlers
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing",
|
||||
_parentIssueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
setIssueCrudOperationState({
|
||||
...issueCrudOperationState,
|
||||
[key]: {
|
||||
toggle: !issueCrudOperationState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExistingIssuesModalClose = () => {
|
||||
handleIssueCrudState("existing", null, null);
|
||||
setLastWidgetAction("sub-work-items");
|
||||
toggleSubIssuesModal(null);
|
||||
};
|
||||
|
||||
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
|
||||
subIssueOperations.addSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
_issue.map((issue) => issue.id)
|
||||
);
|
||||
|
||||
const handleCreateUpdateModalClose = () => {
|
||||
handleIssueCrudState("create", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
setLastWidgetAction("sub-work-items");
|
||||
};
|
||||
|
||||
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
|
||||
if (_issue.parent_id) {
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIssueLinkModalOnClose = () => {
|
||||
toggleIssueLinkModalStore(false);
|
||||
setLastWidgetAction("links");
|
||||
setIssueLinkData(null);
|
||||
};
|
||||
|
||||
const handleRelationOnClose = () => {
|
||||
setRelationKey(null);
|
||||
toggleRelationModal(null, null);
|
||||
setLastWidgetAction("relations");
|
||||
};
|
||||
|
||||
const handleExistingIssueModalOnSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (!relationKey) return;
|
||||
if (data.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one work item.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createRelation(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
relationKey,
|
||||
data.map((i) => i.id)
|
||||
);
|
||||
|
||||
toggleRelationModal(null, null);
|
||||
};
|
||||
|
||||
// helpers
|
||||
const createUpdateModalData: Partial<TIssue> = {
|
||||
parent_id: issueCrudOperationState?.create?.parentIssueId,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const existingIssuesModalSearchParams = {
|
||||
sub_issue: true,
|
||||
issue_id: issueCrudOperationState?.existing?.parentIssueId,
|
||||
};
|
||||
|
||||
// render conditions
|
||||
const shouldRenderExistingIssuesModal =
|
||||
!hideWidgets?.includes("sub-work-items") &&
|
||||
issueCrudOperationState?.existing?.toggle &&
|
||||
issueCrudOperationState?.existing?.parentIssueId &&
|
||||
isSubIssuesModalOpen;
|
||||
|
||||
const shouldRenderCreateUpdateModal =
|
||||
!hideWidgets?.includes("sub-work-items") &&
|
||||
issueCrudOperationState?.create?.toggle &&
|
||||
issueCrudOperationState?.create?.parentIssueId &&
|
||||
isCreateIssueModalOpen;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideWidgets?.includes("links") && (
|
||||
<IssueLinkCreateUpdateModal
|
||||
isModalOpen={isIssueLinkModalOpen}
|
||||
handleOnClose={handleIssueLinkModalOnClose}
|
||||
linkOperations={handleLinkOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderCreateUpdateModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperationState?.create?.toggle}
|
||||
data={createUpdateModalData}
|
||||
onClose={handleCreateUpdateModalClose}
|
||||
onSubmit={handleCreateUpdateModalOnSubmit}
|
||||
isProjectSelectionDisabled
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderExistingIssuesModal && (
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={issueCrudOperationState?.existing?.toggle}
|
||||
handleClose={handleExistingIssuesModalClose}
|
||||
searchParams={existingIssuesModalSearchParams}
|
||||
handleOnSubmit={handleExistingIssuesModalOnSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideWidgets?.includes("relations") && (
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
|
||||
handleClose={handleRelationOnClose}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={handleExistingIssueModalOnSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkItemAdditionalWidgetModals
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import { LinkList } from "../../issue-detail/links";
|
||||
// helper
|
||||
import { useLinkOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType } = props;
|
||||
|
||||
// helper
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
|
||||
return (
|
||||
<LinkList
|
||||
issueId={issueId}
|
||||
linkOperations={handleLinkOperations}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueLink, TIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import type { TLinkOperations } from "../../issue-detail/links";
|
||||
|
||||
export const useLinkOperations = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
issueServiceType: TIssueServiceType
|
||||
): TLinkOperations => {
|
||||
const { createLink, updateLink, removeLink } = useIssueDetail(issueServiceType);
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLinkOperations: TLinkOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, projectId, issueId, data);
|
||||
setToast({
|
||||
message: t("links.toasts.created.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.created.title"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_created.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_created.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async (linkId: string, data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
setToast({
|
||||
message: t("links.toasts.updated.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.updated.title"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_updated.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_updated.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, projectId, issueId, linkId);
|
||||
setToast({
|
||||
message: t("links.toasts.removed.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.removed.title"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
message: t("links.toasts.not_removed.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_removed.title"),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, t]
|
||||
);
|
||||
|
||||
return handleLinkOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
type Props = {
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksActionButton: FC<Props> = observer((props) => {
|
||||
const { customButton, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { toggleIssueLinkModal } = useIssueDetail(issueServiceType);
|
||||
|
||||
// handlers
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIssueLinkModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleOnClick} disabled={disabled}>
|
||||
{customButton ? customButton : <Plus className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueLinksCollapsibleContent } from "./content";
|
||||
import { IssueLinksCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const LinksCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("links");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("links")}
|
||||
title={
|
||||
<IssueLinksCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<IssueLinksCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueLinksActionButton } from "./quick-action-button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, issueId, disabled, issueServiceType } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
const linksCount = issue?.link_count ?? 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
|
||||
</span>
|
||||
),
|
||||
[linksCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={t("common.links")}
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
!disabled && <IssueLinksActionButton issueServiceType={issueServiceType} disabled={disabled} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
// helper
|
||||
import { DeleteIssueModal } from "../../delete-issue-modal";
|
||||
import { RelationIssueList } from "../../relations/issue-list";
|
||||
import { useRelationOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export type TRelationObject = {
|
||||
key: TIssueRelationTypes;
|
||||
i18n_label: string;
|
||||
className: string;
|
||||
icon: (size: number) => React.ReactElement;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
update: TIssueCrudState;
|
||||
delete: TIssueCrudState;
|
||||
removeRelation: TIssueCrudState & { relationKey: string | undefined; relationIssueId: string | undefined };
|
||||
}>({
|
||||
update: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
removeRelation: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
relationKey: undefined,
|
||||
relationIssueId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
relation: { getRelationsByIssueId, removeRelation },
|
||||
toggleDeleteIssueModal,
|
||||
toggleCreateIssueModal,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helper
|
||||
const issueOperations = useRelationOperations();
|
||||
const epicOperations = useRelationOperations(EIssueServiceType.EPICS);
|
||||
|
||||
// derived values
|
||||
const relations = getRelationsByIssueId(issueId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
|
||||
const handleIssueCrudState = (
|
||||
key: "update" | "delete" | "removeRelation",
|
||||
_issueId: string | null,
|
||||
issue: TIssue | null = null,
|
||||
relationKey?: TIssueRelationTypes | null,
|
||||
relationIssueId?: string | null
|
||||
) => {
|
||||
setIssueCrudState((prevState) => ({
|
||||
...prevState,
|
||||
[key]: {
|
||||
toggle: !prevState[key].toggle,
|
||||
issueId: _issueId,
|
||||
issue: issue,
|
||||
relationKey: relationKey,
|
||||
relationIssueId: relationIssueId,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// if relations are not available, return null
|
||||
if (!relations) return null;
|
||||
|
||||
// map relations to array
|
||||
const relationsArray = (Object.keys(relations) as TIssueRelationTypes[])
|
||||
.filter((relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey])
|
||||
.map((relationKey) => {
|
||||
const issueIds = relations[relationKey];
|
||||
const issueRelationOption = ISSUE_RELATION_OPTIONS[relationKey];
|
||||
return {
|
||||
relationKey: relationKey,
|
||||
issueIds: issueIds,
|
||||
icon: issueRelationOption?.icon,
|
||||
label: issueRelationOption?.i18n_label ? t(issueRelationOption?.i18n_label) : "",
|
||||
className: issueRelationOption?.className,
|
||||
};
|
||||
});
|
||||
|
||||
// filter out relations with no issues
|
||||
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
|
||||
|
||||
const shouldRenderIssueDeleteModal =
|
||||
issueCrudState?.delete?.toggle &&
|
||||
issueCrudState?.delete?.issue &&
|
||||
issueCrudState.delete.issueId &&
|
||||
issueCrudState.delete.issue.id;
|
||||
|
||||
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-">
|
||||
{filteredRelationsArray.map((relation) => (
|
||||
<div key={relation.relationKey}>
|
||||
<Collapsible
|
||||
buttonClassName="w-full"
|
||||
title={
|
||||
<div className={`flex items-center gap-1 px-2.5 py-1 h-9 w-full ${relation.className}`}>
|
||||
<span>{relation.icon ? relation.icon(14) : null}</span>
|
||||
<span className="text-sm font-medium leading-5">{relation.label}</span>
|
||||
</div>
|
||||
}
|
||||
defaultOpen
|
||||
>
|
||||
<RelationIssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
relationKey={relation.relationKey}
|
||||
issueIds={relation.issueIds}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shouldRenderIssueDeleteModal && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () => {
|
||||
if (
|
||||
issueCrudState.removeRelation.issueId &&
|
||||
issueCrudState.removeRelation.issue?.project_id &&
|
||||
issueCrudState.removeRelation.relationKey &&
|
||||
issueCrudState.removeRelation.relationIssueId
|
||||
) {
|
||||
await removeRelation(
|
||||
workspaceSlug,
|
||||
issueCrudState.removeRelation.issue.project_id,
|
||||
issueCrudState.removeRelation.issueId,
|
||||
issueCrudState.removeRelation.relationKey as TIssueRelationTypes,
|
||||
issueCrudState.removeRelation.relationIssueId,
|
||||
true
|
||||
);
|
||||
}
|
||||
if (
|
||||
issueCrudState.delete.issue &&
|
||||
issueCrudState.delete.issue.id &&
|
||||
issueCrudState.delete.issue.project_id
|
||||
) {
|
||||
const deleteOperation = !!issueCrudState.delete.issue?.is_epic
|
||||
? epicOperations.remove
|
||||
: issueOperations.remove;
|
||||
await deleteOperation(
|
||||
workspaceSlug,
|
||||
issueCrudState.delete.issue?.project_id,
|
||||
issueCrudState?.delete?.issue?.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
isEpic={!!issueCrudState.delete.issue?.is_epic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderIssueUpdateModal && (
|
||||
<>
|
||||
{!!issueCrudState?.update?.issue?.is_epic ? (
|
||||
<CreateUpdateEpicModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (!_issue.id || !_issue.project_id) return;
|
||||
await epicOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (!_issue.id || !_issue.project_id) return;
|
||||
await issueOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
export type TRelationIssueOperations = {
|
||||
copyLink: (path: string) => void;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useRelationOperations = (
|
||||
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
|
||||
): TRelationIssueOperations => {
|
||||
const { updateIssue, removeIssue } = useIssueDetail(issueServiceType);
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const entityName = issueServiceType === EIssueServiceType.ISSUES ? "Work item" : "Epic";
|
||||
|
||||
const issueOperations: TRelationIssueOperations = useMemo(
|
||||
() => ({
|
||||
copyLink: (path) => {
|
||||
copyUrlToClipboard(path).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", { entity: entityName }),
|
||||
});
|
||||
});
|
||||
},
|
||||
update: async (workspaceSlug, projectId, issueId, data) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
setToast({
|
||||
title: t("toast.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("entity.update.success", { entity: entityName }),
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.update.failed", { entity: entityName }),
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug, projectId, issueId) => {
|
||||
try {
|
||||
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[entityName, removeIssue, t, updateIssue]
|
||||
);
|
||||
|
||||
return issueOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationActionButton: FC<Props> = observer((props) => {
|
||||
const { customButton, issueId, disabled = false, issueServiceType } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleRelationModal, setRelationKey } = useIssueDetail(issueServiceType);
|
||||
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
|
||||
// handlers
|
||||
const handleOnClick = (relationKey: TIssueRelationTypes) => {
|
||||
setRelationKey(relationKey);
|
||||
toggleRelationModal(issueId, relationKey);
|
||||
};
|
||||
|
||||
// button element
|
||||
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={customButtonElement}
|
||||
placement="bottom-start"
|
||||
disabled={disabled}
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
{Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => {
|
||||
if (!item) return <></>;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleOnClick(item.key as TIssueRelationTypes);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon(12)}
|
||||
<span>{t(item.i18n_label)}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { RelationsCollapsibleContent } from "./content";
|
||||
import { RelationsCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationsCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("relations");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("relations")}
|
||||
title={
|
||||
<RelationsCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<RelationsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
// local imports
|
||||
import { RelationActionButton } from "./quick-action-button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hook
|
||||
const {
|
||||
relation: { getRelationCountByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
// derived values
|
||||
const relationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
|
||||
</span>
|
||||
),
|
||||
[relationsCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={t("common.relations")}
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
!disabled && <RelationActionButton issueId={issueId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// local imports
|
||||
import { IssueDetailWidgetActionButtons } from "./action-buttons";
|
||||
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
|
||||
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
renderWidgetModals?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgets: FC<Props> = (props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
disabled,
|
||||
renderWidgetModals = true,
|
||||
issueServiceType,
|
||||
hideWidgets,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5">
|
||||
<IssueDetailWidgetActionButtons
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
hideWidgets={hideWidgets}
|
||||
/>
|
||||
<IssueDetailWidgetCollapsibles
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
hideWidgets={hideWidgets}
|
||||
/>
|
||||
</div>
|
||||
{renderWidgetModals && (
|
||||
<IssueDetailWidgetModals
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueServiceType={issueServiceType}
|
||||
hideWidgets={hideWidgets}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { CreateUpdateIssueModal } from "../../issue-modal/modal";
|
||||
import { useSubIssueOperations } from "./helper";
|
||||
import { SubIssuesListRoot } from "./issues-list/root";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
create: TIssueCrudState;
|
||||
existing: TIssueCrudState;
|
||||
update: TIssueCrudState;
|
||||
delete: TIssueCrudState;
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
existing: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
update: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
toggleCreateIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helpers
|
||||
const subIssueOperations = useSubIssueOperations(issueServiceType);
|
||||
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
|
||||
|
||||
// handler
|
||||
const handleIssueCrudState = useCallback(
|
||||
(key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null) => {
|
||||
setIssueCrudState({
|
||||
...issueCrudState,
|
||||
[key]: {
|
||||
toggle: !issueCrudState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue,
|
||||
},
|
||||
});
|
||||
},
|
||||
[issueCrudState]
|
||||
);
|
||||
|
||||
const handleFetchSubIssues = useCallback(async () => {
|
||||
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
|
||||
try {
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
|
||||
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
|
||||
} catch (error) {
|
||||
console.error("Error fetching sub-work items:", error);
|
||||
} finally {
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", "");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
parentIssueId,
|
||||
projectId,
|
||||
setSubIssueHelpers,
|
||||
subIssueHelpers.issue_visibility,
|
||||
subIssueOperations,
|
||||
workspaceSlug,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
handleFetchSubIssues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentIssueId]);
|
||||
|
||||
// render conditions
|
||||
const shouldRenderDeleteIssueModal =
|
||||
issueCrudState?.delete?.toggle &&
|
||||
issueCrudState?.delete?.issue &&
|
||||
issueCrudState.delete.parentIssueId &&
|
||||
issueCrudState.delete.issue.id;
|
||||
|
||||
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
|
||||
|
||||
return (
|
||||
<>
|
||||
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
||||
<SubIssuesListRoot
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={parentIssueId}
|
||||
spacingLeft={6}
|
||||
canEdit={!disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderDeleteIssueModal && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () =>
|
||||
await subIssueOperations.deleteSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueCrudState?.delete?.parentIssueId as string,
|
||||
issueCrudState?.delete?.issue?.id as string
|
||||
)
|
||||
}
|
||||
isSubIssue
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderUpdateIssueModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
await subIssueOperations.updateSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
_issue.id,
|
||||
_issue,
|
||||
issueCrudState?.update?.issue,
|
||||
true
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import type { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
FilterDisplayProperties,
|
||||
FilterGroupBy,
|
||||
FilterOrderBy,
|
||||
FiltersDropdown,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||
type TSubIssueDisplayFiltersProps = {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
|
||||
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer((props) => {
|
||||
const {
|
||||
isEpic = false,
|
||||
displayProperties,
|
||||
layoutDisplayFiltersOptions,
|
||||
handleDisplayPropertiesUpdate,
|
||||
handleDisplayFiltersUpdate,
|
||||
displayFilters,
|
||||
} = props;
|
||||
|
||||
const isFilterApplied = useMemo(
|
||||
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
|
||||
[displayProperties, displayFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
|
||||
<FiltersDropdown
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div
|
||||
className={cn(
|
||||
"p-1 rounded relative transition-all duration-200",
|
||||
isFilterApplied && "bg-custom-primary-60/20"
|
||||
)}
|
||||
>
|
||||
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 text-custom-text-100" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left">
|
||||
{/* display properties */}
|
||||
<div className="py-2">
|
||||
<FilterDisplayProperties
|
||||
displayProperties={displayProperties}
|
||||
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
|
||||
handleUpdate={handleDisplayPropertiesUpdate}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* group by */}
|
||||
<div className="py-2">
|
||||
<FilterGroupBy
|
||||
displayFilters={displayFilters}
|
||||
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
group_by: val,
|
||||
})
|
||||
}
|
||||
ignoreGroupedFilters={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* order by */}
|
||||
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
|
||||
<div className="py-2">
|
||||
<FilterOrderBy
|
||||
selectedOrderBy={displayFilters?.order_by}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
order_by: val,
|
||||
})
|
||||
}
|
||||
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FiltersDropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { FC } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ListFilter, Search, X } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IIssueFilterOptions, IState } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import {
|
||||
FilterAssignees,
|
||||
FilterDueDate,
|
||||
FilterPriority,
|
||||
FilterProjects,
|
||||
FiltersDropdown,
|
||||
FilterStartDate,
|
||||
FilterState,
|
||||
FilterStateGroup,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
import { isFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
|
||||
type TSubIssueFiltersProps = {
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
filters: IIssueFilterOptions;
|
||||
memberIds: string[] | undefined;
|
||||
states?: IState[];
|
||||
availableFilters: (keyof IIssueFilterOptions)[];
|
||||
};
|
||||
|
||||
export const SubIssueFilters: FC<TSubIssueFiltersProps> = observer((props) => {
|
||||
const { handleFiltersUpdate, filters, memberIds, states, availableFilters } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => !!availableFilters.includes(filter);
|
||||
|
||||
const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FiltersDropdown
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div
|
||||
className={cn(
|
||||
"p-1 rounded relative transition-all duration-200",
|
||||
isFilterApplied && "bg-custom-primary-60/20"
|
||||
)}
|
||||
>
|
||||
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
|
||||
<ListFilter className="h-3.5 w-3.5 text-custom-text-100" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex max-h-[350px] flex-col overflow-hidden">
|
||||
<div className="bg-custom-background-100 p-2.5 pb-0">
|
||||
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
|
||||
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
|
||||
placeholder={t("common.search.label")}
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
<X className="text-custom-text-300" size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 text-left">
|
||||
{/* Priority */}
|
||||
{isFilterEnabled("priority") && (
|
||||
<div className="py-2">
|
||||
<FilterPriority
|
||||
appliedFilters={filters.priority ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* state group */}
|
||||
{isFilterEnabled("state_group") && (
|
||||
<div className="py-2">
|
||||
<FilterStateGroup
|
||||
appliedFilters={filters.state_group ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* State */}
|
||||
{isFilterEnabled("state") && (
|
||||
<div className="py-2">
|
||||
<FilterState
|
||||
appliedFilters={filters.state ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("state", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
states={states}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
{isFilterEnabled("project") && (
|
||||
<div className="py-2">
|
||||
<FilterProjects
|
||||
appliedFilters={filters.project ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("project", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* work item types */}
|
||||
{isFilterEnabled("issue_type") && (
|
||||
<div className="py-2">
|
||||
<FilterIssueTypes
|
||||
appliedFilters={filters.issue_type ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("issue_type", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
{isFilterEnabled("assignees") && (
|
||||
<div className="py-2">
|
||||
<FilterAssignees
|
||||
appliedFilters={filters.assignees ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
|
||||
memberIds={memberIds}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Date */}
|
||||
{isFilterEnabled("start_date") && (
|
||||
<div className="py-2">
|
||||
<FilterStartDate
|
||||
appliedFilters={filters.start_date ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Date */}
|
||||
{isFilterEnabled("target_date") && (
|
||||
<div className="py-2">
|
||||
<FilterDueDate
|
||||
appliedFilters={filters.target_date ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FiltersDropdown>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web helpers
|
||||
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
|
||||
|
||||
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
|
||||
// router
|
||||
const { epicId: epicIdParam } = useParams();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { setSubIssueHelpers },
|
||||
createSubIssues,
|
||||
fetchSubIssues,
|
||||
updateSubIssue,
|
||||
deleteSubIssue,
|
||||
removeSubIssue,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getStateById } = useProjectState();
|
||||
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
// const { updateEpicAnalytics } = useIssueTypes();
|
||||
const { updateAnalytics } = updateEpicAnalytics();
|
||||
|
||||
// derived values
|
||||
const epicId = epicIdParam || epicPeekIssue?.issueId;
|
||||
|
||||
const subIssueOperations: TSubIssueOperations = useMemo(
|
||||
() => ({
|
||||
copyLink: (path) => {
|
||||
copyUrlToClipboard(path).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items", { count: 1 })
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
|
||||
try {
|
||||
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.fetch.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items", { count: 2 })
|
||||
: t("issue.label", { count: 2 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
|
||||
try {
|
||||
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("entity.add.success", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: issueIds.length }),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.add.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: issueIds.length }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
updateSubIssue: async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
issueData,
|
||||
oldIssue = {},
|
||||
fromModal = false
|
||||
) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
|
||||
|
||||
if (issueServiceType === EIssueServiceType.EPICS) {
|
||||
const oldState = getStateById(oldIssue?.state_id)?.group;
|
||||
|
||||
if (oldState && oldIssue && issueData && epicId) {
|
||||
// Check if parent_id is changed if yes then decrement the epic analytics count
|
||||
if (issueData.parent_id && oldIssue?.parent_id && issueData.parent_id !== oldIssue?.parent_id) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if state_id is changed if yes then decrement the old state group count and increment the new state group count
|
||||
if (issueData.state_id) {
|
||||
const newState = getStateById(issueData.state_id)?.group;
|
||||
if (oldState && newState && oldState !== newState) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
incrementStateGroupCount: `${newState}_issues`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("sub_work_item.update.success"),
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("sub_work_item.update.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
removeSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
if (issueServiceType === EIssueServiceType.EPICS) {
|
||||
const issueBeforeRemoval = getIssueById(issueId);
|
||||
const oldState = getStateById(issueBeforeRemoval?.state_id)?.group;
|
||||
|
||||
if (epicId && oldState) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
});
|
||||
}
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("entity.remove.success", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.remove.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
return deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId).then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.delete.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
createSubIssues,
|
||||
deleteSubIssue,
|
||||
epicId,
|
||||
fetchSubIssues,
|
||||
getIssueById,
|
||||
getStateById,
|
||||
issueServiceType,
|
||||
removeSubIssue,
|
||||
setSubIssueHelpers,
|
||||
t,
|
||||
updateAnalytics,
|
||||
updateSubIssue,
|
||||
]
|
||||
);
|
||||
|
||||
return subIssueOperations;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
export * from "./display-filters";
|
||||
export * from "./content";
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronRight, CircleDashed } from "lucide-react";
|
||||
import { ALL_ISSUES } from "@plane/constants";
|
||||
import type { IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { SubIssuesListItem } from "./list-item";
|
||||
|
||||
interface TSubIssuesListGroupProps {
|
||||
workItemIds: string[];
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
group: IGroupByColumn;
|
||||
serviceType: TIssueServiceType;
|
||||
canEdit: boolean;
|
||||
parentIssueId: string;
|
||||
rootIssueId: string;
|
||||
handleIssueCrudState: (
|
||||
key: "create" | "existing" | "update" | "delete",
|
||||
issueId: string,
|
||||
issue?: TIssue | null
|
||||
) => void;
|
||||
subIssueOperations: TSubIssueOperations;
|
||||
storeType?: EIssuesStoreType;
|
||||
spacingLeft?: number;
|
||||
}
|
||||
|
||||
export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props) => {
|
||||
const {
|
||||
group,
|
||||
serviceType,
|
||||
canEdit,
|
||||
parentIssueId,
|
||||
rootIssueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
workItemIds,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
spacingLeft = 0,
|
||||
} = props;
|
||||
|
||||
const isAllIssues = group.id === ALL_ISSUES;
|
||||
|
||||
// states
|
||||
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(true);
|
||||
|
||||
if (!workItemIds.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => setIsCollapsibleOpen(!isCollapsibleOpen)}
|
||||
title={
|
||||
!isAllIssues && (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-all text-custom-text-400", {
|
||||
"rotate-90": isCollapsibleOpen,
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
|
||||
{group.icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
|
||||
</div>
|
||||
<span className="text-sm text-custom-text-100 font-medium">{group.name}</span>
|
||||
<span className="text-sm text-custom-text-400">{workItemIds.length}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
buttonClassName={cn("hidden", !isAllIssues && "block")}
|
||||
>
|
||||
{workItemIds?.map((workItemId) => (
|
||||
<SubIssuesListItem
|
||||
key={workItemId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={rootIssueId}
|
||||
issueId={workItemId}
|
||||
canEdit={canEdit}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
issueServiceType={serviceType}
|
||||
spacingLeft={spacingLeft}
|
||||
storeType={storeType}
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
|
||||
import { ControlLink, CustomMenu } from "@plane/ui";
|
||||
import { cn, generateWorkItemLink } from "@plane/utils";
|
||||
// helpers
|
||||
import { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper";
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
// local components
|
||||
import { SubIssuesListItemProperties } from "./properties";
|
||||
import { SubIssuesListRoot } from "./root";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
rootIssueId: string;
|
||||
spacingLeft: number;
|
||||
canEdit: boolean;
|
||||
handleIssueCrudState: (
|
||||
key: "create" | "existing" | "update" | "delete",
|
||||
issueId: string,
|
||||
issue?: TIssue | null
|
||||
) => void;
|
||||
subIssueOperations: TSubIssueOperations;
|
||||
issueId: string;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
storeType?: EIssuesStoreType;
|
||||
};
|
||||
|
||||
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
rootIssueId,
|
||||
issueId,
|
||||
spacingLeft = 10,
|
||||
canEdit,
|
||||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: {
|
||||
filters: { getSubIssueFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const {
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
} = useIssueDetail();
|
||||
const { fetchSubIssues } = useSubIssueOperations(EIssueServiceType.ISSUES);
|
||||
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(issueServiceType);
|
||||
const project = useProject();
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
// derived values
|
||||
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
|
||||
|
||||
const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId);
|
||||
const subIssueCount = issue?.sub_issues_count ?? 0;
|
||||
|
||||
// derived values
|
||||
const subIssueFilters = getSubIssueFilters(parentIssueId);
|
||||
const displayProperties = subIssueFilters?.displayProperties ?? {};
|
||||
|
||||
//
|
||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
// check if current issue is the root issue
|
||||
const isCurrentIssueRoot = issueId === rootIssueId;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetail?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={issueId}>
|
||||
<ControlLink
|
||||
id={`issue-${issue.id}`}
|
||||
href={workItemLink}
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{issue && (
|
||||
<div
|
||||
className="group relative flex min-h-11 h-full w-full items-center pr-2 py-1 transition-all hover:bg-custom-background-90"
|
||||
style={{ paddingLeft: `${spacingLeft}px` }}
|
||||
>
|
||||
<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 && (
|
||||
<>
|
||||
{subIssueHelpers.preview_loader.includes(issue.id) ? (
|
||||
<div className="flex h-full w-full cursor-not-allowed items-center justify-center rounded-sm bg-custom-background-80 transition-all">
|
||||
<Loader width={14} strokeWidth={2} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer items-center justify-center text-custom-text-400 hover:text-custom-text-300"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!subIssueHelpers.issue_visibility.includes(issueId)) {
|
||||
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
|
||||
await fetchSubIssues(workspaceSlug, projectId, issueId);
|
||||
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
|
||||
}
|
||||
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-all", {
|
||||
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full truncate cursor-pointer items-center gap-3">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
|
||||
<div className="flex-shrink-0">
|
||||
{projectDetail && (
|
||||
<IssueIdentifier
|
||||
projectId={projectDetail.id}
|
||||
issueTypeId={issue.type_id}
|
||||
projectIdentifier={projectDetail.identifier}
|
||||
issueSequenceId={issue.sequence_id}
|
||||
textContainerClassName="text-xs text-custom-text-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
||||
<span className="w-full truncate text-sm text-custom-text-100">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 text-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SubIssuesListItemProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
parentIssueId={parentIssueId}
|
||||
issueId={issueId}
|
||||
canEdit={canEdit}
|
||||
updateSubIssue={subIssueOperations.updateSubIssue}
|
||||
displayProperties={displayProperties}
|
||||
issue={issue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<CustomMenu placement="bottom-end" ellipsis>
|
||||
{canEdit && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleIssueCrudState("update", parentIssueId, { ...issue });
|
||||
toggleCreateIssueModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>{t("issue.edit")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
subIssueOperations.copyLink(workItemLink);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>{t("issue.copy_link")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
{canEdit && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
if (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} />
|
||||
{issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("issue.remove.parent.label")
|
||||
: t("issue.remove.label")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleIssueCrudState("delete", parentIssueId, issue);
|
||||
toggleDeleteIssueModal(issue.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>{t("issue.delete.label")}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ControlLink>
|
||||
|
||||
{/* should not expand the current issue if it is also the root issue*/}
|
||||
{subIssueHelpers.issue_visibility.includes(issueId) &&
|
||||
issue.project_id &&
|
||||
subIssueCount > 0 &&
|
||||
!isCurrentIssueRoot && (
|
||||
<SubIssuesListRoot
|
||||
storeType={storeType}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
parentIssueId={issue.id}
|
||||
rootIssueId={rootIssueId}
|
||||
spacingLeft={spacingLeft + 22}
|
||||
canEdit={canEdit}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// plane imports
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2, CalendarClock } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
import { getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
// hooks
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
parentIssueId: string;
|
||||
issueId: string;
|
||||
canEdit: boolean;
|
||||
updateSubIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string,
|
||||
issueData: Partial<TIssue>,
|
||||
oldIssue?: Partial<TIssue>
|
||||
) => Promise<void>;
|
||||
displayProperties?: IIssueDisplayProperties;
|
||||
issue: TIssue;
|
||||
};
|
||||
|
||||
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, parentIssueId, issueId, canEdit, updateSubIssue, displayProperties, issue } = props;
|
||||
const { t } = useTranslation();
|
||||
const { getStateById } = useProjectState();
|
||||
|
||||
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleStartDate = (date: Date | null) => {
|
||||
if (issue.project_id) {
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
start_date: date ? renderFormattedPayloadDate(date) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetDate = (date: Date | null) => {
|
||||
if (issue.project_id) {
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
target_date: date ? renderFormattedPayloadDate(date) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//derived values
|
||||
const stateDetails = useMemo(() => getStateById(issue.state_id), [getStateById, issue.state_id]);
|
||||
const shouldHighlight = useMemo(
|
||||
() => shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
[issue.target_date, stateDetails?.group]
|
||||
);
|
||||
// date range is enabled only when both dates are available and both dates are enabled
|
||||
const isDateRangeEnabled: boolean = Boolean(
|
||||
issue.start_date && issue.target_date && displayProperties?.start_date && displayProperties?.due_date
|
||||
);
|
||||
|
||||
if (!displayProperties) return <></>;
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
const minDate = getDate(issue.start_date);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
projectId={issue.project_id ?? undefined}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(
|
||||
workspaceSlug,
|
||||
issue.project_id,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
{
|
||||
state_id: val,
|
||||
},
|
||||
{ ...issue }
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
buttonVariant="transparent-without-text"
|
||||
buttonClassName="hover:bg-transparent px-0"
|
||||
iconSize="size-5"
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
priority: val,
|
||||
})
|
||||
}
|
||||
disabled={!canEdit}
|
||||
buttonVariant="border-without-text"
|
||||
buttonClassName="border"
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* merged dates */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={["start_date", "due_date"]}
|
||||
shouldRenderProperty={() => isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateRangeDropdown
|
||||
value={{
|
||||
from: getDate(issue.start_date) || undefined,
|
||||
to: getDate(issue.target_date) || undefined,
|
||||
}}
|
||||
onSelect={(range) => {
|
||||
handleStartDate(range?.from ?? null);
|
||||
handleTargetDate(range?.to ?? null);
|
||||
}}
|
||||
hideIcon={{
|
||||
from: false,
|
||||
}}
|
||||
isClearable
|
||||
mergeDates
|
||||
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlight ? "text-red-500" : ""}
|
||||
disabled={!canEdit}
|
||||
showTooltip
|
||||
customTooltipHeading="Date Range"
|
||||
renderPlaceholder={false}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="start_date"
|
||||
shouldRenderProperty={() => !isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5">
|
||||
<DateDropdown
|
||||
value={issue.start_date ?? null}
|
||||
onChange={handleStartDate}
|
||||
maxDate={maxDate}
|
||||
placeholder={t("common.order_by.start_date")}
|
||||
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
|
||||
optionsClassName="z-30"
|
||||
disabled={!canEdit}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* target/due date */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="due_date"
|
||||
shouldRenderProperty={() => !isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5">
|
||||
<DateDropdown
|
||||
value={issue?.target_date ?? null}
|
||||
onChange={handleTargetDate}
|
||||
minDate={minDate}
|
||||
placeholder={t("common.order_by.due_date")}
|
||||
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlight ? "text-red-500" : ""}
|
||||
clearIconClassName="text-custom-text-100"
|
||||
optionsClassName="z-30"
|
||||
disabled={!canEdit}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<MemberDropdown
|
||||
value={issue.assignee_ids}
|
||||
projectId={issue.project_id ?? undefined}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
assignee_ids: val,
|
||||
})
|
||||
}
|
||||
disabled={!canEdit}
|
||||
multiple
|
||||
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
|
||||
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ListFilter } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
|
||||
// hooks
|
||||
import { SectionEmptyState } from "@/components/empty-state/section-empty-state-root";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
import { SubIssuesListGroup } from "./list-group";
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
rootIssueId: string;
|
||||
spacingLeft: number;
|
||||
canEdit: boolean;
|
||||
handleIssueCrudState: (
|
||||
key: "create" | "existing" | "update" | "delete",
|
||||
issueId: string,
|
||||
issue?: TIssue | null
|
||||
) => void;
|
||||
subIssueOperations: TSubIssueOperations;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
storeType: EIssuesStoreType;
|
||||
};
|
||||
|
||||
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
rootIssueId,
|
||||
canEdit,
|
||||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
spacingLeft = 0,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: {
|
||||
subIssuesByIssueId,
|
||||
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const filters = getSubIssueFilters(rootIssueId);
|
||||
const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]);
|
||||
const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null;
|
||||
const filteredSubWorkItemsCount = (getFilteredSubWorkItems(rootIssueId, filters.filters ?? {}) ?? []).length;
|
||||
|
||||
const groups = getGroupByColumns({
|
||||
groupBy: group_by as GroupByColumnTypes,
|
||||
includeNone: true,
|
||||
isWorkspaceLevel: isWorkspaceLevel(storeType),
|
||||
isEpic: issueServiceType === EIssueServiceType.EPICS,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const getWorkItemIds = useCallback(
|
||||
(groupId: string) => {
|
||||
if (isRootLevel) {
|
||||
const groupedSubIssues = getGroupedSubWorkItems(rootIssueId);
|
||||
return groupedSubIssues?.[groupId] ?? [];
|
||||
}
|
||||
const subIssueIds = subIssuesByIssueId(parentIssueId);
|
||||
return subIssueIds ?? [];
|
||||
},
|
||||
[isRootLevel, subIssuesByIssueId, rootIssueId, getGroupedSubWorkItems, parentIssueId]
|
||||
);
|
||||
|
||||
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isRootLevel && filteredSubWorkItemsCount === 0 ? (
|
||||
<SectionEmptyState
|
||||
title={
|
||||
!isSubWorkItems
|
||||
? t("sub_work_item.empty_state.list_filters.title")
|
||||
: t("sub_work_item.empty_state.sub_list_filters.title")
|
||||
}
|
||||
description={
|
||||
!isSubWorkItems
|
||||
? t("sub_work_item.empty_state.list_filters.description")
|
||||
: t("sub_work_item.empty_state.sub_list_filters.description")
|
||||
}
|
||||
icon={<ListFilter />}
|
||||
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
|
||||
actionElement={
|
||||
<Button variant="neutral-primary" size="sm" onClick={() => resetFilters(rootIssueId)}>
|
||||
{t("sub_work_item.empty_state.list_filters.action")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<SubIssuesListGroup
|
||||
key={group.id}
|
||||
workItemIds={getWorkItemIds(group.id)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
group={group}
|
||||
serviceType={issueServiceType}
|
||||
canEdit={canEdit}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={rootIssueId}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
storeType={storeType}
|
||||
spacingLeft={spacingLeft}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const SubIssuesActionButton: FC<Props> = observer((props) => {
|
||||
const { issueId, customButton, disabled = false, issueServiceType } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
toggleCreateIssueModal,
|
||||
toggleSubIssuesModal,
|
||||
setIssueCrudOperationState,
|
||||
issueCrudOperationState,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
// handlers
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing",
|
||||
_parentIssueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
setIssueCrudOperationState({
|
||||
...issueCrudOperationState,
|
||||
[key]: {
|
||||
toggle: !issueCrudOperationState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.create });
|
||||
handleIssueCrudState("create", issueId, null);
|
||||
toggleCreateIssueModal(true);
|
||||
};
|
||||
|
||||
const handleAddExisting = () => {
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.add_existing });
|
||||
handleIssueCrudState("existing", issueId, null);
|
||||
toggleSubIssuesModal(issue.id);
|
||||
};
|
||||
|
||||
// options
|
||||
const optionItems = [
|
||||
{
|
||||
i18n_label: "common.create_new",
|
||||
icon: <Plus className="h-3 w-3" />,
|
||||
onClick: handleCreateNew,
|
||||
},
|
||||
{
|
||||
i18n_label: "common.add_existing",
|
||||
icon: <WorkItemsIcon className="h-3 w-3" />,
|
||||
onClick: handleAddExisting,
|
||||
},
|
||||
];
|
||||
|
||||
// button element
|
||||
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
|
||||
{optionItems.map((item, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon}
|
||||
<span>{t(item.i18n_label)}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { SubIssuesCollapsibleContent } from "./content";
|
||||
import { SubIssuesCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const SubIssuesCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("sub-work-items");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("sub-work-items")}
|
||||
title={
|
||||
<SubIssuesCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
parentIssueId={issueId}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<SubIssuesCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FC } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EIssueFilterType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE,
|
||||
} from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TIssueServiceType,
|
||||
} from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { SubIssueDisplayFilters } from "./display-filters";
|
||||
import { SubIssueFilters } from "./filters";
|
||||
import { SubIssuesActionButton } from "./quick-action-button";
|
||||
|
||||
type TSubWorkItemTitleActionsProps = {
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
parentId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
|
||||
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, projectId } = props;
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: {
|
||||
filters: { getSubIssueFilters, updateSubWorkItemFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getProjectStates } = useProjectState();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
// derived values
|
||||
const projectStates = getProjectStates(projectId);
|
||||
const projectMemberIds = getProjectMemberIds(projectId, false);
|
||||
const subIssueFilters = getSubIssueFilters(parentId);
|
||||
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list;
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
|
||||
},
|
||||
[updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
const handleDisplayPropertiesUpdate = useCallback(
|
||||
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
|
||||
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
|
||||
},
|
||||
[updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
|
||||
},
|
||||
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
return (
|
||||
// prevent click everywhere
|
||||
<div
|
||||
className="flex gap-2 items-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SubIssueDisplayFilters
|
||||
isEpic={issueServiceType === EIssueServiceType.EPICS}
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
displayProperties={subIssueFilters?.displayProperties ?? {}}
|
||||
displayFilters={subIssueFilters?.displayFilters ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
/>
|
||||
<SubIssueFilters
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
filters={subIssueFilters?.filters ?? {}}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE}
|
||||
/>
|
||||
{!disabled && (
|
||||
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { SubWorkItemTitleActions } from "./title-actions";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
parentIssueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const {
|
||||
isOpen,
|
||||
parentIssueId,
|
||||
disabled,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
} = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
|
||||
const subIssues = subIssuesByIssueId(parentIssueId);
|
||||
// if there are no sub-issues, return null
|
||||
if (!subIssues) return null;
|
||||
|
||||
// calculate percentage of completed sub-issues
|
||||
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
|
||||
const totalCount = subIssues.length;
|
||||
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={`${issueServiceType === EIssueServiceType.EPICS ? t("issue.label", { count: 1 }) : t("common.sub_work_items")}`}
|
||||
indicatorElement={
|
||||
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
|
||||
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
|
||||
<span>
|
||||
{completedCount}/{totalCount} {t("common.done")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actionItemElement={
|
||||
<SubWorkItemTitleActions
|
||||
projectId={projectId}
|
||||
parentId={parentIssueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetButton: FC<Props> = (props) => {
|
||||
const { icon, title, disabled = false } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 rounded px-3 py-1.5",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-400 bg-custom-background-90": disabled,
|
||||
"cursor-pointer text-custom-text-300 hover:bg-custom-background-80": !disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{icon && icon}
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// hooks
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { CycleDropdown } from "@/components/dropdowns/cycle";
|
||||
// ui
|
||||
// helpers
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
type TIssueCycleSelect = {
|
||||
className?: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) => {
|
||||
const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
const handleIssueCycleChange = async (cycleId: string | null) => {
|
||||
if (!issue || issue.cycle_id === cycleId) return;
|
||||
setIsUpdating(true);
|
||||
if (cycleId) await issueOperations.addCycleToIssue?.(workspaceSlug, projectId, cycleId, issueId);
|
||||
else await issueOperations.removeIssueFromCycle?.(workspaceSlug, projectId, issue.cycle_id ?? "", issueId);
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full items-center gap-1", className)}>
|
||||
<CycleDropdown
|
||||
value={issue?.cycle_id ?? null}
|
||||
onChange={handleIssueCycleChange}
|
||||
projectId={projectId}
|
||||
disabled={disableSelect}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="group w-full"
|
||||
buttonContainerClassName="w-full text-left rounded"
|
||||
buttonClassName={`text-sm justify-between ${issue?.cycle_id ? "" : "text-custom-text-400"}`}
|
||||
placeholder={t("cycle.no_cycle")}
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/issues/issue-detail/index.ts
Normal file
1
apps/web/core/components/issues/issue-detail/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { E_SORT_ORDER, TActivityFilters } from "@plane/constants";
|
||||
import { EActivityFilterType, filterActivityOnSelectedFilters } from "@plane/constants";
|
||||
import type { TCommentsOperations } from "@plane/types";
|
||||
// components
|
||||
import { CommentCard } from "@/components/comments/card/root";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues/issue-details/issue-properties-activity";
|
||||
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
|
||||
// local imports
|
||||
import { IssueActivityItem } from "./activity/activity-list";
|
||||
import { IssueActivityLoader } from "./loader";
|
||||
|
||||
type TIssueActivityCommentRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isIntakeIssue: boolean;
|
||||
issueId: string;
|
||||
selectedFilters: TActivityFilters[];
|
||||
activityOperations: TCommentsOperations;
|
||||
showAccessSpecifier?: boolean;
|
||||
disabled?: boolean;
|
||||
sortOrder: E_SORT_ORDER;
|
||||
};
|
||||
|
||||
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
isIntakeIssue,
|
||||
issueId,
|
||||
selectedFilters,
|
||||
activityOperations,
|
||||
showAccessSpecifier,
|
||||
projectId,
|
||||
disabled,
|
||||
sortOrder,
|
||||
} = props;
|
||||
// store hooks
|
||||
const {
|
||||
activity: { getActivityAndCommentsByIssueId },
|
||||
comment: { getCommentById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const activityAndComments = getActivityAndCommentsByIssueId(issueId, sortOrder);
|
||||
|
||||
if (!activityAndComments) return <IssueActivityLoader />;
|
||||
|
||||
if (activityAndComments.length <= 0) return null;
|
||||
|
||||
const filteredActivityAndComments = filterActivityOnSelectedFilters(activityAndComments, selectedFilters);
|
||||
|
||||
const BASE_ACTIVITY_FILTER_TYPES = [
|
||||
EActivityFilterType.ACTIVITY,
|
||||
EActivityFilterType.STATE,
|
||||
EActivityFilterType.ASSIGNEE,
|
||||
EActivityFilterType.DEFAULT,
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filteredActivityAndComments.map((activityComment, index) => {
|
||||
const comment = getCommentById(activityComment.id);
|
||||
return activityComment.activity_type === "COMMENT" ? (
|
||||
<CommentCard
|
||||
key={activityComment.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
comment={comment}
|
||||
activityOperations={activityOperations}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
showAccessSpecifier={!!showAccessSpecifier}
|
||||
showCopyLinkOption={!isIntakeIssue}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
|
||||
<IssueActivityItem
|
||||
activityId={activityComment.id}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
|
||||
<IssueAdditionalPropertiesActivity
|
||||
activityId={activityComment.id}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
) : activityComment.activity_type === "WORKLOG" ? (
|
||||
<IssueActivityWorklog
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
activityComment={activityComment}
|
||||
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, ListFilter } from "lucide-react";
|
||||
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { PopoverMenu } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
|
||||
type TActivityFilter = {
|
||||
selectedFilters: TActivityFilters[];
|
||||
filterOptions: TActivityFilterOption[];
|
||||
};
|
||||
|
||||
export const ActivityFilter: FC<TActivityFilter> = observer((props) => {
|
||||
const { selectedFilters = [], filterOptions } = props;
|
||||
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoverMenu
|
||||
buttonClassName="outline-none"
|
||||
button={
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
prependIcon={<ListFilter className="h-3 w-3" />}
|
||||
className="relative"
|
||||
>
|
||||
<span className="text-custom-text-200">{t("common.filters")}</span>
|
||||
{selectedFilters.length < filterOptions.length && (
|
||||
<span className="absolute h-2 w-2 -right-0.5 -top-0.5 bg-custom-primary-100 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
panelClassName="p-2 rounded-md border border-custom-border-200 bg-custom-background-100"
|
||||
data={filterOptions}
|
||||
keyExtractor={(item) => item.key}
|
||||
render={(item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all bg-custom-background-90",
|
||||
{
|
||||
"bg-custom-primary text-white": item.isSelected,
|
||||
"bg-custom-background-80 text-custom-text-400": item.isSelected && selectedFilters.length === 1,
|
||||
"bg-custom-background-90": !item.isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{item.isSelected && <Check className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
<div className={cn("whitespace-nowrap", item.isSelected ? "text-custom-text-100" : "text-custom-text-200")}>
|
||||
{t(item.labelTranslationKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
// hooks
|
||||
import { ArchiveIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// ui
|
||||
|
||||
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={
|
||||
activity.new_value === "restore" ? (
|
||||
<RotateCcw className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
|
||||
) : (
|
||||
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
|
||||
>
|
||||
{activity.new_value === "restore" ? "restored the work item" : "archived the work item"}.
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { Users } from "lucide-react";
|
||||
// hooks;
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueAssigneeActivity: FC<TIssueAssigneeActivity> = observer((props) => {
|
||||
const { activityId, ends, showIssue = true } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Users className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.old_value === "" ? `added a new assignee ` : `removed the assignee `}
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center font-medium text-custom-text-100 hover:underline capitalize"
|
||||
>
|
||||
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
|
||||
</a>
|
||||
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueAttachmentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueAttachmentActivity: FC<TIssueAttachmentActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Paperclip size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? `uploaded a new attachment` : `removed an attachment`}
|
||||
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { CycleIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueCycleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueCycleActivity: FC<TIssueCycleActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<CycleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
<>
|
||||
<span>added this work item to the cycle </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : activity.verb === "updated" ? (
|
||||
<>
|
||||
<span>set the cycle to </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>removed the work item from the cycle </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { EInboxIssueSource } from "@plane/types";
|
||||
// hooks
|
||||
import { capitalizeFirstLetter } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
|
||||
type TIssueDefaultActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueDefaultActivity: FC<TIssueDefaultActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
const source = activity.source_data?.source;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
activityId={activityId}
|
||||
icon={<WorkItemsIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
source && source !== EInboxIssueSource.IN_APP ? (
|
||||
<span>
|
||||
created the work item via{" "}
|
||||
<span className="font-medium">{capitalizeFirstLetter(source.toLowerCase() || "")}</span>.
|
||||
</span>
|
||||
) : (
|
||||
<span> created the work item.</span>
|
||||
)
|
||||
) : (
|
||||
<span> deleted a work item.</span>
|
||||
)}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { AlignLeft } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueDescriptionActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueDescriptionActivity: FC<TIssueDescriptionActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<AlignLeft size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
updated the description
|
||||
{showIssue ? ` of ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Triangle } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueEstimateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Triangle size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the estimate point to ` : `removed the estimate point`}
|
||||
{activity.new_value ? activity.new_value : activity?.old_value}
|
||||
{showIssue && (activity.new_value ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { IssueCreatorDisplay } from "@/plane-web/components/issues/issue-details/issue-creator";
|
||||
// local imports
|
||||
import { IssueUser } from "../";
|
||||
|
||||
type TIssueActivityBlockComponent = {
|
||||
icon?: ReactNode;
|
||||
activityId: string;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
children: ReactNode;
|
||||
customUserName?: string;
|
||||
};
|
||||
|
||||
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
|
||||
const { icon, activityId, ends, children, customUserName } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
const { isMobile } = usePlatformOS();
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center gap-3 text-xs ${
|
||||
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
|
||||
}`}
|
||||
>
|
||||
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
|
||||
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-center z-[4] bg-custom-background-80 text-custom-text-200">
|
||||
{icon ? icon : <Network className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="w-full truncate text-custom-text-200">
|
||||
{!activity?.field && activity?.verb === "created" ? (
|
||||
<IssueCreatorDisplay activityId={activityId} customUserName={customUserName} />
|
||||
) : (
|
||||
<IssueUser activityId={activityId} customUserName={customUserName} />
|
||||
)}
|
||||
<span> {children} </span>
|
||||
<span>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
|
||||
>
|
||||
<span className="whitespace-nowrap text-custom-text-350"> {calculateTimeAgo(activity.created_at)}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
// hooks
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// ui
|
||||
|
||||
type TIssueLink = {
|
||||
activityId: string;
|
||||
};
|
||||
|
||||
export const IssueLink: FC<TIssueLink> = (props) => {
|
||||
const { activityId } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: activity.workspace_detail?.slug,
|
||||
projectId: activity.project,
|
||||
issueId: activity.issue,
|
||||
projectIdentifier: activity.project_detail.identifier,
|
||||
sequenceId: activity.issue_detail.sequence_id,
|
||||
});
|
||||
return (
|
||||
<Tooltip
|
||||
tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<a
|
||||
aria-disabled={activity.issue === null}
|
||||
href={`${activity.issue_detail ? workItemLink : "#"}`}
|
||||
target={activity.issue === null ? "_self" : "_blank"}
|
||||
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
{activity.issue_detail
|
||||
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
|
||||
: "Work items"}{" "}
|
||||
<span className="font-normal">{activity.issue_detail?.name}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FC } from "react";
|
||||
import Link from "next/link";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
type TIssueUser = {
|
||||
activityId: string;
|
||||
customUserName?: string;
|
||||
};
|
||||
|
||||
export const IssueUser: FC<TIssueUser> = (props) => {
|
||||
const { activityId, customUserName } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{customUserName ? (
|
||||
<span className="text-custom-text-100 font-medium">{customUserName}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
|
||||
className="hover:underline text-custom-text-100 font-medium"
|
||||
>
|
||||
{activity.actor_detail?.display_name}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { IntakeIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueInboxActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueInboxActivity: FC<TIssueInboxActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
const getInboxActivityMessage = () => {
|
||||
switch (activity?.verb) {
|
||||
case "-1":
|
||||
return "declined this work item from intake.";
|
||||
case "0":
|
||||
return "snoozed this work item.";
|
||||
case "1":
|
||||
return "accepted this work item from intake.";
|
||||
case "2":
|
||||
return "declined this work item from intake by marking a duplicate work item.";
|
||||
default:
|
||||
return "updated intake work item status.";
|
||||
}
|
||||
};
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<IntakeIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>{getInboxActivityMessage()}</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export * from "./default";
|
||||
export * from "./name";
|
||||
export * from "./description";
|
||||
export * from "./state";
|
||||
export * from "./assignee";
|
||||
export * from "./priority";
|
||||
export * from "./estimate";
|
||||
export * from "./parent";
|
||||
export * from "./relation";
|
||||
export * from "./start_date";
|
||||
export * from "./target_date";
|
||||
export * from "./cycle";
|
||||
export * from "./module";
|
||||
export * from "./label";
|
||||
export * from "./link";
|
||||
export * from "./attachment";
|
||||
export * from "./archived-at";
|
||||
export * from "./inbox";
|
||||
export * from "./label-activity-chip";
|
||||
|
||||
// helpers
|
||||
export * from "./helpers/activity-block";
|
||||
export * from "./helpers/issue-user";
|
||||
export * from "./helpers/issue-link";
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FC } from "react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
|
||||
type TIssueLabelPill = { name?: string; color?: string };
|
||||
|
||||
export const LabelActivityChip: FC<TIssueLabelPill> = (props) => {
|
||||
const { name, color } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={name}>
|
||||
<span className="inline-flex w-min max-w-32 cursor-default flex-shrink-0 items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color ?? "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-shrink truncate font-medium text-custom-text-100">{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tag } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink, LabelActivityChip } from "./";
|
||||
|
||||
type TIssueLabelActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
const { getLabelById } = useLabel();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
const oldLabelColor = getLabelById(activity?.old_identifier ?? "")?.color;
|
||||
const newLabelColor = getLabelById(activity?.new_identifier ?? "")?.color;
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Tag size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.old_value === "" ? `added a new label ` : `removed the label `}
|
||||
<LabelActivityChip
|
||||
name={activity.old_value === "" ? activity.new_value : activity.old_value}
|
||||
color={activity.old_value === "" ? newLabelColor : oldLabelColor}
|
||||
/>
|
||||
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueLinkActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueLinkActivity: FC<TIssueLinkActivity> = observer((props) => {
|
||||
const { activityId, showIssue = false, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
<>
|
||||
<span>added </span>
|
||||
<a
|
||||
href={`${activity.new_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
</>
|
||||
) : activity.verb === "updated" ? (
|
||||
<>
|
||||
<span>updated the </span>
|
||||
<a
|
||||
href={`${activity.old_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>removed this </span>
|
||||
<a
|
||||
href={`${activity.old_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { ModuleIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueModuleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueModuleActivity: FC<TIssueModuleActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<ModuleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? (
|
||||
<>
|
||||
<span>added this work item to the module </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : activity.verb === "updated" ? (
|
||||
<>
|
||||
<span>set the module to </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>removed the work item from the module </span>
|
||||
<a
|
||||
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="truncate"> {activity.old_value}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Type } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
|
||||
type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueNameActivity: FC<TIssueNameActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Type size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>set the name to {activity.new_value}.</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LayoutPanelTop } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssueParentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueParentActivity: FC<TIssueParentActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<LayoutPanelTop size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the parent to ` : `removed the parent `}
|
||||
{activity.new_value ? (
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
) : (
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
)}
|
||||
{showIssue && (activity.new_value ? ` for ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Signal } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
type TIssuePriorityActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssuePriorityActivity: FC<TIssuePriorityActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Signal size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
set the priority to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue ? ` for ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { getRelationActivityContent, useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
//
|
||||
import { IssueActivityBlockComponent } from "./";
|
||||
|
||||
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueRelationActivity: FC<TIssueRelationActivity> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const activityContent = getRelationActivityContent(activity);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes]?.icon(14) : <></>}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
{activityContent}
|
||||
{activity.old_value === "" ? (
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}.</span>
|
||||
) : (
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}.</span>
|
||||
)}
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// hooks
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
// helpers
|
||||
|
||||
type TIssueStartDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueStartDateActivity: FC<TIssueStartDateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the start date to ` : `removed the start date `}
|
||||
{activity.new_value && (
|
||||
<>
|
||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||
</>
|
||||
)}
|
||||
{showIssue && (activity.new_value ? ` for ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { DoubleCircleIcon } from "@plane/propel/icons";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
// icons
|
||||
|
||||
type TIssueStateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueStateActivity: FC<TIssueStateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue ? ` for ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// hooks
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
// helpers
|
||||
|
||||
type TIssueTargetDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueTargetDateActivity: FC<TIssueTargetDateActivity> = observer((props) => {
|
||||
const { activityId, showIssue = true, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.new_value ? `set the due date to ` : `removed the due date `}
|
||||
{activity.new_value && (
|
||||
<>
|
||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||
</>
|
||||
)}
|
||||
{showIssue && (activity.new_value ? ` for ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { getValidKeysFromObject } from "@plane/utils";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web components
|
||||
import { IssueTypeActivity, AdditionalActivityRoot } from "@/plane-web/components/issues/issue-details";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
// local components
|
||||
import {
|
||||
IssueDefaultActivity,
|
||||
IssueNameActivity,
|
||||
IssueDescriptionActivity,
|
||||
IssueStateActivity,
|
||||
IssueAssigneeActivity,
|
||||
IssuePriorityActivity,
|
||||
IssueEstimateActivity,
|
||||
IssueParentActivity,
|
||||
IssueRelationActivity,
|
||||
IssueStartDateActivity,
|
||||
IssueTargetDateActivity,
|
||||
IssueCycleActivity,
|
||||
IssueModuleActivity,
|
||||
IssueLabelActivity,
|
||||
IssueLinkActivity,
|
||||
IssueAttachmentActivity,
|
||||
IssueArchivedAtActivity,
|
||||
IssueInboxActivity,
|
||||
} from "./actions";
|
||||
|
||||
type TIssueActivityItem = {
|
||||
activityId: string;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
};
|
||||
|
||||
export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
|
||||
const { activityId, ends } = props;
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
comment: {},
|
||||
} = useIssueDetail();
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const activityRelations = getValidKeysFromObject(ISSUE_RELATION_OPTIONS);
|
||||
|
||||
const componentDefaultProps = { activityId, ends };
|
||||
|
||||
const activityField = getActivityById(activityId)?.field;
|
||||
switch (activityField) {
|
||||
case null: // default issue creation
|
||||
return <IssueDefaultActivity {...componentDefaultProps} />;
|
||||
case "state":
|
||||
return <IssueStateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "name":
|
||||
return <IssueNameActivity {...componentDefaultProps} />;
|
||||
case "description":
|
||||
return <IssueDescriptionActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "assignees":
|
||||
return <IssueAssigneeActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "priority":
|
||||
return <IssuePriorityActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "estimate_points":
|
||||
case "estimate_categories":
|
||||
case "estimate_point" /* This case is to handle all the older recorded activities for estimates. Field changed from "estimate_point" -> `estimate_${estimate_type}`*/:
|
||||
return <IssueEstimateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "parent":
|
||||
return <IssueParentActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case activityRelations.find((field) => field === activityField):
|
||||
return <IssueRelationActivity {...componentDefaultProps} />;
|
||||
case "start_date":
|
||||
return <IssueStartDateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "target_date":
|
||||
return <IssueTargetDateActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "cycles":
|
||||
return <IssueCycleActivity {...componentDefaultProps} />;
|
||||
case "modules":
|
||||
return <IssueModuleActivity {...componentDefaultProps} />;
|
||||
case "labels":
|
||||
return <IssueLabelActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "link":
|
||||
return <IssueLinkActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "attachment":
|
||||
return <IssueAttachmentActivity {...componentDefaultProps} showIssue={false} />;
|
||||
case "archived_at":
|
||||
return <IssueArchivedAtActivity {...componentDefaultProps} />;
|
||||
case "intake":
|
||||
case "inbox":
|
||||
return <IssueInboxActivity {...componentDefaultProps} />;
|
||||
case "type":
|
||||
return <IssueTypeActivity {...componentDefaultProps} />;
|
||||
default:
|
||||
return <AdditionalActivityRoot {...componentDefaultProps} field={activityField} />;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import type { TCommentsOperations } from "@plane/types";
|
||||
import { copyUrlToClipboard, formatTextList, generateWorkItemLink } from "@plane/utils";
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const useCommentOperations = (
|
||||
workspaceSlug: string | undefined,
|
||||
projectId: string | undefined,
|
||||
issueId: string | undefined
|
||||
): TCommentsOperations => {
|
||||
// store hooks
|
||||
const {
|
||||
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
|
||||
createComment,
|
||||
updateComment,
|
||||
removeComment,
|
||||
createCommentReaction,
|
||||
removeCommentReaction,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
const { uploadEditorAsset } = useEditorAsset();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId) : undefined;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const operations: TCommentsOperations = useMemo(() => {
|
||||
// Define operations object with all methods
|
||||
const ops: TCommentsOperations = {
|
||||
copyCommentLink: (id) => {
|
||||
if (!workspaceSlug || !issueDetails) return;
|
||||
try {
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails.project_id,
|
||||
issueId,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails.sequence_id,
|
||||
});
|
||||
const commentLink = `${workItemLink}#comment-${id}`;
|
||||
copyUrlToClipboard(commentLink).then(() => {
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.copy_link.success"),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in copying comment link:", error);
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.copy_link.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
createComment: async (data) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
const comment = await createComment(workspaceSlug, projectId, issueId, data);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.create.success"),
|
||||
});
|
||||
return comment;
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.create.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
updateComment: async (commentId, data) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.update.success"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.update.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
removeComment: async (commentId) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await removeComment(workspaceSlug, projectId, issueId, commentId);
|
||||
setToast({
|
||||
title: t("common.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("issue.comments.remove.success"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("common.error.label"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("issue.comments.remove.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
uploadCommentAsset: async (blockId, file, commentId) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
|
||||
const res = await uploadEditorAsset({
|
||||
blockId,
|
||||
data: {
|
||||
entity_identifier: commentId ?? "",
|
||||
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
|
||||
},
|
||||
file,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.log("Error in uploading comment asset:", error);
|
||||
throw new Error(t("issue.comments.upload.error"));
|
||||
}
|
||||
},
|
||||
addCommentReaction: async (commentId, reaction) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
|
||||
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction created successfully",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction creation failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteCommentReaction: async (commentId, reaction) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
|
||||
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Reaction removed successfully",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Reaction remove failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
react: async (commentId, reactionEmoji, userReactions) => {
|
||||
if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji);
|
||||
else await ops.addCommentReaction(commentId, reactionEmoji);
|
||||
},
|
||||
reactionIds: (commentId) => getCommentReactionsByCommentId(commentId),
|
||||
userReactions: (commentId) =>
|
||||
currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [],
|
||||
getReactionUsers: (reaction, reactionIds) => {
|
||||
const reactionUsers = (reactionIds?.[reaction] || [])
|
||||
.map((reactionId) => {
|
||||
const reactionDetails = getCommentReactionById(reactionId);
|
||||
return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null;
|
||||
})
|
||||
.filter((displayName): displayName is string => !!displayName);
|
||||
const formattedUsers = formatTextList(reactionUsers);
|
||||
return formattedUsers;
|
||||
},
|
||||
};
|
||||
return ops;
|
||||
}, [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]);
|
||||
|
||||
return operations;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./activity-comment-root";
|
||||
|
||||
// activity
|
||||
export * from "./activity/activity-list";
|
||||
export * from "./activity-filter";
|
||||
|
||||
// sort
|
||||
export * from "./sort-root";
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssueActivityLoader = () => (
|
||||
<Loader className="space-y-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader.Item className="shrink-0" height="28px" width="28px" />
|
||||
<div className="space-y-2 w-full">
|
||||
<Loader.Item height="8px" width="60%" />
|
||||
<Loader.Item height="8px" width="40%" />
|
||||
<Loader.Item height="10px" width="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader.Item className="shrink-0" height="28px" width="28px" />
|
||||
<div className="space-y-2 w-full">
|
||||
<Loader.Item height="8px" width="40%" />
|
||||
<Loader.Item height="8px" width="60%" />
|
||||
<Loader.Item height="10px" width="80%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader.Item className="shrink-0" height="28px" width="28px" />
|
||||
<div className="space-y-2 w-full">
|
||||
<Loader.Item height="8px" width="60%" />
|
||||
<Loader.Item height="8px" width="40%" />
|
||||
<Loader.Item height="10px" width="100%" />
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import uniq from "lodash-es/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
// plane package imports
|
||||
import type { TActivityFilters } from "@plane/constants";
|
||||
import { E_SORT_ORDER, defaultActivityFilters, EUserPermissions } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
//types
|
||||
import type { TFileSignedURLResponse, TIssueComment } from "@plane/types";
|
||||
// components
|
||||
import { CommentCreate } from "@/components/comments/comment-create";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
|
||||
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
|
||||
import { IssueActivityCommentRoot } from "./activity-comment-root";
|
||||
import { useCommentOperations } from "./helper";
|
||||
import { ActivitySortRoot } from "./sort-root";
|
||||
|
||||
type TIssueActivity = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
isIntakeIssue?: boolean;
|
||||
};
|
||||
|
||||
export type TActivityOperations = {
|
||||
createComment: (data: Partial<TIssueComment>) => Promise<TIssueComment>;
|
||||
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
|
||||
removeComment: (commentId: string) => Promise<void>;
|
||||
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
|
||||
};
|
||||
|
||||
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { setValue: setFilterValue, storedValue: selectedFilters } = useLocalStorage(
|
||||
"issue_activity_filters",
|
||||
defaultActivityFilters
|
||||
);
|
||||
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC);
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { getProjectById } = useProject();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
const currentUserProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
|
||||
const isAdmin = currentUserProjectRole === EUserPermissions.ADMIN;
|
||||
const isGuest = currentUserProjectRole === EUserPermissions.GUEST;
|
||||
const isAssigned = issue?.assignee_ids && currentUser?.id ? issue?.assignee_ids.includes(currentUser?.id) : false;
|
||||
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned);
|
||||
// toggle filter
|
||||
const toggleFilter = (filter: TActivityFilters) => {
|
||||
if (!selectedFilters) return;
|
||||
let _filters = [];
|
||||
if (selectedFilters.includes(filter)) {
|
||||
if (selectedFilters.length === 1) return selectedFilters; // Ensure at least one filter is applied
|
||||
_filters = selectedFilters.filter((f) => f !== filter);
|
||||
} else {
|
||||
_filters = [...selectedFilters, filter];
|
||||
}
|
||||
|
||||
setFilterValue(uniq(_filters));
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
|
||||
};
|
||||
|
||||
// helper hooks
|
||||
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
const project = getProjectById(projectId);
|
||||
const renderCommentCreationBox = useMemo(
|
||||
() => (
|
||||
<CommentCreate
|
||||
workspaceSlug={workspaceSlug}
|
||||
entityId={issueId}
|
||||
activityOperations={activityOperations}
|
||||
showToolbarInitially
|
||||
projectId={projectId}
|
||||
/>
|
||||
),
|
||||
[workspaceSlug, issueId, activityOperations, projectId]
|
||||
);
|
||||
if (!project) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-3">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg text-custom-text-100">{t("common.activity")}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWorklogButtonEnabled && (
|
||||
<IssueActivityWorklogCreateButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<ActivitySortRoot sortOrder={sortOrder || E_SORT_ORDER.ASC} toggleSort={toggleSortOrder} />
|
||||
<ActivityFilterRoot
|
||||
selectedFilters={selectedFilters || defaultActivityFilters}
|
||||
toggleFilter={toggleFilter}
|
||||
isIntakeIssue={isIntakeIssue}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* rendering activity */}
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-[200px]">
|
||||
<div className="space-y-3">
|
||||
{!disabled && sortOrder === E_SORT_ORDER.DESC && renderCommentCreationBox}
|
||||
<IssueActivityCommentRoot
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
isIntakeIssue={isIntakeIssue}
|
||||
issueId={issueId}
|
||||
selectedFilters={selectedFilters || defaultActivityFilters}
|
||||
activityOperations={activityOperations}
|
||||
showAccessSpecifier={!!project.anchor}
|
||||
disabled={disabled}
|
||||
sortOrder={sortOrder || E_SORT_ORDER.ASC}
|
||||
/>
|
||||
{!disabled && sortOrder === E_SORT_ORDER.ASC && renderCommentCreationBox}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { memo } from "react";
|
||||
import { ArrowUpWideNarrow, ArrowDownWideNarrow } from "lucide-react";
|
||||
// plane package imports
|
||||
import type { E_SORT_ORDER } from "@plane/constants";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type TActivitySortRoot = {
|
||||
sortOrder: E_SORT_ORDER;
|
||||
toggleSort: () => void;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
export const ActivitySortRoot: FC<TActivitySortRoot> = memo((props) => (
|
||||
<div
|
||||
className={cn(
|
||||
getButtonStyling("neutral-primary", "sm"),
|
||||
"px-2 text-custom-text-300 cursor-pointer",
|
||||
props.className
|
||||
)}
|
||||
onClick={() => {
|
||||
props.toggleSort();
|
||||
}}
|
||||
>
|
||||
{props.sortOrder === "asc" ? (
|
||||
<ArrowUpWideNarrow className={cn("size-4", props.iconClassName)} />
|
||||
) : (
|
||||
<ArrowDownWideNarrow className={cn("size-4", props.iconClassName)} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
ActivitySortRoot.displayName = "ActivitySortRoot";
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||
import { IssueSubscription } from "./subscription";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ref
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
} = useIssueDetail();
|
||||
const {
|
||||
issues: { restoreIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const projectIdentifier = getProjectIdentifierById(projectId);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
projectIdentifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
// handlers
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}${workItemLink}`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
try {
|
||||
const deleteIssue = issue?.archived_at ? removeArchivedIssue : removeIssue;
|
||||
const redirectionPath = issue?.archived_at
|
||||
? `/${workspaceSlug}/projects/${projectId}/archives/issues`
|
||||
: `/${workspaceSlug}/projects/${projectId}/issues`;
|
||||
|
||||
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
router.push(redirectionPath);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error "),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("issue.restore.success.title"),
|
||||
message: t("issue.restore.success.message"),
|
||||
});
|
||||
router.push(workItemLink);
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("issue.restore.failed.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end flex-shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{currentUser && !issue?.archived_at && (
|
||||
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-custom-text-300">
|
||||
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary"
|
||||
onClick={handleCopyText}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<WorkItemDetailQuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issue}
|
||||
handleDelete={handleDeleteIssue}
|
||||
handleArchive={handleArchiveIssue}
|
||||
handleRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState, Fragment, useEffect } from "react";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Plus, X, Loader } from "lucide-react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// hooks
|
||||
import { Input } from "@plane/ui";
|
||||
// ui
|
||||
// types
|
||||
import type { TLabelOperations } from "./root";
|
||||
|
||||
type ILabelCreate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabel> = {
|
||||
name: "",
|
||||
color: "#ff0000",
|
||||
};
|
||||
|
||||
export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
|
||||
// state
|
||||
const [isCreateToggle, setIsCreateToggle] = useState(false);
|
||||
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// react hook form
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
control,
|
||||
setFocus,
|
||||
} = useForm<Partial<IIssueLabel>>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateToggle) return;
|
||||
|
||||
setFocus("name");
|
||||
reset();
|
||||
}, [isCreateToggle, reset, setFocus]);
|
||||
|
||||
const handleLabel = async (formData: Partial<IIssueLabel>) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
|
||||
const currentLabels = [...(values || []), labelResponse.id];
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||
handleIsCreateToggle();
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200"
|
||||
onClick={handleIsCreateToggle}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isCreateToggle ? <X className="h-2.5 w-2.5" /> : <Plus className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{isCreateToggle ? "Cancel" : "New"}</div>
|
||||
</div>
|
||||
|
||||
{isCreateToggle && (
|
||||
<form className="relative flex items-center gap-x-2 p-1" onSubmit={handleSubmit(handleLabel)}>
|
||||
<div>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Popover>
|
||||
<>
|
||||
<Popover.Button as={Fragment}>
|
||||
<button type="button" ref={setReferenceElement} className="grid place-items-center outline-none">
|
||||
{value && value?.trim() !== "" && (
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: value ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="fixed z-10">
|
||||
<div
|
||||
className="p-2 max-w-xs sm:px-0"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<TwitterPicker triangle={"hide"} color={value} onChange={(value) => onChange(value.hex)} />
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "This is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full text-xs px-1.5 py-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded bg-red-500 p-1"
|
||||
onClick={() => setIsCreateToggle(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-white" />
|
||||
</button>
|
||||
<button type="submit" className="grid place-items-center rounded bg-green-500 p-1" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader className="spin h-3.5 w-3.5 text-white" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./label-list";
|
||||
export * from "./label-list-item";
|
||||
export * from "./create-label";
|
||||
export * from "./select/root";
|
||||
export * from "./select/label-select";
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import type { TLabelOperations } from "./root";
|
||||
|
||||
type TLabelListItem = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
labelId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const LabelListItem: FC<TLabelListItem> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
|
||||
// hooks
|
||||
const { getLabelById } = useLabel();
|
||||
|
||||
const label = getLabelById(labelId);
|
||||
|
||||
const handleLabel = async () => {
|
||||
if (values && !disabled) {
|
||||
const currentLabels = values.filter((_labelId) => _labelId !== labelId);
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||
}
|
||||
};
|
||||
|
||||
if (!label) return <></>;
|
||||
return (
|
||||
<div
|
||||
key={labelId}
|
||||
className={`transition-all relative flex items-center gap-1 truncate border border-custom-border-100 rounded-full text-xs p-0.5 px-1 group ${
|
||||
!disabled ? "cursor-pointer hover:border-red-500/50 hover:bg-red-500/20" : "cursor-not-allowed"
|
||||
} `}
|
||||
onClick={handleLabel}
|
||||
>
|
||||
<div
|
||||
className="rounded-full h-2 w-2 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="truncate">{label.name}</div>
|
||||
{!disabled && (
|
||||
<div className="flex-shrink-0">
|
||||
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { LabelListItem } from "./label-list-item";
|
||||
// types
|
||||
import type { TLabelOperations } from "./root";
|
||||
|
||||
type TLabelList = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const LabelList: FC<TLabelList> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
|
||||
const issueLabels = values || undefined;
|
||||
|
||||
if (!issueId || !issueLabels) return <></>;
|
||||
return (
|
||||
<>
|
||||
{issueLabels.map((labelId) => (
|
||||
<LabelListItem
|
||||
key={labelId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
labelId={labelId}
|
||||
values={issueLabels}
|
||||
labelOperations={labelOperations}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
120
apps/web/core/components/issues/issue-detail/label/root.tsx
Normal file
120
apps/web/core/components/issues/issue-detail/label/root.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IIssueLabel, TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
// ui
|
||||
// types
|
||||
import { LabelList, IssueLabelSelectRoot } from "./";
|
||||
// TODO: Fix this import statement, as core should not import from ee
|
||||
// eslint-disable-next-line import/order
|
||||
|
||||
export type TIssueLabel = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
isInboxIssue?: boolean;
|
||||
onLabelUpdate?: (labelIds: string[]) => void;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export type TLabelOperations = {
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
createLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
|
||||
};
|
||||
|
||||
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
disabled = false,
|
||||
isInboxIssue = false,
|
||||
onLabelUpdate,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { updateIssue } = useIssueDetail(issueServiceType);
|
||||
const { createLabel } = useLabel();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getIssueInboxByIssueId } = useProjectInbox();
|
||||
|
||||
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
|
||||
|
||||
const labelOperations: TLabelOperations = useMemo(
|
||||
() => ({
|
||||
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
if (onLabelUpdate) onLabelUpdate(data.label_ids || []);
|
||||
else await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.update.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
}
|
||||
},
|
||||
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
|
||||
try {
|
||||
const labelResponse = await createLabel(workspaceSlug, projectId, data);
|
||||
if (!isInboxIssue)
|
||||
setToast({
|
||||
title: t("toast.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("label.create.success"),
|
||||
});
|
||||
return labelResponse;
|
||||
} catch (error) {
|
||||
let errMessage = t("label.create.failed");
|
||||
if (error && (error as any).error === "Label with the same name already exists in the project")
|
||||
errMessage = t("label.create.already_exists");
|
||||
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: errMessage,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[updateIssue, createLabel, onLabelUpdate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
<LabelList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
values={issue?.label_ids || []}
|
||||
labelOperations={labelOperations}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<IssueLabelSelectRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
values={issue?.label_ids || []}
|
||||
labelOperations={labelOperations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, Loader, Search, Tag } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// helpers
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
//constants
|
||||
export interface IIssueLabelSelect {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
onSelect: (_labelIds: string[]) => void;
|
||||
onAddLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
|
||||
}
|
||||
|
||||
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { fetchProjectLabels, getProjectLabels } = useLabel();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const canCreateLabel =
|
||||
projectId && allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
const projectLabels = getProjectLabels(projectId);
|
||||
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
|
||||
const fetchLabels = () => {
|
||||
setIsLoading(true);
|
||||
if (!projectLabels && workspaceSlug && projectId)
|
||||
fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const options = (projectLabels ?? []).map((label) => ({
|
||||
value: label.id,
|
||||
query: label.name,
|
||||
content: (
|
||||
<div className="flex items-center justify-start gap-2 overflow-hidden">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block truncate">{label.name}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-end",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const issueLabels = values ?? [];
|
||||
|
||||
const label = (
|
||||
<div
|
||||
className={`relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 py-0.5 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Tag className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
<div className="flex-shrink-0">{t("label.select")}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
if (query !== "" && e.key === "Enter" && !e.nativeEvent.isComposing && canCreateLabel) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await handleAddLabel(query);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLabel = async (labelName: string) => {
|
||||
setSubmitting(true);
|
||||
const label = await onAddLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() });
|
||||
onSelect([...values, label.id]);
|
||||
setQuery("");
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (!issueId || !values) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox
|
||||
as="div"
|
||||
className={`w-auto max-w-full flex-shrink-0 text-left`}
|
||||
value={issueLabels}
|
||||
onChange={(value) => onSelect(value)}
|
||||
multiple
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className="cursor-pointer rounded"
|
||||
onClick={() => !projectLabels && fetchLabels()}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
|
||||
<Combobox.Options className="fixed z-10">
|
||||
<div
|
||||
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="px-2">
|
||||
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("common.search.label")}
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`vertical-scrollbar scrollbar-sm mt-2 max-h-48 space-y-1 overflow-y-scroll px-2 pr-0`}>
|
||||
{isLoading ? (
|
||||
<p className="text-center text-custom-text-200">{t("common.loading")}</p>
|
||||
) : filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ selected }) =>
|
||||
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{option.content}
|
||||
{selected && (
|
||||
<div className="flex-shrink-0">
|
||||
<Check className={`h-3.5 w-3.5`} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : submitting ? (
|
||||
<Loader className="spin h-3.5 w-3.5" />
|
||||
) : canCreateLabel ? (
|
||||
<Combobox.Option
|
||||
value={query}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!query.length) return;
|
||||
handleAddLabel(query);
|
||||
}}
|
||||
className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`}
|
||||
>
|
||||
{query.length ? (
|
||||
<>
|
||||
{/* TODO: Translate here */}+ Add{" "}
|
||||
<span className="text-custom-text-100">"{query}"</span> to labels
|
||||
</>
|
||||
) : (
|
||||
t("label.create.type")
|
||||
)}
|
||||
</Combobox.Option>
|
||||
) : (
|
||||
<p className="text-left text-custom-text-200 ">{t("common.search.no_matching_results")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from "react";
|
||||
// components
|
||||
import type { TLabelOperations } from "../root";
|
||||
import { IssueLabelSelect } from "./label-select";
|
||||
// types
|
||||
|
||||
type TIssueLabelSelectRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
values: string[];
|
||||
labelOperations: TLabelOperations;
|
||||
};
|
||||
|
||||
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
|
||||
|
||||
const handleLabel = async (_labelIds: string[]) => {
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
|
||||
};
|
||||
|
||||
return (
|
||||
<IssueLabelSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
values={values}
|
||||
onSelect={handleLabel}
|
||||
onAddLabel={labelOperations.createLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { TIssueLinkEditableFields, TIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { Input, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TLinkOperations } from "./root";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "remove">;
|
||||
|
||||
export type TIssueLinkCreateFormFieldOptions = TIssueLinkEditableFields & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type TIssueLinkCreateEditModal = {
|
||||
isModalOpen: boolean;
|
||||
handleOnClose?: () => void;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
const defaultValues: TIssueLinkCreateFormFieldOptions = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
|
||||
const { isModalOpen, handleOnClose, linkOperations, issueServiceType } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// react hook form
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<TIssueLinkCreateFormFieldOptions>({
|
||||
defaultValues,
|
||||
});
|
||||
// store hooks
|
||||
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail(issueServiceType);
|
||||
|
||||
const onClose = () => {
|
||||
setIssueLinkData(null);
|
||||
if (handleOnClose) handleOnClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => {
|
||||
const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`;
|
||||
try {
|
||||
if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl });
|
||||
else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("error", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) reset({ ...defaultValues, ...preloadedData });
|
||||
}, [preloadedData, reset, isModalOpen]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
{t("common.url")}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder={t("common.type_or_paste_a_url")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.url && <span className="text-xs text-red-500">{t("common.url_is_invalid")}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
{t("common.display_title")}
|
||||
<span className="text-[10px] block">{t("common.optional")}</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder={t("common.link_title_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{`${
|
||||
preloadedData?.id
|
||||
? isSubmitting
|
||||
? t("common.updating")
|
||||
: t("common.update")
|
||||
: isSubmitting
|
||||
? t("common.adding")
|
||||
: t("common.add")
|
||||
} ${t("common.link")}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
export * from "./link-item";
|
||||
export * from "./link-list";
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
// hooks
|
||||
// ui
|
||||
import { Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { getIconForLink, copyTextToClipboard, calculateTimeAgo } from "@plane/utils";
|
||||
// icons
|
||||
// types
|
||||
// helpers
|
||||
//
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import type { TLinkOperationsModal } from "./create-update-link-modal";
|
||||
|
||||
export type TIssueLinkDetail = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
// props
|
||||
const { linkId, linkOperations, isNotAllowed } = props;
|
||||
// hooks
|
||||
const {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
link: { getLinkById },
|
||||
setIssueLinkData,
|
||||
} = useIssueDetail();
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
|
||||
const Icon = getIconForLink(linkDetail.url);
|
||||
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIssueLinkData(linkDetail);
|
||||
};
|
||||
|
||||
const createdByDetails = getUserDetails(linkDetail.created_by_id);
|
||||
|
||||
return (
|
||||
<div key={linkId}>
|
||||
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-start justify-between gap-2"
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied!",
|
||||
message: "Link copied to clipboard",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 truncate">
|
||||
<span className="py-1">
|
||||
<Icon className="size-3 stroke-2 text-custom-text-350 group-hover:text-custom-text-100 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip
|
||||
tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<span className="truncate text-xs">
|
||||
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIssueLinkModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkOperations.remove(linkDetail.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(linkDetail.created_at)}
|
||||
<br />
|
||||
{createdByDetails && (
|
||||
<>
|
||||
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
apps/web/core/components/issues/issue-detail/links/link-item.tsx
Normal file
121
apps/web/core/components/issues/issue-detail/links/link-item.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, Trash2, Copy, Link } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { calculateTimeAgo, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import type { TLinkOperationsModal } from "./create-update-link-modal";
|
||||
|
||||
type TIssueLinkItem = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
isNotAllowed: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
|
||||
// props
|
||||
const { linkId, linkOperations, isNotAllowed, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
setIssueLinkData,
|
||||
link: { getLinkById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { isMobile } = usePlatformOS();
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
|
||||
// const Icon = getIconForLink(linkDetail.url);
|
||||
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
|
||||
const linkTitle: string | undefined = linkDetail.metadata?.title;
|
||||
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIssueLinkData(linkDetail);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={linkId}
|
||||
className="group 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-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 truncate flex-grow">
|
||||
{faviconUrl ? (
|
||||
<img src={faviconUrl} alt="favicon" className="size-4" />
|
||||
) : (
|
||||
<Link className="size-4 text-custom-text-350 group-hover:text-custom-text-100" />
|
||||
)}
|
||||
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm cursor-pointer flex-grow flex items-center gap-3"
|
||||
>
|
||||
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
|
||||
{linkTitle && linkTitle !== "" && <span className="text-custom-text-400 text-xs">{linkTitle}</span>}
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200">
|
||||
{calculateTimeAgo(linkDetail.created_at)}
|
||||
</p>
|
||||
<span
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.link_copied_to_clipboard"),
|
||||
});
|
||||
}}
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-400 outline-none group-hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</span>
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
buttonClassName="text-custom-text-400 group-hover:text-custom-text-200"
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
toggleIssueLinkModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
{t("common.actions.edit")}
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
linkOperations.remove(linkDetail.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("common.actions.delete")}
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user