UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: трехсекционная шкала прогресса чекеров

This commit is contained in:
DCCONSTRUCTIONS 2026-04-29 18:19:00 +03:00
parent 5f9d9c418e
commit aa348d5d64
9 changed files with 189 additions and 6 deletions

View File

@ -11,6 +11,7 @@ from .state import StateLiteSerializer
from .user import UserLiteSerializer from .user import UserLiteSerializer
from plane.app.serializers.issue import LabelSerializer from plane.app.serializers.issue import LabelSerializer
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project 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): class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@ -86,6 +87,9 @@ class ExternalContourIssueSerializer(BaseSerializer):
label_ids = serializers.SerializerMethodField() label_ids = serializers.SerializerMethodField()
project_detail = ProjectLiteSerializer(source="project", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True)
state_detail = StateLiteSerializer(source="state", 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: class Meta:
model = Issue model = Issue
@ -105,6 +109,9 @@ class ExternalContourIssueSerializer(BaseSerializer):
"label_details", "label_details",
"assignee_ids", "assignee_ids",
"assignee_details", "assignee_details",
"checker_blocks_count",
"checker_items_count",
"checker_items_completed_count",
"state_detail", "state_detail",
"project_detail", "project_detail",
"created_by_detail", "created_by_detail",
@ -126,6 +133,15 @@ class ExternalContourIssueSerializer(BaseSerializer):
for label_bridge in obj.label_issue.all() 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): class ExternalContourMirroredAttachmentSerializer(BaseSerializer):
uploaded_by = serializers.SerializerMethodField() uploaded_by = serializers.SerializerMethodField()

View File

@ -48,6 +48,7 @@ from plane.utils.content_validator import (
validate_binary_data, validate_binary_data,
) )
from plane.utils.date_utils import set_default_issue_start_date 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): class IssueFlatSerializer(BaseSerializer):
@ -773,6 +774,9 @@ class IssueSerializer(DynamicBaseSerializer):
# Count items # Count items
sub_issues_count = serializers.IntegerField(read_only=True) 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) attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True)
@ -796,6 +800,9 @@ class IssueSerializer(DynamicBaseSerializer):
"label_ids", "label_ids",
"assignee_ids", "assignee_ids",
"sub_issues_count", "sub_issues_count",
"checker_blocks_count",
"checker_items_count",
"checker_items_completed_count",
"created_at", "created_at",
"updated_at", "updated_at",
"created_by", "created_by",
@ -809,6 +816,15 @@ class IssueSerializer(DynamicBaseSerializer):
] ]
read_only_fields = fields 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): def validate(self, data):
if ( if (
data.get("state_id") data.get("state_id")
@ -868,6 +884,7 @@ class IssueListDetailSerializer(serializers.Serializer):
"label_ids": self.get_label_ids(instance), "label_ids": self.get_label_ids(instance),
"assignee_ids": self.get_assignee_ids(instance), "assignee_ids": self.get_assignee_ids(instance),
"sub_issues_count": instance.sub_issues_count, "sub_issues_count": instance.sub_issues_count,
**build_issue_checker_progress(instance.detail_layout),
"attachment_count": instance.attachment_count, "attachment_count": instance.attachment_count,
"link_count": instance.link_count, "link_count": instance.link_count,
} }

View File

@ -71,6 +71,7 @@ from plane.utils.grouper import (
) )
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.issue_filters import issue_filters 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.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.utils.timezone_converter import user_timezone_converter from plane.utils.timezone_converter import user_timezone_converter
@ -190,6 +191,7 @@ class IssueListEndpoint(BaseAPIView):
"label_ids", "label_ids",
"assignee_ids", "assignee_ids",
"sub_issues_count", "sub_issues_count",
"detail_layout",
"created_at", "created_at",
"updated_at", "updated_at",
"created_by", "created_by",
@ -205,6 +207,7 @@ class IssueListEndpoint(BaseAPIView):
) )
datetime_fields = ["created_at", "updated_at"] datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone) 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) return Response(issues, status=status.HTTP_200_OK)
@ -470,6 +473,7 @@ class IssueViewSet(BaseViewSet):
"label_ids", "label_ids",
"assignee_ids", "assignee_ids",
"sub_issues_count", "sub_issues_count",
"detail_layout",
"created_at", "created_at",
"updated_at", "updated_at",
"created_by", "created_by",
@ -486,6 +490,7 @@ class IssueViewSet(BaseViewSet):
) )
datetime_fields = ["created_at", "updated_at"] datetime_fields = ["created_at", "updated_at"]
issue = user_timezone_converter(issue, datetime_fields, request.user.user_timezone) 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 # Send the model activity
model_activity.delay( model_activity.delay(
model_name="issue", model_name="issue",
@ -893,6 +898,7 @@ class IssuePaginatedViewSet(BaseViewSet):
# converting the datetime fields in paginated data # converting the datetime fields in paginated data
datetime_fields = ["created_at", "updated_at"] datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone)
paginated_data = attach_issue_checker_progress(paginated_data)
return paginated_data return paginated_data
@ -930,6 +936,7 @@ class IssuePaginatedViewSet(BaseViewSet):
"link_count", "link_count",
"attachment_count", "attachment_count",
"sub_issues_count", "sub_issues_count",
"detail_layout",
] ]
if str(is_description_required).lower() == "true": if str(is_description_required).lower() == "true":

View File

@ -22,6 +22,7 @@ from plane.db.models import (
ModuleIssue, ModuleIssue,
IssueLabel, IssueLabel,
) )
from plane.utils.issue_checker_progress import attach_issue_checker_progress
from typing import Optional, Dict, Tuple, Any, Union, List from typing import Optional, Dict, Tuple, Any, Union, List
@ -118,6 +119,7 @@ def issue_on_results(
"parent_id", "parent_id",
"cycle_id", "cycle_id",
"sub_issues_count", "sub_issues_count",
"detail_layout",
"created_at", "created_at",
"updated_at", "updated_at",
"created_by", "created_by",
@ -141,7 +143,7 @@ def issue_on_results(
original_list.append(sub_group_by) original_list.append(sub_group_by)
required_fields.extend(original_list) required_fields.extend(original_list)
return list(issues.values(*required_fields)) return attach_issue_checker_progress(issues.values(*required_fields))
def issue_group_values( def issue_group_values(

View File

@ -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

View File

@ -28,6 +28,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
import { IssueArchiveService } from "@/services/issue/issue_archive.service"; import { IssueArchiveService } from "@/services/issue/issue_archive.service";
import { IssueService } from "@/services/issue/issue.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"; import { ExternalContourDeleteModal } from "./delete-modal";
type Props = { type Props = {
@ -143,6 +144,10 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white" 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 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); const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group);
if (!issue) return null; if (!issue) return null;
@ -459,8 +464,26 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
</div> </div>
</div> </div>
<div className="flex flex-1 items-center justify-start px-1 pt-7 pb-4 text-left"> <div
className={cn(
"flex flex-1 items-center pb-4",
hasCheckerProgress ? "justify-center px-0.5 pt-4 text-left" : "justify-start px-1 pt-7 text-left"
)}
>
{hasCheckerProgress ? (
<div className="flex w-full flex-col items-stretch gap-3">
<NodedcWorkItemProgress
completedCount={checkerItemsCompleted}
totalCount={checkerItemsTotal}
isActive={isActive}
/>
<div className="line-clamp-4 w-full text-left text-[15px] leading-5 font-medium">
{issue.name}
</div>
</div>
) : (
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div> <div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
)}
</div> </div>
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}> <div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>

View File

@ -21,7 +21,11 @@ import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { usePlatformOS } from "@/hooks/use-platform-os"; 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"; import type { TRenderQuickActions } from "../list/list-view-types";
type Props = { type Props = {
@ -78,6 +82,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const creatorId = creatorDetails?.id || issue.created_by; const creatorId = creatorDetails?.id || issue.created_by;
const workspaceSlug = routerWorkspaceSlug?.toString(); const workspaceSlug = routerWorkspaceSlug?.toString();
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); 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( 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", "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 isActive
@ -216,7 +224,16 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
</div> </div>
); );
const title = ( const title = hasCheckerProgress ? (
<div className="flex w-full flex-col items-stretch gap-3">
<NodedcWorkItemProgress
completedCount={checkerItemsCompleted}
totalCount={checkerItemsTotal}
isActive={isActive}
/>
<div className="line-clamp-4 w-full text-left text-[15px] leading-5 font-medium">{issue.name}</div>
</div>
) : (
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div> <div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
); );
@ -256,7 +273,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
contentClassName="min-h-[220px] px-1" contentClassName="min-h-[220px] px-1"
header={header} header={header}
title={title} 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" titleClassName="line-clamp-none w-full text-[15px] leading-5 font-medium"
footer={footer} footer={footer}
footerClassName="pointer-events-auto items-end gap-3" footerClassName="pointer-events-auto items-end gap-3"

View File

@ -37,6 +37,52 @@ export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white", 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 (
<div className="relative w-full pt-5">
<span
className={cn(
"absolute -top-1 right-6 translate-x-1/2 text-center text-[8px] leading-[10px] font-medium",
isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"
)}
>
{percentage}%
</span>
<div className="flex h-[2.5px] w-full gap-[2.5px]">
{segments.map((segmentFill, segmentIndex) => (
<div
key={segmentIndex}
className={cn("h-full flex-1 overflow-hidden rounded-full", isActive ? "bg-black/20" : "bg-white/16")}
>
<div
className={cn("h-full rounded-full", isActive ? "bg-black" : "bg-[#C3FF66]")}
style={{ width: `${segmentFill}%` }}
/>
</div>
))}
</div>
</div>
);
};
export const NodedcWorkItemCard = ({ export const NodedcWorkItemCard = ({
isActive, isActive,
priority, priority,

View File

@ -56,6 +56,9 @@ export type TBaseIssue = {
estimate_point: string | null; estimate_point: string | null;
sub_issues_count: number; sub_issues_count: number;
checker_blocks_count?: number | null;
checker_items_count?: number | null;
checker_items_completed_count?: number | null;
attachment_count: number; attachment_count: number;
link_count: number; link_count: number;