From aa348d5d6487dc4c4adc1d330e3c65ab9c2fa948 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Apr 2026 18:19:00 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D1=85=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=88=D0=BA=D0=B0=D0=BB=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/serializers/external_contours.py | 16 ++++++ .../apps/api/plane/app/serializers/issue.py | 17 +++++++ .../apps/api/plane/app/views/issue/base.py | 7 +++ plane-src/apps/api/plane/utils/grouper.py | 4 +- .../api/plane/utils/issue_checker_progress.py | 50 +++++++++++++++++++ .../projects/external-contours/board-item.tsx | 27 +++++++++- .../kanban/internal-contour-card.tsx | 25 ++++++++-- .../shared/nodedc-work-item-card.tsx | 46 +++++++++++++++++ plane-src/packages/types/src/issues/issue.ts | 3 ++ 9 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 plane-src/apps/api/plane/utils/issue_checker_progress.py diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py index 798e3eb..5a4a048 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -11,6 +11,7 @@ from .state import StateLiteSerializer from .user import UserLiteSerializer from plane.app.serializers.issue import LabelSerializer from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project +from plane.utils.issue_checker_progress import build_issue_checker_progress class ExternalContourIssuePayloadSerializer(serializers.Serializer): @@ -86,6 +87,9 @@ class ExternalContourIssueSerializer(BaseSerializer): label_ids = serializers.SerializerMethodField() project_detail = ProjectLiteSerializer(source="project", read_only=True) state_detail = StateLiteSerializer(source="state", read_only=True) + checker_blocks_count = serializers.SerializerMethodField() + checker_items_count = serializers.SerializerMethodField() + checker_items_completed_count = serializers.SerializerMethodField() class Meta: model = Issue @@ -105,6 +109,9 @@ class ExternalContourIssueSerializer(BaseSerializer): "label_details", "assignee_ids", "assignee_details", + "checker_blocks_count", + "checker_items_count", + "checker_items_completed_count", "state_detail", "project_detail", "created_by_detail", @@ -126,6 +133,15 @@ class ExternalContourIssueSerializer(BaseSerializer): for label_bridge in obj.label_issue.all() ] + def get_checker_blocks_count(self, obj): + return build_issue_checker_progress(obj.detail_layout).get("checker_blocks_count", 0) + + def get_checker_items_count(self, obj): + return build_issue_checker_progress(obj.detail_layout).get("checker_items_count", 0) + + def get_checker_items_completed_count(self, obj): + return build_issue_checker_progress(obj.detail_layout).get("checker_items_completed_count", 0) + class ExternalContourMirroredAttachmentSerializer(BaseSerializer): uploaded_by = serializers.SerializerMethodField() diff --git a/plane-src/apps/api/plane/app/serializers/issue.py b/plane-src/apps/api/plane/app/serializers/issue.py index af25400..fc71946 100644 --- a/plane-src/apps/api/plane/app/serializers/issue.py +++ b/plane-src/apps/api/plane/app/serializers/issue.py @@ -48,6 +48,7 @@ from plane.utils.content_validator import ( validate_binary_data, ) from plane.utils.date_utils import set_default_issue_start_date +from plane.utils.issue_checker_progress import build_issue_checker_progress class IssueFlatSerializer(BaseSerializer): @@ -773,6 +774,9 @@ class IssueSerializer(DynamicBaseSerializer): # Count items sub_issues_count = serializers.IntegerField(read_only=True) + checker_blocks_count = serializers.SerializerMethodField() + checker_items_count = serializers.SerializerMethodField() + checker_items_completed_count = serializers.SerializerMethodField() attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) @@ -796,6 +800,9 @@ class IssueSerializer(DynamicBaseSerializer): "label_ids", "assignee_ids", "sub_issues_count", + "checker_blocks_count", + "checker_items_count", + "checker_items_completed_count", "created_at", "updated_at", "created_by", @@ -809,6 +816,15 @@ class IssueSerializer(DynamicBaseSerializer): ] read_only_fields = fields + def get_checker_blocks_count(self, obj): + return build_issue_checker_progress(obj.detail_layout).get("checker_blocks_count", 0) + + def get_checker_items_count(self, obj): + return build_issue_checker_progress(obj.detail_layout).get("checker_items_count", 0) + + def get_checker_items_completed_count(self, obj): + return build_issue_checker_progress(obj.detail_layout).get("checker_items_completed_count", 0) + def validate(self, data): if ( data.get("state_id") @@ -868,6 +884,7 @@ class IssueListDetailSerializer(serializers.Serializer): "label_ids": self.get_label_ids(instance), "assignee_ids": self.get_assignee_ids(instance), "sub_issues_count": instance.sub_issues_count, + **build_issue_checker_progress(instance.detail_layout), "attachment_count": instance.attachment_count, "link_count": instance.link_count, } diff --git a/plane-src/apps/api/plane/app/views/issue/base.py b/plane-src/apps/api/plane/app/views/issue/base.py index 81cbe14..3efcfa8 100644 --- a/plane-src/apps/api/plane/app/views/issue/base.py +++ b/plane-src/apps/api/plane/app/views/issue/base.py @@ -71,6 +71,7 @@ from plane.utils.grouper import ( ) from plane.utils.host import base_host from plane.utils.issue_filters import issue_filters +from plane.utils.issue_checker_progress import attach_issue_checker_progress from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.utils.timezone_converter import user_timezone_converter @@ -190,6 +191,7 @@ class IssueListEndpoint(BaseAPIView): "label_ids", "assignee_ids", "sub_issues_count", + "detail_layout", "created_at", "updated_at", "created_by", @@ -205,6 +207,7 @@ class IssueListEndpoint(BaseAPIView): ) datetime_fields = ["created_at", "updated_at"] issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone) + issues = attach_issue_checker_progress(issues) return Response(issues, status=status.HTTP_200_OK) @@ -470,6 +473,7 @@ class IssueViewSet(BaseViewSet): "label_ids", "assignee_ids", "sub_issues_count", + "detail_layout", "created_at", "updated_at", "created_by", @@ -486,6 +490,7 @@ class IssueViewSet(BaseViewSet): ) datetime_fields = ["created_at", "updated_at"] issue = user_timezone_converter(issue, datetime_fields, request.user.user_timezone) + issue = attach_issue_checker_progress([issue])[0] if issue else issue # Send the model activity model_activity.delay( model_name="issue", @@ -893,6 +898,7 @@ class IssuePaginatedViewSet(BaseViewSet): # converting the datetime fields in paginated data datetime_fields = ["created_at", "updated_at"] paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) + paginated_data = attach_issue_checker_progress(paginated_data) return paginated_data @@ -930,6 +936,7 @@ class IssuePaginatedViewSet(BaseViewSet): "link_count", "attachment_count", "sub_issues_count", + "detail_layout", ] if str(is_description_required).lower() == "true": diff --git a/plane-src/apps/api/plane/utils/grouper.py b/plane-src/apps/api/plane/utils/grouper.py index f92fadc..c34a6ea 100644 --- a/plane-src/apps/api/plane/utils/grouper.py +++ b/plane-src/apps/api/plane/utils/grouper.py @@ -22,6 +22,7 @@ from plane.db.models import ( ModuleIssue, IssueLabel, ) +from plane.utils.issue_checker_progress import attach_issue_checker_progress from typing import Optional, Dict, Tuple, Any, Union, List @@ -118,6 +119,7 @@ def issue_on_results( "parent_id", "cycle_id", "sub_issues_count", + "detail_layout", "created_at", "updated_at", "created_by", @@ -141,7 +143,7 @@ def issue_on_results( original_list.append(sub_group_by) required_fields.extend(original_list) - return list(issues.values(*required_fields)) + return attach_issue_checker_progress(issues.values(*required_fields)) def issue_group_values( diff --git a/plane-src/apps/api/plane/utils/issue_checker_progress.py b/plane-src/apps/api/plane/utils/issue_checker_progress.py new file mode 100644 index 0000000..630ebbe --- /dev/null +++ b/plane-src/apps/api/plane/utils/issue_checker_progress.py @@ -0,0 +1,50 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks" + + +def build_issue_checker_progress(detail_layout): + blocks = detail_layout.get(STRUCTURED_BLOCKS_KEY) if isinstance(detail_layout, dict) else [] + + checker_blocks_count = 0 + checker_items_count = 0 + checker_items_completed_count = 0 + + if not isinstance(blocks, list): + blocks = [] + + for block in blocks: + if not isinstance(block, dict) or block.get("type") != "checker": + continue + + checker_blocks_count += 1 + items = block.get("items") + + if not isinstance(items, list): + continue + + for item in items: + if not isinstance(item, dict): + continue + + checker_items_count += 1 + if item.get("checked"): + checker_items_completed_count += 1 + + return { + "checker_blocks_count": checker_blocks_count, + "checker_items_count": checker_items_count, + "checker_items_completed_count": checker_items_completed_count, + } + + +def attach_issue_checker_progress(items): + prepared_items = list(items) + + for item in prepared_items: + detail_layout = item.pop("detail_layout", None) + item.update(build_issue_checker_progress(detail_layout)) + + return prepared_items diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index 7683d8c..c3ac437 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -28,6 +28,7 @@ import { useProjectState } from "@/hooks/store/use-project-state"; import { useUserPermissions } from "@/hooks/store/user"; import { IssueArchiveService } from "@/services/issue/issue_archive.service"; import { IssueService } from "@/services/issue/issue.service"; +import { NodedcWorkItemProgress } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card"; import { ExternalContourDeleteModal } from "./delete-modal"; type Props = { @@ -143,6 +144,10 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white" ); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); + const checkerBlocksTotal = issue.checker_blocks_count ?? 0; + const checkerItemsTotal = issue.checker_items_count ?? 0; + const checkerItemsCompleted = issue.checker_items_completed_count ?? 0; + const hasCheckerProgress = checkerBlocksTotal > 0; const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group); if (!issue) return null; @@ -459,8 +464,26 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard -
-
{issue.name}
+
+ {hasCheckerProgress ? ( +
+ +
+ {issue.name} +
+
+ ) : ( +
{issue.name}
+ )}
diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx index d9950fb..e76d044 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx @@ -21,7 +21,11 @@ import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; import { usePlatformOS } from "@/hooks/use-platform-os"; -import { NodedcWorkItemCard, getNodedcWorkItemCardAppearance } from "../shared/nodedc-work-item-card"; +import { + NodedcWorkItemCard, + NodedcWorkItemProgress, + getNodedcWorkItemCardAppearance, +} from "../shared/nodedc-work-item-card"; import type { TRenderQuickActions } from "../list/list-view-types"; type Props = { @@ -78,6 +82,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban const creatorId = creatorDetails?.id || issue.created_by; const workspaceSlug = routerWorkspaceSlug?.toString(); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); + const checkerBlocksTotal = issue.checker_blocks_count ?? 0; + const checkerItemsTotal = issue.checker_items_count ?? 0; + const checkerItemsCompleted = issue.checker_items_completed_count ?? 0; + const hasCheckerProgress = checkerBlocksTotal > 0; const cornerControlClasses = cn( "flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none", isActive @@ -216,7 +224,16 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
); - const title = ( + const title = hasCheckerProgress ? ( +
+ +
{issue.name}
+
+ ) : (
{issue.name}
); @@ -256,7 +273,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban contentClassName="min-h-[220px] px-1" header={header} title={title} - titleContainerClassName="justify-start px-1 pt-7 pb-4 text-left" + titleContainerClassName={cn( + hasCheckerProgress ? "justify-center px-0.5 pt-4 pb-4 text-left" : "justify-start px-1 pt-7 pb-4 text-left" + )} titleClassName="line-clamp-none w-full text-[15px] leading-5 font-medium" footer={footer} footerClassName="pointer-events-auto items-end gap-3" diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx index 3c91b4c..9bc32c4 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx @@ -37,6 +37,52 @@ export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({ iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white", }); +type TNodedcWorkItemProgressProps = { + completedCount?: number | null; + totalCount?: number | null; + isActive: boolean; +}; + +export const NodedcWorkItemProgress = (props: TNodedcWorkItemProgressProps) => { + const { completedCount, totalCount, isActive } = props; + const total = Math.max(totalCount ?? 0, 0); + const completed = Math.min(Math.max(completedCount ?? 0, 0), total); + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + const segments = [0, 1, 2].map((segmentIndex) => { + const segmentStart = segmentIndex * (100 / 3); + const segmentEnd = (segmentIndex + 1) * (100 / 3); + const filled = Math.min(Math.max(((percentage - segmentStart) / (segmentEnd - segmentStart)) * 100, 0), 100); + + return filled; + }); + + return ( +
+ + {percentage}% + +
+ {segments.map((segmentFill, segmentIndex) => ( +
+
+
+ ))} +
+
+ ); +}; + export const NodedcWorkItemCard = ({ isActive, priority, diff --git a/plane-src/packages/types/src/issues/issue.ts b/plane-src/packages/types/src/issues/issue.ts index a62d128..792ca09 100644 --- a/plane-src/packages/types/src/issues/issue.ts +++ b/plane-src/packages/types/src/issues/issue.ts @@ -56,6 +56,9 @@ export type TBaseIssue = { estimate_point: string | null; sub_issues_count: number; + checker_blocks_count?: number | null; + checker_items_count?: number | null; + checker_items_completed_count?: number | null; attachment_count: number; link_count: number;