Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,6 @@ export enum EFileAssetType {
USER_COVER = "USER_COVER",
WORKSPACE_LOGO = "WORKSPACE_LOGO",
TEAM_SPACE_DESCRIPTION = "TEAM_SPACE_DESCRIPTION",
INITIATIVE_DESCRIPTION = "INITIATIVE_DESCRIPTION",
PROJECT_DESCRIPTION = "PROJECT_DESCRIPTION",
}
51 changes: 51 additions & 0 deletions web/core/components/common/activity/activity-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// hooks
import { Tooltip } from "@plane/ui";
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { TProjectActivity } from "@/plane-web/types";
import { User } from "./user";

type TActivityBlockComponent = {
icon?: ReactNode;
activity: TProjectActivity;
ends: "top" | "bottom" | undefined;
children: ReactNode;
customUserName?: string;
};

export const ActivityBlockComponent: FC<TActivityBlockComponent> = (props) => {
const { icon, activity, ends, children, customUserName } = props;
// hooks
const { isMobile } = usePlatformOS();

if (!activity) return <></>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding error boundary or proper error handling

The early return with empty fragment when activity is null could be improved with proper error handling.

Consider adding error boundary or proper error state:

- if (!activity) return <></>;
+ if (!activity) {
+   console.warn("ActivityBlockComponent: No activity provided");
+   return null;
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!activity) return <></>;
if (!activity) {
console.warn("ActivityBlockComponent: No activity provided");
return null;
}

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>
Comment on lines +32 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve accessibility for activity timeline

The timeline implementation could benefit from better accessibility attributes.

Add proper ARIA attributes and semantic HTML:

- <div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
+ <div 
+   className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" 
+   role="separator"
+   aria-label="Activity timeline"
+ />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80"
role="separator"
aria-label="Activity timeline"
/>
<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">
<User activity={activity} customUserName={customUserName} /> {children}
<div className="mt-1">
<Tooltip
isMobile={isMobile}
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap text-custom-text-350 font-medium">
{calculateTimeAgo(activity.created_at)}
</span>
</Tooltip>
</div>
</div>
</div>
);
};
30 changes: 30 additions & 0 deletions web/core/components/common/activity/activity-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { FC } from "react";
import { observer } from "mobx-react";

import { TProjectActivity } from "@/plane-web/types";
import { ActivityBlockComponent } from "./activity-block";
import { iconsMap, messages } from "./helper";

type TActivityItem = {
activity: TProjectActivity;
showProject?: boolean;
ends?: "top" | "bottom" | undefined;
};

export const ActivityItem: FC<TActivityItem> = observer((props) => {
const { activity, showProject = true, ends } = props;

if (!activity) return null;

const activityType = activity.field;
const { message, customUserName } = messages(activity);
const icon = iconsMap[activityType] || iconsMap.default;

return (
<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
<>{message}</>
</ActivityBlockComponent>
Comment on lines +26 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unnecessary fragment and add error handling

  1. The fragment wrapper is unnecessary as noted by static analysis.
  2. Consider adding error boundary protection for message generation.

Suggested improvements:

<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
- <>{message}</>
+ {message}
</ActivityBlockComponent>

Also, consider wrapping the message generation in a try-catch:

- const { message, customUserName } = messages(activity);
+ let message, customUserName;
+ try {
+   ({ message, customUserName } = messages(activity));
+ } catch (error) {
+   console.error('Failed to generate activity message:', error);
+   message = 'Activity details unavailable';
+   customUserName = undefined;
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
<>{message}</>
</ActivityBlockComponent>
<ActivityBlockComponent icon={icon} activity={activity} ends={ends} customUserName={customUserName}>
{message}
</ActivityBlockComponent>
🧰 Tools
🪛 Biome (1.9.4)

[error] 27-27: Avoid using unnecessary Fragment.

A fragment is redundant if it contains only one child, or if it is the child of a html element, and is not a keyed fragment.
Unsafe fix: Remove the Fragment

(lint/complexity/noUselessFragments)

);
});
279 changes: 279 additions & 0 deletions web/core/components/common/activity/helper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { ReactNode } from "react";
import {
Signal,
RotateCcw,
Network,
Link as LinkIcon,
Calendar,
Tag,
Inbox,
AlignLeft,
Users,
Paperclip,
Type,
Triangle,
FileText,
Globe,
Hash,
Clock,
Bell,
LayoutGrid,
GitBranch,
Timer,
ListTodo,
Layers,
} from "lucide-react";

// components
import { ArchiveIcon, DoubleCircleIcon, ContrastIcon, DiceIcon, Intake } from "@plane/ui";
import { TProjectActivity } from "@/plane-web/types";

type ActivityIconMap = {
[key: string]: ReactNode;
};
export const iconsMap: ActivityIconMap = {
priority: <Signal size={14} className="text-custom-text-200" />,
archived_at: <ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" />,
restored: <RotateCcw className="h-3.5 w-3.5 text-custom-text-200" />,
link: <LinkIcon className="h-3.5 w-3.5 text-custom-text-200" />,
start_date: <Calendar className="h-3.5 w-3.5 text-custom-text-200" />,
target_date: <Calendar className="h-3.5 w-3.5 text-custom-text-200" />,
label: <Tag className="h-3.5 w-3.5 text-custom-text-200" />,
inbox: <Inbox className="h-3.5 w-3.5 text-custom-text-200" />,
description: <AlignLeft className="h-3.5 w-3.5 text-custom-text-200" />,
assignee: <Users className="h-3.5 w-3.5 text-custom-text-200" />,
attachment: <Paperclip className="h-3.5 w-3.5 text-custom-text-200" />,
name: <Type className="h-3.5 w-3.5 text-custom-text-200" />,
state: <DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
estimate: <Triangle size={14} className="text-custom-text-200" />,
cycle: <ContrastIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
module: <DiceIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
page: <FileText className="h-3.5 w-3.5 text-custom-text-200" />,
network: <Globe className="h-3.5 w-3.5 text-custom-text-200" />,
identifier: <Hash className="h-3.5 w-3.5 text-custom-text-200" />,
timezone: <Clock className="h-3.5 w-3.5 text-custom-text-200" />,
is_project_updates_enabled: <Bell className="h-3.5 w-3.5 text-custom-text-200" />,
is_epic_enabled: <LayoutGrid className="h-3.5 w-3.5 text-custom-text-200" />,
is_workflow_enabled: <GitBranch className="h-3.5 w-3.5 text-custom-text-200" />,
is_time_tracking_enabled: <Timer className="h-3.5 w-3.5 text-custom-text-200" />,
is_issue_type_enabled: <ListTodo className="h-3.5 w-3.5 text-custom-text-200" />,
default: <Network className="h-3.5 w-3.5 text-custom-text-200" />,
module_view: <DiceIcon className="h-3.5 w-3.5 text-custom-text-200" />,
cycle_view: <ContrastIcon className="h-3.5 w-3.5 text-custom-text-200" />,
issue_views_view: <Layers className="h-3.5 w-3.5 text-custom-text-200" />,
page_view: <FileText className="h-3.5 w-3.5 text-custom-text-200" />,
intake_view: <Intake className="h-3.5 w-3.5 text-custom-text-200" />,
};

export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => {
const activityType = activity.field;
const newValue = activity.new_value;
const oldValue = activity.old_value;
const verb = activity.verb;

const getBooleanActionText = (value: string) => {
if (value === "true") return "enabled";
if (value === "false") return "disabled";
return verb;
};

switch (activityType) {
case "priority":
return {
message: (
<>
set the priority to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
</>
),
};
case "archived_at":
return {
message: newValue === "restore" ? "restored the project" : "archived the project",
customUserName: newValue === "archive" ? "Plane" : undefined,
};
case "name":
return {
message: (
<>
renamed the project to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
),
};
case "description":
return {
message: newValue ? "updated the project description" : "removed the project description",
};
case "start_date":
return {
message: (
<>
{newValue ? (
<>
set the start date to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
) : (
"removed the start date"
)}
</>
),
};
case "target_date":
return {
message: (
<>
{newValue ? (
<>
set the target date to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
) : (
"removed the target date"
)}
</>
),
};
case "state":
return {
message: (
<>
set the state to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
</>
),
};
case "estimate":
return {
message: (
<>
{newValue ? (
<>
set the estimate point to <span className="font-medium text-custom-text-100">{newValue}</span>
</>
) : (
<>
removed the estimate point
{oldValue && (
<>
{" "}
<span className="font-medium text-custom-text-100">{oldValue}</span>
</>
)}
</>
)}
</>
),
};
case "cycles":
return {
message: (
<>
<span>
{verb} this project {verb === "removed" ? "from" : "to"} the cycle{" "}
</span>
{verb !== "removed" ? (
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex font-medium text-custom-text-100"
>
{activity.new_value}
</a>
Comment on lines +172 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling for navigation links

The cycle link implementation could benefit from proper error handling for missing slugs or identifiers.

Add proper checks:

{verb !== "removed" && activity.workspace_detail?.slug && activity.new_identifier ? (
  <a
    href={`/${activity.workspace_detail.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
    target="_blank"
    rel="noopener noreferrer"
    className="inline-flex font-medium text-custom-text-100"
  >
    {activity.new_value}
  </a>
) : (
  <span className="font-medium text-custom-text-100">
    {activity.new_value || "Unknown cycle"}
  </span>
)}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<a
href={`/${activity.workspace_detail?.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex font-medium text-custom-text-100"
>
{activity.new_value}
</a>
{verb !== "removed" && activity.workspace_detail?.slug && activity.new_identifier ? (
<a
href={`/${activity.workspace_detail.slug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex font-medium text-custom-text-100"
>
{activity.new_value}
</a>
) : (
<span className="font-medium text-custom-text-100">
{activity.new_value || "Unknown cycle"}
</span>
)}

) : (
<span className="font-medium text-custom-text-100">{activity.old_value || "Unknown cycle"}</span>
)}
</>
),
};
case "modules":
return {
message: (
<>
<span>
{verb} this project {verb === "removed" ? "from" : "to"} the module{" "}
</span>
<span className="font-medium text-custom-text-100">
{verb === "removed" ? oldValue : newValue || "Unknown module"}
</span>
</>
),
};
case "labels":
return {
message: (
<>
{verb} the label{" "}
<span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled label"}</span>
</>
),
};
case "inbox":
return {
message: <>{newValue ? "enabled" : "disabled"} inbox</>,
};
case "page":
return {
message: (
<>
{newValue ? "created" : "removed"} the project page{" "}
<span className="font-medium text-custom-text-100">{newValue || oldValue || "Untitled page"}</span>
</>
),
};
case "network":
return {
message: <>{newValue ? "enabled" : "disabled"} network access</>,
};
case "identifier":
return {
message: (
<>
updated project identifier to <span className="font-medium text-custom-text-100">{newValue || "none"}</span>
</>
),
};
case "timezone":
return {
message: (
<>
changed project timezone to{" "}
<span className="font-medium text-custom-text-100">{newValue || "default"}</span>
</>
),
};
case "module_view":
case "cycle_view":
case "issue_views_view":
case "page_view":
case "intake_view":
return {
message: (
<>
{getBooleanActionText(newValue)} {activityType.replace(/_view$/, "").replace(/_/g, " ")} view
</>
),
};
case "is_project_updates_enabled":
return {
message: <>{getBooleanActionText(newValue)} project updates</>,
};
case "is_epic_enabled":
return {
message: <>{getBooleanActionText(newValue)} epics</>,
};
case "is_workflow_enabled":
return {
message: <>{getBooleanActionText(newValue)} custom workflow</>,
};
case "is_time_tracking_enabled":
return {
message: <>{getBooleanActionText(newValue)} time tracking</>,
};
case "is_issue_type_enabled":
return {
message: <>{getBooleanActionText(newValue)} issue types</>,
};
default:
return {
message: `${verb} ${activityType.replace(/_/g, " ")} `,
};
}
};
Comment on lines +68 to +279
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider breaking down the message generation logic

The message generation function is quite large and handles many cases. This could be split into smaller, more manageable functions.

Consider breaking down the switch statement into separate handlers:

const messageHandlers = {
  priority: (activity: TProjectActivity) => ({
    message: (
      <>
        set the priority to <span className="font-medium text-custom-text-100">{activity.new_value || "none"}</span>
      </>
    ),
  }),
  // ... other handlers
};

export const messages = (activity: TProjectActivity) => {
  const handler = messageHandlers[activity.field];
  return handler ? handler(activity) : {
    message: `${activity.verb} ${activity.field.replace(/_/g, " ")} `,
  };
};

This would make the code more maintainable and easier to test.

1 change: 1 addition & 0 deletions web/core/components/common/activity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./activity-item";
27 changes: 27 additions & 0 deletions web/core/components/common/activity/user.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FC } from "react";
import Link from "next/link";
import { TProjectActivity } from "@/plane-web/types";

type TUser = {
activity: TProjectActivity;
customUserName?: string;
};

export const User: FC<TUser> = (props) => {
const { activity, customUserName } = props;

return (
<>
{customUserName || activity.actor_detail?.display_name.includes("-intake") ? (
<span className="text-custom-text-100 font-medium">{customUserName || "Plane"}</span>
) : (
Comment on lines +15 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add null checks for actor_detail properties

The current implementation could throw runtime errors if actor_detail or display_name is undefined.

Suggested fix:

- {customUserName || activity.actor_detail?.display_name.includes("-intake") ? (
+ {customUserName || (activity.actor_detail?.display_name || "").includes("-intake") ? (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{customUserName || activity.actor_detail?.display_name.includes("-intake") ? (
<span className="text-custom-text-100 font-medium">{customUserName || "Plane"}</span>
) : (
{customUserName || (activity.actor_detail?.display_name || "").includes("-intake") ? (
<span className="text-custom-text-100 font-medium">{customUserName || "Plane"}</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>
)}
Comment on lines +18 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add safety checks for profile link and enhance accessibility

The profile link construction needs safety checks for undefined values, and the link should have proper accessibility attributes.

Suggested improvements:

<Link
- href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
+ href={activity?.workspace_detail?.slug && activity?.actor_detail?.id 
+   ? `/${activity.workspace_detail.slug}/profile/${activity.actor_detail.id}`
+   : "#"}
- className="hover:underline text-custom-text-100 font-medium"
+ className="hover:underline text-custom-text-100 font-medium"
+ aria-label={`View ${activity.actor_detail?.display_name}'s profile`}
>
  {activity.actor_detail?.display_name}
</Link>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
)}
<Link
href={activity?.workspace_detail?.slug && activity?.actor_detail?.id
? `/${activity.workspace_detail.slug}/profile/${activity.actor_detail.id}`
: "#"}
className="hover:underline text-custom-text-100 font-medium"
aria-label={`View ${activity.actor_detail?.display_name}'s profile`}
>
{activity.actor_detail?.display_name}
</Link>
)}

</>
);
};
Loading