chore: issue activity, comments, and comment reaction store and component restructure (#3428)

* fix: issue activity and comment change

* chore: posthog enabled

* chore: comment creation in activity

* chore: comment crud in store mutation

* fix: issue activity/ comments `disable` and `showAccessSpecifier` logic.

* chore: comment reaction serializer change

* conflicts: merge conflicts resolved

* conflicts: merge conflicts resolved

* chore: add issue activity/ comments to peek-overview.
* imporve `showAccessIdentifier` logic.

* chore: remove quotes from issue activity.

* chore: use `projectLabels` instead of `workspaceLabels` in labels activity.

* fix: project publish `is_deployed` not updating bug.

* cleanup

* fix: posthog enabled

* fix: typos and the comment endpoint updates

* fix: issue activity icons update

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
guru_sainath 2024-01-23 13:28:58 +05:30 committed by GitHub
parent bb50df0dff
commit f88109ef04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 2555 additions and 395 deletions

View file

@ -0,0 +1,51 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityList } from "./activity/activity-list";
import { IssueCommentCard } from "./comments/comment-card";
// types
import { TActivityOperations } from "./root";
type TIssueActivityCommentRoot = {
workspaceSlug: string;
issueId: string;
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
};
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props;
// hooks
const {
activity: { getActivityCommentByIssueId },
comment: {},
} = useIssueDetail();
const activityComments = getActivityCommentByIssueId(issueId);
if (!activityComments || (activityComments && activityComments.length <= 0)) return <></>;
return (
<div>
{activityComments.map((activityComment, index) =>
activityComment.activity_type === "COMMENT" ? (
<IssueCommentCard
workspaceSlug={workspaceSlug}
commentId={activityComment.id}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === activityComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={showAccessSpecifier}
/>
) : activityComment.activity_type === "ACTIVITY" ? (
<IssueActivityList
activityId={activityComment.id}
ends={index === 0 ? "top" : index === activityComments.length - 1 ? "bottom" : undefined}
/>
) : (
<></>
)
)}
</div>
);
});

View file

@ -0,0 +1,30 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent } from "./";
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={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
{activity.new_value === "restore" ? `restored the issue` : `archived the issue`}.
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,45 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// icons
import { UserGroupIcon } from "@plane/ui";
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={<UserGroupIcon className="h-4 w-4 flex-shrink-0" />}
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,44 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { Paperclip } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// 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} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? `uploaded a new ` : `removed an attachment`}
{activity.verb === "created" && (
<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"
>
attachment
</a>
)}
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,69 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent } from "./";
// icons
import { ContrastIcon } from "@plane/ui";
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={<ContrastIcon className="h-4 w-4 flex-shrink-0 text-[#6b7280]" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this issue 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 issue 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,31 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent } from "./";
// icons
import { LayersIcon } from "@plane/ui";
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 <></>;
return (
<IssueActivityBlockComponent
activityId={activityId}
icon={<LayersIcon width={12} height={12} color="#6b7280" aria-hidden="true" />}
ends={ends}
>
<>{activity.verb === "created" ? " created the issue." : " deleted an issue."}</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,34 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// 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={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
updated the description
{showIssue ? ` of ` : ``}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,50 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { Triangle } from "lucide-react";
// hooks
import { useEstimate, useIssueDetail } from "hooks/store";
// 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 { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate();
const activity = getActivityById(activityId);
if (!activity) return <></>;
const estimateValue = getEstimatePointValue(Number(activity.new_value));
const currentPoint = Number(activity.new_value) + 1;
return (
<IssueActivityBlockComponent
icon={<Triangle size={14} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.new_value ? `set the estimate point to ` : `removed the estimate point `}
{activity.new_value && (
<>
<span className="font-medium text-custom-text-100">
{areEstimatesEnabledForCurrentProject
? estimateValue
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
</span>
</>
)}
{showIssue && (activity.new_value ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}.
</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,52 @@
import { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// ui
import { Tooltip } from "@plane/ui";
// components
import { IssueUser } from "../";
// helpers
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper";
type TIssueActivityBlockComponent = {
icon?: ReactNode;
activityId: string;
ends: "top" | "bottom" | undefined;
children: ReactNode;
};
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
const { icon, activityId, ends, children } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
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={true} />
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-center z-10 bg-custom-background-80 text-custom-text-200">
{icon ? icon : <Network className="w-3.5 h-3.5" />}
</div>
<div className="w-full text-custom-text-200">
<IssueUser activityId={activityId} />
<span> {children} </span>
<span>
<Tooltip
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap"> {calculateTimeAgo(activity.created_at)}</span>
</Tooltip>
</span>
</div>
</div>
);
};

View file

@ -0,0 +1,39 @@
import { FC } from "react";
// hooks
import { useIssueDetail } from "hooks/store";
// ui
import { Tooltip } from "@plane/ui";
type TIssueLink = {
activityId: string;
};
export const IssueLink: FC<TIssueLink> = (props) => {
const { activityId } = props;
// hooks
const {
activity: { getActivityById },
} = useIssueDetail();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
<a
aria-disabled={activity.issue === null}
href={`${
activity.issue_detail
? `/${activity.workspace_detail?.slug}/projects/${activity.project}/issues/${activity.issue}`
: "#"
}`}
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}` : "Issue"}{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
</a>
</Tooltip>
);
};

View file

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

View file

@ -0,0 +1,22 @@
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";
// helpers
export * from "./helpers/activity-block";
export * from "./helpers/issue-user";
export * from "./helpers/issue-link";

View file

@ -0,0 +1,58 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { Tag } from "lucide-react";
// hooks
import { useIssueDetail, useLabel } from "hooks/store";
// components
import { IssueActivityBlockComponent, IssueLink } 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 { projectLabels } = useLabel();
const activity = getActivityById(activityId);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Tag size={14} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.old_value === "" ? `added a new label ` : `removed the label `}
{activity.old_value === "" ? (
<span className="inline-flex w-min 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: projectLabels?.find((l) => l.id === activity.new_identifier)?.color ?? "#000000",
}}
aria-hidden="true"
/>
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.new_value}</span>
</span>
) : (
<span className="inline-flex w-min 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: projectLabels?.find((l) => l.id === activity.old_identifier)?.color ?? "#000000",
}}
aria-hidden="true"
/>
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.old_value}</span>
</span>
)}
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}
</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,70 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// 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} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this </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,69 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent } from "./";
// icons
import { DiceIcon } from "@plane/ui";
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={<DiceIcon className="h-4 w-4 flex-shrink-0 text-[#6b7280]" />}
activityId={activityId}
ends={ends}
>
<>
{activity.verb === "created" ? (
<>
<span>added this issue 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 issue 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.new_value}</span>
</a>
</>
)}
</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,30 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// 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={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>
<>set the name to {activity.new_value}.</>
</IssueActivityBlockComponent>
);
});

View file

@ -0,0 +1,39 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { LayoutPanelTop } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// 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} color="#6b7280" 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 { FC } from "react";
import { observer } from "mobx-react";
import { Signal } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// 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} color="#6b7280" 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,50 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent } from "./";
// component helpers
import { issueRelationObject } from "components/issues/issue-detail/relation-select";
// types
import { TIssueRelationTypes } from "@plane/types";
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);
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={activity.field ? issueRelationObject[activity.field as TIssueRelationTypes].icon(14) : <></>}
activityId={activityId}
ends={ends}
>
<>
{activity.field === "blocking" &&
(activity.old_value === "" ? `marked this issue is blocking issue ` : `removed the blocking issue `)}
{activity.field === "blocked_by" &&
(activity.old_value === ""
? `marked this issue is being blocked by `
: `removed this issue being blocked by issue `)}
{activity.field === "duplicate" &&
(activity.old_value === "" ? `marked this issue as duplicate of ` : `removed this issue as a duplicate of `)}
{activity.field === "relates_to" &&
(activity.old_value === "" ? `marked that this issue relates to ` : `removed the relation from `)}
{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 { FC } from "react";
import { observer } from "mobx-react";
import { CalendarDays } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
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} color="#6b7280" 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,35 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// icons
import { DoubleCircleIcon } from "@plane/ui";
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" />}
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 { FC } from "react";
import { observer } from "mobx-react";
import { CalendarDays } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityBlockComponent, IssueLink } from "./";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
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} color="#6b7280" 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,80 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import {
IssueDefaultActivity,
IssueNameActivity,
IssueDescriptionActivity,
IssueStateActivity,
IssueAssigneeActivity,
IssuePriorityActivity,
IssueEstimateActivity,
IssueParentActivity,
IssueRelationActivity,
IssueStartDateActivity,
IssueTargetDateActivity,
IssueCycleActivity,
IssueModuleActivity,
IssueLabelActivity,
IssueLinkActivity,
IssueAttachmentActivity,
IssueArchivedAtActivity,
} from "./actions";
type TIssueActivityList = {
activityId: string;
ends: "top" | "bottom" | undefined;
};
export const IssueActivityList: FC<TIssueActivityList> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
activity: { getActivityById },
comment: {},
} = useIssueDetail();
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_point":
return <IssueEstimateActivity {...componentDefaultProps} showIssue={false} />;
case "parent":
return <IssueParentActivity {...componentDefaultProps} showIssue={false} />;
case ["blocking", "blocked_by", "duplicate", "relates_to"].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} />;
default:
return <></>;
}
});

View file

@ -0,0 +1,32 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueActivityList } from "./activity-list";
type TIssueActivityRoot = {
issueId: string;
};
export const IssueActivityRoot: FC<TIssueActivityRoot> = observer((props) => {
const { issueId } = props;
// hooks
const {
activity: { getActivitiesByIssueId },
} = useIssueDetail();
const activityIds = getActivitiesByIssueId(issueId);
if (!activityIds) return <></>;
return (
<div>
{activityIds.map((activityId, index) => (
<IssueActivityList
activityId={activityId}
ends={index === 0 ? "top" : index === activityIds.length - 1 ? "bottom" : undefined}
/>
))}
</div>
);
});

View file

@ -0,0 +1,66 @@
import { FC, ReactNode } from "react";
import { MessageCircle } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
// helpers
import { calculateTimeAgo } from "helpers/date-time.helper";
type TIssueCommentBlock = {
commentId: string;
ends: "top" | "bottom" | undefined;
quickActions: ReactNode;
children: ReactNode;
};
export const IssueCommentBlock: FC<TIssueCommentBlock> = (props) => {
const { commentId, ends, quickActions, children } = props;
// hooks
const {
comment: { getCommentById },
} = useIssueDetail();
const comment = getCommentById(commentId);
if (!comment) return <></>;
return (
<div className={`relative flex gap-3 ${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={true} />
<div className="flex-shrink-0 relative w-7 h-7 rounded-full flex justify-center items-center z-10 bg-gray-500 text-white border border-white uppercase font-medium">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<img
src={comment.actor_detail.avatar}
alt={
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
/>
) : (
<>
{comment.actor_detail.is_bot
? comment.actor_detail.first_name.charAt(0)
: comment.actor_detail.display_name.charAt(0)}
</>
)}
<div className="absolute top-2 left-4 w-5 h-5 rounded-full overflow-hidden flex justify-center items-center bg-custom-background-80">
<MessageCircle className="w-3 h-3" color="#6b7280" />
</div>
</div>
<div className="w-full relative flex ">
<div className="w-full space-y-1">
<div>
<div className="text-xs capitalize">
{comment.actor_detail.is_bot
? comment.actor_detail.first_name + " Bot"
: comment.actor_detail.display_name}
</div>
<div className="text-xs text-custom-text-200">commented {calculateTimeAgo(comment.created_at)}</div>
</div>
<div>{children}</div>
</div>
<div className="flex-shrink-0 ">{quickActions}</div>
</div>
</div>
);
};

View file

@ -0,0 +1,173 @@
import { FC, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react";
// hooks
import { useIssueDetail, useMention, useUser } from "hooks/store";
// components
import { IssueCommentBlock } from "./comment-block";
import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor";
import { IssueCommentReaction } from "../../reactions/issue-comment";
// ui
import { CustomMenu } from "@plane/ui";
// services
import { FileService } from "services/file.service";
// types
import { TIssueComment } from "@plane/types";
import { TActivityOperations } from "../root";
const fileService = new FileService();
type TIssueCommentCard = {
workspaceSlug: string;
commentId: string;
activityOperations: TActivityOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier?: boolean;
};
export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
const { workspaceSlug, commentId, activityOperations, ends, showAccessSpecifier = false } = props;
// hooks
const {
comment: { getCommentById },
} = useIssueDetail();
const { currentUser } = useUser();
const { mentionHighlights, mentionSuggestions } = useMention();
// refs
const editorRef = useRef<any>(null);
const showEditorRef = useRef<any>(null);
// state
const [isEditing, setIsEditing] = useState(false);
const comment = getCommentById(commentId);
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<Partial<TIssueComment>>({
defaultValues: { comment_html: comment?.comment_html },
});
const onEnter = (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;
setIsEditing(false);
activityOperations.updateComment(comment.id, formData);
editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html);
};
useEffect(() => {
isEditing && setFocus("comment_html");
}, [isEditing, setFocus]);
if (!comment || !currentUser) return <></>;
return (
<IssueCommentBlock
commentId={commentId}
quickActions={
<>
{currentUser?.id === comment.actor && (
<CustomMenu ellipsis>
<CustomMenu.MenuItem onClick={() => setIsEditing(true)} className="flex items-center gap-1">
<Pencil className="h-3 w-3" />
Edit comment
</CustomMenu.MenuItem>
{showAccessSpecifier && (
<>
{comment.access === "INTERNAL" ? (
<CustomMenu.MenuItem
onClick={() => activityOperations.updateComment(comment.id, { access: "EXTERNAL" })}
className="flex items-center gap-1"
>
<Globe2 className="h-3 w-3" />
Switch to public comment
</CustomMenu.MenuItem>
) : (
<CustomMenu.MenuItem
onClick={() => activityOperations.updateComment(comment.id, { access: "INTERNAL" })}
className="flex items-center gap-1"
>
<Lock className="h-3 w-3" />
Switch to private comment
</CustomMenu.MenuItem>
)}
</>
)}
<CustomMenu.MenuItem
onClick={() => activityOperations.removeComment(comment.id)}
className="flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
Delete comment
</CustomMenu.MenuItem>
</CustomMenu>
)}
</>
}
ends={ends}
>
<>
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
<div>
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(onEnter)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(comment?.workspace_detail?.slug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={watch("comment_html") ?? ""}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => setValue("comment_html", comment_html)}
mentionSuggestions={mentionSuggestions}
mentionHighlights={mentionHighlights}
/>
</div>
<div className="flex gap-1 self-end">
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isSubmitting}
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
>
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
</button>
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => setIsEditing(false)}
>
<X className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
<div className={`relative ${isEditing ? "hidden" : ""}`}>
{showAccessSpecifier && (
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
{comment.access === "INTERNAL" ? <Lock className="h-3 w-3" /> : <Globe2 className="h-3 w-3" />}
</div>
)}
<LiteReadOnlyEditorWithRef
ref={showEditorRef}
value={comment.comment_html ?? ""}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
mentionHighlights={mentionHighlights}
/>
<IssueCommentReaction
workspaceSlug={workspaceSlug}
projectId={comment?.project_detail?.id}
commentId={comment.id}
currentUser={currentUser}
/>
</div>
</>
</IssueCommentBlock>
);
};

View file

@ -0,0 +1,111 @@
import { FC, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
// components
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
import { Button } from "@plane/ui";
// services
import { FileService } from "services/file.service";
// types
import { TActivityOperations } from "../root";
import { TIssueComment } from "@plane/types";
// icons
import { Globe2, Lock } from "lucide-react";
const fileService = new FileService();
type TIssueCommentCreate = {
workspaceSlug: string;
activityOperations: TActivityOperations;
disabled: boolean;
showAccessSpecifier?: boolean;
};
type commentAccessType = {
icon: any;
key: string;
label: "Private" | "Public";
};
const commentAccess: commentAccessType[] = [
{
icon: Lock,
key: "INTERNAL",
label: "Private",
},
{
icon: Globe2,
key: "EXTERNAL",
label: "Public",
},
];
export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const { workspaceSlug, activityOperations, disabled, showAccessSpecifier = false } = props;
// refs
const editorRef = useRef<any>(null);
// react hook form
const {
handleSubmit,
control,
formState: { isSubmitting },
reset,
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "<p></p>" } });
const onSubmit = async (formData: Partial<TIssueComment>) => {
await activityOperations.createComment(formData).finally(() => {
reset({ comment_html: "" });
editorRef.current?.clearEditor();
});
};
return (
<div>
<Controller
name="access"
control={control}
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={(e) => {
handleSubmit(onSubmit)(e);
}}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={!value ? "<p></p>" : value}
customClassName="p-2"
editorContentCustomClassNames="min-h-[35px]"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
commentAccessSpecifier={
showAccessSpecifier
? { accessValue: accessValue ?? "INTERNAL", onAccessChange, showAccessSpecifier, commentAccess }
: undefined
}
submitButton={
<Button
disabled={isSubmitting || disabled}
variant="primary"
type="submit"
className="!px-2.5 !py-1.5 !text-xs"
onClick={(e) => {
handleSubmit(onSubmit)(e);
}}
>
{isSubmitting ? "Adding..." : "Comment"}
</Button>
}
/>
)}
/>
)}
/>
</div>
);
};

View file

@ -0,0 +1,40 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { IssueCommentCard } from "./comment-card";
// types
import { TActivityOperations } from "../root";
type TIssueCommentRoot = {
workspaceSlug: string;
issueId: string;
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
};
export const IssueCommentRoot: FC<TIssueCommentRoot> = observer((props) => {
const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props;
// hooks
const {
comment: { getCommentsByIssueId },
} = useIssueDetail();
const commentIds = getCommentsByIssueId(issueId);
if (!commentIds) return <></>;
return (
<div>
{commentIds.map((commentId, index) => (
<IssueCommentCard
workspaceSlug={workspaceSlug}
commentId={commentId}
ends={index === 0 ? "top" : index === commentIds.length - 1 ? "bottom" : undefined}
activityOperations={activityOperations}
showAccessSpecifier={showAccessSpecifier}
/>
))}
</div>
);
});

View file

@ -0,0 +1,12 @@
export * from "./root";
export * from "./activity-comment-root";
// activity
export * from "./activity/root";
export * from "./activity/activity-list";
// issue comment
export * from "./comments/root";
export * from "./comments/comment-card";
export * from "./comments/comment-create";

View file

@ -0,0 +1,183 @@
import { FC, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { History, LucideIcon, MessageCircle, ListRestart } from "lucide-react";
// hooks
import { useIssueDetail, useProject } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { IssueActivityCommentRoot, IssueActivityRoot, IssueCommentRoot, IssueCommentCreate } from "./";
// types
import { TIssueComment } from "@plane/types";
type TIssueActivity = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
type TActivityTabs = "all" | "activity" | "comments";
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
{
key: "all",
title: "All Activity",
icon: History,
},
{
key: "activity",
title: "Updates",
icon: ListRestart,
},
{
key: "comments",
title: "Comments",
icon: MessageCircle,
},
];
export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
};
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// hooks
const { createComment, updateComment, removeComment } = useIssueDetail();
const { setToastAlert } = useToast();
const { getProjectById } = useProject();
// state
const [activityTab, setActivityTab] = useState<TActivityTabs>("all");
const activityOperations: TActivityOperations = useMemo(
() => ({
createComment: async (data: Partial<TIssueComment>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await createComment(workspaceSlug, projectId, issueId, data);
setToastAlert({
title: "Comment created successfully.",
type: "success",
message: "Comment created successfully.",
});
} catch (error) {
setToastAlert({
title: "Comment creation failed.",
type: "error",
message: "Comment creation failed. Please try again later.",
});
}
},
updateComment: async (commentId: string, data: Partial<TIssueComment>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
setToastAlert({
title: "Comment updated successfully.",
type: "success",
message: "Comment updated successfully.",
});
} catch (error) {
setToastAlert({
title: "Comment update failed.",
type: "error",
message: "Comment update failed. Please try again later.",
});
}
},
removeComment: async (commentId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeComment(workspaceSlug, projectId, issueId, commentId);
setToastAlert({
title: "Comment removed successfully.",
type: "success",
message: "Comment removed successfully.",
});
} catch (error) {
setToastAlert({
title: "Comment remove failed.",
type: "error",
message: "Comment remove failed. Please try again later.",
});
}
},
}),
[workspaceSlug, projectId, issueId, createComment, updateComment, removeComment, setToastAlert]
);
const project = getProjectById(projectId);
if (!project) return <></>;
return (
<div className="space-y-3 pt-3">
{/* header */}
<div className="text-lg text-custom-text-100">Activity</div>
{/* rendering activity */}
<div className="space-y-3">
<div className="relative flex items-center gap-1">
{activityTabs.map((tab) => (
<div
key={tab.key}
className={`relative flex items-center px-2 py-1.5 gap-1 cursor-pointer transition-all rounded
${
tab.key === activityTab
? `text-custom-text-100 bg-custom-background-80`
: `text-custom-text-200 hover:bg-custom-background-80`
}`}
onClick={() => setActivityTab(tab.key)}
>
<div className="flex-shrink-0 w-4 h-4 flex justify-center items-center">
<tab.icon className="w-3 h-3" />
</div>
<div className="text-sm">{tab.title}</div>
</div>
))}
</div>
<div className="min-h-[200px]">
{activityTab === "all" ? (
<div className="space-y-3">
<IssueActivityCommentRoot
workspaceSlug={workspaceSlug}
issueId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={project.is_deployed}
/>
{!disabled && (
<IssueCommentCreate
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
disabled={disabled}
showAccessSpecifier={project.is_deployed}
/>
)}
</div>
) : activityTab === "activity" ? (
<IssueActivityRoot issueId={issueId} />
) : (
<div className="space-y-3">
<IssueCommentRoot
workspaceSlug={workspaceSlug}
issueId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={project.is_deployed}
/>
{!disabled && (
<IssueCommentCreate
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
disabled={disabled}
showAccessSpecifier={project.is_deployed}
/>
)}
</div>
)}
</div>
</div>
</div>
);
});