feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// hooks
// components
import { cn } from "@plane/utils";
import { CycleDropdown } from "@/components/dropdowns/cycle";
// ui
// helpers
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TIssueOperations } from "./root";
type TIssueCycleSelect = {
className?: string;
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled?: boolean;
};
export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) => {
const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
const { t } = useTranslation();
// states
const [isUpdating, setIsUpdating] = useState(false);
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const disableSelect = disabled || isUpdating;
const handleIssueCycleChange = async (cycleId: string | null) => {
if (!issue || issue.cycle_id === cycleId) return;
setIsUpdating(true);
if (cycleId) await issueOperations.addCycleToIssue?.(workspaceSlug, projectId, cycleId, issueId);
else await issueOperations.removeIssueFromCycle?.(workspaceSlug, projectId, issue.cycle_id ?? "", issueId);
setIsUpdating(false);
};
return (
<div className={cn("flex h-full items-center gap-1", className)}>
<CycleDropdown
value={issue?.cycle_id ?? null}
onChange={handleIssueCycleChange}
projectId={projectId}
disabled={disableSelect}
buttonVariant="transparent-with-text"
className="group w-full"
buttonContainerClassName="w-full text-left rounded"
buttonClassName={`text-sm justify-between ${issue?.cycle_id ? "" : "text-custom-text-400"}`}
placeholder={t("cycle.no_cycle")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
);
});

View File

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

View File

@@ -0,0 +1,103 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import type { E_SORT_ORDER, TActivityFilters } from "@plane/constants";
import { EActivityFilterType, filterActivityOnSelectedFilters } from "@plane/constants";
import type { TCommentsOperations } from "@plane/types";
// components
import { CommentCard } from "@/components/comments/card/root";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web components
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues/issue-details/issue-properties-activity";
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
// local imports
import { IssueActivityItem } from "./activity/activity-list";
import { IssueActivityLoader } from "./loader";
type TIssueActivityCommentRoot = {
workspaceSlug: string;
projectId: string;
isIntakeIssue: boolean;
issueId: string;
selectedFilters: TActivityFilters[];
activityOperations: TCommentsOperations;
showAccessSpecifier?: boolean;
disabled?: boolean;
sortOrder: E_SORT_ORDER;
};
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
const {
workspaceSlug,
isIntakeIssue,
issueId,
selectedFilters,
activityOperations,
showAccessSpecifier,
projectId,
disabled,
sortOrder,
} = props;
// store hooks
const {
activity: { getActivityAndCommentsByIssueId },
comment: { getCommentById },
} = useIssueDetail();
// derived values
const activityAndComments = getActivityAndCommentsByIssueId(issueId, sortOrder);
if (!activityAndComments) return <IssueActivityLoader />;
if (activityAndComments.length <= 0) return null;
const filteredActivityAndComments = filterActivityOnSelectedFilters(activityAndComments, selectedFilters);
const BASE_ACTIVITY_FILTER_TYPES = [
EActivityFilterType.ACTIVITY,
EActivityFilterType.STATE,
EActivityFilterType.ASSIGNEE,
EActivityFilterType.DEFAULT,
];
return (
<div>
{filteredActivityAndComments.map((activityComment, index) => {
const comment = getCommentById(activityComment.id);
return activityComment.activity_type === "COMMENT" ? (
<CommentCard
key={activityComment.id}
workspaceSlug={workspaceSlug}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={!!showAccessSpecifier}
showCopyLinkOption={!isIntakeIssue}
disabled={disabled}
projectId={projectId}
/>
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
<IssueActivityItem
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
<IssueAdditionalPropertiesActivity
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "WORKLOG" ? (
<IssueActivityWorklog
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
activityComment={activityComment}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : (
<></>
);
})}
</div>
);
});

View File

@@ -0,0 +1,68 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Check, ListFilter } from "lucide-react";
import type { TActivityFilters, TActivityFilterOption } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PopoverMenu } from "@plane/ui";
// helper
import { cn } from "@plane/utils";
// constants
type TActivityFilter = {
selectedFilters: TActivityFilters[];
filterOptions: TActivityFilterOption[];
};
export const ActivityFilter: FC<TActivityFilter> = observer((props) => {
const { selectedFilters = [], filterOptions } = props;
// hooks
const { t } = useTranslation();
return (
<PopoverMenu
buttonClassName="outline-none"
button={
<Button
variant="neutral-primary"
size="sm"
prependIcon={<ListFilter className="h-3 w-3" />}
className="relative"
>
<span className="text-custom-text-200">{t("common.filters")}</span>
{selectedFilters.length < filterOptions.length && (
<span className="absolute h-2 w-2 -right-0.5 -top-0.5 bg-custom-primary-100 rounded-full" />
)}
</Button>
}
panelClassName="p-2 rounded-md border border-custom-border-200 bg-custom-background-100"
data={filterOptions}
keyExtractor={(item) => item.key}
render={(item) => (
<div
key={item.key}
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={item.onClick}
>
<div
className={cn(
"flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all bg-custom-background-90",
{
"bg-custom-primary text-white": item.isSelected,
"bg-custom-background-80 text-custom-text-400": item.isSelected && selectedFilters.length === 1,
"bg-custom-background-90": !item.isSelected,
}
)}
>
{item.isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<div className={cn("whitespace-nowrap", item.isSelected ? "text-custom-text-100" : "text-custom-text-200")}>
{t(item.labelTranslationKey)}
</div>
</div>
)}
/>
);
});

View File

@@ -0,0 +1,42 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { RotateCcw } from "lucide-react";
// hooks
import { ArchiveIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// ui
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={
activity.new_value === "restore" ? (
<RotateCcw className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
) : (
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
)
}
activityId={activityId}
ends={ends}
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
>
{activity.new_value === "restore" ? "restored the work item" : "archived the work item"}.
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,43 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// icons
import { Users } from "lucide-react";
// hooks;
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueAssigneeActivity: FC<TIssueAssigneeActivity> = observer((props) => {
const { activityId, ends, showIssue = true } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Users className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
{activity.old_value === "" ? `added a new assignee ` : `removed the assignee `}
<a
href={`/${activity.workspace_detail?.slug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center font-medium text-custom-text-100 hover:underline capitalize"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
</a>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Paperclip } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueAttachmentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueAttachmentActivity: FC<TIssueAttachmentActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Paperclip size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? `uploaded a new attachment` : `removed an attachment`}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { CycleIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// icons
type TIssueCycleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueCycleActivity: FC<TIssueCycleActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CycleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this work item to the cycle </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
</a>
</>
) : activity.verb === "updated" ? (
<>
<span>set the cycle to </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
) : (
<>
<span>removed the work item from the cycle </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,50 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { WorkItemsIcon } from "@plane/propel/icons";
import { EInboxIssueSource } from "@plane/types";
// hooks
import { capitalizeFirstLetter } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueActivityBlockComponent } from "./";
type TIssueDefaultActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueDefaultActivity: FC<TIssueDefaultActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
const source = activity.source_data?.source;
return (
<IssueActivityBlockComponent
activityId={activityId}
icon={<WorkItemsIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />}
ends={ends}
>
<>
{activity.verb === "created" ? (
source && source !== EInboxIssueSource.IN_APP ? (
<span>
created the work item via{" "}
<span className="font-medium">{capitalizeFirstLetter(source.toLowerCase() || "")}</span>.
</span>
) : (
<span> created the work item.</span>
)
) : (
<span> deleted a work item.</span>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { AlignLeft } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueDescriptionActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueDescriptionActivity: FC<TIssueDescriptionActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<AlignLeft size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
updated the description
{showIssue ? ` of ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,36 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Triangle } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueEstimateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Triangle size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the estimate point to ` : `removed the estimate point`}
{activity.new_value ? activity.new_value : activity?.old_value}
{showIssue && (activity.new_value ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,61 @@
"use client";
import type { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { IssueCreatorDisplay } from "@/plane-web/components/issues/issue-details/issue-creator";
// local imports
import { IssueUser } from "../";
type TIssueActivityBlockComponent = {
icon?: ReactNode;
activityId: string;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
const { icon, activityId, ends, children, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
const { isMobile } = usePlatformOS();
if (!activity) return <></>;
return (
<div
className={`relative flex items-center gap-3 text-xs ${
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
}`}
>
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-center z-[4] bg-custom-background-80 text-custom-text-200">
{icon ? icon : <Network className="w-3.5 h-3.5" />}
</div>
<div className="w-full truncate text-custom-text-200">
{!activity?.field && activity?.verb === "created" ? (
<IssueCreatorDisplay activityId={activityId} customUserName={customUserName} />
) : (
<IssueUser activityId={activityId} customUserName={customUserName} />
)}
<span> {children} </span>
<span>
<Tooltip
isMobile={isMobile}
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap text-custom-text-350"> {calculateTimeAgo(activity.created_at)}</span>
</Tooltip>
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
"use client";
import type { FC } from "react";
// hooks
import { Tooltip } from "@plane/propel/tooltip";
import { generateWorkItemLink } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
type TIssueLink = {
activityId: string;
};
export const IssueLink: FC<TIssueLink> = (props) => {
const { activityId } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const { isMobile } = usePlatformOS();
const activity = getActivityById(activityId);
if (!activity) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug: activity.workspace_detail?.slug,
projectId: activity.project,
issueId: activity.issue,
projectIdentifier: activity.project_detail.identifier,
sequenceId: activity.issue_detail.sequence_id,
});
return (
<Tooltip
tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
isMobile={isMobile}
>
<a
aria-disabled={activity.issue === null}
href={`${activity.issue_detail ? workItemLink : "#"}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Work items"}{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
</a>
</Tooltip>
);
};

View File

@@ -0,0 +1,36 @@
import type { FC } from "react";
import Link from "next/link";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
type TIssueUser = {
activityId: string;
customUserName?: string;
};
export const IssueUser: FC<TIssueUser> = (props) => {
const { activityId, customUserName } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<>
{customUserName ? (
<span className="text-custom-text-100 font-medium">{customUserName}</span>
) : (
<Link
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="hover:underline text-custom-text-100 font-medium"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
);
};

View File

@@ -0,0 +1,46 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { IntakeIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// icons
type TIssueInboxActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueInboxActivity: FC<TIssueInboxActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
const getInboxActivityMessage = () => {
switch (activity?.verb) {
case "-1":
return "declined this work item from intake.";
case "0":
return "snoozed this work item.";
case "1":
return "accepted this work item from intake.";
case "2":
return "declined this work item from intake by marking a duplicate work item.";
default:
return "updated intake work item status.";
}
};
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<IntakeIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>{getInboxActivityMessage()}</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,24 @@
export * from "./default";
export * from "./name";
export * from "./description";
export * from "./state";
export * from "./assignee";
export * from "./priority";
export * from "./estimate";
export * from "./parent";
export * from "./relation";
export * from "./start_date";
export * from "./target_date";
export * from "./cycle";
export * from "./module";
export * from "./label";
export * from "./link";
export * from "./attachment";
export * from "./archived-at";
export * from "./inbox";
export * from "./label-activity-chip";
// helpers
export * from "./helpers/activity-block";
export * from "./helpers/issue-user";
export * from "./helpers/issue-link";

View File

@@ -0,0 +1,22 @@
import type { FC } from "react";
import { Tooltip } from "@plane/propel/tooltip";
type TIssueLabelPill = { name?: string; color?: string };
export const LabelActivityChip: FC<TIssueLabelPill> = (props) => {
const { name, color } = props;
return (
<Tooltip tooltipContent={name}>
<span className="inline-flex w-min max-w-32 cursor-default flex-shrink-0 items-center gap-2 truncate whitespace-nowrap rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: color ?? "#000000",
}}
aria-hidden="true"
/>
<span className="flex-shrink truncate font-medium text-custom-text-100">{name}</span>
</span>
</Tooltip>
);
};

View File

@@ -0,0 +1,42 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Tag } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useLabel } from "@/hooks/store/use-label";
// components
import { IssueActivityBlockComponent, IssueLink, LabelActivityChip } from "./";
type TIssueLabelActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const { getLabelById } = useLabel();
const activity = getActivityById(activityId);
const oldLabelColor = getLabelById(activity?.old_identifier ?? "")?.color;
const newLabelColor = getLabelById(activity?.new_identifier ?? "")?.color;
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Tag size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.old_value === "" ? `added a new label ` : `removed the label `}
<LabelActivityChip
name={activity.old_value === "" ? activity.new_value : activity.old_value}
color={activity.old_value === "" ? newLabelColor : oldLabelColor}
/>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,70 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueLinkActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueLinkActivity: FC<TIssueLinkActivity> = observer((props) => {
const { activityId, showIssue = false, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added </span>
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
</a>
</>
) : activity.verb === "updated" ? (
<>
<span>updated the </span>
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
</a>
</>
) : (
<>
<span>removed this </span>
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
</a>
</>
)}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,71 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { ModuleIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
// icons
type TIssueModuleActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueModuleActivity: FC<TIssueModuleActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ModuleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this work item to the module </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
</a>
</>
) : activity.verb === "updated" ? (
<>
<span>set the module to </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
</a>
</>
) : (
<>
<span>removed the work item from the module </span>
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/modules/${activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.old_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,30 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Type } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent } from "./";
type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueNameActivity: FC<TIssueNameActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Type size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>set the name to {activity.new_value}.</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { LayoutPanelTop } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssueParentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueParentActivity: FC<TIssueParentActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<LayoutPanelTop size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the parent to ` : `removed the parent `}
{activity.new_value ? (
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
) : (
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,34 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { Signal } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
type TIssuePriorityActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssuePriorityActivity: FC<TIssuePriorityActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Signal size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
set the priority to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,39 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { getRelationActivityContent, useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
//
import { IssueActivityBlockComponent } from "./";
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
export const IssueRelationActivity: FC<TIssueRelationActivity> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const activityContent = getRelationActivityContent(activity);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes]?.icon(14) : <></>}
activityId={activityId}
ends={ends}
>
{activityContent}
{activity.old_value === "" ? (
<span className="font-medium text-custom-text-100">{activity.new_value}.</span>
) : (
<span className="font-medium text-custom-text-100">{activity.old_value}.</span>
)}
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,41 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { CalendarDays } from "lucide-react";
// hooks
import { renderFormattedDate } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// helpers
type TIssueStartDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueStartDateActivity: FC<TIssueStartDateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the start date to ` : `removed the start date `}
{activity.new_value && (
<>
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,37 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { DoubleCircleIcon } from "@plane/propel/icons";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// icons
type TIssueStateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueStateActivity: FC<TIssueStateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>
<>
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue ? ` for ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,41 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { CalendarDays } from "lucide-react";
// hooks
import { renderFormattedDate } from "@plane/utils";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// helpers
type TIssueTargetDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
export const IssueTargetDateActivity: FC<TIssueTargetDateActivity> = observer((props) => {
const { activityId, showIssue = true, ends } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the due date to ` : `removed the due date `}
{activity.new_value && (
<>
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
</>
)}
{showIssue && (activity.new_value ? ` for ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View File

@@ -0,0 +1,95 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// helpers
import { getValidKeysFromObject } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web components
import { IssueTypeActivity, AdditionalActivityRoot } from "@/plane-web/components/issues/issue-details";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local components
import {
IssueDefaultActivity,
IssueNameActivity,
IssueDescriptionActivity,
IssueStateActivity,
IssueAssigneeActivity,
IssuePriorityActivity,
IssueEstimateActivity,
IssueParentActivity,
IssueRelationActivity,
IssueStartDateActivity,
IssueTargetDateActivity,
IssueCycleActivity,
IssueModuleActivity,
IssueLabelActivity,
IssueLinkActivity,
IssueAttachmentActivity,
IssueArchivedAtActivity,
IssueInboxActivity,
} from "./actions";
type TIssueActivityItem = {
activityId: string;
ends: "top" | "bottom" | undefined;
};
export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
comment: {},
} = useIssueDetail();
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const activityRelations = getValidKeysFromObject(ISSUE_RELATION_OPTIONS);
const componentDefaultProps = { activityId, ends };
const activityField = getActivityById(activityId)?.field;
switch (activityField) {
case null: // default issue creation
return <IssueDefaultActivity {...componentDefaultProps} />;
case "state":
return <IssueStateActivity {...componentDefaultProps} showIssue={false} />;
case "name":
return <IssueNameActivity {...componentDefaultProps} />;
case "description":
return <IssueDescriptionActivity {...componentDefaultProps} showIssue={false} />;
case "assignees":
return <IssueAssigneeActivity {...componentDefaultProps} showIssue={false} />;
case "priority":
return <IssuePriorityActivity {...componentDefaultProps} showIssue={false} />;
case "estimate_points":
case "estimate_categories":
case "estimate_point" /* This case is to handle all the older recorded activities for estimates. Field changed from "estimate_point" -> `estimate_${estimate_type}`*/:
return <IssueEstimateActivity {...componentDefaultProps} showIssue={false} />;
case "parent":
return <IssueParentActivity {...componentDefaultProps} showIssue={false} />;
case activityRelations.find((field) => field === activityField):
return <IssueRelationActivity {...componentDefaultProps} />;
case "start_date":
return <IssueStartDateActivity {...componentDefaultProps} showIssue={false} />;
case "target_date":
return <IssueTargetDateActivity {...componentDefaultProps} showIssue={false} />;
case "cycles":
return <IssueCycleActivity {...componentDefaultProps} />;
case "modules":
return <IssueModuleActivity {...componentDefaultProps} />;
case "labels":
return <IssueLabelActivity {...componentDefaultProps} showIssue={false} />;
case "link":
return <IssueLinkActivity {...componentDefaultProps} showIssue={false} />;
case "attachment":
return <IssueAttachmentActivity {...componentDefaultProps} showIssue={false} />;
case "archived_at":
return <IssueArchivedAtActivity {...componentDefaultProps} />;
case "intake":
case "inbox":
return <IssueInboxActivity {...componentDefaultProps} />;
case "type":
return <IssueTypeActivity {...componentDefaultProps} />;
default:
return <AdditionalActivityRoot {...componentDefaultProps} field={activityField} />;
}
});

View File

@@ -0,0 +1,195 @@
import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { TCommentsOperations } from "@plane/types";
import { copyUrlToClipboard, formatTextList, generateWorkItemLink } from "@plane/utils";
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
export const useCommentOperations = (
workspaceSlug: string | undefined,
projectId: string | undefined,
issueId: string | undefined
): TCommentsOperations => {
// store hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
createComment,
updateComment,
removeComment,
createCommentReaction,
removeCommentReaction,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { getUserDetails } = useMember();
const { uploadEditorAsset } = useEditorAsset();
const { data: currentUser } = useUser();
// derived values
const issueDetails = issueId ? getIssueById(issueId) : undefined;
const projectDetails = projectId ? getProjectById(projectId) : undefined;
// translation
const { t } = useTranslation();
const operations: TCommentsOperations = useMemo(() => {
// Define operations object with all methods
const ops: TCommentsOperations = {
copyCommentLink: (id) => {
if (!workspaceSlug || !issueDetails) return;
try {
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails.project_id,
issueId,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails.sequence_id,
});
const commentLink = `${workItemLink}#comment-${id}`;
copyUrlToClipboard(commentLink).then(() => {
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.copy_link.success"),
});
});
} catch (error) {
console.error("Error in copying comment link:", error);
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.copy_link.error"),
});
}
},
createComment: async (data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
const comment = await createComment(workspaceSlug, projectId, issueId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.create.success"),
});
return comment;
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.create.error"),
});
}
},
updateComment: async (commentId, data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.update.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.update.error"),
});
}
},
removeComment: async (commentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeComment(workspaceSlug, projectId, issueId, commentId);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.remove.success"),
});
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.remove.error"),
});
}
},
uploadCommentAsset: async (blockId, file, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await uploadEditorAsset({
blockId,
data: {
entity_identifier: commentId ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
throw new Error(t("issue.comments.upload.error"));
}
},
addCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction created successfully",
});
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction creation failed",
});
}
},
deleteCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction removed successfully",
});
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction remove failed",
});
}
},
react: async (commentId, reactionEmoji, userReactions) => {
if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji);
else await ops.addCommentReaction(commentId, reactionEmoji);
},
reactionIds: (commentId) => getCommentReactionsByCommentId(commentId),
userReactions: (commentId) =>
currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [],
getReactionUsers: (reaction, reactionIds) => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
},
};
return ops;
}, [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]);
return operations;
};

View File

@@ -0,0 +1,10 @@
export * from "./root";
export * from "./activity-comment-root";
// activity
export * from "./activity/activity-list";
export * from "./activity-filter";
// sort
export * from "./sort-root";

View File

@@ -0,0 +1,31 @@
// plane imports
import { Loader } from "@plane/ui";
export const IssueActivityLoader = () => (
<Loader className="space-y-8">
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="40%" />
<Loader.Item height="8px" width="60%" />
<Loader.Item height="10px" width="80%" />
</div>
</div>
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
</Loader>
);

View File

@@ -0,0 +1,150 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import uniq from "lodash-es/uniq";
import { observer } from "mobx-react";
// plane package imports
import type { TActivityFilters } from "@plane/constants";
import { E_SORT_ORDER, defaultActivityFilters, EUserPermissions } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// i18n
import { useTranslation } from "@plane/i18n";
//types
import type { TFileSignedURLResponse, TIssueComment } from "@plane/types";
// components
import { CommentCreate } from "@/components/comments/comment-create";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
// plane web components
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
import { IssueActivityCommentRoot } from "./activity-comment-root";
import { useCommentOperations } from "./helper";
import { ActivitySortRoot } from "./sort-root";
type TIssueActivity = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
isIntakeIssue?: boolean;
};
export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<TIssueComment>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
};
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
// i18n
const { t } = useTranslation();
// hooks
const { setValue: setFilterValue, storedValue: selectedFilters } = useLocalStorage(
"issue_activity_filters",
defaultActivityFilters
);
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC);
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
const currentUserProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isAdmin = currentUserProjectRole === EUserPermissions.ADMIN;
const isGuest = currentUserProjectRole === EUserPermissions.GUEST;
const isAssigned = issue?.assignee_ids && currentUser?.id ? issue?.assignee_ids.includes(currentUser?.id) : false;
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned);
// toggle filter
const toggleFilter = (filter: TActivityFilters) => {
if (!selectedFilters) return;
let _filters = [];
if (selectedFilters.includes(filter)) {
if (selectedFilters.length === 1) return selectedFilters; // Ensure at least one filter is applied
_filters = selectedFilters.filter((f) => f !== filter);
} else {
_filters = [...selectedFilters, filter];
}
setFilterValue(uniq(_filters));
};
const toggleSortOrder = () => {
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
};
// helper hooks
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
const project = getProjectById(projectId);
const renderCommentCreationBox = useMemo(
() => (
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={issueId}
activityOperations={activityOperations}
showToolbarInitially
projectId={projectId}
/>
),
[workspaceSlug, issueId, activityOperations, projectId]
);
if (!project) return <></>;
return (
<div className="space-y-4 pt-3">
{/* header */}
<div className="flex items-center justify-between">
<div className="text-lg text-custom-text-100">{t("common.activity")}</div>
<div className="flex items-center gap-2">
{isWorklogButtonEnabled && (
<IssueActivityWorklogCreateButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
<ActivitySortRoot sortOrder={sortOrder || E_SORT_ORDER.ASC} toggleSort={toggleSortOrder} />
<ActivityFilterRoot
selectedFilters={selectedFilters || defaultActivityFilters}
toggleFilter={toggleFilter}
isIntakeIssue={isIntakeIssue}
projectId={projectId}
/>
</div>
</div>
{/* rendering activity */}
<div className="space-y-3">
<div className="min-h-[200px]">
<div className="space-y-3">
{!disabled && sortOrder === E_SORT_ORDER.DESC && renderCommentCreationBox}
<IssueActivityCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
isIntakeIssue={isIntakeIssue}
issueId={issueId}
selectedFilters={selectedFilters || defaultActivityFilters}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
disabled={disabled}
sortOrder={sortOrder || E_SORT_ORDER.ASC}
/>
{!disabled && sortOrder === E_SORT_ORDER.ASC && renderCommentCreationBox}
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,36 @@
"use client";
import type { FC } from "react";
import { memo } from "react";
import { ArrowUpWideNarrow, ArrowDownWideNarrow } from "lucide-react";
// plane package imports
import type { E_SORT_ORDER } from "@plane/constants";
import { getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
export type TActivitySortRoot = {
sortOrder: E_SORT_ORDER;
toggleSort: () => void;
className?: string;
iconClassName?: string;
};
export const ActivitySortRoot: FC<TActivitySortRoot> = memo((props) => (
<div
className={cn(
getButtonStyling("neutral-primary", "sm"),
"px-2 text-custom-text-300 cursor-pointer",
props.className
)}
onClick={() => {
props.toggleSort();
}}
>
{props.sortOrder === "asc" ? (
<ArrowUpWideNarrow className={cn("size-4", props.iconClassName)} />
) : (
<ArrowDownWideNarrow className={cn("size-4", props.iconClassName)} />
)}
</div>
));
ActivitySortRoot.displayName = "ActivitySortRoot";

View File

@@ -0,0 +1,180 @@
"use client";
import type { FC } from "react";
import React, { useRef } from "react";
import { observer } from "mobx-react";
import { LinkIcon } from "lucide-react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
import { IssueSubscription } from "./subscription";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
};
export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId } = props;
const { t } = useTranslation();
// ref
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useAppRouter();
// hooks
const { data: currentUser } = useUser();
const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
removeIssue,
archiveIssue,
} = useIssueDetail();
const {
issues: { restoreIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
const {
issues: { removeIssue: removeArchivedIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
const projectIdentifier = getProjectIdentifierById(projectId);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug,
projectId,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
// handlers
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}${workItemLink}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.copied_to_clipboard"),
});
});
};
const handleDeleteIssue = async () => {
try {
const deleteIssue = issue?.archived_at ? removeArchivedIssue : removeIssue;
const redirectionPath = issue?.archived_at
? `/${workspaceSlug}/projects/${projectId}/archives/issues`
: `/${workspaceSlug}/projects/${projectId}/issues`;
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
router.push(redirectionPath);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
});
});
} catch (error) {
setToast({
title: t("toast.error "),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
error: error as Error,
});
}
};
const handleArchiveIssue = async () => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
error: error as Error,
});
}
};
const handleRestore = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("issue.restore.success.title"),
message: t("issue.restore.success.message"),
});
router.push(workItemLink);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("issue.restore.failed.message"),
});
});
};
return (
<>
<div className="flex items-center justify-end flex-shrink-0">
<div className="flex flex-wrap items-center gap-4">
{currentUser && !issue?.archived_at && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<div className="flex flex-wrap items-center gap-2.5 text-custom-text-300">
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<button
type="button"
className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary"
onClick={handleCopyText}
>
<LinkIcon className="h-4 w-4" />
</button>
</Tooltip>
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issue}
handleDelete={handleDeleteIssue}
handleArchive={handleArchiveIssue}
handleRestore={handleRestore}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,166 @@
"use client";
import type { FC } from "react";
import { useState, Fragment, useEffect } from "react";
import { TwitterPicker } from "react-color";
import { Controller, useForm } from "react-hook-form";
import { usePopper } from "react-popper";
import { Plus, X, Loader } from "lucide-react";
import { Popover } from "@headlessui/react";
import type { IIssueLabel } from "@plane/types";
// hooks
import { Input } from "@plane/ui";
// ui
// types
import type { TLabelOperations } from "./root";
type ILabelCreate = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled?: boolean;
};
const defaultValues: Partial<IIssueLabel> = {
name: "",
color: "#ff0000",
};
export const LabelCreate: FC<ILabelCreate> = (props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
// state
const [isCreateToggle, setIsCreateToggle] = useState(false);
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// react hook form
const {
handleSubmit,
formState: { errors, isSubmitting },
reset,
control,
setFocus,
} = useForm<Partial<IIssueLabel>>({
defaultValues,
});
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
useEffect(() => {
if (!isCreateToggle) return;
setFocus("name");
reset();
}, [isCreateToggle, reset, setFocus]);
const handleLabel = async (formData: Partial<IIssueLabel>) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
const currentLabels = [...(values || []), labelResponse.id];
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
handleIsCreateToggle();
reset(defaultValues);
};
return (
<>
<div
className="relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200"
onClick={handleIsCreateToggle}
>
<div className="flex-shrink-0">
{isCreateToggle ? <X className="h-2.5 w-2.5" /> : <Plus className="h-2.5 w-2.5" />}
</div>
<div className="flex-shrink-0">{isCreateToggle ? "Cancel" : "New"}</div>
</div>
{isCreateToggle && (
<form className="relative flex items-center gap-x-2 p-1" onSubmit={handleSubmit(handleLabel)}>
<div>
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<Popover>
<>
<Popover.Button as={Fragment}>
<button type="button" ref={setReferenceElement} className="grid place-items-center outline-none">
{value && value?.trim() !== "" && (
<span
className="h-5 w-5 rounded"
style={{
backgroundColor: value ?? "black",
}}
/>
)}
</button>
</Popover.Button>
<Popover.Panel className="fixed z-10">
<div
className="p-2 max-w-xs sm:px-0"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<TwitterPicker triangle={"hide"} color={value} onChange={(value) => onChange(value.hex)} />
</div>
</Popover.Panel>
</>
</Popover>
)}
/>
</div>
<Controller
control={control}
name="name"
rules={{
required: "This is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value ?? ""}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full text-xs px-1.5 py-1"
disabled={isSubmitting}
/>
)}
/>
<button
type="button"
className="grid place-items-center rounded bg-red-500 p-1"
onClick={() => setIsCreateToggle(false)}
disabled={disabled}
>
<X className="h-3.5 w-3.5 text-white" />
</button>
<button type="submit" className="grid place-items-center rounded bg-green-500 p-1" disabled={isSubmitting}>
{isSubmitting ? (
<Loader className="spin h-3.5 w-3.5 text-white" />
) : (
<Plus className="h-3.5 w-3.5 text-white" />
)}
</button>
</form>
)}
</>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./root";
export * from "./label-list";
export * from "./label-list-item";
export * from "./create-label";
export * from "./select/root";
export * from "./select/label-select";

View File

@@ -0,0 +1,55 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// types
import { useLabel } from "@/hooks/store/use-label";
import type { TLabelOperations } from "./root";
type TLabelListItem = {
workspaceSlug: string;
projectId: string;
issueId: string;
labelId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelListItem: FC<TLabelListItem> = observer((props) => {
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
// hooks
const { getLabelById } = useLabel();
const label = getLabelById(labelId);
const handleLabel = async () => {
if (values && !disabled) {
const currentLabels = values.filter((_labelId) => _labelId !== labelId);
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
}
};
if (!label) return <></>;
return (
<div
key={labelId}
className={`transition-all relative flex items-center gap-1 truncate border border-custom-border-100 rounded-full text-xs p-0.5 px-1 group ${
!disabled ? "cursor-pointer hover:border-red-500/50 hover:bg-red-500/20" : "cursor-not-allowed"
} `}
onClick={handleLabel}
>
<div
className="rounded-full h-2 w-2 flex-shrink-0"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<div className="truncate">{label.name}</div>
{!disabled && (
<div className="flex-shrink-0">
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,38 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// components
import { LabelListItem } from "./label-list-item";
// types
import type { TLabelOperations } from "./root";
type TLabelList = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelList: FC<TLabelList> = observer((props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
const issueLabels = values || undefined;
if (!issueId || !issueLabels) return <></>;
return (
<>
{issueLabels.map((labelId) => (
<LabelListItem
key={labelId}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
labelId={labelId}
values={issueLabels}
labelOperations={labelOperations}
disabled={disabled}
/>
))}
</>
);
});

View File

@@ -0,0 +1,120 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IIssueLabel, TIssue, TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// components
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
// ui
// types
import { LabelList, IssueLabelSelectRoot } from "./";
// TODO: Fix this import statement, as core should not import from ee
// eslint-disable-next-line import/order
export type TIssueLabel = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
isInboxIssue?: boolean;
onLabelUpdate?: (labelIds: string[]) => void;
issueServiceType?: TIssueServiceType;
};
export type TLabelOperations = {
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
createLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
};
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
disabled = false,
isInboxIssue = false,
onLabelUpdate,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
const { t } = useTranslation();
// hooks
const { updateIssue } = useIssueDetail(issueServiceType);
const { createLabel } = useLabel();
const {
issue: { getIssueById },
} = useIssueDetail(issueServiceType);
const { getIssueInboxByIssueId } = useProjectInbox();
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
const labelOperations: TLabelOperations = useMemo(
() => ({
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
if (onLabelUpdate) onLabelUpdate(data.label_ids || []);
else await updateIssue(workspaceSlug, projectId, issueId, data);
} catch (error) {
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.update.failed", { entity: t("issue.label", { count: 1 }) }),
});
}
},
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
try {
const labelResponse = await createLabel(workspaceSlug, projectId, data);
if (!isInboxIssue)
setToast({
title: t("toast.success"),
type: TOAST_TYPE.SUCCESS,
message: t("label.create.success"),
});
return labelResponse;
} catch (error) {
let errMessage = t("label.create.failed");
if (error && (error as any).error === "Label with the same name already exists in the project")
errMessage = t("label.create.already_exists");
setToast({
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: errMessage,
});
throw error;
}
},
}),
[updateIssue, createLabel, onLabelUpdate]
);
return (
<div className="relative flex flex-wrap items-center gap-1">
<LabelList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
disabled={disabled}
/>
{!disabled && (
<IssueLabelSelectRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,219 @@
import { Fragment, useState } from "react";
import { observer } from "mobx-react";
import { usePopper } from "react-popper";
import { Check, Loader, Search, Tag } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane imports
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IIssueLabel } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// helpers
import { getTabIndex } from "@plane/utils";
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useUserPermissions } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
//constants
export interface IIssueLabelSelect {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
onSelect: (_labelIds: string[]) => void;
onAddLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
}
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel } = props;
const { t } = useTranslation();
// store hooks
const { isMobile } = usePlatformOS();
const { fetchProjectLabels, getProjectLabels } = useLabel();
const { allowPermissions } = useUserPermissions();
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [query, setQuery] = useState("");
const [submitting, setSubmitting] = useState<boolean>(false);
const canCreateLabel =
projectId && allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
const projectLabels = getProjectLabels(projectId);
const { baseTabIndex } = getTabIndex(undefined, isMobile);
const fetchLabels = () => {
setIsLoading(true);
if (!projectLabels && workspaceSlug && projectId)
fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false));
};
const options = (projectLabels ?? []).map((label) => ({
value: label.id,
query: label.name,
content: (
<div className="flex items-center justify-start gap-2 overflow-hidden">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
}}
/>
<div className="line-clamp-1 inline-block truncate">{label.name}</div>
</div>
),
}));
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-end",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const issueLabels = values ?? [];
const label = (
<div
className={`relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-full border border-custom-border-100 p-0.5 px-2 py-0.5 text-xs text-custom-text-300 transition-all hover:bg-custom-background-90 hover:text-custom-text-200`}
>
<div className="flex-shrink-0">
<Tag className="h-2.5 w-2.5" />
</div>
<div className="flex-shrink-0">{t("label.select")}</div>
</div>
);
const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
if (query !== "" && e.key === "Enter" && !e.nativeEvent.isComposing && canCreateLabel) {
e.stopPropagation();
e.preventDefault();
await handleAddLabel(query);
}
};
const handleAddLabel = async (labelName: string) => {
setSubmitting(true);
const label = await onAddLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() });
onSelect([...values, label.id]);
setQuery("");
setSubmitting(false);
};
if (!issueId || !values) return <></>;
return (
<>
<Combobox
as="div"
className={`w-auto max-w-full flex-shrink-0 text-left`}
value={issueLabels}
onChange={(value) => onSelect(value)}
multiple
>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className="cursor-pointer rounded"
onClick={() => !projectLabels && fetchLabels()}
>
{label}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="px-2">
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
tabIndex={baseTabIndex}
/>
</div>
</div>
<div className={`vertical-scrollbar scrollbar-sm mt-2 max-h-48 space-y-1 overflow-y-scroll px-2 pr-0`}>
{isLoading ? (
<p className="text-center text-custom-text-200">{t("common.loading")}</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
selected ? "text-custom-text-100" : "text-custom-text-200"
}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && (
<div className="flex-shrink-0">
<Check className={`h-3.5 w-3.5`} />
</div>
)}
</>
)}
</Combobox.Option>
))
) : submitting ? (
<Loader className="spin h-3.5 w-3.5" />
) : canCreateLabel ? (
<Combobox.Option
value={query}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!query.length) return;
handleAddLabel(query);
}}
className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`}
>
{query.length ? (
<>
{/* TODO: Translate here */}+ Add{" "}
<span className="text-custom-text-100">&quot;{query}&quot;</span> to labels
</>
) : (
t("label.create.type")
)}
</Combobox.Option>
) : (
<p className="text-left text-custom-text-200 ">{t("common.search.no_matching_results")}</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
</>
);
});

View File

@@ -0,0 +1,32 @@
import type { FC } from "react";
// components
import type { TLabelOperations } from "../root";
import { IssueLabelSelect } from "./label-select";
// types
type TIssueLabelSelectRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
};
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
const handleLabel = async (_labelIds: string[]) => {
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
};
return (
<IssueLabelSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={values}
onSelect={handleLabel}
onAddLabel={labelOperations.createLabel}
/>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
// plane types
import { Button } from "@plane/propel/button";
import type { TIssueLinkEditableFields, TIssueServiceType } from "@plane/types";
// plane ui
import { Input, ModalCore } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TLinkOperations } from "./root";
export type TLinkOperationsModal = Exclude<TLinkOperations, "remove">;
export type TIssueLinkCreateFormFieldOptions = TIssueLinkEditableFields & {
id?: string;
};
export type TIssueLinkCreateEditModal = {
isModalOpen: boolean;
handleOnClose?: () => void;
linkOperations: TLinkOperationsModal;
issueServiceType: TIssueServiceType;
};
const defaultValues: TIssueLinkCreateFormFieldOptions = {
title: "",
url: "",
};
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
const { isModalOpen, handleOnClose, linkOperations, issueServiceType } = props;
// i18n
const { t } = useTranslation();
// react hook form
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<TIssueLinkCreateFormFieldOptions>({
defaultValues,
});
// store hooks
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail(issueServiceType);
const onClose = () => {
setIssueLinkData(null);
if (handleOnClose) handleOnClose();
};
const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => {
const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`;
try {
if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl });
else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl });
onClose();
} catch (error) {
console.error("error", error);
}
};
useEffect(() => {
if (isModalOpen) reset({ ...defaultValues, ...preloadedData });
}, [preloadedData, reset, isModalOpen]);
return (
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5 p-5">
<h3 className="text-xl font-medium text-custom-text-200">
{preloadedData?.id ? t("common.update_link") : t("common.add_link")}
</h3>
<div className="mt-2 space-y-3">
<div>
<label htmlFor="url" className="mb-2 text-custom-text-200">
{t("common.url")}
</label>
<Controller
control={control}
name="url"
rules={{
required: "URL is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="url"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
placeholder={t("common.type_or_paste_a_url")}
className="w-full"
/>
)}
/>
{errors.url && <span className="text-xs text-red-500">{t("common.url_is_invalid")}</span>}
</div>
<div>
<label htmlFor="title" className="mb-2 text-custom-text-200">
{t("common.display_title")}
<span className="text-[10px] block">{t("common.optional")}</span>
</label>
<Controller
control={control}
name="title"
render={({ field: { value, onChange, ref } }) => (
<Input
id="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.title)}
placeholder={t("common.link_title_placeholder")}
className="w-full"
/>
)}
/>
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{`${
preloadedData?.id
? isSubmitting
? t("common.updating")
: t("common.update")
: isSubmitting
? t("common.adding")
: t("common.add")
} ${t("common.link")}`}
</Button>
</div>
</form>
</ModalCore>
);
});

View File

@@ -0,0 +1,6 @@
export * from "./root";
export * from "./links";
export * from "./link-detail";
export * from "./link-item";
export * from "./link-list";

View File

@@ -0,0 +1,126 @@
"use client";
import type { FC } from "react";
// hooks
// ui
import { Pencil, Trash2, ExternalLink } from "lucide-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { getIconForLink, copyTextToClipboard, calculateTimeAgo } from "@plane/utils";
// icons
// types
// helpers
//
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
import type { TLinkOperationsModal } from "./create-update-link-modal";
export type TIssueLinkDetail = {
linkId: string;
linkOperations: TLinkOperationsModal;
isNotAllowed: boolean;
};
export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
// props
const { linkId, linkOperations, isNotAllowed } = props;
// hooks
const {
toggleIssueLinkModal: toggleIssueLinkModalStore,
link: { getLinkById },
setIssueLinkData,
} = useIssueDetail();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
const Icon = getIconForLink(linkDetail.url);
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail);
};
const createdByDetails = getUserDetails(linkDetail.created_by_id);
return (
<div key={linkId}>
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div
className="flex w-full cursor-pointer items-start justify-between gap-2"
onClick={() => {
copyTextToClipboard(linkDetail.url);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied!",
message: "Link copied to clipboard",
});
}}
>
<div className="flex items-start gap-2 truncate">
<span className="py-1">
<Icon className="size-3 stroke-2 text-custom-text-350 group-hover:text-custom-text-100 flex-shrink-0" />
</span>
<Tooltip
tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
isMobile={isMobile}
>
<span className="truncate text-xs">
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
</span>
</Tooltip>
</div>
{!isNotAllowed && (
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</button>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
linkOperations.remove(linkDetail.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(linkDetail.created_at)}
<br />
{createdByDetails && (
<>
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
</>
)}
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,121 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Pencil, Trash2, Copy, Link } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import { calculateTimeAgo, copyTextToClipboard } from "@plane/utils";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePlatformOS } from "@/hooks/use-platform-os";
import type { TLinkOperationsModal } from "./create-update-link-modal";
type TIssueLinkItem = {
linkId: string;
linkOperations: TLinkOperationsModal;
isNotAllowed: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
// props
const { linkId, linkOperations, isNotAllowed, issueServiceType = EIssueServiceType.ISSUES } = props;
// hooks
const { t } = useTranslation();
const {
toggleIssueLinkModal: toggleIssueLinkModalStore,
setIssueLinkData,
link: { getLinkById },
} = useIssueDetail(issueServiceType);
const { isMobile } = usePlatformOS();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
// const Icon = getIconForLink(linkDetail.url);
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
const linkTitle: string | undefined = linkDetail.metadata?.title;
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail);
};
return (
<>
<div
key={linkId}
className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
>
<div className="flex items-center gap-2.5 truncate flex-grow">
{faviconUrl ? (
<img src={faviconUrl} alt="favicon" className="size-4" />
) : (
<Link className="size-4 text-custom-text-350 group-hover:text-custom-text-100" />
)}
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm cursor-pointer flex-grow flex items-center gap-3"
>
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
{linkTitle && linkTitle !== "" && <span className="text-custom-text-400 text-xs">{linkTitle}</span>}
</a>
</Tooltip>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200">
{calculateTimeAgo(linkDetail.created_at)}
</p>
<span
onClick={() => {
copyTextToClipboard(linkDetail.url);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.link_copied_to_clipboard"),
});
}}
className="relative grid place-items-center rounded p-1 text-custom-text-400 outline-none group-hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
>
<Copy className="h-3.5 w-3.5 stroke-[1.5]" />
</span>
<CustomMenu
ellipsis
buttonClassName="text-custom-text-400 group-hover:text-custom-text-200"
placement="bottom-end"
closeOnSelect
disabled={isNotAllowed}
>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
toggleIssueLinkModal(true);
}}
>
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
{t("common.actions.edit")}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center gap-2"
onClick={() => {
linkOperations.remove(linkDetail.id);
}}
>
<Trash2 className="h-3 w-3" />
{t("common.actions.delete")}
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,45 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import type { TIssueServiceType } from "@plane/types";
// computed
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinkItem } from "./link-item";
import type { TLinkOperations } from "./root";
type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
type TLinkList = {
issueId: string;
linkOperations: TLinkOperationsModal;
disabled?: boolean;
issueServiceType: TIssueServiceType;
};
export const LinkList: FC<TLinkList> = observer((props) => {
// props
const { issueId, linkOperations, disabled = false, issueServiceType } = props;
// hooks
const {
link: { getLinksByIssueId },
} = useIssueDetail(issueServiceType);
const issueLinks = getLinksByIssueId(issueId);
if (!issueLinks) return null;
return (
<div className="flex flex-col gap-2 py-4">
{issueLinks.map((linkId) => (
<IssueLinkItem
key={linkId}
linkId={linkId}
linkOperations={linkOperations}
isNotAllowed={disabled}
issueServiceType={issueServiceType}
/>
))}
</div>
);
});

View File

@@ -0,0 +1,38 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// computed
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { IssueLinkDetail } from "./link-detail";
// hooks
import type { TLinkOperations } from "./root";
export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
export type TIssueLinkList = {
issueId: string;
linkOperations: TLinkOperationsModal;
disabled?: boolean;
};
export const IssueLinkList: FC<TIssueLinkList> = observer((props) => {
// props
const { issueId, linkOperations, disabled = false } = props;
// hooks
const {
link: { getLinksByIssueId },
} = useIssueDetail();
const issueLinks = getLinksByIssueId(issueId);
if (!issueLinks) return <></>;
return (
<div className="space-y-2">
{issueLinks &&
issueLinks.length > 0 &&
issueLinks.map((linkId) => (
<IssueLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
))}
</div>
);
});

View File

@@ -0,0 +1,142 @@
"use client";
import type { FC } from "react";
import { useCallback, useMemo, useState } from "react";
import { Plus } from "lucide-react";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueLink } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { IssueLinkCreateUpdateModal } from "./create-update-link-modal";
import { IssueLinkList } from "./links";
export type TLinkOperations = {
create: (data: Partial<TIssueLink>) => Promise<void>;
update: (linkId: string, data: Partial<TIssueLink>) => Promise<void>;
remove: (linkId: string) => Promise<void>;
};
export type TIssueLinkRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
// props
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// hooks
const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail();
// state
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
const toggleIssueLinkModal = useCallback(
(modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModal(modalToggle);
},
[toggleIssueLinkModalStore]
);
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: "The link has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Link created",
});
toggleIssueLinkModal(false);
} catch (error: any) {
setToast({
message: error?.data?.error ?? "The link could not be created",
type: TOAST_TYPE.ERROR,
title: "Link not created",
});
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: "The link has been successfully updated",
type: TOAST_TYPE.SUCCESS,
title: "Link updated",
});
toggleIssueLinkModal(false);
} catch (error) {
setToast({
message: "The link could not be updated",
type: TOAST_TYPE.ERROR,
title: "Link not updated",
});
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: "The link has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Link removed",
});
toggleIssueLinkModal(false);
} catch {
setToast({
message: "The link could not be removed",
type: TOAST_TYPE.ERROR,
title: "Link not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, toggleIssueLinkModal]
);
const handleOnClose = () => {
toggleIssueLinkModal(false);
};
return (
<>
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModal}
handleOnClose={handleOnClose}
linkOperations={handleLinkOperations}
issueServiceType={EIssueServiceType.ISSUES}
/>
<div className="py-1 text-xs">
<div className="flex items-center justify-between gap-2">
<h4>Links</h4>
{!disabled && (
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
onClick={() => toggleIssueLinkModal(true)}
disabled={disabled}
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
<div>
<IssueLinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,199 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
import useSize from "@/hooks/use-window-size";
// plane web components
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// services
import { WorkItemVersionService } from "@/services/issue";
// local imports
import { IssueDescriptionInput } from "../description-input";
import { IssueDetailWidgets } from "../issue-detail-widgets";
import { NameDescriptionUpdateStatus } from "../issue-update-status";
import { PeekOverviewProperties } from "../peek-overview/properties";
import { IssueTitleInput } from "../title-input";
import { IssueActivity } from "./issue-activity";
import { IssueParentDetail } from "./parent";
import { IssueReaction } from "./reactions";
import type { TIssueOperations } from "./root";
// services init
const workItemVersionService = new WorkItemVersionService();
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
isEditable: boolean;
isArchived: boolean;
};
export const IssueMainContent: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, isEditable, isArchived } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// states
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
// hooks
const windowSize = useSize();
const { data: currentUser } = useUser();
const { getUserDetails } = useMember();
const {
issue: { getIssueById },
peekIssue,
} = useIssueDetail();
const { getProjectById } = useProject();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
// derived values
const projectDetails = getProjectById(projectId);
const issue = issueId ? getIssueById(issueId) : undefined;
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
workspaceSlug,
projectDetails?.workspace.toString(),
projectDetails?.id,
{
name: issue?.name,
description_html: getTextContent(issue?.description_html),
issueId: issue?.id,
}
);
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => setIsSubmitting("saved"), 2000);
} else if (isSubmitting === "submitting") setShowAlert(true);
}, [isSubmitting, setShowAlert, setIsSubmitting]);
if (!issue || !issue.project_id) return <></>;
const isPeekModeActive = Boolean(peekIssue);
return (
<>
<div className="rounded-lg space-y-4">
{issue.parent_id && (
<IssueParentDetail
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issue={issue}
issueOperations={issueOperations}
/>
)}
<div className="mb-2.5 flex items-center justify-between gap-4">
<IssueTypeSwitcher issueId={issueId} disabled={isArchived || !isEditable} />
<div className="flex items-center gap-3">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
{duplicateIssues?.length > 0 && (
<DeDupeIssuePopoverRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
rootIssueId={issueId}
issues={duplicateIssues}
issueOperations={issueOperations}
renderDeDupeActionModals={!isPeekModeActive}
/>
)}
</div>
</div>
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={isArchived || !isEditable}
value={issue.name}
containerClassName="-ml-3"
/>
<IssueDescriptionInput
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issue.description_html}
disabled={isArchived || !isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
<div className="flex items-center justify-between gap-2">
{currentUser && (
<IssueReaction
className="flex-shrink-0"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
disabled={isArchived}
/>
)}
{isEditable && (
<DescriptionVersionsRoot
className="flex-shrink-0"
entityInformation={{
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
createdByDisplayName: getUserDetails(issue.created_by ?? "")?.display_name ?? "",
id: issueId,
isRestoreDisabled: !isEditable || isArchived,
}}
fetchHandlers={{
listDescriptionVersions: (issueId) =>
workItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
retrieveDescriptionVersion: (issueId, versionId) =>
workItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
}}
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
<IssueDetailWidgets
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable || isArchived}
renderWidgetModals={!isPeekModeActive}
issueServiceType={EIssueServiceType.ISSUES}
/>
{windowSize[0] < 768 && (
<PeekOverviewProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable || isArchived}
/>
)}
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={isArchived} />
</>
);
});

View File

@@ -0,0 +1,78 @@
import React, { useState } from "react";
import { xor } from "lodash-es";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// hooks
// components
import { cn } from "@plane/utils";
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
// ui
// helpers
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TIssueOperations } from "./root";
type TIssueModuleSelect = {
className?: string;
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
disabled?: boolean;
};
export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props) => {
const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
const { t } = useTranslation();
// states
const [isUpdating, setIsUpdating] = useState(false);
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const disableSelect = disabled || isUpdating;
const handleIssueModuleChange = async (moduleIds: string[]) => {
if (!issue || !issue.module_ids) return;
setIsUpdating(true);
const updatedModuleIds = xor(issue.module_ids, moduleIds);
const modulesToAdd: string[] = [];
const modulesToRemove: string[] = [];
for (const moduleId of updatedModuleIds) {
if (issue.module_ids.includes(moduleId)) {
modulesToRemove.push(moduleId);
} else {
modulesToAdd.push(moduleId);
}
}
await issueOperations.changeModulesInIssue?.(workspaceSlug, projectId, issueId, modulesToAdd, modulesToRemove);
setIsUpdating(false);
};
return (
<div className={cn(`flex h-full items-center gap-1`, className)}>
<ModuleDropdown
projectId={projectId}
value={issue?.module_ids ?? []}
onChange={handleIssueModuleChange}
placeholder={t("module.no_module")}
disabled={disableSelect}
className="group h-full w-full"
buttonContainerClassName="w-full rounded"
buttonClassName={`min-h-8 text-sm justify-between ${issue?.module_ids?.length ? "" : "text-custom-text-400"}`}
buttonVariant="transparent-with-text"
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
multiple
itemClassName="px-2"
/>
</div>
);
});

View File

@@ -0,0 +1,132 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Pencil, X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local imports
import { ParentIssuesListModal } from "../parent-issues-list-modal";
type TIssueParentSelect = {
className?: string;
disabled?: boolean;
issueId: string;
projectId: string;
workspaceSlug: string;
handleParentIssue: (_issueId?: string | null) => Promise<void>;
handleRemoveSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string
) => Promise<void>;
workItemLink: string;
};
export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props) => {
const {
className = "",
disabled = false,
issueId,
projectId,
workspaceSlug,
handleParentIssue,
handleRemoveSubIssue,
workItemLink,
} = props;
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined;
const parentIssueProjectDetails =
parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined;
const { isMobile } = usePlatformOS();
if (!issue) return <></>;
return (
<>
<ParentIssuesListModal
projectId={projectId}
issueId={issueId}
isOpen={isParentIssueModalOpen === issueId}
handleClose={() => toggleParentIssueModal(null)}
onChange={(issue: any) => handleParentIssue(issue?.id)}
/>
<button
type="button"
className={cn(
"group flex items-center justify-between gap-2 px-2 py-0.5 rounded outline-none",
{
"cursor-not-allowed": disabled,
"hover:bg-custom-background-80": !disabled,
"bg-custom-background-80": isParentIssueModalOpen,
},
className
)}
onClick={() => toggleParentIssueModal(issue.id)}
disabled={disabled}
>
{issue.parent_id && parentIssue ? (
<div className="flex items-center gap-1 bg-green-500/20 rounded px-1.5 py-1">
<Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}>
<Link href={workItemLink} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
{parentIssue?.project_id && parentIssueProjectDetails && (
<IssueIdentifier
projectId={parentIssue.project_id}
issueTypeId={parentIssue.type_id}
projectIdentifier={parentIssueProjectDetails?.identifier}
issueSequenceId={parentIssue.sequence_id}
textContainerClassName="text-xs font-medium text-green-700"
/>
)}
</Link>
</Tooltip>
{!disabled && (
<Tooltip tooltipContent={t("common.remove")} position="bottom" isMobile={isMobile}>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveSubIssue(workspaceSlug, projectId, parentIssue.id, issueId);
}}
>
<X className="h-2.5 w-2.5 text-custom-text-300 hover:text-red-500" />
</span>
</Tooltip>
)}
</div>
) : (
<span className="text-sm text-custom-text-400">{t("issue.add.parent")}</span>
)}
{!disabled && (
<span
className={cn("p-1 flex-shrink-0 opacity-0 group-hover:opacity-100", {
"text-custom-text-400": !issue.parent_id && !parentIssue,
})}
>
<Pencil className="h-2.5 w-2.5 flex-shrink-0" />
</span>
)}
</button>
</>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./root";
export * from "./siblings";
export * from "./sibling-item";

View File

@@ -0,0 +1,109 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { MinusCircle } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import type { TIssue } from "@plane/types";
// component
// ui
import { ControlLink, CustomMenu } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
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";
// types
import type { TIssueOperations } from "../root";
import { IssueParentSiblings } from "./siblings";
export type TIssueParentDetail = {
workspaceSlug: string;
projectId: string;
issueId: string;
issue: TIssue;
issueOperations: TIssueOperations;
};
export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
// router
const router = useRouter();
const { t } = useTranslation();
// hooks
const { issueMap } = useIssues();
const { getProjectStates } = useProjectState();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject();
// derived values
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
const isParentEpic = parentIssue?.is_epic;
const projectIdentifier = getProjectIdentifierById(parentIssue?.project_id);
const issueParentState = getProjectStates(parentIssue?.project_id)?.find(
(state) => state?.id === parentIssue?.state_id
);
const stateColor = issueParentState?.color || undefined;
if (!parentIssue) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: parentIssue?.project_id,
issueId: parentIssue.id,
projectIdentifier,
sequenceId: parentIssue.sequence_id,
isEpic: isParentEpic,
});
const handleParentIssueClick = () => {
if (isParentEpic) router.push(workItemLink);
else handleRedirection(workspaceSlug, parentIssue, isMobile);
};
return (
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<ControlLink href={workItemLink} onClick={handleParentIssueClick}>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
{parentIssue.project_id && (
<IssueIdentifier
projectId={parentIssue.project_id}
issueId={parentIssue.id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<span className="truncate text-custom-text-100">{(parentIssue?.name ?? "").substring(0, 50)}</span>
</div>
</ControlLink>
<CustomMenu ellipsis optionsClassName="p-1.5">
<div className="border-b border-custom-border-300 text-xs font-medium text-custom-text-200">
{t("issue.sibling.label")}
</div>
<IssueParentSiblings workspaceSlug={workspaceSlug} currentIssue={issue} parentIssue={parentIssue} />
<CustomMenu.MenuItem
onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })}
className="flex items-center gap-2 py-2 text-red-500"
>
<MinusCircle className="h-4 w-4" />
<span>{t("issue.remove.parent.label")}</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</>
);
});

View File

@@ -0,0 +1,62 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
type TIssueParentSiblingItem = {
workspaceSlug: string;
issueId: string;
};
export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = observer((props) => {
const { workspaceSlug, issueId } = props;
// hooks
const { getProjectById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issueDetail = (issueId && getIssueById(issueId)) || undefined;
if (!issueDetail) return <></>;
const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetail?.project_id,
issueId: issueDetail?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetail?.sequence_id,
});
return (
<>
<CustomMenu.MenuItem
key={issueDetail.id}
onClick={() => window.open(workItemLink, "_blank", "noopener,noreferrer")}
>
<div className="flex items-center gap-2 py-0.5">
{issueDetail.project_id && projectDetails?.identifier && (
<IssueIdentifier
projectId={issueDetail.project_id}
issueTypeId={issueDetail.type_id}
projectIdentifier={projectDetails?.identifier}
issueSequenceId={issueDetail.sequence_id}
textContainerClassName="text-xs"
/>
)}
</div>
</CustomMenu.MenuItem>
</>
);
});

View File

@@ -0,0 +1,56 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import type { TIssue } from "@plane/types";
// components
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import { IssueParentSiblingItem } from "./sibling-item";
export type TIssueParentSiblings = {
workspaceSlug: string;
currentIssue: TIssue;
parentIssue: TIssue;
};
export const IssueParentSiblings: FC<TIssueParentSiblings> = observer((props) => {
const { workspaceSlug, currentIssue, parentIssue } = props;
// hooks
const {
fetchSubIssues,
subIssues: { subIssuesByIssueId },
} = useIssueDetail();
const { isLoading } = useSWR(
parentIssue && parentIssue.project_id
? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
: null,
parentIssue && parentIssue.project_id
? () => fetchSubIssues(workspaceSlug, parentIssue.project_id!, parentIssue.id)
: null
);
const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined;
return (
<div className="my-1">
{isLoading ? (
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
Loading
</div>
) : subIssueIds && subIssueIds.length > 0 ? (
subIssueIds.map(
(issueId) =>
currentIssue.id != issueId && (
<IssueParentSiblingItem key={issueId} workspaceSlug={workspaceSlug} issueId={issueId} />
)
)
) : (
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
No sibling work items
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./reaction-selector";
export * from "./issue";
// export * from "./issue-comment";

View File

@@ -0,0 +1,138 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUser } from "@plane/types";
// components
import { cn, formatTextList } from "@plane/utils";
// helper
import { renderEmoji } from "@/helpers/emoji.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
import { ReactionSelector } from "./reaction-selector";
export type TIssueCommentReaction = {
workspaceSlug: string;
projectId: string;
commentId: string;
currentUser: IUser;
disabled?: boolean;
};
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
// hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
createCommentReaction,
removeCommentReaction,
} = useIssueDetail();
const { getUserDetails } = useMember();
const reactionIds = getCommentReactionsByCommentId(commentId);
const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction);
const issueCommentReactionOperations = useMemo(
() => ({
create: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction created successfully",
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction creation failed",
});
}
},
remove: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction removed successfully",
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction remove failed",
});
}
},
react: async (reaction: string) => {
if (userReactions.includes(reaction)) await issueCommentReactionOperations.remove(reaction);
else await issueCommentReactionOperations.create(reaction);
},
}),
[workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions]
);
const getReactionUsers = (reaction: string): string => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
return reactionDetails
? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name
: null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
};
return (
<div className="relative mt-4 flex items-center gap-1.5">
{!disabled && (
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={issueCommentReactionOperations.react}
/>
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={getReactionUsers(reaction)}>
<button
type="button"
onClick={() => !disabled && issueCommentReactionOperations.react(reaction)}
key={reaction}
className={cn(
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
</div>
);
});

View File

@@ -0,0 +1,133 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUser } from "@plane/types";
// hooks
// ui
import { cn, formatTextList } from "@plane/utils";
// helpers
import { renderEmoji } from "@/helpers/emoji.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
import { ReactionSelector } from "./reaction-selector";
export type TIssueReaction = {
workspaceSlug: string;
projectId: string;
issueId: string;
currentUser: IUser;
disabled?: boolean;
className?: string;
};
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
// hooks
const {
reaction: { getReactionsByIssueId, reactionsByUser, getReactionById },
createReaction,
removeReaction,
} = useIssueDetail();
const { getUserDetails } = useMember();
const reactionIds = getReactionsByIssueId(issueId);
const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction);
const issueReactionOperations = useMemo(
() => ({
create: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await createReaction(workspaceSlug, projectId, issueId, reaction);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction created successfully",
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction creation failed",
});
}
},
remove: async (reaction: string) => {
try {
if (!workspaceSlug || !projectId || !issueId || !currentUser?.id) throw new Error("Missing fields");
await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Reaction removed successfully",
});
} catch (error) {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Reaction remove failed",
});
}
},
react: async (reaction: string) => {
if (userReactions.includes(reaction)) await issueReactionOperations.remove(reaction);
else await issueReactionOperations.create(reaction);
},
}),
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions]
);
const getReactionUsers = (reaction: string): string => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getReactionById(reactionId);
return reactionDetails
? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name
: null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
};
return (
<div className={cn("relative mt-4 flex items-center gap-1.5", className)}>
{!disabled && (
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={getReactionUsers(reaction)}>
<button
type="button"
onClick={() => !disabled && issueReactionOperations.react(reaction)}
key={reaction}
className={cn(
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
</div>
);
});

View File

@@ -0,0 +1,74 @@
import { Fragment } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "@/helpers/emoji.helper";
// icons
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
value?: string | string[] | null;
onSelect: (emoji: string) => void;
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex items-center justify-center rounded-md px-2 ${
size === "sm" ? "h-6 w-6" : size === "md" ? "h-7 w-7" : "h-8 w-8"
}`}
>
<SmilePlus className="h-3.5 w-3.5 text-custom-text-100" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`absolute z-10 bg-custom-sidebar-background-100 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="rounded-md border border-custom-border-200 bg-custom-sidebar-background-100 p-1">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex select-none items-center justify-between rounded-md p-1 text-sm hover:bg-custom-sidebar-background-90"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View File

@@ -0,0 +1,167 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Pencil, X } from "lucide-react";
// Plane
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { ISearchIssueResponse } from "@plane/types";
import { cn, generateWorkItemLink } from "@plane/utils";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { usePlatformOS } from "@/hooks/use-platform-os";
// Plane web imports
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import type { TIssueRelationTypes } from "@/plane-web/types";
import type { TRelationObject } from "../issue-detail-widgets/relations";
type TIssueRelationSelect = {
className?: string;
workspaceSlug: string;
projectId: string;
issueId: string;
relationKey: TIssueRelationTypes;
disabled?: boolean;
};
export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((props) => {
const { className = "", workspaceSlug, projectId, issueId, relationKey, disabled = false } = props;
// hooks
const { getProjectById } = useProject();
const {
createRelation,
removeRelation,
relation: { getRelationByIssueIdRelationType },
isRelationModalOpen,
toggleRelationModal,
} = useIssueDetail();
const { issueMap } = useIssues();
const { isMobile } = usePlatformOS();
const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey);
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
const onSubmit = async (data: ISearchIssueResponse[]) => {
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);
};
if (!relationIssueIds) return null;
const isRelationKeyModalActive =
isRelationModalOpen?.relationType === relationKey && isRelationModalOpen?.issueId === issueId;
const currRelationOption: TRelationObject | undefined = ISSUE_RELATION_OPTIONS[relationKey];
return (
<>
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isRelationKeyModalActive}
handleClose={() => toggleRelationModal(null, null)}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
<button
type="button"
className={cn(
"group flex items-center gap-2 rounded px-2 py-0.5 outline-none",
{
"cursor-not-allowed": disabled,
"hover:bg-custom-background-80": !disabled,
"bg-custom-background-80": isRelationKeyModalActive,
},
className
)}
onClick={() => toggleRelationModal(issueId, relationKey)}
disabled={disabled}
>
<div className="flex w-full items-start justify-between">
{relationIssueIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-2 py-0.5">
{relationIssueIds.map((relationIssueId) => {
const currentIssue = issueMap[relationIssueId];
if (!currentIssue) return;
const projectDetails = getProjectById(currentIssue.project_id);
return (
<div
key={relationIssueId}
className={`group flex items-center gap-1 rounded px-1.5 pb-1 pt-1 leading-3 hover:bg-custom-background-90 ${currRelationOption?.className}`}
>
<Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name} isMobile={isMobile}>
<Link
href={generateWorkItemLink({
workspaceSlug,
projectId: projectDetails?.id,
issueId: currentIssue.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: currentIssue?.sequence_id,
})}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium"
onClick={(e) => e.stopPropagation()}
>
{`${projectDetails?.identifier}-${currentIssue?.sequence_id}`}
</Link>
</Tooltip>
{!disabled && (
<Tooltip tooltipContent="Remove" position="bottom" isMobile={isMobile}>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeRelation(workspaceSlug, projectId, issueId, relationKey, relationIssueId);
}}
>
<X className="h-2.5 w-2.5 text-custom-text-300 hover:text-red-500" />
</span>
</Tooltip>
)}
</div>
);
})}
</div>
) : (
<span className="text-sm text-custom-text-400">{currRelationOption?.placeholder}</span>
)}
{!disabled && (
<span
className={cn("flex-shrink-0 p-1 opacity-0 group-hover:opacity-100", {
"text-custom-text-400": relationIssueIds.length === 0,
})}
>
<Pencil className="h-2.5 w-2.5 flex-shrink-0" />
</span>
)}
</div>
</button>
</>
);
});

View File

@@ -0,0 +1,335 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// components
import { EmptyState } from "@/components/common/empty-state";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// images
import emptyIssue from "@/public/empty-state/issue.svg";
// local components
import { IssuePeekOverview } from "../peek-overview";
import { IssueMainContent } from "./main-content";
import { IssueDetailsSidebar } from "./sidebar";
export type TIssueOperations = {
fetch: (workspaceSlug: string, projectId: string, issueId: string, loader?: boolean) => Promise<void>;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addCycleToIssue?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
removeIssueFromModule?: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) => Promise<void>;
changeModulesInIssue?: (
workspaceSlug: string,
projectId: string,
issueId: string,
addModuleIds: string[],
removeModuleIds: string[]
) => Promise<void>;
};
export type TIssueDetailRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
is_archived?: boolean;
};
export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
const { t } = useTranslation();
const { workspaceSlug, projectId, issueId, is_archived = false } = props;
// router
const router = useAppRouter();
// hooks
const {
issue: { getIssueById },
fetchIssue,
updateIssue,
removeIssue,
archiveIssue,
addCycleToIssue,
addIssueToCycle,
removeIssueFromCycle,
changeModulesInIssue,
removeIssueFromModule,
} = useIssueDetail();
const {
issues: { removeIssue: removeArchivedIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { allowPermissions } = useUserPermissions();
const { issueDetailSidebarCollapsed } = useAppTheme();
const issueOperations: TIssueOperations = useMemo(
() => ({
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await fetchIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.error("Error fetching the parent issue:", error);
}
},
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
} catch (error) {
console.log("Error in updating issue:", error);
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("entity.update.failed", { entity: t("issue.label") }),
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId);
else await removeIssue(workspaceSlug, projectId, issueId);
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("entity.delete.success", { entity: t("issue.label") }),
});
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
});
} catch (error) {
console.log("Error in deleting issue:", error);
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label") }),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId },
error: error as Error,
});
}
},
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
});
} catch (error) {
console.log("Error in archiving issue:", error);
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
error: error as Error,
});
}
},
addCycleToIssue: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
try {
await addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
}
},
removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
try {
const removeFromCyclePromise = removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
setPromiseToast(removeFromCyclePromise, {
loading: t("issue.remove.cycle.loading"),
success: {
title: t("common.success"),
message: () => t("issue.remove.cycle.success"),
},
error: {
title: t("common.error.label"),
message: () => t("issue.remove.cycle.failed"),
},
});
await removeFromCyclePromise;
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
}
},
removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
try {
const removeFromModulePromise = removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
setPromiseToast(removeFromModulePromise, {
loading: t("issue.remove.module.loading"),
success: {
title: t("common.success"),
message: () => t("issue.remove.module.success"),
},
error: {
title: t("common.error.label"),
message: () => t("issue.remove.module.failed"),
},
});
await removeFromModulePromise;
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
} catch (error) {
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
error: error as Error,
});
}
},
changeModulesInIssue: async (
workspaceSlug: string,
projectId: string,
issueId: string,
addModuleIds: string[],
removeModuleIds: string[]
) => {
const promise = await changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
captureSuccess({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId },
});
return promise;
},
}),
[
is_archived,
fetchIssue,
updateIssue,
removeIssue,
archiveIssue,
removeArchivedIssue,
addIssueToCycle,
addCycleToIssue,
removeIssueFromCycle,
changeModulesInIssue,
removeIssueFromModule,
t,
issueId,
]
);
// issue details
const issue = getIssueById(issueId);
// checking if issue is editable, based on user role
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
return (
<>
{!issue ? (
<EmptyState
image={emptyIssue}
title={t("issue.empty_state.issue_detail.title")}
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
}}
/>
) : (
<div className="flex h-full w-full overflow-hidden">
<div className="max-w-2/3 h-full w-full space-y-8 overflow-y-auto px-9 py-5">
<IssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
isEditable={isEditable}
isArchived={is_archived}
/>
</div>
<div
className="fixed right-0 z-[5] h-full w-full min-w-[300px] border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 sm:w-1/2 md:relative md:w-1/3 lg:min-w-80 xl:min-w-96"
style={issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
>
<IssueDetailsSidebar
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
isEditable={!is_archived && isEditable}
/>
</div>
</div>
)}
{/* peek overview */}
<IssuePeekOverview />
</>
);
});

View File

@@ -0,0 +1,310 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { CalendarCheck2, CalendarClock, LayoutPanelTop, Signal, Tag, Triangle, UserCircle2, Users } from "lucide-react";
// i18n
import { useTranslation } from "@plane/i18n";
// ui
import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons";
import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
// components
import { DateDropdown } from "@/components/dropdowns/date";
import { EstimateDropdown } from "@/components/dropdowns/estimate";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
// plane web components
// components
import { WorkItemAdditionalSidebarProperties } from "@/plane-web/components/issues/issue-details/additional-properties";
import { IssueParentSelectRoot } from "@/plane-web/components/issues/issue-details/parent-select-root";
import { IssueWorklogProperty } from "@/plane-web/components/issues/worklog/property";
import { IssueCycleSelect } from "./cycle-select";
import { IssueLabel } from "./label";
import { IssueModuleSelect } from "./module-select";
import type { TIssueOperations } from "./root";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
isEditable: boolean;
};
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { t } = useTranslation();
const { workspaceSlug, projectId, issueId, issueOperations, isEditable } = props;
// store hooks
const { getProjectById } = useProject();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getUserDetails } = useMember();
const { getStateById } = useProjectState();
const issue = getIssueById(issueId);
if (!issue) return <></>;
const createdByDetails = getUserDetails(issue.created_by);
// derived values
const projectDetails = getProjectById(issue.project_id);
const stateDetails = getStateById(issue.state_id);
const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());
return (
<>
<div className="flex items-center h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="h-full w-full overflow-y-auto px-6">
<h5 className="mt-6 text-sm font-medium">{t("common.properties")}</h5>
{/* TODO: render properties using a common component */}
<div className={`mb-2 mt-3 space-y-2.5 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.state")}</span>
</div>
<StateDropdown
value={issue?.state_id}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<Users className="h-4 w-4 flex-shrink-0" />
<span>{t("common.assignees")}</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder={t("issue.add.assignee")}
multiple
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm justify-between ${
issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"
}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<Signal className="h-4 w-4 flex-shrink-0" />
<span>{t("common.priority")}</span>
</div>
<PriorityDropdown
value={issue?.priority}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!isEditable}
buttonVariant="border-with-text"
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
buttonContainerClassName="w-full text-left"
buttonClassName="w-min h-auto whitespace-nowrap"
/>
</div>
{createdByDetails && (
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
<span>{t("common.created_by")}</span>
</div>
<div className="w-full h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-not-allowed">
<ButtonAvatars showTooltip userIds={createdByDetails.id} />
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
</div>
</div>
)}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<CalendarClock className="h-4 w-4 flex-shrink-0" />
<span>{t("common.order_by.start_date")}</span>
</div>
<DateDropdown
placeholder={t("issue.add.start_date")}
value={issue.start_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
start_date: val ? renderFormattedPayloadDate(val) : null,
})
}
maxDate={maxDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline"
// TODO: add this logic
// showPlaceholderIcon
/>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<CalendarCheck2 className="h-4 w-4 flex-shrink-0" />
<span>{t("common.order_by.due_date")}</span>
</div>
<DateDropdown
placeholder={t("issue.add.due_date")}
value={issue.target_date}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={cn("text-sm", {
"text-custom-text-400": !issue.target_date,
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
})}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
// TODO: add this logic
// showPlaceholderIcon
/>
</div>
{projectId && areEstimateEnabledByProjectId(projectId) && (
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<Triangle className="h-4 w-4 flex-shrink-0" />
<span>{t("common.estimate")}</span>
</div>
<EstimateDropdown
value={issue?.estimate_point ?? undefined}
onChange={(val: string | undefined) =>
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
}
projectId={projectId}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
placeholder={t("common.none")}
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
</div>
)}
{projectDetails?.module_view && (
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<ModuleIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.modules")}</span>
</div>
<IssueModuleSelect
className="w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable}
/>
</div>
)}
{projectDetails?.cycle_view && (
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<CycleIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("common.cycle")}</span>
</div>
<IssueCycleSelect
className="w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable}
/>
</div>
)}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<LayoutPanelTop className="h-4 w-4 flex-shrink-0" />
<span>{t("common.parent")}</span>
</div>
<IssueParentSelectRoot
className="h-full w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
disabled={!isEditable}
/>
</div>
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-sm text-custom-text-300">
<Tag className="h-4 w-4 flex-shrink-0" />
<span>{t("common.labels")}</span>
</div>
<div className="h-full min-h-8 w-3/5 flex-grow">
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
/>
</div>
</div>
<IssueWorklogProperty
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
/>
<WorkItemAdditionalSidebarProperties
workItemId={issue.id}
workItemTypeId={issue.type_id}
projectId={projectId}
workspaceSlug={workspaceSlug}
isEditable={isEditable}
/>
</div>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,101 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { isNil } from "lodash-es";
import { observer } from "mobx-react";
import { Bell, BellOff } from "lucide-react";
// plane-i18n
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// UI
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EIssueServiceType } from "@plane/types";
import { Loader } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useUserPermissions } from "@/hooks/store/user";
export type TIssueSubscription = {
workspaceSlug: string;
projectId: string;
issueId: string;
serviceType?: EIssueServiceType;
};
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
const { workspaceSlug, projectId, issueId, serviceType = EIssueServiceType.ISSUES } = props;
const { t } = useTranslation();
// hooks
const {
subscription: { getSubscriptionByIssueId },
createSubscription,
removeSubscription,
} = useIssueDetail(serviceType);
// state
const [loading, setLoading] = useState(false);
// hooks
const { allowPermissions } = useUserPermissions();
const isSubscribed = getSubscriptionByIssueId(issueId);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const handleSubscription = async () => {
setLoading(true);
try {
if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId);
else await createSubscription(workspaceSlug, projectId, issueId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: isSubscribed
? t("issue.subscription.actions.unsubscribed")
: t("issue.subscription.actions.subscribed"),
});
setLoading(false);
} catch {
setLoading(false);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("common.error.message"),
});
}
};
if (isNil(isSubscribed))
return (
<Loader>
<Loader.Item width="106px" height="28px" />
</Loader>
);
return (
<div>
<Button
size="sm"
prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={handleSubscription}
disabled={!isEditable || loading}
>
{loading ? (
<span>
<span className="hidden sm:block">{t("common.loading")}</span>
</span>
) : isSubscribed ? (
<div className="hidden sm:block">{t("common.actions.unsubscribe")}</div>
) : (
<div className="hidden sm:block">{t("common.actions.subscribe")}</div>
)}
</Button>
</div>
);
});