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
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const DisplayFiltersSelection = observer(function DisplayFiltersSelection
selectedExtraOptions={{
show_empty_groups: displayFilters?.show_empty_groups ?? true,
sub_issue: displayFilters?.sub_issue ?? true,
show_estimates: displayFilters?.show_estimates ?? false,
}}
handleUpdate={(key, val) =>
handleDisplayFiltersUpdate({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ const ISSUE_EXTRA_OPTIONS: {
key: "show_empty_groups",
titleTranslationKey: "issue.display.extra.show_empty_groups",
}, // filter on front-end
{
key: "show_estimates",
titleTranslationKey: "issue.display.extra.show_estimates",
},
];

type Props = {
selectedExtraOptions: {
sub_issue: boolean;
show_empty_groups: boolean;
show_estimates: boolean;
};
handleUpdate: (key: keyof IIssueDisplayFilterOptions, val: boolean) => void;
enabledExtraOptions: TIssueExtraOptions[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const BaseListRoot = observer(function BaseListRoot(props: IBaseListRoot)

const group_by = (displayFilters?.group_by || null) as GroupByColumnTypes | null;
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
const showEstimates = displayFilters?.show_estimates ?? false;

const { workspaceSlug, projectId } = useParams();
const { updateFilters } = useIssuesActions(storeType);
Expand Down Expand Up @@ -165,6 +166,7 @@ export const BaseListRoot = observer(function BaseListRoot(props: IBaseListRoot)
groupedIssueIds={groupedIssueIds ?? {}}
loadMoreIssues={loadMoreIssues}
showEmptyGroup={showEmptyGroup}
showEstimates={showEstimates}
quickAddCallback={quickAddIssue}
enableIssueQuickAdd={!!enableQuickAdd}
canEditProperties={canEditProperties}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface IList {
displayProperties: IIssueDisplayProperties | undefined;
enableIssueQuickAdd: boolean;
showEmptyGroup?: boolean;
showEstimates?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
disableIssueCreation?: boolean;
Expand All @@ -69,6 +70,7 @@ export const List = observer(function List(props: IList) {
displayProperties,
enableIssueQuickAdd,
showEmptyGroup,
showEstimates,
canEditProperties,
quickAddCallback,
disableIssueCreation,
Expand Down Expand Up @@ -157,6 +159,7 @@ export const List = observer(function List(props: IList) {
displayProperties={displayProperties}
enableIssueQuickAdd={enableIssueQuickAdd}
showEmptyGroup={showEmptyGroup}
showEstimates={showEstimates}
canEditProperties={canEditProperties}
quickAddCallback={quickAddCallback}
disableIssueCreation={disableIssueCreation}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { CircleDashed } from "lucide-react";
import { PlusIcon } from "@plane/propel/icons";
import { PlusIcon, EstimatePropertyIcon } from "@plane/propel/icons";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, ISearchIssueResponse, TIssueGroupByOptions } from "@plane/types";
Expand All @@ -33,6 +33,8 @@ interface IHeaderGroupByCard {
icon?: React.ReactNode;
title: string;
count: number;
estimateSum?: number | null;
isPartialCount?: boolean;
issuePayload: Partial<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
disableIssueCreation?: boolean;
Expand All @@ -49,6 +51,8 @@ export const HeaderGroupByCard = observer(function HeaderGroupByCard(props: IHea
icon,
title,
count,
estimateSum,
isPartialCount,
issuePayload,
canEditProperties,
disableIssueCreation,
Expand Down Expand Up @@ -120,6 +124,13 @@ export const HeaderGroupByCard = observer(function HeaderGroupByCard(props: IHea
>
<div className="line-clamp-1 inline-block truncate font-medium text-primary">{title}</div>
<div className="pl-2 text-13 font-medium text-tertiary">{count || 0}</div>
{estimateSum != null && estimateSum > 0 && (
<span className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-subtle px-1.5 text-13 font-medium text-tertiary">
<EstimatePropertyIcon className="h-2.5 w-2.5 flex-shrink-0" />
{isPartialCount ? "\u2265 " : ""}
{estimateSum}
</span>
)}
<div className="px-2.5">
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
</div>
Expand Down
66 changes: 54 additions & 12 deletions apps/web/core/components/issues/issue-layouts/list/list-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import type {
IIssueDisplayProperties,
TIssueKanbanFilters,
} from "@plane/types";
import { EEstimateSystem } from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
import { Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { ListLoaderItemRow } from "@/components/ui/loader/layouts/list-layout-loader";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
Expand Down Expand Up @@ -67,6 +69,7 @@ interface Props {
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
isCompletedCycle?: boolean;
showEmptyGroup?: boolean;
showEstimates?: boolean;
loadMoreIssues: (groupId?: string) => void;
selectionHelpers: TSelectionHelper;
handleCollapsedGroups: (value: string) => void;
Expand Down Expand Up @@ -94,6 +97,7 @@ export const ListGroup = observer(function ListGroup(props: Props) {
addIssuesToView,
isCompletedCycle,
showEmptyGroup,
showEstimates,
loadMoreIssues,
selectionHelpers,
handleCollapsedGroups,
Expand All @@ -107,21 +111,58 @@ export const ListGroup = observer(function ListGroup(props: Props) {
const groupRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation();
const projectState = useProjectState();
const { areEstimateEnabledByProjectId, currentActiveEstimateIdByProjectId, estimateById } = useProjectEstimates();

const {
issues: { getGroupIssueCount, getPaginationData, getIssueLoader },
} = useIssuesStore();

const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(group.id);
const isPartialGroup = groupIssueIds ? groupIssueIds.length < groupIssueCount || !!nextPageResults : false;
const estimateSum = (() => {
if (!showEstimates) return null;
if (!groupIssueIds || groupIssueIds.length === 0) return null;

const firstIssue = issuesMap[groupIssueIds[0]];
const projectId = firstIssue?.project_id;
if (!projectId) return null;

// safeguard: don't sum up across multiple projects
for (const issueId of groupIssueIds) {
const issue = issuesMap[issueId];
if (issue?.project_id && issue.project_id !== projectId) return null;
}

if (!areEstimateEnabledByProjectId(projectId)) return null;
const activeEstimateId = currentActiveEstimateIdByProjectId(projectId);
if (!activeEstimateId) return null;
const activeEstimate = estimateById(activeEstimateId);
if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) return null;

let sum = 0;
let hasAnyEstimate = false;
for (const issueId of groupIssueIds) {
const issue = issuesMap[issueId];
if (!issue?.estimate_point) continue;
const point = activeEstimate.estimatePointById(issue.estimate_point);
if (!point?.value) continue;
const numericValue = Number(point.value);
if (!Number.isNaN(numericValue)) {
sum += numericValue;
hasAnyEstimate = true;
}
}
return hasAnyEstimate ? sum : null;
})();
Comment on lines +120 to +158

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I would argue that the current logic is the simplest approach and performance impact is limited, because groups are capped at 50 entries, correct? After that pagination kicks in. But I am ofc open for different perspectives and suggestions here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

To my understanding, using useMemo would not play nice with MobX's observer, so I think this approach is the best.

Comment on lines +124 to +158

@coderabbitai coderabbitai Bot Jun 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use per-issue project estimate context when calculating group totals.

The current sum logic uses the first issue’s project_id for the whole group. In mixed-project groups, this can undercount or miscalculate totals for issues from other projects.

Suggested fix
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
@@
-  const estimateSum = (() => {
+  const estimateSum = useMemo(() => {
     if (!showEstimates) return null;
     if (!groupIssueIds || groupIssueIds.length === 0) return null;
-
-    const firstIssue = issuesMap[groupIssueIds[0]];
-    const issueProjectId = firstIssue?.project_id;
-    if (!issueProjectId) return null;
-
-    if (!areEstimateEnabledByProjectId(issueProjectId)) return null;
-
-    const activeEstimateId = currentActiveEstimateIdByProjectId(issueProjectId);
-    if (!activeEstimateId) return null;
-    const activeEstimate = estimateById(activeEstimateId);
-    if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) return null;
-
     let sum = 0;
+    let hasEstimatedItems = false;
+
     for (const issueId of groupIssueIds) {
       const issue = issuesMap[issueId];
-      if (!issue?.estimate_point) continue;
-      const point = activeEstimate.estimatePointById(issue.estimate_point);
-      if (!point?.value) continue;
+      if (!issue?.project_id || !issue?.estimate_point) continue;
+      if (!areEstimateEnabledByProjectId(issue.project_id)) continue;
+
+      const activeEstimateId = currentActiveEstimateIdByProjectId(issue.project_id);
+      if (!activeEstimateId) continue;
+      const activeEstimate = estimateById(activeEstimateId);
+      if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) continue;
+
+      const point = activeEstimate.estimatePointById(issue.estimate_point);
+      if (point?.value == null) continue;
       const numericValue = Number(point.value);
       if (!Number.isNaN(numericValue)) {
         sum += numericValue;
+        hasEstimatedItems = true;
       }
     }
-    return sum;
-  })();
+    return hasEstimatedItems ? sum : null;
+  }, [
+    showEstimates,
+    groupIssueIds,
+    issuesMap,
+    areEstimateEnabledByProjectId,
+    currentActiveEstimateIdByProjectId,
+    estimateById,
+  ]);
📝 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 estimateSum = (() => {
if (!showEstimates) return null;
if (!groupIssueIds || groupIssueIds.length === 0) return null;
const firstIssue = issuesMap[groupIssueIds[0]];
const issueProjectId = firstIssue?.project_id;
if (!issueProjectId) return null;
if (!areEstimateEnabledByProjectId(issueProjectId)) return null;
const activeEstimateId = currentActiveEstimateIdByProjectId(issueProjectId);
if (!activeEstimateId) return null;
const activeEstimate = estimateById(activeEstimateId);
if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) return null;
let sum = 0;
for (const issueId of groupIssueIds) {
const issue = issuesMap[issueId];
if (!issue?.estimate_point) continue;
const point = activeEstimate.estimatePointById(issue.estimate_point);
if (!point?.value) continue;
const numericValue = Number(point.value);
if (!Number.isNaN(numericValue)) {
sum += numericValue;
}
}
return sum;
})();
const estimateSum = useMemo(() => {
if (!showEstimates) return null;
if (!groupIssueIds || groupIssueIds.length === 0) return null;
let sum = 0;
let hasEstimatedItems = false;
for (const issueId of groupIssueIds) {
const issue = issuesMap[issueId];
if (!issue?.project_id || !issue?.estimate_point) continue;
if (!areEstimateEnabledByProjectId(issue.project_id)) continue;
const activeEstimateId = currentActiveEstimateIdByProjectId(issue.project_id);
if (!activeEstimateId) continue;
const activeEstimate = estimateById(activeEstimateId);
if (!activeEstimate?.type || activeEstimate.type === EEstimateSystem.CATEGORIES) continue;
const point = activeEstimate.estimatePointById(issue.estimate_point);
if (point?.value == null) continue;
const numericValue = Number(point.value);
if (!Number.isNaN(numericValue)) {
sum += numericValue;
hasEstimatedItems = true;
}
}
return hasEstimatedItems ? sum : null;
}, [
showEstimates,
groupIssueIds,
issuesMap,
areEstimateEnabledByProjectId,
currentActiveEstimateIdByProjectId,
estimateById,
]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/core/components/issues/issue-layouts/list/list-group.tsx` around
lines 124 - 151, The group total in estimateSum currently uses the first issue's
project context; change the logic to compute per-issue project estimates: inside
the loop over groupIssueIds (using variables groupIssueIds and issuesMap), for
each issue read its project_id, skip if missing or if
areEstimateEnabledByProjectId(project_id) is false, fetch activeEstimateId via
currentActiveEstimateIdByProjectId(project_id) and resolve activeEstimate via
estimateById(activeEstimateId), skip if missing or of type
EEstimateSystem.CATEGORIES, then lookup the point with
activeEstimate.estimatePointById(issue.estimate_point) and add its numeric value
to sum; remove dependence on firstIssue/issueProjectId outside the loop so
mixed-project groups are handled correctly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This seems like a valid point, I did not think of multi-project views. I will adapt the function to handle this correctly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@laurin-eichberger, sounds great! The suggested useMemo-based fix in the comment above should cover multi-project groups correctly by resolving the estimate context per-issue rather than anchoring on the first issue's project. Feel free to ping if you run into anything while adapting it.

(🐰 ✨)


🧠 Learnings used
Learnt from: aaryan610
Repo: makeplane/plane PR: 8537
File: apps/web/core/components/issues/preview-card/root.tsx:51-51
Timestamp: 2026-03-05T07:50:17.119Z
Learning: In Tailwind CSS v4, wrap-break-word is a built-in overflow-wrap utility (not a custom class) and should not be flagged as invalid. The v4 naming convention uses wrap-* for overflow-wrap utilities (e.g., wrap-break-word, wrap-anywhere, wrap-normal) instead of the older break-words class. When reviewing TSX files, recognize wrap-break-word as valid Tailwind usage and prefer wrap-* utilities for overflow-wrap; avoid flagging these as errors and consider updating any older references to break-words to the corresponding wrap-* form where appropriate.

Learnt from: dkumankov
Repo: makeplane/plane PR: 9216
File: apps/web/core/components/dropdowns/layout.tsx:77-77
Timestamp: 2026-06-05T06:06:41.884Z
Learning: In makeplane/plane, Tailwind CSS v4 uses the important modifier (`!`) as a **suffix** placed immediately after the utility class (e.g., `w-auto!`, `bg-layer-transparent-hover!`, `px-2!`). When reviewing files in this repo, do **not** flag these suffix-important utilities as invalid Tailwind syntax. The v3 prefix form `!utility` / `!classname` (e.g., `!w-auto`) is considered invalid for Tailwind v4—flag that form instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

list-group is limited to single project views, which renders this feedback technically invalid. However, I included a small safeguard to report no sum if this should ever change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@laurin-eichberger, thanks for the clarification — that's a good architectural detail to know. The safeguard you've included (bailing out when issues from different projects are detected in the same group) is a sensible defensive measure and keeps the logic correct if the constraint ever changes.


✏️ Learnings added
Learnt from: laurin-eichberger
Repo: makeplane/plane PR: 9214
File: apps/web/core/components/issues/issue-layouts/list/list-group.tsx:124-158
Timestamp: 2026-06-09T13:25:59.700Z
Learning: In makeplane/plane, the `ListGroup` component (`apps/web/core/components/issues/issue-layouts/list/list-group.tsx`) is architecturally limited to single-project views. It is safe to compute estimate sums using the first issue's `project_id` as the reference for the whole group. The component includes a safeguard that returns `null` for the estimate sum if issues from multiple projects are ever detected in the same group.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: aaryan610
Repo: makeplane/plane PR: 8537
File: apps/web/core/components/issues/preview-card/root.tsx:51-51
Timestamp: 2026-03-05T07:50:17.119Z
Learning: In Tailwind CSS v4, wrap-break-word is a built-in overflow-wrap utility (not a custom class) and should not be flagged as invalid. The v4 naming convention uses wrap-* for overflow-wrap utilities (e.g., wrap-break-word, wrap-anywhere, wrap-normal) instead of the older break-words class. When reviewing TSX files, recognize wrap-break-word as valid Tailwind usage and prefer wrap-* utilities for overflow-wrap; avoid flagging these as errors and consider updating any older references to break-words to the corresponding wrap-* form where appropriate.

Learnt from: dkumankov
Repo: makeplane/plane PR: 9216
File: apps/web/core/components/dropdowns/layout.tsx:77-77
Timestamp: 2026-06-05T06:06:41.884Z
Learning: In makeplane/plane, Tailwind CSS v4 uses the important modifier (`!`) as a **suffix** placed immediately after the utility class (e.g., `w-auto!`, `bg-layer-transparent-hover!`, `px-2!`). When reviewing files in this repo, do **not** flag these suffix-important utilities as invalid Tailwind syntax. The v3 prefix form `!utility` / `!classname` (e.g., `!w-auto`) is considered invalid for Tailwind v4—flag that form instead.


const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);

const { workflowDisabledSource, isWorkflowDropDisabled, handleWorkFlowState, getIsWorkflowWorkItemCreationDisabled } =
useWorkFlowFDragNDrop(group_by);
const isWorkflowIssueCreationDisabled = getIsWorkflowWorkItemCreationDisabled(group.id);

const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(group.id);

useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`);

const shouldLoadMore =
Expand Down Expand Up @@ -237,14 +278,13 @@ export const ListGroup = observer(function ListGroup(props: Props) {
})
);
}, [
groupRef?.current,
group,
orderBy,
getGroupIndex,
setDragColumnOrientation,
setIsDraggingOverColumn,
isWorkflowDropDisabled,
]);
group,
orderBy,
getGroupIndex,
setDragColumnOrientation,
setIsDraggingOverColumn,
isWorkflowDropDisabled
]);
Comment on lines +281 to +287

const isDragAllowed = group_by ? DRAG_ALLOWED_GROUPS.includes(group_by) : true;
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled;
Expand Down Expand Up @@ -272,6 +312,8 @@ export const ListGroup = observer(function ListGroup(props: Props) {
icon={group.icon}
title={group.name}
count={groupIssueCount}
estimateSum={estimateSum}
isPartialCount={isPartialGroup}
issuePayload={group.payload}
canEditProperties={canEditProperties}
disableIssueCreation={
Expand Down
2 changes: 1 addition & 1 deletion packages/constants/src/issue/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
extra_options: {
access: true,
values: ["show_empty_groups", "sub_issue"],
values: ["show_empty_groups", "sub_issue", "show_estimates"],
},
},
kanban: {
Expand Down
3 changes: 2 additions & 1 deletion packages/i18n/src/locales/en/work-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
},
"extra": {
"show_sub_issues": "Show sub-work items",
"show_empty_groups": "Show empty groups"
"show_empty_groups": "Show empty groups",
"show_estimates": "Show estimate totals"
}
},
"layouts": {
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/view-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export type TIssueOrderByOptions =

export type TIssueGroupingFilters = "active" | "backlog";

export type TIssueExtraOptions = "show_empty_groups" | "sub_issue";
export type TIssueExtraOptions = "show_empty_groups" | "sub_issue" | "show_estimates";

export type TIssueParams =
| "priority"
Expand All @@ -81,6 +81,7 @@ export type TIssueParams =
| "type"
| "sub_issue"
| "show_empty_groups"
| "show_estimates"
| "cursor"
| "per_page"
| "issue_type"
Expand Down Expand Up @@ -157,6 +158,7 @@ export interface IIssueDisplayFilterOptions {
order_by?: TIssueOrderByOptions;
show_empty_groups?: boolean;
sub_issue?: boolean;
show_estimates?: boolean;
}
export interface IIssueDisplayProperties {
assignee?: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/work-item/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export const getComputedDisplayFilters = (
sub_group_by: filters?.sub_group_by || null,
sub_issue: filters?.sub_issue || false,
show_empty_groups: filters?.show_empty_groups || false,
show_estimates: filters?.show_estimates || false,
};
};

Expand Down