Skip to content
Open
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
145 changes: 103 additions & 42 deletions apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import { MoreHorizontal } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { PriorityIcon, priorityBlockClasses } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { TIssue } from "@plane/types";
// ui
import { ControlLink } from "@plane/ui";
import { ControlLink, Avatar } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
Expand Down Expand Up @@ -49,32 +52,39 @@ export const CalendarIssueBlock = observer(
const { getIsIssuePeeked } = useIssueDetail();
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
const { labelMap } = useLabel();
const { getUserDetails } = useMember();
const storeType = useIssueStoreType() as CalendarStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();

const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
const stateColor =
getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const priority = issue?.priority || "none";
const assignees = issue?.assignee_ids?.map((id) => getUserDetails(id)).filter(Boolean) || [];
const labels = issue?.label_ids?.map((id) => labelMap[id]).filter(Boolean) || [];

// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile);
const handleIssuePeekOverview = (issue: TIssue) =>
handleRedirection(workspaceSlug.toString(), issue, isMobile);

Comment on lines +69 to 71
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

Possible crash when workspaceSlug is undefined.

Calling toString() on undefined throws. Use String() or guard before redirect.

-const handleIssuePeekOverview = (issue: TIssue) =>
-  handleRedirection(workspaceSlug.toString(), issue, isMobile);
+const handleIssuePeekOverview = (issue: TIssue) => {
+  const slug = workspaceSlug ? String(workspaceSlug) : "";
+  return handleRedirection(slug, issue, isMobile);
+};
📝 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
const handleIssuePeekOverview = (issue: TIssue) =>
handleRedirection(workspaceSlug.toString(), issue, isMobile);
const handleIssuePeekOverview = (issue: TIssue) => {
const slug = workspaceSlug ? String(workspaceSlug) : "";
return handleRedirection(slug, issue, isMobile);
};
🤖 Prompt for AI Agents
In apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx around
lines 69 to 71, calling workspaceSlug.toString() can crash when workspaceSlug is
undefined; guard against undefined or coerce safely. Replace the direct
.toString() with a safe conversion like String(workspaceSlug) or add an early
guard (if (!workspaceSlug) return or handle fallback) before calling
handleRedirection so you never call toString() on undefined.

useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));

const customActionButton = (
<div
ref={menuActionRef}
className={`w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${
isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
className={`w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
Comment on lines 74 to 83
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

Use a button for the action trigger (accessibility + semantics).

Clickable div lacks keyboard/ARIA support. Switch to a with aria-pressed and an accessible label.

-  <div
-    ref={menuActionRef}
-    className={`w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
-      }`}
-    onClick={() => setIsMenuActive(!isMenuActive)}
-  >
-    <MoreHorizontal className="h-3.5 w-3.5" />
-  </div>
+  <button
+    ref={menuActionRef}
+    type="button"
+    aria-pressed={isMenuActive}
+    aria-label="Open quick actions"
+    className={`w-full rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"}`}
+    onClick={() => setIsMenuActive((v) => !v)}
+  >
+    <MoreHorizontal className="h-3.5 w-3.5" />
+  </button>
📝 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
const customActionButton = (
<div
ref={menuActionRef}
className={`w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${
isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
className={`w-full cursor-pointer rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
const customActionButton = (
<button
ref={menuActionRef}
type="button"
aria-pressed={isMenuActive}
aria-label="Open quick actions"
className={`w-full rounded p-1 text-custom-sidebar-text-400 hover:bg-custom-background-80 ${isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"}`}
onClick={() => setIsMenuActive((v) => !v)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
);
🤖 Prompt for AI Agents
In apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx around
lines 74 to 83, replace the clickable div with a semantic <button type="button">
element: keep the same className and onClick behavior, forward the menuActionRef
to the button ref, add aria-pressed={isMenuActive} to reflect state, and provide
an accessible label via aria-label (or include a visually-hidden text) such as
"More actions" so the control is keyboard-focusable and screen-reader friendly.


const isMenuActionRefAboveScreenBottom =
menuActionRef?.current && menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220;
menuActionRef?.current &&
menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220;

const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";

Expand Down Expand Up @@ -103,51 +113,102 @@ export const CalendarIssueBlock = observer(
)}

<div
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

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

The blockRef reference was removed from this div (line 115), but it's still being used in the quickActions call (line 155). This will cause the parentRef to be null, potentially breaking the quick actions functionality.

Suggested change
<div
<div
ref={blockRef}

Copilot uses AI. Check for mistakes.

ref={blockRef}
className={cn(
"group/calendar-block flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 ",
"group/calendar-block flex flex-col w-full gap-2 rounded md:px-2 px-4 py-2 min-h-[60px] md:min-h-[70px]",
priorityBlockClasses[priority],
{
"bg-custom-background-90 shadow-custom-shadow-rg border-custom-primary-100": isDragging,
"bg-custom-background-100 hover:bg-custom-background-90": !isDragging,
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
"hover:bg-opacity-80 transition-all duration-200": !isDragging,
"border-2 border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
}
)}
>
<div className="flex h-full items-center gap-1.5 truncate">
<span
className="h-full w-0.5 flex-shrink-0 rounded"
style={{
backgroundColor: stateColor,
}}
/>
{issue.project_id && (
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
textContainerClassName="text-sm md:text-xs text-custom-text-300"
displayProperties={issuesFilter?.issueFilters?.displayProperties}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<PriorityIcon priority={priority} size={12} />
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: stateColor }}
/>
)}
{issue.project_id && (
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
textContainerClassName="text-xs text-custom-text-400"
displayProperties={issuesFilter?.issueFilters?.displayProperties}
/>
)}
</div>

<div
className={cn("flex-shrink-0 size-5", {
"hidden group-hover/calendar-block:block": !isMobile,
block: isMenuActive,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{quickActions({
issue,
parentRef: blockRef,
customActionButton,
placement,
})}
</div>
</div>

<div className="flex-1">
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<div className="truncate text-sm font-medium md:font-normal md:text-xs">{issue.name}</div>
<div className="text-sm font-medium md:font-normal md:text-xs text-custom-text-100 line-clamp-2">
{issue.name}
</div>
</Tooltip>
</div>
<div
className={cn("flex-shrink-0 size-5", {
"hidden group-hover/calendar-block:block": !isMobile,
block: isMenuActive,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{quickActions({
issue,
parentRef: blockRef,
customActionButton,
placement,
})}

<div className="flex items-center justify-between gap-2 mt-1">
<div className="flex items-center gap-1 flex-wrap">
{labels.slice(0, 2).map((label) => (
<span
key={label.id}
className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium truncate max-w-20"
style={{
backgroundColor: `${label.color}20`,
color: label.color,
borderColor: `${label.color}40`,
}}
>
{label.name}
</span>
))}
{labels.length > 2 && (
<span className="text-xs text-custom-text-300">
+{labels.length - 2}
</span>
)}
</div>

<div className="flex items-center -space-x-1">
{assignees.slice(0, 3).map((assignee) => (
<Tooltip
key={assignee?.id}
tooltipContent={assignee?.display_name || assignee?.first_name}
isMobile={isMobile}
>
<Avatar
src={assignee?.avatar_url}
name={assignee?.display_name || assignee?.first_name}
size="sm"
/>
</Tooltip>
))}
{assignees.length > 3 && (
<div className="flex items-center justify-center bg-custom-background-80 text-custom-text-200 rounded-full ring-2 ring-white text-xs font-medium">
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

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

The hardcoded size and styling for the assignee overflow indicator lacks proper sizing classes. It should have explicit width/height classes like w-6 h-6 to ensure consistent sizing with the Avatar component.

Suggested change
<div className="flex items-center justify-center bg-custom-background-80 text-custom-text-200 rounded-full ring-2 ring-white text-xs font-medium">
<div className="flex items-center justify-center w-6 h-6 bg-custom-background-80 text-custom-text-200 rounded-full ring-2 ring-white text-xs font-medium">

Copilot uses AI. Check for mistakes.

+{assignees.length - 3}
</div>
)}
</div>
</div>
</div>
</>
Expand Down
41 changes: 24 additions & 17 deletions packages/propel/src/icons/priority-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,34 @@ interface IPriorityIcon {
withContainer?: boolean;
}

const priorityClasses = {
urgent: "bg-red-600/20 text-red-600 border-red-600",
high: "bg-orange-500/20 text-orange-500 border-orange-500",
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500",
low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100",
none: "bg-custom-background-80 text-custom-text-200 border-custom-border-300",
};

export const priorityBlockClasses: Record<TIssuePriorities, string> = {
urgent: "bg-red-500/10 border border-red-500/30",
high: "bg-orange-500/10 border border-orange-500/30",
medium: "bg-yellow-500/10 border border-yellow-500/30",
low: "bg-blue-500/10 border border-blue-500/30",
none: "bg-gray-500/10 border border-gray-500/30",
Comment on lines +24 to +28
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

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

The priorityBlockClasses uses different color schemes than priorityClasses (e.g., blue for low priority vs custom-primary-100). This inconsistency could confuse users as the same priority level would appear in different colors in different contexts.

Suggested change
urgent: "bg-red-500/10 border border-red-500/30",
high: "bg-orange-500/10 border border-orange-500/30",
medium: "bg-yellow-500/10 border border-yellow-500/30",
low: "bg-blue-500/10 border border-blue-500/30",
none: "bg-gray-500/10 border border-gray-500/30",
urgent: "bg-red-600/20 border border-red-600",
high: "bg-orange-500/20 border border-orange-500",
medium: "bg-yellow-500/20 border border-yellow-500",
low: "bg-custom-primary-100/20 border border-custom-primary-100",
none: "bg-custom-background-80 border border-custom-border-300",

Copilot uses AI. Check for mistakes.

};

const icons = {
urgent: AlertCircle,
high: SignalHigh,
medium: SignalMedium,
low: SignalLow,
none: Ban,
};

export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
const { priority, className = "", containerClassName = "", size = 14, withContainer = false } = props;

const priorityClasses = {
urgent: "bg-red-600/20 text-red-600 border-red-600",
high: "bg-orange-500/20 text-orange-500 border-orange-500",
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500",
low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100",
none: "bg-custom-background-80 text-custom-text-200 border-custom-border-300",
};

// get priority icon
const icons = {
urgent: AlertCircle,
high: SignalHigh,
medium: SignalMedium,
low: SignalLow,
none: Ban,
};
const Icon = icons[priority ?? "none"];

if (!Icon) return null;

return (
Expand Down