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
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// local imports
|
||||
import { IssueAttachmentItemList } from "../../attachment/attachment-item-list";
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// helper
|
||||
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
return (
|
||||
<IssueAttachmentItemList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
attachmentHelpers={attachmentHelpers}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// types
|
||||
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
|
||||
|
||||
export type TAttachmentOperations = {
|
||||
create: (file: File) => Promise<void>;
|
||||
remove: (attachmentId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TAttachmentSnapshot = {
|
||||
uploadStatus: TAttachmentUploadStatus[] | undefined;
|
||||
};
|
||||
|
||||
export type TAttachmentHelpers = {
|
||||
operations: TAttachmentOperations;
|
||||
snapshot: TAttachmentSnapshot;
|
||||
};
|
||||
|
||||
export const useAttachmentOperations = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
|
||||
): TAttachmentHelpers => {
|
||||
const {
|
||||
attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
const attachmentOperations: TAttachmentOperations = useMemo(
|
||||
() => ({
|
||||
create: async (file) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
|
||||
setPromiseToast(attachmentUploadPromise, {
|
||||
loading: "Uploading attachment...",
|
||||
success: {
|
||||
title: "Attachment uploaded",
|
||||
message: () => "The attachment has been successfully uploaded",
|
||||
},
|
||||
error: {
|
||||
title: "Attachment not uploaded",
|
||||
message: () => "The attachment could not be uploaded",
|
||||
},
|
||||
});
|
||||
|
||||
await attachmentUploadPromise;
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.add,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (attachmentId) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
|
||||
setToast({
|
||||
message: "The attachment has been successfully removed",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Attachment removed",
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.attachment.remove,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
message: "The Attachment could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Attachment not removed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
|
||||
);
|
||||
const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId);
|
||||
|
||||
return {
|
||||
operations: attachmentOperations,
|
||||
snapshot: { uploadStatus: attachmentsUploadStatus },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { FileRejection } from "react-dropzone";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// local imports
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, customButton, disabled = false, issueServiceType } = props;
|
||||
// state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// store hooks
|
||||
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
// operations
|
||||
const { operations: attachmentOperations } = useAttachmentOperations(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
issueServiceType
|
||||
);
|
||||
// handlers
|
||||
const handleFetchPropertyActivities = useCallback(() => {
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
}, [fetchActivities, workspaceSlug, projectId, issueId]);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
|
||||
|
||||
if (rejectedFiles.length === 0) {
|
||||
const currentFile: File = acceptedFiles[0];
|
||||
if (!currentFile || !workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
attachmentOperations
|
||||
.create(currentFile)
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "File could not be attached. Try uploading again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
handleFetchPropertyActivities();
|
||||
setLastWidgetAction("attachments");
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
totalAttachedFiles > 1
|
||||
? "Only one file can be uploaded at a time."
|
||||
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
|
||||
});
|
||||
return;
|
||||
},
|
||||
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
// TODO: Remove extra div and move event propagation to button
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button {...getRootProps()} type="button" disabled={disabled}>
|
||||
<input {...getInputProps()} />
|
||||
{customButton ? customButton : <Plus className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueAttachmentsCollapsibleContent } from "./content";
|
||||
import { IssueAttachmentsCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const AttachmentsCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("attachments");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("attachments")}
|
||||
title={
|
||||
<IssueAttachmentsCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<IssueAttachmentsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueAttachmentActionButton } from "./quick-action-button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const attachmentCount = issue?.attachment_count ?? 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
|
||||
</span>
|
||||
),
|
||||
[attachmentCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={t("common.attachments")}
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
!disabled && (
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { WorkItemAdditionalWidgetCollapsibles } from "@/plane-web/components/issues/issue-detail-widgets/collapsibles";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
// local imports
|
||||
import { AttachmentsCollapsible } from "./attachments";
|
||||
import { LinksCollapsible } from "./links";
|
||||
import { RelationsCollapsible } from "./relations";
|
||||
import { SubIssuesCollapsible } from "./sub-issues";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { subIssuesByIssueId },
|
||||
attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId },
|
||||
relation: { getRelationCountByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const subIssues = subIssuesByIssueId(issueId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
|
||||
// render conditions
|
||||
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0 && !hideWidgets?.includes("sub-work-items");
|
||||
const shouldRenderRelations = issueRelationsCount > 0 && !hideWidgets?.includes("relations");
|
||||
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0 && !hideWidgets?.includes("links");
|
||||
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
|
||||
const attachmentsCount = getAttachmentsCountByIssueId(issueId);
|
||||
const shouldRenderAttachments =
|
||||
attachmentsCount > 0 ||
|
||||
(!!attachmentUploads && attachmentUploads.length > 0 && !hideWidgets?.includes("attachments"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderSubIssues && (
|
||||
<SubIssuesCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderRelations && (
|
||||
<RelationsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderLinks && (
|
||||
<LinksCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderAttachments && (
|
||||
<AttachmentsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
<WorkItemAdditionalWidgetCollapsibles
|
||||
disabled={disabled}
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// plane web imports
|
||||
import { WorkItemAdditionalWidgetModals } from "@/plane-web/components/issues/issue-detail-widgets/modals";
|
||||
// local imports
|
||||
import { IssueLinkCreateUpdateModal } from "../issue-detail/links/create-update-link-modal";
|
||||
// helpers
|
||||
import { CreateUpdateIssueModal } from "../issue-modal/modal";
|
||||
import { useLinkOperations } from "./links/helper";
|
||||
import { useSubIssueOperations } from "./sub-issues/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueServiceType, hideWidgets } = props;
|
||||
// store hooks
|
||||
const {
|
||||
isIssueLinkModalOpen,
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
setIssueLinkData,
|
||||
isCreateIssueModalOpen,
|
||||
toggleCreateIssueModal,
|
||||
isSubIssuesModalOpen,
|
||||
toggleSubIssuesModal,
|
||||
relationKey,
|
||||
isRelationModalOpen,
|
||||
setRelationKey,
|
||||
setLastWidgetAction,
|
||||
toggleRelationModal,
|
||||
createRelation,
|
||||
issueCrudOperationState,
|
||||
setIssueCrudOperationState,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helper hooks
|
||||
const subIssueOperations = useSubIssueOperations(issueServiceType);
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
|
||||
// handlers
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing",
|
||||
_parentIssueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
setIssueCrudOperationState({
|
||||
...issueCrudOperationState,
|
||||
[key]: {
|
||||
toggle: !issueCrudOperationState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExistingIssuesModalClose = () => {
|
||||
handleIssueCrudState("existing", null, null);
|
||||
setLastWidgetAction("sub-work-items");
|
||||
toggleSubIssuesModal(null);
|
||||
};
|
||||
|
||||
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
|
||||
subIssueOperations.addSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
_issue.map((issue) => issue.id)
|
||||
);
|
||||
|
||||
const handleCreateUpdateModalClose = () => {
|
||||
handleIssueCrudState("create", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
setLastWidgetAction("sub-work-items");
|
||||
};
|
||||
|
||||
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
|
||||
if (_issue.parent_id) {
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIssueLinkModalOnClose = () => {
|
||||
toggleIssueLinkModalStore(false);
|
||||
setLastWidgetAction("links");
|
||||
setIssueLinkData(null);
|
||||
};
|
||||
|
||||
const handleRelationOnClose = () => {
|
||||
setRelationKey(null);
|
||||
toggleRelationModal(null, null);
|
||||
setLastWidgetAction("relations");
|
||||
};
|
||||
|
||||
const handleExistingIssueModalOnSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (!relationKey) return;
|
||||
if (data.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one work item.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createRelation(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
relationKey,
|
||||
data.map((i) => i.id)
|
||||
);
|
||||
|
||||
toggleRelationModal(null, null);
|
||||
};
|
||||
|
||||
// helpers
|
||||
const createUpdateModalData: Partial<TIssue> = {
|
||||
parent_id: issueCrudOperationState?.create?.parentIssueId,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const existingIssuesModalSearchParams = {
|
||||
sub_issue: true,
|
||||
issue_id: issueCrudOperationState?.existing?.parentIssueId,
|
||||
};
|
||||
|
||||
// render conditions
|
||||
const shouldRenderExistingIssuesModal =
|
||||
!hideWidgets?.includes("sub-work-items") &&
|
||||
issueCrudOperationState?.existing?.toggle &&
|
||||
issueCrudOperationState?.existing?.parentIssueId &&
|
||||
isSubIssuesModalOpen;
|
||||
|
||||
const shouldRenderCreateUpdateModal =
|
||||
!hideWidgets?.includes("sub-work-items") &&
|
||||
issueCrudOperationState?.create?.toggle &&
|
||||
issueCrudOperationState?.create?.parentIssueId &&
|
||||
isCreateIssueModalOpen;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideWidgets?.includes("links") && (
|
||||
<IssueLinkCreateUpdateModal
|
||||
isModalOpen={isIssueLinkModalOpen}
|
||||
handleOnClose={handleIssueLinkModalOnClose}
|
||||
linkOperations={handleLinkOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderCreateUpdateModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperationState?.create?.toggle}
|
||||
data={createUpdateModalData}
|
||||
onClose={handleCreateUpdateModalClose}
|
||||
onSubmit={handleCreateUpdateModalOnSubmit}
|
||||
isProjectSelectionDisabled
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderExistingIssuesModal && (
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={issueCrudOperationState?.existing?.toggle}
|
||||
handleClose={handleExistingIssuesModalClose}
|
||||
searchParams={existingIssuesModalSearchParams}
|
||||
handleOnSubmit={handleExistingIssuesModalOnSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideWidgets?.includes("relations") && (
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
|
||||
handleClose={handleRelationOnClose}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={handleExistingIssueModalOnSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkItemAdditionalWidgetModals
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import { LinkList } from "../../issue-detail/links";
|
||||
// helper
|
||||
import { useLinkOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType } = props;
|
||||
|
||||
// helper
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
|
||||
return (
|
||||
<LinkList
|
||||
issueId={issueId}
|
||||
linkOperations={handleLinkOperations}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueLink, TIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import type { TLinkOperations } from "../../issue-detail/links";
|
||||
|
||||
export const useLinkOperations = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
issueServiceType: TIssueServiceType
|
||||
): TLinkOperations => {
|
||||
const { createLink, updateLink, removeLink } = useIssueDetail(issueServiceType);
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLinkOperations: TLinkOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, projectId, issueId, data);
|
||||
setToast({
|
||||
message: t("links.toasts.created.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.created.title"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_created.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_created.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async (linkId: string, data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
setToast({
|
||||
message: t("links.toasts.updated.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.updated.title"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_updated.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_updated.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, projectId, issueId, linkId);
|
||||
setToast({
|
||||
message: t("links.toasts.removed.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.removed.title"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
message: t("links.toasts.not_removed.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_removed.title"),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, t]
|
||||
);
|
||||
|
||||
return handleLinkOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
type Props = {
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksActionButton: FC<Props> = observer((props) => {
|
||||
const { customButton, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { toggleIssueLinkModal } = useIssueDetail(issueServiceType);
|
||||
|
||||
// handlers
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIssueLinkModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleOnClick} disabled={disabled}>
|
||||
{customButton ? customButton : <Plus className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueLinksCollapsibleContent } from "./content";
|
||||
import { IssueLinksCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const LinksCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("links");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("links")}
|
||||
title={
|
||||
<IssueLinksCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<IssueLinksCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { IssueLinksActionButton } from "./quick-action-button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, issueId, disabled, issueServiceType } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
const linksCount = issue?.link_count ?? 0;
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
|
||||
</span>
|
||||
),
|
||||
[linksCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={t("common.links")}
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
!disabled && <IssueLinksActionButton issueServiceType={issueServiceType} disabled={disabled} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
// helper
|
||||
import { DeleteIssueModal } from "../../delete-issue-modal";
|
||||
import { RelationIssueList } from "../../relations/issue-list";
|
||||
import { useRelationOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export type TRelationObject = {
|
||||
key: TIssueRelationTypes;
|
||||
i18n_label: string;
|
||||
className: string;
|
||||
icon: (size: number) => React.ReactElement;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
update: TIssueCrudState;
|
||||
delete: TIssueCrudState;
|
||||
removeRelation: TIssueCrudState & { relationKey: string | undefined; relationIssueId: string | undefined };
|
||||
}>({
|
||||
update: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
removeRelation: {
|
||||
toggle: false,
|
||||
issueId: undefined,
|
||||
issue: undefined,
|
||||
relationKey: undefined,
|
||||
relationIssueId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
relation: { getRelationsByIssueId, removeRelation },
|
||||
toggleDeleteIssueModal,
|
||||
toggleCreateIssueModal,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helper
|
||||
const issueOperations = useRelationOperations();
|
||||
const epicOperations = useRelationOperations(EIssueServiceType.EPICS);
|
||||
|
||||
// derived values
|
||||
const relations = getRelationsByIssueId(issueId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
|
||||
const handleIssueCrudState = (
|
||||
key: "update" | "delete" | "removeRelation",
|
||||
_issueId: string | null,
|
||||
issue: TIssue | null = null,
|
||||
relationKey?: TIssueRelationTypes | null,
|
||||
relationIssueId?: string | null
|
||||
) => {
|
||||
setIssueCrudState((prevState) => ({
|
||||
...prevState,
|
||||
[key]: {
|
||||
toggle: !prevState[key].toggle,
|
||||
issueId: _issueId,
|
||||
issue: issue,
|
||||
relationKey: relationKey,
|
||||
relationIssueId: relationIssueId,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// if relations are not available, return null
|
||||
if (!relations) return null;
|
||||
|
||||
// map relations to array
|
||||
const relationsArray = (Object.keys(relations) as TIssueRelationTypes[])
|
||||
.filter((relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey])
|
||||
.map((relationKey) => {
|
||||
const issueIds = relations[relationKey];
|
||||
const issueRelationOption = ISSUE_RELATION_OPTIONS[relationKey];
|
||||
return {
|
||||
relationKey: relationKey,
|
||||
issueIds: issueIds,
|
||||
icon: issueRelationOption?.icon,
|
||||
label: issueRelationOption?.i18n_label ? t(issueRelationOption?.i18n_label) : "",
|
||||
className: issueRelationOption?.className,
|
||||
};
|
||||
});
|
||||
|
||||
// filter out relations with no issues
|
||||
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
|
||||
|
||||
const shouldRenderIssueDeleteModal =
|
||||
issueCrudState?.delete?.toggle &&
|
||||
issueCrudState?.delete?.issue &&
|
||||
issueCrudState.delete.issueId &&
|
||||
issueCrudState.delete.issue.id;
|
||||
|
||||
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-">
|
||||
{filteredRelationsArray.map((relation) => (
|
||||
<div key={relation.relationKey}>
|
||||
<Collapsible
|
||||
buttonClassName="w-full"
|
||||
title={
|
||||
<div className={`flex items-center gap-1 px-2.5 py-1 h-9 w-full ${relation.className}`}>
|
||||
<span>{relation.icon ? relation.icon(14) : null}</span>
|
||||
<span className="text-sm font-medium leading-5">{relation.label}</span>
|
||||
</div>
|
||||
}
|
||||
defaultOpen
|
||||
>
|
||||
<RelationIssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
relationKey={relation.relationKey}
|
||||
issueIds={relation.issueIds}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shouldRenderIssueDeleteModal && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () => {
|
||||
if (
|
||||
issueCrudState.removeRelation.issueId &&
|
||||
issueCrudState.removeRelation.issue?.project_id &&
|
||||
issueCrudState.removeRelation.relationKey &&
|
||||
issueCrudState.removeRelation.relationIssueId
|
||||
) {
|
||||
await removeRelation(
|
||||
workspaceSlug,
|
||||
issueCrudState.removeRelation.issue.project_id,
|
||||
issueCrudState.removeRelation.issueId,
|
||||
issueCrudState.removeRelation.relationKey as TIssueRelationTypes,
|
||||
issueCrudState.removeRelation.relationIssueId,
|
||||
true
|
||||
);
|
||||
}
|
||||
if (
|
||||
issueCrudState.delete.issue &&
|
||||
issueCrudState.delete.issue.id &&
|
||||
issueCrudState.delete.issue.project_id
|
||||
) {
|
||||
const deleteOperation = !!issueCrudState.delete.issue?.is_epic
|
||||
? epicOperations.remove
|
||||
: issueOperations.remove;
|
||||
await deleteOperation(
|
||||
workspaceSlug,
|
||||
issueCrudState.delete.issue?.project_id,
|
||||
issueCrudState?.delete?.issue?.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
isEpic={!!issueCrudState.delete.issue?.is_epic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderIssueUpdateModal && (
|
||||
<>
|
||||
{!!issueCrudState?.update?.issue?.is_epic ? (
|
||||
<CreateUpdateEpicModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (!_issue.id || !_issue.project_id) return;
|
||||
await epicOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (!_issue.id || !_issue.project_id) return;
|
||||
await issueOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
export type TRelationIssueOperations = {
|
||||
copyLink: (path: string) => void;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useRelationOperations = (
|
||||
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
|
||||
): TRelationIssueOperations => {
|
||||
const { updateIssue, removeIssue } = useIssueDetail(issueServiceType);
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const entityName = issueServiceType === EIssueServiceType.ISSUES ? "Work item" : "Epic";
|
||||
|
||||
const issueOperations: TRelationIssueOperations = useMemo(
|
||||
() => ({
|
||||
copyLink: (path) => {
|
||||
copyUrlToClipboard(path).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", { entity: entityName }),
|
||||
});
|
||||
});
|
||||
},
|
||||
update: async (workspaceSlug, projectId, issueId, data) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
setToast({
|
||||
title: t("toast.success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("entity.update.success", { entity: entityName }),
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.update.failed", { entity: entityName }),
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug, projectId, issueId) => {
|
||||
try {
|
||||
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId },
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[entityName, removeIssue, t, updateIssue]
|
||||
);
|
||||
|
||||
return issueOperations;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import type { TIssueRelationTypes } from "@/plane-web/types";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationActionButton: FC<Props> = observer((props) => {
|
||||
const { customButton, issueId, disabled = false, issueServiceType } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleRelationModal, setRelationKey } = useIssueDetail(issueServiceType);
|
||||
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
|
||||
// handlers
|
||||
const handleOnClick = (relationKey: TIssueRelationTypes) => {
|
||||
setRelationKey(relationKey);
|
||||
toggleRelationModal(issueId, relationKey);
|
||||
};
|
||||
|
||||
// button element
|
||||
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={customButtonElement}
|
||||
placement="bottom-start"
|
||||
disabled={disabled}
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
{Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => {
|
||||
if (!item) return <></>;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleOnClick(item.key as TIssueRelationTypes);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon(12)}
|
||||
<span>{t(item.i18n_label)}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { RelationsCollapsibleContent } from "./content";
|
||||
import { RelationsCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationsCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("relations");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("relations")}
|
||||
title={
|
||||
<RelationsCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<RelationsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
// local imports
|
||||
import { RelationActionButton } from "./quick-action-button";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const { isOpen, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hook
|
||||
const {
|
||||
relation: { getRelationCountByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
// derived values
|
||||
const relationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
|
||||
|
||||
// indicator element
|
||||
const indicatorElement = useMemo(
|
||||
() => (
|
||||
<span className="flex items-center justify-center ">
|
||||
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
|
||||
</span>
|
||||
),
|
||||
[relationsCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={t("common.relations")}
|
||||
indicatorElement={indicatorElement}
|
||||
actionItemElement={
|
||||
!disabled && <RelationActionButton issueId={issueId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// local imports
|
||||
import { IssueDetailWidgetActionButtons } from "./action-buttons";
|
||||
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
|
||||
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
renderWidgetModals?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgets: FC<Props> = (props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
disabled,
|
||||
renderWidgetModals = true,
|
||||
issueServiceType,
|
||||
hideWidgets,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5">
|
||||
<IssueDetailWidgetActionButtons
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
hideWidgets={hideWidgets}
|
||||
/>
|
||||
<IssueDetailWidgetCollapsibles
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
hideWidgets={hideWidgets}
|
||||
/>
|
||||
</div>
|
||||
{renderWidgetModals && (
|
||||
<IssueDetailWidgetModals
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueServiceType={issueServiceType}
|
||||
hideWidgets={hideWidgets}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { CreateUpdateIssueModal } from "../../issue-modal/modal";
|
||||
import { useSubIssueOperations } from "./helper";
|
||||
import { SubIssuesListRoot } from "./issues-list/root";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
|
||||
|
||||
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, parentIssueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
create: TIssueCrudState;
|
||||
existing: TIssueCrudState;
|
||||
update: TIssueCrudState;
|
||||
delete: TIssueCrudState;
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
existing: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
update: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
parentIssueId: undefined,
|
||||
issue: undefined,
|
||||
},
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
toggleCreateIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helpers
|
||||
const subIssueOperations = useSubIssueOperations(issueServiceType);
|
||||
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
|
||||
|
||||
// handler
|
||||
const handleIssueCrudState = useCallback(
|
||||
(key: "create" | "existing" | "update" | "delete", _parentIssueId: string | null, issue: TIssue | null = null) => {
|
||||
setIssueCrudState({
|
||||
...issueCrudState,
|
||||
[key]: {
|
||||
toggle: !issueCrudState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue,
|
||||
},
|
||||
});
|
||||
},
|
||||
[issueCrudState]
|
||||
);
|
||||
|
||||
const handleFetchSubIssues = useCallback(async () => {
|
||||
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
|
||||
try {
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
|
||||
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
|
||||
} catch (error) {
|
||||
console.error("Error fetching sub-work items:", error);
|
||||
} finally {
|
||||
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", "");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
parentIssueId,
|
||||
projectId,
|
||||
setSubIssueHelpers,
|
||||
subIssueHelpers.issue_visibility,
|
||||
subIssueOperations,
|
||||
workspaceSlug,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
handleFetchSubIssues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentIssueId]);
|
||||
|
||||
// render conditions
|
||||
const shouldRenderDeleteIssueModal =
|
||||
issueCrudState?.delete?.toggle &&
|
||||
issueCrudState?.delete?.issue &&
|
||||
issueCrudState.delete.parentIssueId &&
|
||||
issueCrudState.delete.issue.id;
|
||||
|
||||
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
|
||||
|
||||
return (
|
||||
<>
|
||||
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
||||
<SubIssuesListRoot
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={parentIssueId}
|
||||
spacingLeft={6}
|
||||
canEdit={!disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderDeleteIssueModal && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () =>
|
||||
await subIssueOperations.deleteSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueCrudState?.delete?.parentIssueId as string,
|
||||
issueCrudState?.delete?.issue?.id as string
|
||||
)
|
||||
}
|
||||
isSubIssue
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderUpdateIssueModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
await subIssueOperations.updateSubIssue(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
_issue.id,
|
||||
_issue,
|
||||
issueCrudState?.update?.issue,
|
||||
true
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import type { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
FilterDisplayProperties,
|
||||
FilterGroupBy,
|
||||
FilterOrderBy,
|
||||
FiltersDropdown,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||
type TSubIssueDisplayFiltersProps = {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
|
||||
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer((props) => {
|
||||
const {
|
||||
isEpic = false,
|
||||
displayProperties,
|
||||
layoutDisplayFiltersOptions,
|
||||
handleDisplayPropertiesUpdate,
|
||||
handleDisplayFiltersUpdate,
|
||||
displayFilters,
|
||||
} = props;
|
||||
|
||||
const isFilterApplied = useMemo(
|
||||
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
|
||||
[displayProperties, displayFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
|
||||
<FiltersDropdown
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div
|
||||
className={cn(
|
||||
"p-1 rounded relative transition-all duration-200",
|
||||
isFilterApplied && "bg-custom-primary-60/20"
|
||||
)}
|
||||
>
|
||||
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 text-custom-text-100" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left">
|
||||
{/* display properties */}
|
||||
<div className="py-2">
|
||||
<FilterDisplayProperties
|
||||
displayProperties={displayProperties}
|
||||
displayPropertiesToRender={layoutDisplayFiltersOptions.display_properties}
|
||||
handleUpdate={handleDisplayPropertiesUpdate}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* group by */}
|
||||
<div className="py-2">
|
||||
<FilterGroupBy
|
||||
displayFilters={displayFilters}
|
||||
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
group_by: val,
|
||||
})
|
||||
}
|
||||
ignoreGroupedFilters={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* order by */}
|
||||
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
|
||||
<div className="py-2">
|
||||
<FilterOrderBy
|
||||
selectedOrderBy={displayFilters?.order_by}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
order_by: val,
|
||||
})
|
||||
}
|
||||
orderByOptions={layoutDisplayFiltersOptions?.display_filters.order_by ?? []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FiltersDropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web helpers
|
||||
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
|
||||
|
||||
export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSubIssueOperations => {
|
||||
// router
|
||||
const { epicId: epicIdParam } = useParams();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { setSubIssueHelpers },
|
||||
createSubIssues,
|
||||
fetchSubIssues,
|
||||
updateSubIssue,
|
||||
deleteSubIssue,
|
||||
removeSubIssue,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getStateById } = useProjectState();
|
||||
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
// const { updateEpicAnalytics } = useIssueTypes();
|
||||
const { updateAnalytics } = updateEpicAnalytics();
|
||||
|
||||
// derived values
|
||||
const epicId = epicIdParam || epicPeekIssue?.issueId;
|
||||
|
||||
const subIssueOperations: TSubIssueOperations = useMemo(
|
||||
() => ({
|
||||
copyLink: (path) => {
|
||||
copyUrlToClipboard(path).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items", { count: 1 })
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
fetchSubIssues: async (workspaceSlug, projectId, parentIssueId) => {
|
||||
try {
|
||||
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.fetch.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items", { count: 2 })
|
||||
: t("issue.label", { count: 2 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
addSubIssue: async (workspaceSlug, projectId, parentIssueId, issueIds) => {
|
||||
try {
|
||||
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("entity.add.success", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: issueIds.length }),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.add.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: issueIds.length }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
updateSubIssue: async (
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
issueData,
|
||||
oldIssue = {},
|
||||
fromModal = false
|
||||
) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
|
||||
|
||||
if (issueServiceType === EIssueServiceType.EPICS) {
|
||||
const oldState = getStateById(oldIssue?.state_id)?.group;
|
||||
|
||||
if (oldState && oldIssue && issueData && epicId) {
|
||||
// Check if parent_id is changed if yes then decrement the epic analytics count
|
||||
if (issueData.parent_id && oldIssue?.parent_id && issueData.parent_id !== oldIssue?.parent_id) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if state_id is changed if yes then decrement the old state group count and increment the new state group count
|
||||
if (issueData.state_id) {
|
||||
const newState = getStateById(issueData.state_id)?.group;
|
||||
if (oldState && newState && oldState !== newState) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
incrementStateGroupCount: `${newState}_issues`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("sub_work_item.update.success"),
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.update,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("sub_work_item.update.error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
removeSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
if (issueServiceType === EIssueServiceType.EPICS) {
|
||||
const issueBeforeRemoval = getIssueById(issueId);
|
||||
const oldState = getStateById(issueBeforeRemoval?.state_id)?.group;
|
||||
|
||||
if (epicId && oldState) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
});
|
||||
}
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("entity.remove.success", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.remove.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteSubIssue: async (workspaceSlug, projectId, parentIssueId, issueId) => {
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
return deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId).then(() => {
|
||||
captureSuccess({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
});
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
});
|
||||
} catch (error) {
|
||||
captureError({
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.delete,
|
||||
payload: { id: issueId, parent_id: parentIssueId },
|
||||
error: error as Error,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("entity.delete.failed", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("common.sub_work_items")
|
||||
: t("issue.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
createSubIssues,
|
||||
deleteSubIssue,
|
||||
epicId,
|
||||
fetchSubIssues,
|
||||
getIssueById,
|
||||
getStateById,
|
||||
issueServiceType,
|
||||
removeSubIssue,
|
||||
setSubIssueHelpers,
|
||||
t,
|
||||
updateAnalytics,
|
||||
updateSubIssue,
|
||||
]
|
||||
);
|
||||
|
||||
return subIssueOperations;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./content";
|
||||
export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
export * from "./display-filters";
|
||||
export * from "./content";
|
||||
@@ -0,0 +1,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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ListFilter } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/types";
|
||||
// hooks
|
||||
import { SectionEmptyState } from "@/components/empty-state/section-empty-state-root";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
import { SubIssuesListGroup } from "./list-group";
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssueId: string;
|
||||
rootIssueId: string;
|
||||
spacingLeft: number;
|
||||
canEdit: boolean;
|
||||
handleIssueCrudState: (
|
||||
key: "create" | "existing" | "update" | "delete",
|
||||
issueId: string,
|
||||
issue?: TIssue | null
|
||||
) => void;
|
||||
subIssueOperations: TSubIssueOperations;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
storeType: EIssuesStoreType;
|
||||
};
|
||||
|
||||
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssueId,
|
||||
rootIssueId,
|
||||
canEdit,
|
||||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
spacingLeft = 0,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: {
|
||||
subIssuesByIssueId,
|
||||
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const filters = getSubIssueFilters(rootIssueId);
|
||||
const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]);
|
||||
const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null;
|
||||
const filteredSubWorkItemsCount = (getFilteredSubWorkItems(rootIssueId, filters.filters ?? {}) ?? []).length;
|
||||
|
||||
const groups = getGroupByColumns({
|
||||
groupBy: group_by as GroupByColumnTypes,
|
||||
includeNone: true,
|
||||
isWorkspaceLevel: isWorkspaceLevel(storeType),
|
||||
isEpic: issueServiceType === EIssueServiceType.EPICS,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const getWorkItemIds = useCallback(
|
||||
(groupId: string) => {
|
||||
if (isRootLevel) {
|
||||
const groupedSubIssues = getGroupedSubWorkItems(rootIssueId);
|
||||
return groupedSubIssues?.[groupId] ?? [];
|
||||
}
|
||||
const subIssueIds = subIssuesByIssueId(parentIssueId);
|
||||
return subIssueIds ?? [];
|
||||
},
|
||||
[isRootLevel, subIssuesByIssueId, rootIssueId, getGroupedSubWorkItems, parentIssueId]
|
||||
);
|
||||
|
||||
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{isRootLevel && filteredSubWorkItemsCount === 0 ? (
|
||||
<SectionEmptyState
|
||||
title={
|
||||
!isSubWorkItems
|
||||
? t("sub_work_item.empty_state.list_filters.title")
|
||||
: t("sub_work_item.empty_state.sub_list_filters.title")
|
||||
}
|
||||
description={
|
||||
!isSubWorkItems
|
||||
? t("sub_work_item.empty_state.list_filters.description")
|
||||
: t("sub_work_item.empty_state.sub_list_filters.description")
|
||||
}
|
||||
icon={<ListFilter />}
|
||||
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
|
||||
actionElement={
|
||||
<Button variant="neutral-primary" size="sm" onClick={() => resetFilters(rootIssueId)}>
|
||||
{t("sub_work_item.empty_state.list_filters.action")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<SubIssuesListGroup
|
||||
key={group.id}
|
||||
workItemIds={getWorkItemIds(group.id)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
group={group}
|
||||
serviceType={issueServiceType}
|
||||
canEdit={canEdit}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={rootIssueId}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
storeType={storeType}
|
||||
spacingLeft={spacingLeft}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const SubIssuesActionButton: FC<Props> = observer((props) => {
|
||||
const { issueId, customButton, disabled = false, issueServiceType } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
toggleCreateIssueModal,
|
||||
toggleSubIssuesModal,
|
||||
setIssueCrudOperationState,
|
||||
issueCrudOperationState,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
// handlers
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing",
|
||||
_parentIssueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
setIssueCrudOperationState({
|
||||
...issueCrudOperationState,
|
||||
[key]: {
|
||||
toggle: !issueCrudOperationState[key].toggle,
|
||||
parentIssueId: _parentIssueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.create });
|
||||
handleIssueCrudState("create", issueId, null);
|
||||
toggleCreateIssueModal(true);
|
||||
};
|
||||
|
||||
const handleAddExisting = () => {
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_EVENTS.sub_issue.add_existing });
|
||||
handleIssueCrudState("existing", issueId, null);
|
||||
toggleSubIssuesModal(issue.id);
|
||||
};
|
||||
|
||||
// options
|
||||
const optionItems = [
|
||||
{
|
||||
i18n_label: "common.create_new",
|
||||
icon: <Plus className="h-3 w-3" />,
|
||||
onClick: handleCreateNew,
|
||||
},
|
||||
{
|
||||
i18n_label: "common.add_existing",
|
||||
icon: <WorkItemsIcon className="h-3 w-3" />,
|
||||
onClick: handleAddExisting,
|
||||
},
|
||||
];
|
||||
|
||||
// button element
|
||||
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
|
||||
{optionItems.map((item, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.icon}
|
||||
<span>{t(item.i18n_label)}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// local imports
|
||||
import { SubIssuesCollapsibleContent } from "./content";
|
||||
import { SubIssuesCollapsibleTitle } from "./title";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const SubIssuesCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("sub-work-items");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("sub-work-items")}
|
||||
title={
|
||||
<SubIssuesCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
parentIssueId={issueId}
|
||||
disabled={disabled}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<SubIssuesCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FC } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EIssueFilterType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE,
|
||||
} from "@plane/constants";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TIssueServiceType,
|
||||
} from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { SubIssueDisplayFilters } from "./display-filters";
|
||||
import { SubIssueFilters } from "./filters";
|
||||
import { SubIssuesActionButton } from "./quick-action-button";
|
||||
|
||||
type TSubWorkItemTitleActionsProps = {
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
parentId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
|
||||
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, projectId } = props;
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: {
|
||||
filters: { getSubIssueFilters, updateSubWorkItemFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getProjectStates } = useProjectState();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
// derived values
|
||||
const projectStates = getProjectStates(projectId);
|
||||
const projectMemberIds = getProjectMemberIds(projectId, false);
|
||||
const subIssueFilters = getSubIssueFilters(parentId);
|
||||
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list;
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
|
||||
},
|
||||
[updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
const handleDisplayPropertiesUpdate = useCallback(
|
||||
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
|
||||
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
|
||||
},
|
||||
[updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
|
||||
},
|
||||
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
return (
|
||||
// prevent click everywhere
|
||||
<div
|
||||
className="flex gap-2 items-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SubIssueDisplayFilters
|
||||
isEpic={issueServiceType === EIssueServiceType.EPICS}
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
displayProperties={subIssueFilters?.displayProperties ?? {}}
|
||||
displayFilters={subIssueFilters?.displayFilters ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
/>
|
||||
<SubIssueFilters
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
filters={subIssueFilters?.filters ?? {}}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE}
|
||||
/>
|
||||
{!disabled && (
|
||||
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { SubWorkItemTitleActions } from "./title-actions";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
parentIssueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
const {
|
||||
isOpen,
|
||||
parentIssueId,
|
||||
disabled,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
} = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
|
||||
const subIssues = subIssuesByIssueId(parentIssueId);
|
||||
// if there are no sub-issues, return null
|
||||
if (!subIssues) return null;
|
||||
|
||||
// calculate percentage of completed sub-issues
|
||||
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
|
||||
const totalCount = subIssues.length;
|
||||
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title={`${issueServiceType === EIssueServiceType.EPICS ? t("issue.label", { count: 1 }) : t("common.sub_work_items")}`}
|
||||
indicatorElement={
|
||||
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
|
||||
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
|
||||
<span>
|
||||
{completedCount}/{totalCount} {t("common.done")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actionItemElement={
|
||||
<SubWorkItemTitleActions
|
||||
projectId={projectId}
|
||||
parentId={parentIssueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetButton: FC<Props> = (props) => {
|
||||
const { icon, title, disabled = false } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 rounded px-3 py-1.5",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-400 bg-custom-background-90": disabled,
|
||||
"cursor-pointer text-custom-text-300 hover:bg-custom-background-80": !disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{icon && icon}
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user