Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
"use client";
import type { FC } from "react";
import React from "react";
import { Link, Paperclip } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { ViewsIcon, RelationPropertyIcon } from "@plane/propel/icons";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// plane web imports
import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons";
// local imports
import { IssueAttachmentActionButton } from "./attachments";
import { IssueLinksActionButton } from "./links";
import { RelationActionButton } from "./relations";
import { SubIssuesActionButton } from "./sub-issues";
import { IssueDetailWidgetButton } from "./widget-button";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
// translation
const { t } = useTranslation();
return (
<div className="flex items-center flex-wrap gap-2">
{!hideWidgets?.includes("sub-work-items") && (
<SubIssuesActionButton
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("issue.add.sub_issue")}
icon={<ViewsIcon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("relations") && (
<RelationActionButton
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("issue.add.relation")}
icon={<RelationPropertyIcon 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={<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{!hideWidgets?.includes("attachments") && (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title={t("common.attach")}
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
/>
}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
<WorkItemAdditionalWidgetActionButtons
disabled={disabled}
hideWidgets={hideWidgets ?? []}
issueServiceType={issueServiceType}
projectId={projectId}
workItemId={issueId}
workspaceSlug={workspaceSlug}
/>
</div>
);
};

View File

@@ -0,0 +1,33 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// local imports
import { IssueAttachmentItemList } from "../../attachment/attachment-item-list";
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// helper
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId, issueServiceType);
return (
<IssueAttachmentItemList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
issueServiceType={issueServiceType}
/>
);
});

View File

@@ -0,0 +1,104 @@
"use client";
import { useMemo } from "react";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
export type TAttachmentOperations = {
create: (file: File) => Promise<void>;
remove: (attachmentId: string) => Promise<void>;
};
export type TAttachmentSnapshot = {
uploadStatus: TAttachmentUploadStatus[] | undefined;
};
export type TAttachmentHelpers = {
operations: TAttachmentOperations;
snapshot: TAttachmentSnapshot;
};
export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
issueId: string,
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
): TAttachmentHelpers => {
const {
attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId },
} = useIssueDetail(issueServiceType);
const attachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (file) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
},
});
await attachmentUploadPromise;
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
payload: { id: issueId },
error: error as Error,
});
throw error;
}
},
remove: async (attachmentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
setToast({
message: "The attachment has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Attachment removed",
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
payload: { id: issueId },
error: error as Error,
});
setToast({
message: "The Attachment could not be removed",
type: TOAST_TYPE.ERROR,
title: "Attachment not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId);
return {
operations: attachmentOperations,
snapshot: { uploadStatus: attachmentsUploadStatus },
};
};

View File

@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,107 @@
"use client";
import type { FC } from "react";
import React, { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { Plus } from "lucide-react";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// local imports
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false, issueServiceType } = props;
// state
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
// file size
const { maxFileSize } = useFileSize();
// operations
const { operations: attachmentOperations } = useAttachmentOperations(
workspaceSlug,
projectId,
issueId,
issueServiceType
);
// handlers
const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
attachmentOperations
.create(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "File could not be attached. Try uploading again.",
});
})
.finally(() => {
handleFetchPropertyActivities();
setLastWidgetAction("attachments");
setIsLoading(false);
});
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
totalAttachedFiles > 1
? "Only one file can be uploaded at a time."
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
});
return;
},
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
});
return (
<div
onClick={(e) => {
// TODO: Remove extra div and move event propagation to button
e.stopPropagation();
}}
>
<button {...getRootProps()} type="button" disabled={disabled}>
<input {...getInputProps()} />
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
</div>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueAttachmentsCollapsibleContent } from "./content";
import { IssueAttachmentsCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const AttachmentsCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("attachments");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("attachments")}
title={
<IssueAttachmentsCollapsibleTitle
isOpen={isCollapsibleOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
buttonClassName="w-full"
>
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,63 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueAttachmentActionButton } from "./quick-action-button";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
const attachmentCount = issue?.attachment_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
</span>
),
[attachmentCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title={t("common.attachments")}
indicatorElement={indicatorElement}
actionItemElement={
!disabled && (
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,98 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { WorkItemAdditionalWidgetCollapsibles } from "@/plane-web/components/issues/issue-detail-widgets/collapsibles";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local imports
import { AttachmentsCollapsible } from "./attachments";
import { LinksCollapsible } from "./links";
import { RelationsCollapsible } from "./relations";
import { SubIssuesCollapsible } from "./sub-issues";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
// store hooks
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId },
relation: { getRelationCountByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
const subIssues = subIssuesByIssueId(issueId);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
// render conditions
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0 && !hideWidgets?.includes("sub-work-items");
const shouldRenderRelations = issueRelationsCount > 0 && !hideWidgets?.includes("relations");
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0 && !hideWidgets?.includes("links");
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
const attachmentsCount = getAttachmentsCountByIssueId(issueId);
const shouldRenderAttachments =
attachmentsCount > 0 ||
(!!attachmentUploads && attachmentUploads.length > 0 && !hideWidgets?.includes("attachments"));
return (
<div className="flex flex-col">
{shouldRenderSubIssues && (
<SubIssuesCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderRelations && (
<RelationsCollapsible
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderLinks && (
<LinksCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderAttachments && (
<AttachmentsCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
<WorkItemAdditionalWidgetCollapsibles
disabled={disabled}
hideWidgets={hideWidgets ?? []}
issueServiceType={issueServiceType}
projectId={projectId}
workItemId={issueId}
workspaceSlug={workspaceSlug}
/>
</div>
);
});

View File

@@ -0,0 +1,205 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web imports
import { WorkItemAdditionalWidgetModals } from "@/plane-web/components/issues/issue-detail-widgets/modals";
// local imports
import { IssueLinkCreateUpdateModal } from "../issue-detail/links/create-update-link-modal";
// helpers
import { CreateUpdateIssueModal } from "../issue-modal/modal";
import { useLinkOperations } from "./links/helper";
import { useSubIssueOperations } from "./sub-issues/helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueServiceType, hideWidgets } = props;
// store hooks
const {
isIssueLinkModalOpen,
toggleIssueLinkModal: toggleIssueLinkModalStore,
setIssueLinkData,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
relationKey,
isRelationModalOpen,
setRelationKey,
setLastWidgetAction,
toggleRelationModal,
createRelation,
issueCrudOperationState,
setIssueCrudOperationState,
} = useIssueDetail(issueServiceType);
// helper hooks
const subIssueOperations = useSubIssueOperations(issueServiceType);
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
// handlers
const handleIssueCrudState = (
key: "create" | "existing",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudOperationState({
...issueCrudOperationState,
[key]: {
toggle: !issueCrudOperationState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const handleExistingIssuesModalClose = () => {
handleIssueCrudState("existing", null, null);
setLastWidgetAction("sub-work-items");
toggleSubIssuesModal(null);
};
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
subIssueOperations.addSubIssue(
workspaceSlug,
projectId,
issueId,
_issue.map((issue) => issue.id)
);
const handleCreateUpdateModalClose = () => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
setLastWidgetAction("sub-work-items");
};
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
}
};
const handleIssueLinkModalOnClose = () => {
toggleIssueLinkModalStore(false);
setLastWidgetAction("links");
setIssueLinkData(null);
};
const handleRelationOnClose = () => {
setRelationKey(null);
toggleRelationModal(null, null);
setLastWidgetAction("relations");
};
const handleExistingIssueModalOnSubmit = async (data: ISearchIssueResponse[]) => {
if (!relationKey) return;
if (data.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one work item.",
});
return;
}
await createRelation(
workspaceSlug,
projectId,
issueId,
relationKey,
data.map((i) => i.id)
);
toggleRelationModal(null, null);
};
// helpers
const createUpdateModalData: Partial<TIssue> = {
parent_id: issueCrudOperationState?.create?.parentIssueId,
project_id: projectId,
};
const existingIssuesModalSearchParams = {
sub_issue: true,
issue_id: issueCrudOperationState?.existing?.parentIssueId,
};
// render conditions
const shouldRenderExistingIssuesModal =
!hideWidgets?.includes("sub-work-items") &&
issueCrudOperationState?.existing?.toggle &&
issueCrudOperationState?.existing?.parentIssueId &&
isSubIssuesModalOpen;
const shouldRenderCreateUpdateModal =
!hideWidgets?.includes("sub-work-items") &&
issueCrudOperationState?.create?.toggle &&
issueCrudOperationState?.create?.parentIssueId &&
isCreateIssueModalOpen;
return (
<>
{!hideWidgets?.includes("links") && (
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModalOpen}
handleOnClose={handleIssueLinkModalOnClose}
linkOperations={handleLinkOperations}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderCreateUpdateModal && (
<CreateUpdateIssueModal
isOpen={issueCrudOperationState?.create?.toggle}
data={createUpdateModalData}
onClose={handleCreateUpdateModalClose}
onSubmit={handleCreateUpdateModalOnSubmit}
isProjectSelectionDisabled
/>
)}
{shouldRenderExistingIssuesModal && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudOperationState?.existing?.toggle}
handleClose={handleExistingIssuesModalClose}
searchParams={existingIssuesModalSearchParams}
handleOnSubmit={handleExistingIssuesModalOnSubmit}
/>
)}
{!hideWidgets?.includes("relations") && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
handleClose={handleRelationOnClose}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={handleExistingIssueModalOnSubmit}
workspaceLevelToggle
/>
)}
<WorkItemAdditionalWidgetModals
hideWidgets={hideWidgets ?? []}
issueServiceType={issueServiceType}
projectId={projectId}
workItemId={issueId}
workspaceSlug={workspaceSlug}
/>
</>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import type { FC } from "react";
import React from "react";
import type { TIssueServiceType } from "@plane/types";
// components
import { LinkList } from "../../issue-detail/links";
// helper
import { useLinkOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType } = props;
// helper
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
return (
<LinkList
issueId={issueId}
linkOperations={handleLinkOperations}
disabled={disabled}
issueServiceType={issueServiceType}
/>
);
};

View File

@@ -0,0 +1,82 @@
"use client";
import { useMemo } from "react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueLink, TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import type { TLinkOperations } from "../../issue-detail/links";
export const useLinkOperations = (
workspaceSlug: string,
projectId: string,
issueId: string,
issueServiceType: TIssueServiceType
): TLinkOperations => {
const { createLink, updateLink, removeLink } = useIssueDetail(issueServiceType);
// i18n
const { t } = useTranslation();
const handleLinkOperations: TLinkOperations = useMemo(
() => ({
create: async (data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await createLink(workspaceSlug, projectId, issueId, data);
setToast({
message: t("links.toasts.created.message"),
type: TOAST_TYPE.SUCCESS,
title: t("links.toasts.created.title"),
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? t("links.toasts.not_created.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_created.title"),
});
throw error;
}
},
update: async (linkId: string, data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
setToast({
message: t("links.toasts.updated.message"),
type: TOAST_TYPE.SUCCESS,
title: t("links.toasts.updated.title"),
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? t("links.toasts.not_updated.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_updated.title"),
});
throw error;
}
},
remove: async (linkId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeLink(workspaceSlug, projectId, issueId, linkId);
setToast({
message: t("links.toasts.removed.message"),
type: TOAST_TYPE.SUCCESS,
title: t("links.toasts.removed.title"),
});
} catch {
setToast({
message: t("links.toasts.not_removed.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_removed.title"),
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, t]
);
return handleLinkOperations;
};

View File

@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,34 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueLinksActionButton: FC<Props> = observer((props) => {
const { customButton, disabled = false, issueServiceType } = props;
// store hooks
const { toggleIssueLinkModal } = useIssueDetail(issueServiceType);
// handlers
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
};
return (
<button type="button" onClick={handleOnClick} disabled={disabled}>
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
);
});

View File

@@ -0,0 +1,52 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinksCollapsibleContent } from "./content";
import { IssueLinksCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const LinksCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("links");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("links")}
title={
<IssueLinksCollapsibleTitle
isOpen={isCollapsibleOpen}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
buttonClassName="w-full"
>
<IssueLinksCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinksActionButton } from "./quick-action-button";
type Props = {
isOpen: boolean;
issueId: string;
disabled: boolean;
issueServiceType: TIssueServiceType;
};
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, issueId, disabled, issueServiceType } = props;
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
const linksCount = issue?.link_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
</span>
),
[linksCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title={t("common.links")}
indicatorElement={indicatorElement}
actionItemElement={
!disabled && <IssueLinksActionButton issueServiceType={issueServiceType} disabled={disabled} />
}
/>
);
});

View File

@@ -0,0 +1,237 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// components
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
// helper
import { DeleteIssueModal } from "../../delete-issue-modal";
import { RelationIssueList } from "../../relations/issue-list";
import { useRelationOperations } from "./helper";
type Props = {
workspaceSlug: string;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
export type TRelationObject = {
key: TIssueRelationTypes;
i18n_label: string;
className: string;
icon: (size: number) => React.ReactElement;
placeholder: string;
};
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
// plane hooks
const { t } = useTranslation();
// state
const [issueCrudState, setIssueCrudState] = useState<{
update: TIssueCrudState;
delete: TIssueCrudState;
removeRelation: TIssueCrudState & { relationKey: string | undefined; relationIssueId: string | undefined };
}>({
update: {
toggle: false,
issueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
issueId: undefined,
issue: undefined,
},
removeRelation: {
toggle: false,
issueId: undefined,
issue: undefined,
relationKey: undefined,
relationIssueId: undefined,
},
});
// store hooks
const {
relation: { getRelationsByIssueId, removeRelation },
toggleDeleteIssueModal,
toggleCreateIssueModal,
} = useIssueDetail(issueServiceType);
// helper
const issueOperations = useRelationOperations();
const epicOperations = useRelationOperations(EIssueServiceType.EPICS);
// derived values
const relations = getRelationsByIssueId(issueId);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const handleIssueCrudState = (
key: "update" | "delete" | "removeRelation",
_issueId: string | null,
issue: TIssue | null = null,
relationKey?: TIssueRelationTypes | null,
relationIssueId?: string | null
) => {
setIssueCrudState((prevState) => ({
...prevState,
[key]: {
toggle: !prevState[key].toggle,
issueId: _issueId,
issue: issue,
relationKey: relationKey,
relationIssueId: relationIssueId,
},
}));
};
// if relations are not available, return null
if (!relations) return null;
// map relations to array
const relationsArray = (Object.keys(relations) as TIssueRelationTypes[])
.filter((relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey])
.map((relationKey) => {
const issueIds = relations[relationKey];
const issueRelationOption = ISSUE_RELATION_OPTIONS[relationKey];
return {
relationKey: relationKey,
issueIds: issueIds,
icon: issueRelationOption?.icon,
label: issueRelationOption?.i18n_label ? t(issueRelationOption?.i18n_label) : "",
className: issueRelationOption?.className,
};
});
// filter out relations with no issues
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
const shouldRenderIssueDeleteModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.issueId &&
issueCrudState.delete.issue.id;
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
<div className="flex flex-col gap-">
{filteredRelationsArray.map((relation) => (
<div key={relation.relationKey}>
<Collapsible
buttonClassName="w-full"
title={
<div className={`flex items-center gap-1 px-2.5 py-1 h-9 w-full ${relation.className}`}>
<span>{relation.icon ? relation.icon(14) : null}</span>
<span className="text-sm font-medium leading-5">{relation.label}</span>
</div>
}
defaultOpen
>
<RelationIssueList
workspaceSlug={workspaceSlug}
issueId={issueId}
relationKey={relation.relationKey}
issueIds={relation.issueIds}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
issueServiceType={issueServiceType}
/>
</Collapsible>
</div>
))}
</div>
{shouldRenderIssueDeleteModal && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () => {
if (
issueCrudState.removeRelation.issueId &&
issueCrudState.removeRelation.issue?.project_id &&
issueCrudState.removeRelation.relationKey &&
issueCrudState.removeRelation.relationIssueId
) {
await removeRelation(
workspaceSlug,
issueCrudState.removeRelation.issue.project_id,
issueCrudState.removeRelation.issueId,
issueCrudState.removeRelation.relationKey as TIssueRelationTypes,
issueCrudState.removeRelation.relationIssueId,
true
);
}
if (
issueCrudState.delete.issue &&
issueCrudState.delete.issue.id &&
issueCrudState.delete.issue.project_id
) {
const deleteOperation = !!issueCrudState.delete.issue?.is_epic
? epicOperations.remove
: issueOperations.remove;
await deleteOperation(
workspaceSlug,
issueCrudState.delete.issue?.project_id,
issueCrudState?.delete?.issue?.id
);
}
}}
isEpic={!!issueCrudState.delete.issue?.is_epic}
/>
)}
{shouldRenderIssueUpdateModal && (
<>
{!!issueCrudState?.update?.issue?.is_epic ? (
<CreateUpdateEpicModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
if (!_issue.id || !_issue.project_id) return;
await epicOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
}}
/>
) : (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
if (!_issue.id || !_issue.project_id) return;
await issueOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
}}
/>
)}
</>
)}
</>
);
});

View File

@@ -0,0 +1,85 @@
"use client";
import { useMemo } from "react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
export type TRelationIssueOperations = {
copyLink: (path: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
export const useRelationOperations = (
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
): TRelationIssueOperations => {
const { updateIssue, removeIssue } = useIssueDetail(issueServiceType);
const { t } = useTranslation();
// derived values
const entityName = issueServiceType === EIssueServiceType.ISSUES ? "Work item" : "Epic";
const issueOperations: TRelationIssueOperations = useMemo(
() => ({
copyLink: (path) => {
copyUrlToClipboard(path).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", { entity: entityName }),
});
});
},
update: async (workspaceSlug, projectId, issueId, data) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
setToast({
title: t("toast.success"),
type: TOAST_TYPE.SUCCESS,
message: t("entity.update.success", { entity: entityName }),
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.update.failed", { entity: entityName }),
});
}
},
remove: async (workspaceSlug, projectId, issueId) => {
try {
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
});
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
error: error as Error,
});
}
},
}),
[entityName, removeIssue, t, updateIssue]
);
return issueOperations;
};

View File

@@ -0,0 +1,4 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";

View File

@@ -0,0 +1,67 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
type Props = {
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const RelationActionButton: FC<Props> = observer((props) => {
const { customButton, issueId, disabled = false, issueServiceType } = props;
const { t } = useTranslation();
// store hooks
const { toggleRelationModal, setRelationKey } = useIssueDetail(issueServiceType);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
// handlers
const handleOnClick = (relationKey: TIssueRelationTypes) => {
setRelationKey(relationKey);
toggleRelationModal(issueId, relationKey);
};
// button element
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<CustomMenu
customButton={customButtonElement}
placement="bottom-start"
disabled={disabled}
maxHeight="lg"
closeOnSelect
>
{Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => {
if (!item) return <></>;
return (
<CustomMenu.MenuItem
key={index}
onClick={() => {
handleOnClick(item.key as TIssueRelationTypes);
}}
>
<div className="flex items-center gap-2">
{item.icon(12)}
<span>{t(item.i18n_label)}</span>
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
});

View File

@@ -0,0 +1,50 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { RelationsCollapsibleContent } from "./content";
import { RelationsCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const RelationsCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("relations");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("relations")}
title={
<RelationsCollapsibleTitle
isOpen={isCollapsibleOpen}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
buttonClassName="w-full"
>
<RelationsCollapsibleContent
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local imports
import { RelationActionButton } from "./quick-action-button";
type Props = {
isOpen: boolean;
issueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const { t } = useTranslation();
// store hook
const {
relation: { getRelationCountByIssueId },
} = useIssueDetail(issueServiceType);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
// derived values
const relationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
</span>
),
[relationsCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title={t("common.relations")}
indicatorElement={indicatorElement}
actionItemElement={
!disabled && <RelationActionButton issueId={issueId} disabled={disabled} issueServiceType={issueServiceType} />
}
/>
);
});

View File

@@ -0,0 +1,64 @@
"use client";
import type { FC } from "react";
import React from "react";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// local imports
import { IssueDetailWidgetActionButtons } from "./action-buttons";
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
renderWidgetModals?: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
};
export const IssueDetailWidgets: FC<Props> = (props) => {
const {
workspaceSlug,
projectId,
issueId,
disabled,
renderWidgetModals = true,
issueServiceType,
hideWidgets,
} = props;
return (
<>
<div className="flex flex-col gap-5">
<IssueDetailWidgetActionButtons
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
/>
<IssueDetailWidgetCollapsibles
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
/>
</div>
{renderWidgetModals && (
<IssueDetailWidgetModals
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
/>
)}
</>
);
};

View File

@@ -0,0 +1,177 @@
"use client";
import type { FC } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { observer } from "mobx-react";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { CreateUpdateIssueModal } from "../../issue-modal/modal";
import { useSubIssueOperations } from "./helper";
import { SubIssuesListRoot } from "./issues-list/root";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// state
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
update: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
// store hooks
const {
toggleCreateIssueModal,
toggleDeleteIssueModal,
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
} = useIssueDetail(issueServiceType);
// helpers
const subIssueOperations = useSubIssueOperations(issueServiceType);
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// handler
const handleIssueCrudState = useCallback(
(key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue,
},
});
},
[issueCrudState]
);
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
try {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
} catch (error) {
console.error("Error fetching sub-work items:", error);
} finally {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", "");
}
}
}, [
parentIssueId,
projectId,
setSubIssueHelpers,
subIssueHelpers.issue_visibility,
subIssueOperations,
workspaceSlug,
]);
useEffect(() => {
handleFetchSubIssues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentIssueId]);
// render conditions
const shouldRenderDeleteIssueModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.parentIssueId &&
issueCrudState.delete.issue.id;
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<SubIssuesListRoot
storeType={EIssuesStoreType.PROJECT}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={6}
canEdit={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
/>
)}
{shouldRenderDeleteIssueModal && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await subIssueOperations.deleteSubIssue(
workspaceSlug,
projectId,
issueCrudState?.delete?.parentIssueId as string,
issueCrudState?.delete?.issue?.id as string
)
}
isSubIssue
/>
)}
{shouldRenderUpdateIssueModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.updateSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.id,
_issue,
issueCrudState?.update?.issue,
true
);
}}
/>
)}
</>
);
});

View File

@@ -0,0 +1,102 @@
import type { FC } from "react";
import { useMemo } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { SlidersHorizontal } from "lucide-react";
// plane imports
import type { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
import { cn } from "@plane/utils";
// components
import {
FilterDisplayProperties,
FilterGroupBy,
FilterOrderBy,
FiltersDropdown,
} from "@/components/issues/issue-layouts/filters";
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
type TSubIssueDisplayFiltersProps = {
displayProperties: IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
isEpic?: boolean;
};
export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer((props) => {
const {
isEpic = false,
displayProperties,
layoutDisplayFiltersOptions,
handleDisplayPropertiesUpdate,
handleDisplayFiltersUpdate,
displayFilters,
} = props;
const isFilterApplied = useMemo(
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
[displayProperties, displayFilters]
);
return (
<>
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
<FiltersDropdown
placement="bottom-end"
menuButton={
<div
className={cn(
"p-1 rounded relative transition-all duration-200",
isFilterApplied && "bg-custom-primary-60/20"
)}
>
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
<SlidersHorizontal className="h-3.5 w-3.5 text-custom-text-100" />
</div>
}
>
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left">
{/* display properties */}
<div className="py-2">
<FilterDisplayProperties
displayProperties={displayProperties}
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
handleUpdate={handleDisplayPropertiesUpdate}
isEpic={isEpic}
/>
</div>
{/* group by */}
<div className="py-2">
<FilterGroupBy
displayFilters={displayFilters}
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
group_by: val,
})
}
ignoreGroupedFilters={[]}
/>
</div>
{/* order by */}
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
<div className="py-2">
<FilterOrderBy
selectedOrderBy={displayFilters?.order_by}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
order_by: val,
})
}
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
/>
</div>
)}
</div>
</FiltersDropdown>
)}
</>
);
});

View File

@@ -0,0 +1,169 @@
import type { FC } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { ListFilter, Search } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
import type { IIssueFilterOptions, IState } from "@plane/types";
import { cn } from "@plane/utils";
import {
FilterAssignees,
FilterDueDate,
FilterPriority,
FilterProjects,
FiltersDropdown,
FilterStartDate,
FilterState,
FilterStateGroup,
} from "@/components/issues/issue-layouts/filters";
import { isFiltersApplied } from "@/components/issues/issue-layouts/utils";
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
type TSubIssueFiltersProps = {
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
filters: IIssueFilterOptions;
memberIds: string[] | undefined;
states?: IState[];
availableFilters: (keyof IIssueFilterOptions)[];
};
export const SubIssueFilters: FC<TSubIssueFiltersProps> = observer((props) => {
const { handleFiltersUpdate, filters, memberIds, states, availableFilters } = props;
// plane hooks
const { t } = useTranslation();
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => !!availableFilters.includes(filter);
const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]);
return (
<>
<FiltersDropdown
placement="bottom-end"
menuButton={
<div
className={cn(
"p-1 rounded relative transition-all duration-200",
isFilterApplied && "bg-custom-primary-60/20"
)}
>
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
<ListFilter className="h-3.5 w-3.5 text-custom-text-100" />
</div>
}
>
<div className="flex max-h-[350px] flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder={t("common.search.label")}
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<CloseIcon className="text-custom-text-300" height={12} width={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 text-left">
{/* Priority */}
{isFilterEnabled("priority") && (
<div className="py-2">
<FilterPriority
appliedFilters={filters.priority ?? null}
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state group */}
{isFilterEnabled("state_group") && (
<div className="py-2">
<FilterStateGroup
appliedFilters={filters.state_group ?? null}
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* State */}
{isFilterEnabled("state") && (
<div className="py-2">
<FilterState
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFiltersUpdate("state", val)}
searchQuery={filtersSearchQuery}
states={states}
/>
</div>
)}
{/* Projects */}
{isFilterEnabled("project") && (
<div className="py-2">
<FilterProjects
appliedFilters={filters.project ?? null}
handleUpdate={(val) => handleFiltersUpdate("project", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* work item types */}
{isFilterEnabled("issue_type") && (
<div className="py-2">
<FilterIssueTypes
appliedFilters={filters.issue_type ?? null}
handleUpdate={(val) => handleFiltersUpdate("issue_type", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Assignees */}
{isFilterEnabled("assignees") && (
<div className="py-2">
<FilterAssignees
appliedFilters={filters.assignees ?? null}
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
memberIds={memberIds}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Start Date */}
{isFilterEnabled("start_date") && (
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Target Date */}
{isFilterEnabled("target_date") && (
<div className="py-2">
<FilterDueDate
appliedFilters={filters.target_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
</div>
</div>
</FiltersDropdown>
</>
);
});

View File

@@ -0,0 +1,252 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { copyUrlToClipboard } from "@plane/utils";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProjectState } from "@/hooks/store/use-project-state";
// plane web helpers
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
// router
const { epicId: epicIdParam } = useParams();
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
subIssues: { setSubIssueHelpers },
createSubIssues,
fetchSubIssues,
updateSubIssue,
deleteSubIssue,
removeSubIssue,
} = useIssueDetail(issueServiceType);
const { getStateById } = useProjectState();
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
// const { updateEpicAnalytics } = useIssueTypes();
const { updateAnalytics } = updateEpicAnalytics();
// derived values
const epicId = epicIdParam || epicPeekIssue?.issueId;
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyLink: (path) => {
copyUrlToClipboard(path).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items", { count: 1 })
: t("issue.label", { count: 1 }),
}),
});
});
},
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.fetch.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items", { count: 2 })
: t("issue.label", { count: 2 }),
}),
});
}
},
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("entity.add.success", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: issueIds.length }),
}),
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.add.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: issueIds.length }),
}),
});
}
},
updateSubIssue: async (
workspaceSlug,
projectId,
parentIssueId,
issueId,
issueData,
oldIssue = {},
fromModal = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
if (issueServiceType === EIssueServiceType.EPICS) {
const oldState = getStateById(oldIssue?.state_id)?.group;
if (oldState && oldIssue && issueData && epicId) {
// Check if parent_id is changed if yes then decrement the epic analytics count
if (issueData.parent_id && oldIssue?.parent_id && issueData.parent_id !== oldIssue?.parent_id) {
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
decrementStateGroupCount: `${oldState}_issues`,
});
}
// Check if state_id is changed if yes then decrement the old state group count and increment the new state group count
if (issueData.state_id) {
const newState = getStateById(issueData.state_id)?.group;
if (oldState && newState && oldState !== newState) {
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
decrementStateGroupCount: `${oldState}_issues`,
incrementStateGroupCount: `${newState}_issues`,
});
}
}
}
}
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
payload: { id: issueId, parent_id: parentIssueId },
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("sub_work_item.update.success"),
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
payload: { id: issueId, parent_id: parentIssueId },
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("sub_work_item.update.error"),
});
}
},
removeSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
if (issueServiceType === EIssueServiceType.EPICS) {
const issueBeforeRemoval = getIssueById(issueId);
const oldState = getStateById(issueBeforeRemoval?.state_id)?.group;
if (epicId && oldState) {
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
decrementStateGroupCount: `${oldState}_issues`,
});
}
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("entity.remove.success", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: 1 }),
}),
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
payload: { id: issueId, parent_id: parentIssueId },
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
payload: { id: issueId, parent_id: parentIssueId },
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.remove.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: 1 }),
}),
});
}
},
deleteSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
return deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId).then(() => {
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
payload: { id: issueId, parent_id: parentIssueId },
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
payload: { id: issueId, parent_id: parentIssueId },
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("entity.delete.failed", {
entity:
issueServiceType === EIssueServiceType.ISSUES
? t("common.sub_work_items")
: t("issue.label", { count: 1 }),
}),
});
}
},
}),
[
createSubIssues,
deleteSubIssue,
epicId,
fetchSubIssues,
getIssueById,
getStateById,
issueServiceType,
removeSubIssue,
setSubIssueHelpers,
t,
updateAnalytics,
updateSubIssue,
]
);
return subIssueOperations;
};

View File

@@ -0,0 +1,6 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
export * from "./display-filters";
export * from "./content";

View File

@@ -0,0 +1,98 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { CircleDashed } from "lucide-react";
import { ALL_ISSUES } from "@plane/constants";
import { ChevronRightIcon } from "@plane/propel/icons";
import type { IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { Collapsible } from "@plane/ui";
import { cn } from "@plane/utils";
import { SubIssuesListItem } from "./list-item";
interface TSubIssuesListGroupProps {
workItemIds: string[];
projectId: string;
workspaceSlug: string;
group: IGroupByColumn;
serviceType: TIssueServiceType;
canEdit: boolean;
parentIssueId: string;
rootIssueId: string;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
storeType?: EIssuesStoreType;
spacingLeft?: number;
}
export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props) => {
const {
group,
serviceType,
canEdit,
parentIssueId,
rootIssueId,
projectId,
workspaceSlug,
handleIssueCrudState,
subIssueOperations,
workItemIds,
storeType = EIssuesStoreType.PROJECT,
spacingLeft = 0,
} = props;
const isAllIssues = group.id === ALL_ISSUES;
// states
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(true);
if (!workItemIds.length) return null;
return (
<>
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => setIsCollapsibleOpen(!isCollapsibleOpen)}
title={
!isAllIssues && (
<div className="flex items-center gap-2 p-3">
<ChevronRightIcon
className={cn("size-3.5 transition-all text-custom-text-400", {
"rotate-90": isCollapsibleOpen,
})}
strokeWidth={2.5}
/>
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
{group.icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
</div>
<span className="text-sm text-custom-text-100 font-medium">{group.name}</span>
<span className="text-sm text-custom-text-400">{workItemIds.length}</span>
</div>
)
}
buttonClassName={cn("hidden", !isAllIssues && "block")}
>
{workItemIds?.map((workItemId) => (
<SubIssuesListItem
key={workItemId}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={workItemId}
canEdit={canEdit}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={serviceType}
spacingLeft={spacingLeft}
storeType={storeType}
/>
))}
</Collapsible>
</>
);
});

View File

@@ -0,0 +1,267 @@
"use client";
import { observer } from "mobx-react";
import { Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { CloseIcon, ChevronRightIcon } from "@plane/propel/icons";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
import { ControlLink, CustomMenu } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// helpers
import { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper";
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local components
import { SubIssuesListItemProperties } from "./properties";
import { SubIssuesListRoot } from "./root";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
canEdit: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueId: string;
issueServiceType?: TIssueServiceType;
storeType?: EIssuesStoreType;
};
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
issueId,
spacingLeft = 10,
canEdit,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
storeType = EIssuesStoreType.PROJECT,
} = props;
const { t } = useTranslation();
const {
issue: { getIssueById },
subIssues: {
filters: { getSubIssueFilters },
},
} = useIssueDetail(issueServiceType);
const {
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
} = useIssueDetail();
const { fetchSubIssues } = useSubIssueOperations(EIssueServiceType.ISSUES);
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(issueServiceType);
const project = useProject();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
const issue = getIssueById(issueId);
// derived values
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
const subIssueHelpers = subIssueHelpersByIssueId(parentIssueId);
const subIssueCount = issue?.sub_issues_count ?? 0;
// derived values
const subIssueFilters = getSubIssueFilters(parentIssueId);
const displayProperties = subIssueFilters?.displayProperties ?? {};
//
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
if (!issue) return <></>;
// check if current issue is the root issue
const isCurrentIssueRoot = issueId === rootIssueId;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetail?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<div key={issueId}>
<ControlLink
id={`issue-${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
>
{issue && (
<div
className="group relative flex min-h-11 h-full w-full items-center pr-2 py-1 transition-all hover:bg-custom-background-90"
style={{ paddingLeft: `${spacingLeft}px` }}
>
<div className="flex size-5 items-center justify-center flex-shrink-0">
{/* disable the chevron when current issue is also the root issue*/}
{subIssueCount > 0 && !isCurrentIssueRoot && (
<>
{subIssueHelpers.preview_loader.includes(issue.id) ? (
<div className="flex h-full w-full cursor-not-allowed items-center justify-center rounded-sm bg-custom-background-80 transition-all">
<Loader width={14} strokeWidth={2} className="animate-spin" />
</div>
) : (
<div
className="flex h-full w-full cursor-pointer items-center justify-center text-custom-text-400 hover:text-custom-text-300"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!subIssueHelpers.issue_visibility.includes(issueId)) {
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
await fetchSubIssues(workspaceSlug, projectId, issueId);
setSubIssueHelpers(parentIssueId, "preview_loader", issueId);
}
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
}}
>
<ChevronRightIcon
className={cn("size-3.5 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
})}
strokeWidth={2.5}
/>
</div>
)}
</>
)}
</div>
<div className="flex w-full truncate cursor-pointer items-center gap-3">
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
</WithDisplayPropertiesHOC>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span className="w-full truncate text-sm text-custom-text-100">{issue.name}</span>
</Tooltip>
</div>
<div
className="flex-shrink-0 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<SubIssuesListItemProperties
workspaceSlug={workspaceSlug}
parentIssueId={parentIssueId}
issueId={issueId}
canEdit={canEdit}
updateSubIssue={subIssueOperations.updateSubIssue}
displayProperties={displayProperties}
issue={issue}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("update", parentIssueId, { ...issue });
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.edit")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
subIssueOperations.copyLink(workItemLink);
}}
>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.copy_link")}</span>
</div>
</CustomMenu.MenuItem>
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
if (issue.project_id)
subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id);
}}
>
<div className="flex items-center gap-2">
<CloseIcon className="h-3.5 w-3.5" strokeWidth={2} />
{issueServiceType === EIssueServiceType.ISSUES
? t("issue.remove.parent.label")
: t("issue.remove.label")}
</div>
</CustomMenu.MenuItem>
)}
{canEdit && (
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("delete", parentIssueId, issue);
toggleDeleteIssueModal(issue.id);
}}
>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("issue.delete.label")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</div>
)}
</ControlLink>
{/* should not expand the current issue if it is also the root issue*/}
{subIssueHelpers.issue_visibility.includes(issueId) &&
issue.project_id &&
subIssueCount > 0 &&
!isCurrentIssueRoot && (
<SubIssuesListRoot
storeType={storeType}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}
rootIssueId={rootIssueId}
spacingLeft={spacingLeft + 22}
canEdit={canEdit}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,220 @@
// plane imports
import type { SyntheticEvent } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { StartDatePropertyIcon, DueDatePropertyIcon } from "@plane/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: React.FC<Props> = observer((props) => {
const { workspaceSlug, parentIssueId, issueId, canEdit, updateSubIssue, displayProperties, issue } = props;
const { t } = useTranslation();
const { getStateById } = useProjectState();
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
};
const handleStartDate = (date: Date | null) => {
if (issue.project_id) {
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
start_date: date ? renderFormattedPayloadDate(date) : null,
});
}
};
const handleTargetDate = (date: Date | null) => {
if (issue.project_id) {
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
target_date: date ? renderFormattedPayloadDate(date) : null,
});
}
};
//derived values
const stateDetails = useMemo(() => getStateById(issue.state_id), [getStateById, issue.state_id]);
const shouldHighlight = useMemo(
() => shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
[issue.target_date, stateDetails?.group]
);
// date range is enabled only when both dates are available and both dates are enabled
const isDateRangeEnabled: boolean = Boolean(
issue.start_date && issue.target_date && displayProperties?.start_date && displayProperties?.due_date
);
if (!displayProperties) return <></>;
const maxDate = getDate(issue.target_date);
const minDate = getDate(issue.start_date);
return (
<div className="relative flex items-center gap-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!canEdit}
buttonVariant="transparent-without-text"
buttonClassName="hover:bg-transparent px-0"
iconSize="size-5"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!canEdit}
buttonVariant="border-without-text"
buttonClassName="border"
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* merged dates */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={() => isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
value={{
from: getDate(issue.start_date) || undefined,
to: getDate(issue.target_date) || undefined,
}}
onSelect={(range) => {
handleStartDate(range?.from ?? null);
handleTargetDate(range?.to ?? null);
}}
hideIcon={{
from: false,
}}
isClearable
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
disabled={!canEdit}
showTooltip
customTooltipHeading="Date Range"
renderPlaceholder={false}
/>
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="start_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5">
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<StartDatePropertyIcon 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={<DueDatePropertyIcon className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
clearIconClassName="text-custom-text-100"
optionsClassName="z-30"
disabled={!canEdit}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
assignee_ids: val,
})
}
disabled={!canEdit}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</WithDisplayPropertiesHOC>
</div>
);
});

View File

@@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { ListFilter } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
// hooks
import { SectionEmptyState } from "@/components/empty-state/section-empty-state-root";
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { SubIssuesListGroup } from "./list-group";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
canEdit: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
storeType: EIssuesStoreType;
};
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
const {
workspaceSlug,
projectId,
parentIssueId,
rootIssueId,
canEdit,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
storeType = EIssuesStoreType.PROJECT,
spacingLeft = 0,
} = props;
const { t } = useTranslation();
// store hooks
const {
subIssues: {
subIssuesByIssueId,
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
},
} = useIssueDetail(issueServiceType);
// derived values
const filters = getSubIssueFilters(rootIssueId);
const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]);
const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null;
const filteredSubWorkItemsCount = (getFilteredSubWorkItems(rootIssueId, filters.filters ?? {}) ?? []).length;
const groups = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
isEpic: issueServiceType === EIssueServiceType.EPICS,
projectId,
});
const getWorkItemIds = useCallback(
(groupId: string) => {
if (isRootLevel) {
const groupedSubIssues = getGroupedSubWorkItems(rootIssueId);
return groupedSubIssues?.[groupId] ?? [];
}
const subIssueIds = subIssuesByIssueId(parentIssueId);
return subIssueIds ?? [];
},
[isRootLevel, subIssuesByIssueId, rootIssueId, getGroupedSubWorkItems, parentIssueId]
);
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
return (
<div className="relative">
{isRootLevel && filteredSubWorkItemsCount === 0 ? (
<SectionEmptyState
title={
!isSubWorkItems
? t("sub_work_item.empty_state.list_filters.title")
: t("sub_work_item.empty_state.sub_list_filters.title")
}
description={
!isSubWorkItems
? t("sub_work_item.empty_state.list_filters.description")
: t("sub_work_item.empty_state.sub_list_filters.description")
}
icon={<ListFilter />}
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
actionElement={
<Button variant="neutral-primary" size="sm" onClick={() => resetFilters(rootIssueId)}>
{t("sub_work_item.empty_state.list_filters.action")}
</Button>
}
/>
) : (
groups?.map((group) => (
<SubIssuesListGroup
key={group.id}
workItemIds={getWorkItemIds(group.id)}
projectId={projectId}
workspaceSlug={workspaceSlug}
group={group}
serviceType={issueServiceType}
canEdit={canEdit}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
storeType={storeType}
spacingLeft={spacingLeft}
/>
))
)}
</div>
);
});

View File

@@ -0,0 +1,103 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { WorkItemsIcon } from "@plane/propel/icons";
import type { TIssue, TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type Props = {
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const SubIssuesActionButton: FC<Props> = observer((props) => {
const { issueId, customButton, disabled = false, issueServiceType } = props;
// translation
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
toggleCreateIssueModal,
toggleSubIssuesModal,
setIssueCrudOperationState,
issueCrudOperationState,
} = useIssueDetail(issueServiceType);
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
// handlers
const handleIssueCrudState = (
key: "create" | "existing",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudOperationState({
...issueCrudOperationState,
[key]: {
toggle: !issueCrudOperationState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const handleCreateNew = () => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.create });
handleIssueCrudState("create", issueId, null);
toggleCreateIssueModal(true);
};
const handleAddExisting = () => {
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.add_existing });
handleIssueCrudState("existing", issueId, null);
toggleSubIssuesModal(issue.id);
};
// options
const optionItems = [
{
i18n_label: "common.create_new",
icon: <Plus className="h-3 w-3" />,
onClick: handleCreateNew,
},
{
i18n_label: "common.add_existing",
icon: <WorkItemsIcon className="h-3 w-3" />,
onClick: handleAddExisting,
},
];
// button element
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{optionItems.map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={() => {
item.onClick();
}}
>
<div className="flex items-center gap-2">
{item.icon}
<span>{t(item.i18n_label)}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
});

View File

@@ -0,0 +1,53 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { SubIssuesCollapsibleContent } from "./content";
import { SubIssuesCollapsibleTitle } from "./title";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const SubIssuesCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
// store hooks
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
// derived values
const isCollapsibleOpen = openWidgets.includes("sub-work-items");
return (
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => toggleOpenWidget("sub-work-items")}
title={
<SubIssuesCollapsibleTitle
isOpen={isCollapsibleOpen}
parentIssueId={issueId}
disabled={disabled}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
}
buttonClassName="w-full"
>
<SubIssuesCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
</Collapsible>
);
});

View File

@@ -0,0 +1,113 @@
import type { FC } from "react";
import { useCallback } from "react";
import { cloneDeep } from "lodash-es";
import { observer } from "mobx-react";
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE,
} from "@plane/constants";
import type {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssueServiceType,
} from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProjectState } from "@/hooks/store/use-project-state";
import { SubIssueDisplayFilters } from "./display-filters";
import { SubIssueFilters } from "./filters";
import { SubIssuesActionButton } from "./quick-action-button";
type TSubWorkItemTitleActionsProps = {
disabled: boolean;
issueServiceType?: TIssueServiceType;
parentId: string;
projectId: string;
};
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, projectId } = props;
// store hooks
const {
subIssues: {
filters: { getSubIssueFilters, updateSubWorkItemFilters },
},
} = useIssueDetail(issueServiceType);
const { getProjectStates } = useProjectState();
const {
project: { getProjectMemberIds },
} = useMember();
// derived values
const projectStates = getProjectStates(projectId);
const projectMemberIds = getProjectMemberIds(projectId, false);
const subIssueFilters = getSubIssueFilters(parentId);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list;
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
},
[updateSubWorkItemFilters, parentId]
);
const handleDisplayPropertiesUpdate = useCallback(
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
},
[updateSubWorkItemFilters, parentId]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
},
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
);
return (
// prevent click everywhere
<div
className="flex gap-2 items-center"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<SubIssueDisplayFilters
isEpic={issueServiceType === EIssueServiceType.EPICS}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayProperties={subIssueFilters?.displayProperties ?? {}}
displayFilters={subIssueFilters?.displayFilters ?? {}}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
handleDisplayFiltersUpdate={handleDisplayFilters}
/>
<SubIssueFilters
handleFiltersUpdate={handleFiltersUpdate}
filters={subIssueFilters?.filters ?? {}}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE}
/>
{!disabled && (
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
)}
</div>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { SubWorkItemTitleActions } from "./title-actions";
type Props = {
isOpen: boolean;
parentIssueId: string;
disabled: boolean;
issueServiceType?: TIssueServiceType;
projectId: string;
workspaceSlug: string;
};
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const {
isOpen,
parentIssueId,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
workspaceSlug,
} = props;
// translation
const { t } = useTranslation();
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
// if there are no sub-issues, return null
if (!subIssues) return null;
// calculate percentage of completed sub-issues
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
const totalCount = subIssues.length;
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
return (
<CollapsibleButton
isOpen={isOpen}
title={`${issueServiceType === EIssueServiceType.EPICS ? t("issue.label", { count: 1 }) : t("common.sub_work_items")}`}
indicatorElement={
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
<span>
{completedCount}/{totalCount} {t("common.done")}
</span>
</div>
}
actionItemElement={
<SubWorkItemTitleActions
projectId={projectId}
parentId={parentIssueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
}
/>
);
});

View File

@@ -0,0 +1,29 @@
"use client";
import type { FC } from "react";
import React from "react";
// helpers
import { cn } from "@plane/utils";
type Props = {
icon: React.ReactNode;
title: string;
disabled?: boolean;
};
export const IssueDetailWidgetButton: FC<Props> = (props) => {
const { icon, title, disabled = false } = props;
return (
<div
className={cn(
"h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 rounded px-3 py-1.5",
{
"cursor-not-allowed text-custom-text-400 bg-custom-background-90": disabled,
"cursor-pointer text-custom-text-300 hover:bg-custom-background-80": !disabled,
}
)}
>
{icon && icon}
<span className="text-sm font-medium">{title}</span>
</div>
);
};