snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "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";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// 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 function ArchiveIssueModal(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();
return;
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.archive.failed.message"),
})
)
.finally(() => setIsArchiving(false));
};
return (
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.LG}>
<div className="px-5 py-4">
<h3 className="text-18 font-medium 2xl:text-20">
{t("issue.archive.label")} {projectDetails?.identifier} {issue.sequence_id}
</h3>
<p className="mt-3 text-13 text-secondary">{t("issue.archive.confirm_message")}</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? t("common.archiving") : t("common.archive")}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function ArchivedIssuesHeader() {
// 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>
);
});

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { CloseOutline, WarningCircleOutline } from "@makeplane/propel/icons";
// ui
import { Tooltip } from "@makeplane/propel/components/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 = observer(function IssueAttachmentsDetail(props: TIssueAttachmentsDetail) {
// 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-subtle bg-surface-1 px-4 py-2 text-13">
<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 label={fileName} layout="stacked" disabled={isMobile}>
<span className="text-13">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
<Tooltip
label={`${
getUserDetails(attachment.updated_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
layout="stacked"
disabled={isMobile}
>
<span>
<WarningCircleOutline className="h-3 w-3" />
</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-11 text-secondary">
<span>{fileExtension.toUpperCase()}</span>
<span>{convertBytesToSize(attachment.attributes.size)}</span>
</div>
</div>
</div>
</Link>
{!disabled && (
<button type="button" onClick={() => setIsDeleteIssueAttachmentModalOpen(true)}>
<CloseOutline className="h-4 w-4 text-secondary hover:text-primary" />
</button>
)}
</div>
</>
);
});

View File

@@ -0,0 +1,157 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadOutline } from "@makeplane/propel/icons";
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 "@/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 = observer(function IssueAttachmentItemList(props: TIssueAttachmentItemList) {
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 top-0 left-0 z-30 flex h-full w-full items-center justify-center bg-surface-2/75">
<div className="flex items-center justify-center rounded-md bg-surface-1 p-1">
<div className="flex flex-col items-center justify-center rounded-md border border-dashed border-strong px-5 py-6">
<UploadOutline className="size-7" />
<span className="text-13 text-tertiary">{t("attachment.drag_and_drop")}</span>
</div>
</div>
</div>
)}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
))}
</div>
</>
)}
</>
);
});

View File

@@ -0,0 +1,107 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { DeleteOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/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 = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
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 h-11 items-center justify-between gap-3 px-3 hover:bg-surface-2">
<div className="flex items-center gap-3 truncate text-13">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip label={`${fileName}.${fileExtension}`} layout="stacked" disabled={isMobile}>
<p className="truncate font-medium text-secondary">{`${fileName}.${fileExtension}`}</p>
</Tooltip>
<span className="flex size-1.5 rounded-full bg-layer-1" />
<span className="shrink-0 text-placeholder">{convertBytesToSize(attachment.attributes.size)}</span>
</div>
<div className="flex items-center gap-3">
{attachment?.created_by && (
<>
<Tooltip
label={`${
getUserDetails(attachment?.created_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
layout="stacked"
disabled={isMobile}
>
<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">
<DeleteOutline className="h-3.5 w-3.5" />
<span>{t("common.actions.delete")}</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</button>
</>
);
});

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// ui
import { CircularProgress } from "@makeplane/propel/components/circular-progress";
import { Tooltip } from "@makeplane/propel/components/tooltip";
// 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 = observer(function IssueAttachmentsUploadItem(props: 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="pointer-events-none flex h-11 items-center justify-between gap-3 bg-surface-2 pr-2 pl-9">
<div className="flex items-center gap-3 truncate text-13">
<div className="flex-shrink-0">{fileIcon}</div>
<Tooltip label={fileName} layout="stacked" disabled={isMobile}>
<p className="truncate font-medium text-secondary">{fileName}</p>
</Tooltip>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<span className="flex-shrink-0">
<CircularProgress value={uploadStatus.progress} size="md" variant="brand" aria-label="Upload progress" />
</span>
<div className="flex-shrink-0 text-13 font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { CircularProgress } from "@makeplane/propel/components/circular-progress";
import { Tooltip } from "@makeplane/propel/components/tooltip";
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 = observer(function IssueAttachmentsUploadDetails(props: 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="pointer-events-none flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-subtle bg-surface-2 px-4 py-2 text-13">
<div className="flex flex-shrink-0 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 label={fileName} layout="stacked" disabled={isMobile}>
<span className="text-13">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-11 text-secondary">
<span>{fileExtension.toUpperCase()}</span>
</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<span className="flex-shrink-0">
<CircularProgress value={uploadStatus.progress} size="md" variant="brand" aria-label="Upload progress" />
</span>
<div className="flex-shrink-0 text-13 font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
// plane web hooks
import { useFileSize } from "@/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 = observer(function IssueAttachmentUpload(props: 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-accent-primary/5 px-4 text-11 text-accent-primary ${
isDragActive ? "border-accent-strong bg-accent-primary/10" : "border-subtle"
} ${isDragReject ? "bg-danger-subtle" : ""} ${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-danger-primary">{fileError}</p>
) : isLoading ? (
<p className="text-center">Uploading...</p>
) : (
<p className="text-center">Click or drag a file here</p>
)}
</span>
</div>
);
});

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueAttachmentsList(props: TIssueAttachmentsList) {
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}
/>
))}
</>
);
});

View File

@@ -0,0 +1,76 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueAttachmentDeleteModal(props: 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.
</>
}
/>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueAttachmentRoot(props: TIssueAttachmentRoot) {
// props
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// hooks
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<div className="relative space-y-3">
<h3 className="text-body-sm-medium">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>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// ui
import { Banner } from "@makeplane/propel/components/banner";
import { Button } from "@makeplane/propel/components/button";
import { MARKETING_PLANE_ONE_PAGE_LINK } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// hooks
import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
type Props = {
className?: string;
selectionHelpers: TSelectionHelper;
};
export const IssueBulkOperationsRoot = observer(function IssueBulkOperationsRoot(props: Props) {
const { className, selectionHelpers } = props;
// translation
const { t } = useTranslation();
// store hooks
const { isSelectionActive } = useMultipleSelectStore();
if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null;
return (
<div className={cn("sticky bottom-0 left-0 z-[2] grid h-20 place-items-center px-3.5", className)}>
<Banner
placement="inline"
variant="accent"
icon={null}
title={t("bulk_operations.upgrade_banner.message")}
actions={
<Button
variant="primary"
size="sm"
stretch="auto"
label={t("bulk_operations.upgrade_banner.cta")}
nativeButton={false}
render={
<a
href={MARKETING_PLANE_ONE_PAGE_LINK}
target="_blank"
rel="noopener noreferrer"
aria-label={t("bulk_operations.upgrade_banner.cta")}
/>
}
/>
}
render={<div className="w-full" />}
/>
</div>
);
});

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
// ui
import { Button } from "@plane/propel/button";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
type Props = {
isOpen: boolean;
handleClose: () => void;
onDiscard: () => void;
onConfirm: () => Promise<void>;
};
export function ConfirmIssueDiscard(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 (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="px-4 pt-5 pb-4 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">
<h3 className="text-16 leading-6 font-medium text-primary">Save this draft?</h3>
<div className="mt-2">
<p className="text-13 text-secondary">
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="secondary" onClick={onDiscard}>
Discard
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleDeletion} loading={isLoading}>
{isLoading ? "Saving" : "Save to Drafts"}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function CreateIssueToastActionItems(
props: TCreateIssueToastActionItems
) {
const { workspaceSlug, 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="-ml-2 flex items-center gap-1 text-11 text-secondary">
<a
href={workItemLink}
target="_blank"
rel="noopener noreferrer"
className="rounded-sm px-2 py-1 font-medium text-accent-primary hover:bg-surface-2"
>
{`View ${isEpic ? "epic" : "work item"}`}
</a>
{copied ? (
<>
<span className="cursor-default px-2 py-1 text-secondary">Copied!</span>
</>
) : (
<>
<button
className="hidden cursor-pointer rounded-sm px-2 py-1 text-tertiary group-hover:flex hover:bg-surface-2 hover:text-secondary"
onClick={copyToClipboard}
>
Copy link
</button>
</>
)}
</div>
);
});

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
type Props = {
isOpen: boolean;
handleClose: () => void;
dataId?: string | null | undefined;
data?: TIssue | TDeDupeIssue;
isSubIssue?: boolean;
onSubmit?: () => Promise<void>;
isEpic?: boolean;
};
export const DeleteIssueModal = observer(function DeleteIssueModal(props: 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="font-medium break-words text-primary">
{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.`}
</>
}
/>
);
});

View File

@@ -0,0 +1,140 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { BarOutline, PreferencesOutline } from "@makeplane/propel/icons";
// 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/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(function HeaderFilters(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={<PreferencesOutline 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 px-2 md:block" onClick={() => setAnalyticsModal(true)} variant="secondary" size="lg">
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<BarOutline className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</>
);
});

View File

@@ -0,0 +1,131 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { Circle } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { NewTabOutline, WorkItemsOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CountChip } from "@/components/common/count-chip";
// constants
import { HeaderFilters } from "@/components/issues/filters";
// helpers
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
export const IssuesHeader = observer(function IssuesHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks
const {
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.PROJECT);
// i18n
const { t } = useTranslation();
const { currentProjectDetails, loader } = useProject();
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { isMobile } = usePlatformOS();
const SPACE_APP_URL =
(SPACE_BASE_URL.trim() === "" && typeof window !== "undefined" ? window.location.origin : SPACE_BASE_URL) +
SPACE_BASE_PATH;
const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsOutline className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
{issuesCount && issuesCount > 0 ? (
<Tooltip
label={`There are ${issuesCount} ${issuesCount > 1 ? "work items" : "work item"} in this project`}
layout="stacked"
side="bottom"
disabled={isMobile}
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</div>
{currentProjectDetails?.anchor ? (
<a
href={publishedURL}
className="group flex items-center gap-1.5 rounded-sm bg-accent-primary/10 px-2.5 py-1 text-11 font-medium text-accent-primary"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-accent-primary" strokeWidth={2} />
{t("workspace_projects.network.public.title")}
<NewTabOutline className="hidden h-3 w-3 group-hover:block" />
</a>
) : (
<></>
)}
</Header.LeftItem>
<Header.RightItem>
<div className="hidden gap-2 md:flex">
<HeaderFilters
projectId={projectId}
currentProjectDetails={currentProjectDetails}
workspaceSlug={workspaceSlug}
canUserCreateIssue={canUserCreateIssue}
/>
</div>
{canUserCreateIssue && (
<Button
variant="primary"
size="lg"
onClick={() => {
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}
>
<div className="block sm:hidden">{t("issue.label", { count: 1 })}</div>
<div className="hidden sm:block">{t("issue.add.label")}</div>
</Button>
)}
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { AttachOutline, LinkOutline, RelationsOutline, ViewsOutline } from "@makeplane/propel/icons";
import { useTranslation } from "@plane/i18n";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// 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 function IssueDetailWidgetActionButtons(props: Props) {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
// translation
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
{!hideWidgets?.includes("sub-work-items") && (
<SubIssuesActionButton
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("issue.add.sub_issue")}
icon={<ViewsOutline className="h-3.5 w-3.5 flex-shrink-0" />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("relations") && (
<RelationActionButton
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("issue.add.relation")}
icon={<RelationsOutline className="h-3.5 w-3.5 flex-shrink-0" />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("links") && (
<IssueLinksActionButton
customButton={
<IssueDetailWidgetButton
title={t("issue.add.link")}
icon={<LinkOutline className="h-3.5 w-3.5 flex-shrink-0" />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("attachments") && (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("common.attach")}
icon={<AttachOutline className="h-3.5 w-3.5 flex-shrink-0" />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueAttachmentsCollapsibleContent(props: 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}
/>
);
});

View File

@@ -0,0 +1,85 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { setPromiseToast, 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";
// 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) => {
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;
},
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",
});
} catch (_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 },
};
};

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { AddOutline } from "@makeplane/propel/icons";
// 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 "@/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 = observer(function IssueAttachmentActionButton(props: 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 : <AddOutline className="h-4 w-4" />}
</button>
</div>
);
});

View File

@@ -0,0 +1,61 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { Collapsible } from "@makeplane/propel/components/collapsible";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueAttachmentsCollapsibleContent } from "./content";
import { IssueAttachmentsCollapsibleTitle } from "./title";
import { IssueAttachmentActionButton } from "./quick-action-button";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const AttachmentsCollapsible = observer(function AttachmentsCollapsible(props: 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
open={isCollapsibleOpen}
onOpenChange={() => toggleOpenWidget("attachments")}
trigger={<IssueAttachmentsCollapsibleTitle issueId={issueId} issueServiceType={issueServiceType} />}
trailing={
isCollapsibleOpen && !disabled ? (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
) : undefined
}
>
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
issueId: string;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsCollapsibleTitle = observer(function IssueAttachmentsCollapsibleTitle(props: Props) {
const { issueId, 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;
return (
<span className="inline-flex items-center gap-2">
{t("common.attachments")}
<span className="flex items-center justify-center">
<p className="text-14 leading-3! text-tertiary">{attachmentCount}</p>
</span>
</span>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
import { useTimeLineRelationOptions } from "@/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 = observer(function IssueDetailWidgetCollapsibles(props: 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}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,200 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
// 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 = observer(function IssueDetailWidgetModals(props: 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
/>
)}
</>
);
});

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function IssueLinksCollapsibleContent(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}
/>
);
}

View File

@@ -0,0 +1,87 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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;
};

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { AddOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueLinksActionButton(props: 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 : <AddOutline className="h-4 w-4" />}
</button>
);
});

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { Collapsible } from "@makeplane/propel/components/collapsible";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinksCollapsibleContent } from "./content";
import { IssueLinksCollapsibleTitle } from "./title";
import { IssueLinksActionButton } from "./quick-action-button";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const LinksCollapsible = observer(function LinksCollapsible(props: 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
open={isCollapsibleOpen}
onOpenChange={() => toggleOpenWidget("links")}
trigger={<IssueLinksCollapsibleTitle issueId={issueId} issueServiceType={issueServiceType} />}
trailing={
isCollapsibleOpen && !disabled ? (
<IssueLinksActionButton issueServiceType={issueServiceType} disabled={disabled} />
) : undefined
}
>
<IssueLinksCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
issueId: string;
issueServiceType: TIssueServiceType;
};
export const IssueLinksCollapsibleTitle = observer(function IssueLinksCollapsibleTitle(props: Props) {
const { issueId, 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;
return (
<span className="inline-flex items-center gap-2">
{t("common.links")}
<span className="flex items-center justify-center">
<p className="text-14 leading-3! text-tertiary">{linksCount}</p>
</span>
</span>
);
});

View File

@@ -0,0 +1,245 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { Collapsible } from "@makeplane/propel/components/collapsible";
import { useTranslation } from "@plane/i18n";
import type { TIssue, TIssueServiceType, TIssueRelationTypes } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
import { useTimeLineRelationOptions } from "@/components/relations";
import { CreateUpdateEpicModal } from "@/components/epic-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// 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 = observer(function RelationsCollapsibleContent(props: 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="gap- flex flex-col">
{filteredRelationsArray.map((relation) => (
<div key={relation.relationKey}>
<Collapsible
defaultOpen
icon={
relation.icon ? (
<span className={cn("flex items-center justify-center rounded p-0.5", relation.className)}>
{relation.icon(14)}
</span>
) : undefined
}
trigger={<span className="text-13 leading-5 font-medium">{relation.label}</span>}
>
{/* Cancels this nested Collapsible's own panel inset so rows realign under
the outer Relations collapsible's single inset instead of stacking two. */}
<div className="-mx-3">
<RelationIssueList
workspaceSlug={workspaceSlug}
issueId={issueId}
relationKey={relation.relationKey}
issueIds={relation.issueIds}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
issueServiceType={issueServiceType}
/>
</div>
</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);
}}
/>
)}
</>
)}
</>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
// plane imports
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 { 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: async (path) => {
await copyUrlToClipboard(path);
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);
setToast({
title: t("toast.success"),
type: TOAST_TYPE.SUCCESS,
message: t("entity.update.success", { entity: entityName }),
});
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.update.failed", { entity: entityName }),
});
}
},
remove: async (workspaceSlug, projectId, issueId) => {
return removeIssue(workspaceSlug, projectId, issueId);
},
}),
[entityName, removeIssue, t, updateIssue]
);
return issueOperations;
};

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { AddOutline } from "@makeplane/propel/icons";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { useTimeLineRelationOptions } from "@/components/relations";
// types
import type { TIssueRelationTypes } from "@plane/types";
type Props = {
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const RelationActionButton = observer(function RelationActionButton(props: 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}</> : <AddOutline 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
// oxlint-disable-next-line react/no-array-index-key
key={index}
onClick={() => {
handleOnClick(item.key);
}}
>
<div className="flex items-center gap-2">
{item.icon(12)}
<span>{t(item.i18n_label)}</span>
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
});

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { Collapsible } from "@makeplane/propel/components/collapsible";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { RelationsCollapsibleContent } from "./content";
import { RelationsCollapsibleTitle } from "./title";
import { RelationActionButton } from "./quick-action-button";
type Props = {
workspaceSlug: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const RelationsCollapsible = observer(function RelationsCollapsible(props: Props) {
const { workspaceSlug, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("relations");
return (
<Collapsible
open={isCollapsibleOpen}
onOpenChange={() => toggleOpenWidget("relations")}
trigger={<RelationsCollapsibleTitle issueId={issueId} issueServiceType={issueServiceType} />}
trailing={
isCollapsibleOpen && !disabled ? (
<RelationActionButton issueId={issueId} disabled={disabled} issueServiceType={issueServiceType} />
) : undefined
}
>
<RelationsCollapsibleContent
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { useTimeLineRelationOptions } from "@/components/relations";
type Props = {
issueId: string;
issueServiceType?: TIssueServiceType;
};
export const RelationsCollapsibleTitle = observer(function RelationsCollapsibleTitle(props: Props) {
const { issueId, 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);
return (
<span className="inline-flex items-center gap-2">
{t("common.relations")}
<span className="flex items-center justify-center">
<p className="text-14 leading-3! text-tertiary">{relationsCount}</p>
</span>
</span>
);
});

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function IssueDetailWidgets(props: Props) {
const {
workspaceSlug,
projectId,
issueId,
disabled,
renderWidgetModals = true,
issueServiceType,
hideWidgets,
} = props;
return (
<>
<div className="flex flex-col space-y-4">
<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}
/>
)}
</>
);
}

View File

@@ -0,0 +1,175 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function SubIssuesCollapsibleContent(props: 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 () => {
const currentSubIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
if (!currentSubIssueHelpers.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, subIssueHelpersByIssueId, 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
);
}}
/>
)}
</>
);
});

View File

@@ -0,0 +1,107 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { PreferencesOutline } from "@makeplane/propel/icons";
// 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 = observer(function SubIssueDisplayFilters(props: TSubIssueDisplayFiltersProps) {
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(
"relative rounded-sm p-1 transition-all duration-200",
isFilterApplied && "bg-accent-primary/20"
)}
>
{isFilterApplied && <span className="absolute -top-1 -right-1 rounded-full bg-accent-primary p-1" />}
<PreferencesOutline className="h-3.5 w-3.5 text-primary" />
</div>
}
>
<div className="vertical-scrollbar relative scrollbar-sm h-full max-h-[25rem] w-full divide-y divide-subtle-1 overflow-hidden overflow-y-auto px-2.5 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>
)}
</>
);
});

View File

@@ -0,0 +1,161 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { CloseOutline, FilterOutline, SearchOutline } from "@makeplane/propel/icons";
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";
type TSubIssueFiltersProps = {
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
filters: IIssueFilterOptions;
memberIds: string[] | undefined;
states?: IState[];
availableFilters: (keyof IIssueFilterOptions)[];
};
export const SubIssueFilters = observer(function SubIssueFilters(props: TSubIssueFiltersProps) {
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(
"relative rounded-sm p-1 transition-all duration-200",
isFilterApplied && "bg-accent-primary/20"
)}
>
{isFilterApplied && <span className="absolute -top-1 -right-1 rounded-full bg-accent-primary p-1" />}
<FilterOutline className="h-3.5 w-3.5 text-primary" />
</div>
}
>
<div className="flex max-h-[350px] flex-col overflow-hidden">
<div className="bg-surface-1 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-1.5 py-1 text-11">
<SearchOutline className="text-placeholder" width={12} height={12} />
<input
type="text"
className="w-full bg-surface-2 outline-none placeholder:text-placeholder"
placeholder={t("common.search.label")}
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<CloseOutline className="text-tertiary" height={12} width={12} />
</button>
)}
</div>
</div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-subtle-1 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>
)}
{/* 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>
</>
);
});

View File

@@ -0,0 +1,173 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
// plane imports
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 { useIssueDetail } from "@/hooks/store/use-issue-detail";
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
// translation
const { t } = useTranslation();
// store hooks
const {
subIssues: { setSubIssueHelpers },
createSubIssues,
fetchSubIssues,
updateSubIssue,
deleteSubIssue,
removeSubIssue,
} = useIssueDetail(issueServiceType);
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyLink: async (path) => {
await copyUrlToClipboard(path);
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);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("sub_work_item.update.success"),
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (_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);
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 }),
}),
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (_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);
await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (_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,
fetchSubIssues,
issueServiceType,
removeSubIssue,
setSubIssueHelpers,
t,
updateSubIssue,
]
);
return subIssueOperations;
};

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./content";
export * from "./display-filters";
export * from "./quick-action-button";
export * from "./root";
export * from "./title";

View File

@@ -0,0 +1,99 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { CircleDashed } from "lucide-react";
import { Collapsible } from "@makeplane/propel/components/collapsible";
import { ALL_ISSUES } from "@plane/constants";
import type { IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
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 = observer(function SubIssuesListGroup(props: TSubIssuesListGroupProps) {
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;
const items = 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}
/>
));
if (isAllIssues) return <>{items}</>;
return (
<Collapsible
open={isCollapsibleOpen}
onOpenChange={setIsCollapsibleOpen}
icon={
<div className="grid shrink-0 place-items-center overflow-hidden">
{group.icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
</div>
}
trigger={
<>
<span className="text-13 font-medium text-primary">{group.name}</span>
<span className="text-13 text-placeholder">{workItemIds.length}</span>
</>
}
>
{/* Cancels this nested Collapsible's own panel inset — the outer sub-work-items
Collapsible (root.tsx) already contributes one; without this, grouped rows
would sit inset twice. */}
<div className="-mx-3">{items}</div>
</Collapsible>
);
});

View File

@@ -0,0 +1,270 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { ChevronRightOutline, CloseOutline, DeleteOutline, EditOutline, LinkOutline } from "@makeplane/propel/icons";
// plane imports
import { Tooltip } from "@makeplane/propel/components/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";
// components
import { IssueIdentifier } from "@/components/issues/issue-detail/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 = observer(function SubIssuesListItem(props: 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 h-full min-h-11 w-full items-center py-1 pr-2 transition-all hover:bg-surface-2"
style={{ paddingLeft: `${spacingLeft}px` }}
>
<div className="flex size-5 flex-shrink-0 items-center justify-center">
{/* 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-xs bg-layer-1 transition-all">
<LinkOutline width={14} height={14} className="animate-spin" />
</div>
) : (
<div
className="flex h-full w-full cursor-pointer items-center justify-center text-placeholder hover:text-tertiary"
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);
}}
>
<ChevronRightOutline
className={cn("size-3.5 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
})}
/>
</div>
)}
</>
)}
</div>
<div className="flex w-full cursor-pointer items-center gap-3 truncate">
<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}
size="xs"
variant="secondary"
/>
)}
</div>
</WithDisplayPropertiesHOC>
<Tooltip label={issue.name} layout="stacked" disabled={isMobile}>
<span className="w-0 flex-1 truncate text-13 text-primary">{issue.name}</span>
</Tooltip>
</div>
<div
className="flex-shrink-0 text-13"
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-13">
<CustomMenu placement="bottom-end" ellipsis>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("update", parentIssueId, { ...issue });
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<EditOutline className="h-3.5 w-3.5" />
<span>{t("issue.edit")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
subIssueOperations.copyLink(workItemLink);
}}
>
<div className="flex items-center gap-2">
<LinkOutline className="h-3.5 w-3.5" />
<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">
<CloseOutline className="h-3.5 w-3.5" />
{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">
<DeleteOutline className="h-3.5 w-3.5" />
<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>
);
});

View File

@@ -0,0 +1,227 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import type { SyntheticEvent } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { DueDateOutline, StartDateOutline } from "@makeplane/propel/icons";
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 = observer(function SubIssuesListItemProperties(props: 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"
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,
}}
placement="top-end"
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-danger-primary" : ""}
disabled={!canEdit}
showTooltip
customTooltipHeading="Date Range"
renderPlaceholder={false}
renderInPortal
/>
</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={<StartDateOutline 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={<DueDateOutline className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-danger-primary" : ""}
clearIconClassName="text-primary"
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>
);
});

View File

@@ -0,0 +1,131 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { FilterOutline } from "@makeplane/propel/icons";
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 = observer(function SubIssuesListRoot(props: 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={<FilterOutline />}
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
actionElement={
<Button variant="secondary" 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>
);
});

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { AddOutline, WorkItemsOutline } from "@makeplane/propel/icons";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const SubIssuesActionButton = observer(function SubIssuesActionButton(props: 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 = () => {
handleIssueCrudState("create", issueId, null);
toggleCreateIssueModal(true);
};
const handleAddExisting = () => {
handleIssueCrudState("existing", issueId, null);
toggleSubIssuesModal(issue.id);
};
// options
const optionItems = [
{
i18n_label: "common.create_new",
icon: <AddOutline className="h-3 w-3" />,
onClick: handleCreateNew,
},
{
i18n_label: "common.add_existing",
icon: <WorkItemsOutline className="h-3 w-3" />,
onClick: handleAddExisting,
},
];
// button element
const customButtonElement = customButton ? <>{customButton}</> : <AddOutline 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>
);
});

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { Collapsible } from "@makeplane/propel/components/collapsible";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { SubIssuesCollapsibleContent } from "./content";
import { SubIssuesCollapsibleTitle } from "./title";
import { SubWorkItemTitleActions } from "./title-actions";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const SubIssuesCollapsible = observer(function SubIssuesCollapsible(props: 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
open={isCollapsibleOpen}
onOpenChange={() => toggleOpenWidget("sub-work-items")}
trigger={<SubIssuesCollapsibleTitle parentIssueId={issueId} issueServiceType={issueServiceType} />}
trailing={
isCollapsibleOpen ? (
<SubWorkItemTitleActions
projectId={projectId}
parentId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
) : undefined
}
>
<SubIssuesCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,118 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function SubWorkItemTitleActions(props: TSubWorkItemTitleActionsProps) {
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 items-center gap-2"
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>
);
});

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { CircularProgress } from "@makeplane/propel/components/circular-progress";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
parentIssueId: string;
issueServiceType?: TIssueServiceType;
};
export const SubIssuesCollapsibleTitle = observer(function SubIssuesCollapsibleTitle(props: Props) {
const { parentIssueId, issueServiceType = EIssueServiceType.ISSUES } = 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 (
<span className="inline-flex items-center gap-2">
{issueServiceType === EIssueServiceType.EPICS ? t("issue.label", { count: 1 }) : t("common.sub_work_items")}
<span className="flex items-center gap-1.5 text-13 text-tertiary">
<CircularProgress
value={percentage}
size="md"
variant={percentage === 100 ? "success" : "brand"}
aria-label="Sub-work-item progress"
/>
<span>
{completedCount}/{totalCount} {t("common.done")}
</span>
</span>
</span>
);
});

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// helpers
import { Button } from "@plane/propel/button";
type Props = {
icon: React.ReactNode;
title: string;
disabled?: boolean;
};
export function IssueDetailWidgetButton(props: Props) {
const { icon, title, disabled = false } = props;
return (
<Button variant={"secondary"} disabled={disabled} size="lg">
{icon && icon}
<span className="text-body-xs-medium">{title}</span>
</Button>
);
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function IssueCycleSelect(props: TIssueCycleSelect) {
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 h-7.5 rounded-sm"
buttonClassName={`text-body-xs-medium justify-between ${issue?.cycle_id ? "" : "text-placeholder"}`}
placeholder={t("cycle.no_cycle")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TIdentifierTextProps, TIdentifierTextVariant, TIssueIdentifierSize } from "@plane/types";
import { cn } from "@plane/utils";
const SIZE_MAP: Record<TIssueIdentifierSize, string> = {
xs: "text-caption-sm-regular",
sm: "text-caption-sm-medium",
md: "text-caption-md-medium",
lg: "text-caption-lg-medium",
};
const VARIANT_MAP: Record<TIdentifierTextVariant, string> = {
default: "text-tertiary",
secondary: "text-secondary",
tertiary: "text-tertiary",
primary: "text-primary",
"primary-subtle": "text-primary/80",
success: "text-success-primary",
};
export function IdentifierText(props: TIdentifierTextProps) {
const { identifier, enableClickToCopyIdentifier = false, size = "lg", variant = "default" } = props;
// handlers
const handleCopyIssueIdentifier = () => {
if (enableClickToCopyIdentifier) {
navigator.clipboard
.writeText(identifier)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Work item ID copied to clipboard",
});
return;
})
.catch(() => {
console.error("Failed to copy work item ID");
});
}
};
const textSizeClassName = SIZE_MAP[size];
const variantClassName = VARIANT_MAP[variant];
return (
<Tooltip label="Click to copy" disabled={!enableClickToCopyIdentifier}>
<button
type="button"
className={cn("text-12 font-medium whitespace-nowrap text-tertiary", textSizeClassName, variantClassName, {
"cursor-pointer": enableClickToCopyIdentifier,
})}
onClick={handleCopyIssueIdentifier}
disabled={!enableClickToCopyIdentifier}
>
{identifier}
</button>
</Tooltip>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import type { E_SORT_ORDER, TActivityFilters, EActivityFilterType } from "@plane/constants";
import { BASE_ACTIVITY_FILTER_TYPES, 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";
// 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 = observer(function IssueActivityCommentRoot(props: TIssueActivityCommentRoot) {
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);
return (
<div>
{filteredActivityAndComments.map((activityComment, index) => {
const comment = getCommentById(activityComment.id);
return activityComment.activity_type === "COMMENT" ? (
<CommentCard
key={activityComment.id}
workspaceSlug={workspaceSlug}
entityId={issueId}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={!!showAccessSpecifier}
showCopyLinkOption={!isIntakeIssue}
disabled={disabled}
projectId={projectId}
enableReplies
/>
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
<IssueActivityItem
key={activityComment.id}
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : null;
})}
</div>
);
});

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { FilterOutline, TickOutline } from "@makeplane/propel/icons";
// plane imports
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { PopoverMenu } from "@plane/ui";
// helper
import { cn } from "@plane/utils";
// constants
type TActivityFilter = {
selectedFilters: TActivityFilters[];
filterOptions: TActivityFilterOption[];
};
export const ActivityFilter = observer(function ActivityFilter(props: TActivityFilter) {
const { selectedFilters = [], filterOptions } = props;
// hooks
const { t } = useTranslation();
return (
<PopoverMenu
buttonClassName="outline-none"
button={
<>
<IconButton variant="tertiary" icon={FilterOutline} />
{selectedFilters.length < filterOptions.length && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-accent-primary" />
)}
</>
}
panelClassName="p-2 rounded-md border border-subtle bg-surface-1"
data={filterOptions}
keyExtractor={(item) => item.key}
render={(item) => (
<div
key={item.key}
className="flex cursor-pointer items-center gap-2 rounded-xs p-1 px-2 text-13 transition-all hover:bg-layer-1"
onClick={item.onClick}
>
<div
className={cn(
"flex h-3 w-3 flex-shrink-0 items-center justify-center rounded-xs bg-surface-2 transition-all",
{
"bg-accent-primary text-on-color": item.isSelected,
"bg-layer-1 text-placeholder": item.isSelected && selectedFilters.length === 1,
"bg-surface-2": !item.isSelected,
}
)}
>
{item.isSelected && <TickOutline className="h-2.5 w-2.5" />}
</div>
<div className={cn("whitespace-nowrap", item.isSelected ? "text-primary" : "text-secondary")}>
{t(item.labelTranslationKey)}
</div>
</div>
)}
/>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ArchiveOutline, RefreshOutline } from "@makeplane/propel/icons";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// ui
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueArchivedAtActivity = observer(function IssueArchivedAtActivity(props: TIssueArchivedAtActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={
activity.new_value === "restore" ? (
<RefreshOutline className="h-3.5 w-3.5 text-secondary" aria-hidden="true" />
) : (
<ArchiveOutline className="h-3.5 w-3.5 text-secondary" 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>
);
});

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// icons
import { MembersOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueAssigneeActivity(props: TIssueAssigneeActivity) {
const { activityId, ends, showIssue = true } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MembersOutline className="h-3.5 w-3.5 flex-shrink-0 text-secondary" />}
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-primary capitalize hover:underline"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
</a>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { AttachOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueAttachmentActivity(props: TIssueAttachmentActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<AttachOutline width={14} height={14} className="text-secondary" 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>
);
});

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { CyclesOutline } from "@makeplane/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 = observer(function IssueCycleActivity(props: TIssueCycleActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CyclesOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
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-primary 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-primary 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-primary hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { WorkItemsOutline } from "@makeplane/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 = observer(function IssueDefaultActivity(props: TIssueDefaultActivity) {
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={<WorkItemsOutline width={14} height={14} className="text-secondary" 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>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { AlignLeftOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueDescriptionActivity(props: TIssueDescriptionActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<AlignLeftOutline width={14} height={14} className="text-secondary" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
updated the description
{showIssue ? ` of ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { EstimateOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueEstimateActivity(props: TIssueEstimateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<EstimateOutline className="h-3.5 w-3.5 text-secondary" 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>
);
});

View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { HierarchyOutline } from "@makeplane/propel/icons";
// plane imports
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { IssueUser } from "../";
import { IssueCreatorDisplay } from "./issue-creator";
type TIssueActivityBlockComponent = {
icon?: ReactNode;
activityId: string;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export function IssueActivityBlockComponent(props: TIssueActivityBlockComponent) {
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-caption-sm-regular ${
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
}`}
>
<div className="absolute top-0 bottom-0 left-[13px] w-px bg-layer-3" aria-hidden />
<div className="z-[4] flex h-7 w-7 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg border border-subtle bg-layer-2 text-secondary shadow-raised-100">
{icon ? icon : <HierarchyOutline className="h-3.5 w-3.5" />}
</div>
<div className="w-full truncate text-secondary">
{!activity?.field && activity?.verb === "created" ? (
<IssueCreatorDisplay activityId={activityId} customUserName={customUserName} />
) : (
<IssueUser activityId={activityId} customUserName={customUserName} />
)}
<span> {children} </span>
<span>
<Tooltip
label={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
disabled={isMobile}
>
<span className="whitespace-nowrap text-tertiary"> {calculateTimeAgo(activity.created_at)}</span>
</Tooltip>
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TIssueActivity } from "@plane/types";
export const getRelationActivityContent = (activity: TIssueActivity | undefined): string | undefined => {
if (!activity) return;
switch (activity.field) {
case "blocking":
return activity.old_value === ""
? `marked this work item is blocking work item `
: `removed the blocking work item `;
case "blocked_by":
return activity.old_value === ""
? `marked this work item is being blocked by `
: `removed this work item being blocked by work item `;
case "duplicate":
return activity.old_value === ""
? `marked this work item as duplicate of `
: `removed this work item as a duplicate of `;
case "relates_to":
return activity.old_value === "" ? `marked that this work item relates to ` : `removed the relation from `;
}
return;
};

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type TIssueUser = {
activityId: string;
customUserName?: string;
};
export function IssueCreatorDisplay(props: TIssueUser) {
const { activityId, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<>
{customUserName ? (
<span className="font-medium text-primary">{customUserName || "Plane"}</span>
) : (
<Link
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="font-medium text-primary hover:underline"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
);
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssueLink = {
activityId: string;
};
export function IssueLink(props: TIssueLink) {
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
label={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
layout="stacked"
disabled={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-primary hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Work items"}{" "}
<span className="font-regular">{activity.issue_detail?.name}</span>
</a>
</Tooltip>
);
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type TIssueUser = {
activityId: string;
customUserName?: string;
};
export function IssueUser(props: TIssueUser) {
const { activityId, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<>
{customUserName ? (
<span className="font-medium text-primary">{customUserName}</span>
) : (
<Link
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="font-medium text-primary hover:underline"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
);
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { IntakeOutline } from "@makeplane/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 = observer(function IssueInboxActivity(props: TIssueInboxActivity) {
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={<IntakeOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
activityId={activityId}
ends={ends}
>
<>{getInboxActivityMessage()}</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Tooltip } from "@makeplane/propel/components/tooltip";
type TIssueLabelPill = { name?: string; color?: string };
export function LabelActivityChip(props: TIssueLabelPill) {
const { name, color } = props;
return (
<Tooltip label={name ?? ""} layout="stacked">
<span className="inline-flex w-min max-w-32 flex-shrink-0 cursor-default items-center gap-2 truncate rounded-full border border-strong px-2 py-0.5 text-11 whitespace-nowrap">
<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-primary">{name}</span>
</span>
</Tooltip>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { LabelsOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueLabelActivity(props: TIssueLabelActivity) {
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={<LabelsOutline height={14} width={14} className="text-secondary" />}
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>
);
});

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ChatOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueLinkActivity(props: TIssueLinkActivity) {
const { activityId, showIssue = false, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ChatOutline width={14} height={14} className="text-secondary" 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-primary 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-primary 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-primary hover:underline"
>
link
</a>
</>
)}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { ModuleOutline } from "@makeplane/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 = observer(function IssueModuleActivity(props: TIssueModuleActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ModuleOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
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-primary 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-primary 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-primary hover:underline"
>
<span className="truncate"> {activity.old_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { TextOutline } from "@makeplane/propel/icons";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueNameActivity = observer(function IssueNameActivity(props: TIssueNameActivity) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<TextOutline width={14} height={14} className="text-secondary" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>set the name to {activity.new_value}.</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ParentOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueParentActivity(props: TIssueParentActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ParentOutline className="h-3.5 w-3.5 text-secondary" 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-primary">{activity.new_value}</span>
) : (
<span className="font-medium text-primary">{activity.old_value}</span>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { PriorityOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssuePriorityActivity(props: TIssuePriorityActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<PriorityOutline className="h-3.5 w-3.5 text-secondary" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
set the priority to <span className="font-medium text-primary">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// types
import type { TIssueRelationTypes } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { useTimeLineRelationOptions } from "@/components/relations";
// local helpers
import { IssueActivityBlockComponent } from "./";
import { getRelationActivityContent } from "./helpers/activity";
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueRelationActivity = observer(function IssueRelationActivity(props: TIssueRelationActivity) {
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-primary">{activity.new_value}.</span>
) : (
<span className="font-medium text-primary">{activity.old_value}.</span>
)}
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { CalendarOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueStartDateActivity(props: TIssueStartDateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarOutline width={14} height={14} className="text-secondary" 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-primary">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// hooks
import { StateOutline } from "@makeplane/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 = observer(function IssueStateActivity(props: TIssueStateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<StateOutline className="h-4 w-4 flex-shrink-0 text-secondary" />}
activityId={activityId}
ends={ends}
>
<>
set the state to <span className="font-medium text-primary">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { CalendarOutline } from "@makeplane/propel/icons";
// 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 = observer(function IssueTargetDateActivity(props: TIssueTargetDateActivity) {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarOutline width={14} height={14} className="text-secondary" 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-primary">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// helpers
import { getValidKeysFromObject } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useTimeLineRelationOptions } from "@/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 = observer(function IssueActivityItem(props: TIssueActivityItem) {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
// oxlint-disable-next-line no-empty-pattern
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} />;
default:
return null;
}
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
import { ACTIVITY_FILTER_TYPE_OPTIONS } from "@plane/constants";
// components
import { ActivityFilter } from "@/components/issues/issue-detail/issue-activity";
export type TActivityFilterRoot = {
selectedFilters: TActivityFilters[];
toggleFilter: (filter: TActivityFilters) => void;
projectId: string;
isIntakeIssue?: boolean;
};
export function ActivityFilterRoot(props: TActivityFilterRoot) {
const { selectedFilters, toggleFilter } = props;
const filters: TActivityFilterOption[] = Object.entries(ACTIVITY_FILTER_TYPE_OPTIONS).map(([key, value]) => {
const filterKey = key as TActivityFilters;
return {
key: filterKey,
labelTranslationKey: value.labelTranslationKey,
isSelected: selectedFilters.includes(filterKey),
onClick: () => toggleFilter(filterKey),
};
});
return <ActivityFilter selectedFilters={selectedFilters} filterOptions={filters} />;
}

View File

@@ -0,0 +1,216 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 useWorkItemCommentOperations = (
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, duplicateEditorAsset } = 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"));
}
},
duplicateCommentAsset: async (assetId, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await duplicateEditorAsset({
assetId,
entityId: commentId || undefined,
entityType: EFileAssetType.COMMENT_DESCRIPTION,
projectId,
workspaceSlug,
});
return res;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
},
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;
};

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
export * from "./activity-comment-root";
// activity
export * from "./activity/activity-list";
export * from "./activity-filter";
// sort
export * from "./sort-root";

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { Loader } from "@plane/ui";
export function IssueActivityLoader() {
return (
<Loader className="space-y-8">
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="w-full space-y-2">
<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="w-full space-y-2">
<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="w-full space-y-2">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
</Loader>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 } 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 { useProject } from "@/hooks/store/use-project";
// local imports
import { IssueActivityCommentRoot } from "./activity-comment-root";
import { useWorkItemCommentOperations } from "./helper";
import { ActivitySortRoot } from "./sort-root";
import { ActivityFilterRoot } from "./filter-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 = observer(function IssueActivity(props: TIssueActivity) {
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);
const { getProjectById } = useProject();
// 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 = useWorkItemCommentOperations(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">
{/* header */}
<div className="flex items-center justify-between">
<div className="text-h5-medium text-primary">{t("common.activity")}</div>
<div className="flex items-center gap-2">
<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>
);
});

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { memo } from "react";
import { SortAscendingOutline, SortDescendingOutline } from "@makeplane/propel/icons";
// plane package imports
import { E_SORT_ORDER } from "@plane/constants";
import { IconButton } from "@plane/propel/icon-button";
export type TActivitySortRoot = {
sortOrder: E_SORT_ORDER;
toggleSort: () => void;
};
export const ActivitySortRoot = memo(function ActivitySortRoot(props: TActivitySortRoot) {
const SortIcon = props.sortOrder === E_SORT_ORDER.ASC ? SortAscendingOutline : SortDescendingOutline;
return <IconButton variant="tertiary" icon={SortIcon} onClick={props.toggleSort} />;
});
ActivitySortRoot.displayName = "ActivitySortRoot";

View File

@@ -0,0 +1,165 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { LinkOutline } from "@makeplane/propel/icons";
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
// 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 = observer(function IssueDetailQuickActions(props: 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 = async () => {
try {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
await copyTextToClipboard(`${originURL}${workItemLink}`);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.copied_to_clipboard"),
});
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
});
}
};
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`;
await deleteIssue(workspaceSlug, projectId, issueId);
router.push(redirectionPath);
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
}
};
const handleArchiveIssue = async () => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("issue.archive.failed.message"),
});
}
};
const handleRestore = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
try {
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString());
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("issue.restore.success.title"),
message: t("issue.restore.success.message"),
});
router.push(workItemLink);
} catch (_error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("issue.restore.failed.message"),
});
}
};
return (
<>
<div className="flex flex-shrink-0 items-center justify-end">
<div className="flex flex-wrap items-center gap-2">
{currentUser && !issue?.archived_at && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<div className="flex flex-wrap items-center gap-2 text-tertiary">
<Tooltip label={t("common.actions.copy_link")} disabled={isMobile}>
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={LinkOutline} />
</Tooltip>
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issue}
handleDelete={handleDeleteIssue}
handleArchive={handleArchiveIssue}
handleRestore={handleRestore}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import type { TIssueIdentifierProps } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { IdentifierText } from "@/components/issues/issue-detail/identifier-text";
export const IssueIdentifier = observer(function IssueIdentifier(props: TIssueIdentifierProps) {
const { projectId, variant, size, displayProperties, enableClickToCopyIdentifier = false } = props;
// store hooks
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// Determine if the component is using store data or not
const isUsingStoreData = "issueId" in props;
// derived values
const issue = isUsingStoreData ? getIssueById(props.issueId) : null;
const projectIdentifier = isUsingStoreData ? getProjectIdentifierById(projectId) : props.projectIdentifier;
const issueSequenceId = isUsingStoreData ? issue?.sequence_id : props.issueSequenceId;
const shouldRenderIssueID = displayProperties ? displayProperties.key : true;
if (!shouldRenderIssueID) return null;
return (
<div className="flex shrink-0 items-center space-x-2">
<IdentifierText
identifier={`${projectIdentifier}-${issueSequenceId}`}
enableClickToCopyIdentifier={enableClickToCopyIdentifier}
variant={variant}
size={size}
/>
</div>
);
});

View File

@@ -0,0 +1,178 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, Fragment, useEffect } from "react";
import { TwitterPicker } from "react-color";
import { Controller, useForm } from "react-hook-form";
import { usePopper } from "react-popper";
import { AddOutline, CloseOutline, LoadingOutline } from "@makeplane/propel/icons";
import { Popover } from "@headlessui/react";
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import type { IIssueLabel } from "@plane/types";
// hooks
// 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 function LabelCreate(props: ILabelCreate) {
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-subtle p-0.5 px-2 text-11 text-tertiary transition-all hover:bg-surface-2 hover:text-secondary"
onClick={handleIsCreateToggle}
>
<div className="flex-shrink-0">
{isCreateToggle ? <CloseOutline className="h-2.5 w-2.5" /> : <AddOutline 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-sm"
style={{
backgroundColor: value ?? "black",
}}
/>
)}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
<div
className="max-w-xs p-2 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 } }) => (
<Field name="name" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="name"
name="name"
type="text"
value={value ?? ""}
onChange={onChange}
ref={ref}
placeholder="Title"
disabled={isSubmitting}
/>
</InputGroup>
</Field>
)}
/>
<button
type="button"
className="grid place-items-center rounded-sm bg-danger-primary p-1"
onClick={() => setIsCreateToggle(false)}
disabled={disabled}
>
<CloseOutline className="h-3.5 w-3.5 text-on-color" />
</button>
<button
type="submit"
className="grid place-items-center rounded-sm bg-success-primary p-1"
disabled={isSubmitting}
>
{isSubmitting ? (
<LoadingOutline className="spin h-3.5 w-3.5 text-on-color" />
) : (
<AddOutline className="h-3.5 w-3.5 text-on-color" />
)}
</button>
</form>
)}
</>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Button } from "@plane/propel/button";
import { CloseOutline, LabelsFilled } from "@makeplane/propel/icons";
// 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 = observer(function LabelListItem(props: TLabelListItem) {
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 (
<Button variant="tertiary" size="sm" key={labelId} onClick={handleLabel} disabled={disabled}>
<LabelsFilled className="size-3" color={label.color ?? "#000000"} />
<span className="text-body-xs-regular">{label.name}</span>
{!disabled && <CloseOutline className="h-2.5 w-2.5 transition-all group-hover:text-danger-primary" />}
</Button>
);
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function LabelList(props: TLabelList) {
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}
/>
))}
</>
);
});

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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
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 = observer(function IssueLabel(props: TIssueLabel) {
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 min-h-7.5 w-full flex-wrap items-center gap-1 px-2">
<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>
);
});

Some files were not shown because too many files have changed in this diff Show More