[WEB-3751] chore: work item state icon improvement (#6960)

* chore: return order based on group

* chore: order for workspace stats endpoint

* chore: state response updated

* chore: state icon types updated

* chore: state icon updated

* chore: state settings new icon implementation

* chore: icon implementation

* chore: code refactor

* chore: code refactor

* chore: code refactor

* fix: order field type

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
Anmol Singh Bhatia
2025-04-29 14:33:53 +05:30
committed by GitHub
parent baabb82669
commit f5449c8f93
33 changed files with 376 additions and 145 deletions

View File

@@ -172,14 +172,14 @@ class ProjectAPIEndpoint(BaseAPIView):
states = [
{
"name": "Backlog",
"color": "#A3A3A3",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#3A3A3A",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
@@ -191,13 +191,13 @@ class ProjectAPIEndpoint(BaseAPIView):
},
{
"name": "Done",
"color": "#16A34A",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#EF4444",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},

View File

@@ -1,11 +1,13 @@
# Module imports
from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State
class StateSerializer(BaseSerializer):
order = serializers.FloatField(required=False)
class Meta:
model = State
fields = [
@@ -18,6 +20,7 @@ class StateSerializer(BaseSerializer):
"default",
"description",
"sequence",
"order",
]
read_only_fields = ["workspace", "project"]

View File

@@ -275,14 +275,14 @@ class ProjectViewSet(BaseViewSet):
states = [
{
"name": "Backlog",
"color": "#A3A3A3",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#3A3A3A",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
@@ -294,13 +294,13 @@ class ProjectViewSet(BaseViewSet):
},
{
"name": "Done",
"color": "#16A34A",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#EF4444",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},

View File

@@ -1,5 +1,6 @@
# Python imports
from itertools import groupby
from collections import defaultdict
# Django imports
from django.db.utils import IntegrityError
@@ -74,7 +75,19 @@ class StateViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
states = StateSerializer(self.get_queryset(), many=True).data
grouped_states = defaultdict(list)
for state in states:
grouped_states[state["group"]].append(state)
for group, group_states in grouped_states.items():
count = len(group_states)
for index, state in enumerate(group_states, start=1):
state["order"] = index / count
grouped = request.GET.get("grouped", False)
if grouped == "true":
state_dict = {}
for key, value in groupby(
@@ -83,6 +96,7 @@ class StateViewSet(BaseViewSet):
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)

View File

@@ -8,6 +8,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import State
from plane.app.permissions import WorkspaceEntityPermission
from plane.utils.cache import cache_response
from collections import defaultdict
class WorkspaceStatesEndpoint(BaseAPIView):
@@ -22,5 +23,16 @@ class WorkspaceStatesEndpoint(BaseAPIView):
project__archived_at__isnull=True,
is_triage=False,
)
grouped_states = defaultdict(list)
for state in states:
grouped_states[state.group].append(state)
for group, group_states in grouped_states.items():
count = len(group_states)
for index, state in enumerate(group_states, start=1):
state.order = index / count
serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,7 @@
export enum EIconSize {
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
}

View File

@@ -32,3 +32,4 @@ export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./icon";

View File

@@ -12,6 +12,7 @@ export interface IState {
project_id: string;
sequence: number;
workspace_id: string;
order: number;
}
export interface IStateLite {

View File

@@ -1,39 +1,27 @@
import * as React from "react";
import { ISvgIcons } from "../type";
import { DashedCircle } from "./dashed-circle";
export const BacklogGroupIcon: React.FC<ISvgIcons> = ({
width = "20",
height = "20",
className,
color = "#a3a3a3",
}) => (
color = "#60646C",
}) => {
// SVG parameters
const viewBoxSize = 16;
const center = viewBoxSize / 2;
const radius = 6;
return (
<svg
height={height}
width={width}
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 323.15 323.03"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
fill={color}
d="M163.42,322.92A172.12,172.12,0,0,1,104.8,312.7c-3.92-1.4-5.22-3.05-3.07-7.1,2.4-4.52,3-11.38,6.64-13.48s9.34,2.47,14.23,3.81c29.55,8.11,58.78,7.25,87.57-3.31,4.08-1.5,5.86-1.05,7.09,3.21a82.63,82.63,0,0,0,4.6,11c1.19,2.57,1,4.06-2,5.2a163.84,163.84,0,0,1-40.05,9.76C173.84,322.45,167.89,323.34,163.42,322.92Z"
/>
<path
fill={color}
d="M.07,163a174.76,174.76,0,0,1,10.07-58c1.59-4.57,3.53-5.59,7.8-3.2a61,61,0,0,0,10.11,4.19c3.11,1.06,4.07,2.46,2.71,5.79-6.43,15.73-9.17,32.33-9.23,49.14a132.65,132.65,0,0,0,8.17,47.35c2.44,6.5,2.33,6.57-4.06,9.35-3.35,1.45-6.83,2.63-10.11,4.23-2.44,1.19-3.54.49-4.43-1.86a162.3,162.3,0,0,1-10-41C.51,173.12-.24,167.17.07,163Z"
/>
<path
fill={color}
d="M323,160.16a169.68,169.68,0,0,1-10.2,58.09c-1.45,4.08-3.21,5.07-7.14,3a105.3,105.3,0,0,0-11.48-4.81c-2.23-.85-3.2-1.85-2.16-4.41a133.86,133.86,0,0,0,9.57-48.59,132,132,0,0,0-8.9-50.69c-1.67-4.24-.8-5.79,3.29-7a84,84,0,0,0,11-4.62c2.65-1.24,4.05-.82,5.16,2.12a159.68,159.68,0,0,1,9.68,39C322.56,148.71,323.52,155.17,323,160.16Z"
/>
<path
fill={color}
d="M161.59,0a164.28,164.28,0,0,1,58,10.72c2.81,1,3.75,2,2.41,4.93-2,4.38-3.86,8.84-5.5,13.37-.93,2.56-2.28,2.77-4.53,1.87a137.94,137.94,0,0,0-99.35-.52c-3.43,1.32-5.3,1.35-6.45-2.69a50.33,50.33,0,0,0-4.55-11c-2.25-3.93-.36-5.11,2.9-6.29A165.32,165.32,0,0,1,161.59,0Z"
/>
</g>
</g>
<DashedCircle center={center} radius={radius} color={color} percentage={0} />
</svg>
);
};

View File

@@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
export const CancelledGroupIcon: React.FC<ISvgIcons> = ({
className = "",
color = "#ef4444",
color = "#9AA4BC",
height = "20",
width = "20",
...rest
@@ -17,16 +17,11 @@ export const CancelledGroupIcon: React.FC<ISvgIcons> = ({
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<g clipPath="url(#clip0_4052_100277)">
<path
d="M8 8.84L10.58 11.42C10.7 11.54 10.84 11.6 11 11.6C11.16 11.6 11.3 11.54 11.42 11.42C11.54 11.3 11.6 11.16 11.6 11C11.6 10.84 11.54 10.7 11.42 10.58L8.84 8L11.42 5.42C11.54 5.3 11.6 5.16 11.6 5C11.6 4.84 11.54 4.7 11.42 4.58C11.3 4.46 11.16 4.4 11 4.4C10.84 4.4 10.7 4.46 10.58 4.58L8 7.16L5.42 4.58C5.3 4.46 5.16 4.4 5 4.4C4.84 4.4 4.7 4.46 4.58 4.58C4.46 4.7 4.4 4.84 4.4 5C4.4 5.16 4.46 5.3 4.58 5.42L7.16 8L4.58 10.58C4.46 10.7 4.4 10.84 4.4 11C4.4 11.16 4.46 11.3 4.58 11.42C4.7 11.54 4.84 11.6 5 11.6C5.16 11.6 5.3 11.54 5.42 11.42L8 8.84ZM8 16C6.90667 16 5.87333 15.79 4.9 15.37C3.92667 14.95 3.07667 14.3767 2.35 13.65C1.62333 12.9233 1.05 12.0733 0.63 11.1C0.21 10.1267 0 9.09333 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62333 3.06 2.35 2.34C3.07667 1.62 3.92667 1.05 4.9 0.63C5.87333 0.21 6.90667 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.09333 15.79 10.1267 15.37 11.1C14.95 12.0733 14.38 12.9233 13.66 13.65C12.94 14.3767 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM8 14.8C9.89333 14.8 11.5 14.1367 12.82 12.81C14.14 11.4833 14.8 9.88 14.8 8C14.8 6.10667 14.14 4.5 12.82 3.18C11.5 1.86 9.89333 1.2 8 1.2C6.12 1.2 4.51667 1.86 3.19 3.18C1.86333 4.5 1.2 6.10667 1.2 8C1.2 9.88 1.86333 11.4833 3.19 12.81C4.51667 14.1367 6.12 14.8 8 14.8Z"
fillRule="evenodd"
clipRule="evenodd"
d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM11.1018 4.89826C11.3947 5.19115 11.3947 5.66603 11.1018 5.95892L9.06068 8.00002L11.1018 10.0411C11.3947 10.334 11.3947 10.8089 11.1018 11.1018C10.8089 11.3947 10.334 11.3947 10.0411 11.1018L8.00002 9.06068L5.95892 11.1018C5.66603 11.3947 5.19115 11.3947 4.89826 11.1018C4.60537 10.8089 4.60537 10.334 4.89826 10.0411L6.93936 8.00002L4.89826 5.95892C4.60537 5.66603 4.60537 5.19115 4.89826 4.89826C5.19115 4.60537 5.66603 4.60537 5.95892 4.89826L8.00002 6.93936L10.0411 4.89826C10.334 4.60537 10.8089 4.60537 11.1018 4.89826Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_4052_100277">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View File

@@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
export const CompletedGroupIcon: React.FC<ISvgIcons> = ({
className = "",
color = "#16a34a",
color = "#46A758",
height = "20",
width = "20",
...rest
@@ -18,7 +18,9 @@ export const CompletedGroupIcon: React.FC<ISvgIcons> = ({
{...rest}
>
<path
d="M6.80486 9.80731L4.84856 7.85103C4.73197 7.73443 4.58542 7.67478 4.4089 7.67208C4.23238 7.66937 4.08312 7.72902 3.96113 7.85103C3.83913 7.97302 3.77814 8.12093 3.77814 8.29474C3.77814 8.46855 3.83913 8.61645 3.96113 8.73844L6.27206 11.0494C6.42428 11.2016 6.60188 11.2777 6.80486 11.2777C7.00782 11.2777 7.18541 11.2016 7.33764 11.0494L12.0227 6.36435C12.1393 6.24776 12.1989 6.10121 12.2016 5.92469C12.2043 5.74817 12.1447 5.59891 12.0227 5.47692C11.9007 5.35493 11.7528 5.29393 11.579 5.29393C11.4051 5.29393 11.2572 5.35493 11.1353 5.47692L6.80486 9.80731ZM8.00141 16C6.89494 16 5.85491 15.79 4.88132 15.3701C3.90772 14.9502 3.06082 14.3803 2.34064 13.6604C1.62044 12.9405 1.05028 12.094 0.63017 11.1208C0.210057 10.1477 0 9.10788 0 8.00141C0 6.89494 0.209966 5.85491 0.629896 4.88132C1.04983 3.90772 1.61972 3.06082 2.33958 2.34064C3.05946 1.62044 3.90598 1.05028 4.87915 0.630171C5.8523 0.210058 6.89212 0 7.99859 0C9.10506 0 10.1451 0.209966 11.1187 0.629897C12.0923 1.04983 12.9392 1.61972 13.6594 2.33959C14.3796 3.05946 14.9497 3.90598 15.3698 4.87915C15.7899 5.8523 16 6.89212 16 7.99859C16 9.10506 15.79 10.1451 15.3701 11.1187C14.9502 12.0923 14.3803 12.9392 13.6604 13.6594C12.9405 14.3796 12.094 14.9497 11.1208 15.3698C10.1477 15.7899 9.10788 16 8.00141 16ZM8 14.7369C9.88071 14.7369 11.4737 14.0842 12.779 12.779C14.0842 11.4737 14.7369 9.88071 14.7369 8C14.7369 6.11929 14.0842 4.52631 12.779 3.22104C11.4737 1.91577 9.88071 1.26314 8 1.26314C6.11929 1.26314 4.52631 1.91577 3.22104 3.22104C1.91577 4.52631 1.26314 6.11929 1.26314 8C1.26314 9.88071 1.91577 11.4737 3.22104 12.779C4.52631 14.0842 6.11929 14.7369 8 14.7369Z"
fillRule="evenodd"
clipRule="evenodd"
d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM11.3587 6.18828C11.6007 5.85214 11.5244 5.38343 11.1882 5.14141C10.8521 4.89938 10.3834 4.97568 10.1414 5.31183L7.03706 9.62335L5.25956 7.97751C4.95563 7.69609 4.4811 7.71434 4.19968 8.01828C3.91826 8.32221 3.93651 8.79673 4.24045 9.07815L6.64045 11.3004C6.79816 11.4464 7.01095 11.5178 7.22481 11.4963C7.43868 11.4749 7.63307 11.3627 7.75865 11.1883L11.3587 6.18828Z"
fill={color}
/>
</svg>

View File

@@ -0,0 +1,40 @@
import * as React from "react";
interface DashedCircleProps {
center: number;
radius: number;
color: string;
percentage: number;
totalSegments?: number;
}
export const DashedCircle: React.FC<DashedCircleProps> = ({ center, color, percentage, totalSegments = 15 }) => {
// Ensure percentage is between 0 and 100
const validPercentage = Math.max(0, Math.min(100, percentage));
// Generate dashed segments for the circle
const generateDashedCircle = () => {
const segments = [];
const angleIncrement = 360 / totalSegments;
for (let i = 0; i < totalSegments; i++) {
// Calculate the angle for this segment (starting from top/12 o'clock position)
const angle = i * angleIncrement - 90; // -90 adjusts to start from top center
// Calculate if this segment should be hidden based on percentage
const segmentStartPercentage = (i / totalSegments) * 100;
const isSegmentVisible = segmentStartPercentage >= validPercentage;
if (isSegmentVisible) {
segments.push(
<g key={i} transform={`translate(${center} ${center}) rotate(${angle})`}>
<line x1={5.75} y1="0" x2={6.5} y2="0" stroke={color} strokeWidth={1.21} strokeLinecap="round" />
</g>
);
}
}
return segments;
};
return <g>{generateDashedCircle()}</g>;
};

View File

@@ -1,9 +1,11 @@
import { EIconSize } from "@plane/constants";
export interface IStateGroupIcon {
className?: string;
color?: string;
stateGroup: TStateGroups;
height?: string;
width?: string;
size?: EIconSize;
percentage?: number;
}
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
@@ -11,9 +13,19 @@ export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "
export const STATE_GROUP_COLORS: {
[key in TStateGroups]: string;
} = {
backlog: "#d9d9d9",
unstarted: "#3f76ff",
started: "#f59e0b",
completed: "#16a34a",
cancelled: "#dc2626",
backlog: "#60646C",
unstarted: "#60646C",
started: "#F59E0B",
completed: "#46A758",
cancelled: "#9AA4BC",
};
export const STATE_GROUP_SIZES: {
[key in EIconSize]: string;
} = {
[EIconSize.XS]: "10px",
[EIconSize.SM]: "12px",
[EIconSize.MD]: "14px",
[EIconSize.LG]: "16px",
[EIconSize.XL]: "18px",
};

View File

@@ -0,0 +1,32 @@
import * as React from "react";
interface ProgressCircleProps {
center: number;
radius: number;
color: string;
strokeWidth: number;
circumference: number;
dashOffset: number;
}
export const ProgressCircle: React.FC<ProgressCircleProps> = ({
center,
radius,
color,
strokeWidth,
circumference,
dashOffset,
}) => (
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
/>
);

View File

@@ -1,43 +1,65 @@
import * as React from "react";
import { ISvgIcons } from "../type";
import { DashedCircle } from "./dashed-circle";
import { ProgressCircle } from "./progress-circle";
// StateIcon component implementation
export const StartedGroupIcon: React.FC<ISvgIcons> = ({
width = "20",
height = "20",
className,
color = "#f39e1f",
}) => (
<svg
height={height}
width={width}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 152.93 152.95"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
fill={color}
d="M77.74,0C35.63-.62.78,32.9,0,74.94c-.77,42.74,33,77.34,76.23,78A76.48,76.48,0,0,0,77.74,0ZM75.46,142.68a66.24,66.24,0,1,1,3-132.45c35.71,1,66.31,31.26,64.16,70.08A66.23,66.23,0,0,1,75.46,142.68Z"
color = "#F59E0B",
percentage = 100,
}) => {
// Ensure percentage is between 0 and 100
const normalized =
typeof percentage === "number"
? percentage <= 1
? percentage * 100 // treat 0-1 as fraction
: percentage // already 0-100
: 100; // fallback
const validPercentage = Math.max(0, Math.min(100, normalized));
// SVG parameters
const viewBoxSize = 16;
const center = viewBoxSize / 2;
const radius = 6;
const strokeWidth = 1.5;
// Calculate the circumference of the circle
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - validPercentage / 100);
const dashOffsetSmall = circumference * (1 - 100 / 100);
return (
<svg width={width} height={height} viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} className={className}>
{/* Dashed background circle with segments that disappear with progress */}
<DashedCircle center={center} radius={radius} color={color} percentage={validPercentage} />
{/* render smaller circle in the middle */}
<circle
cx={6}
cy={6}
r={3}
strokeWidth={1.5}
strokeLinecap="round"
fill="none"
transform={`rotate(-90 8 6)`}
strokeDasharray={circumference}
strokeDashoffset={dashOffsetSmall}
stroke={color}
/>
<path
fill={color}
d="M124.29,76.58a49.52,49.52,0,0,1-3.11,16.9c-.38,1-.77,1.27-1.81.78-2.15-1-4.34-1.92-6.56-2.72-1.3-.46-1.51-1-1-2.3a36.61,36.61,0,0,0,.64-23.77c-1-3.48-1.06-3.47,2.38-4.88,1.57-.65,3.15-1.27,4.68-2,.94-.44,1.34-.22,1.69.75A49.74,49.74,0,0,1,124.29,76.58Z"
{/* Solid progress circle */}
<ProgressCircle
center={center}
radius={radius}
color={color}
strokeWidth={strokeWidth}
circumference={circumference}
dashOffset={dashOffset}
/>
<path
fill={color}
d="M94.65,32.63c-.1.22-.19.42-.27.63-1,2.5-2.08,5-3.09,7.51-.28.69-.55.89-1.37.59a37.3,37.3,0,0,0-26.82,0c-.91.34-1.15.08-1.46-.7-1-2.46-2-4.92-3.06-7.34-.42-.92-.07-1.18.69-1.46a47.66,47.66,0,0,1,34.43,0C94.06,32,94.68,32,94.65,32.63Z"
/>
<path
fill={color}
d="M28.72,76.67a48.27,48.27,0,0,1,3-17.13c.45-1.25.92-1.34,2-.83,2.25,1,4.56,2,6.87,2.87.86.34,1.05.67.71,1.58a36.85,36.85,0,0,0-.07,26.36c.36,1,.3,1.46-.75,1.86-2.38.9-4.72,1.88-7,2.92-1,.43-1.33.2-1.68-.76A46.76,46.76,0,0,1,28.72,76.67Z"
/>
<path
fill={color}
d="M76.37,124.22a48.11,48.11,0,0,1-16.91-3.08c-1.05-.38-1.26-.8-.79-1.82,1-2.31,2-4.66,2.93-7,.34-.87.69-1.06,1.61-.72a37.06,37.06,0,0,0,26.67,0c.75-.28,1.09-.23,1.39.55,1,2.56,2,5.13,3.18,7.65.49,1.08-.3,1.13-.86,1.34A46.53,46.53,0,0,1,76.37,124.22Z"
/>
</g>
</g>
</svg>
);
};

View File

@@ -1,11 +1,12 @@
import * as React from "react";
import { EIconSize } from "@plane/constants";
import { BacklogGroupIcon } from "./backlog-group-icon";
import { CancelledGroupIcon } from "./cancelled-group-icon";
import { CompletedGroupIcon } from "./completed-group-icon";
import { IStateGroupIcon, STATE_GROUP_COLORS, STATE_GROUP_SIZES } from "./helper";
import { StartedGroupIcon } from "./started-group-icon";
import { UnstartedGroupIcon } from "./unstarted-group-icon";
import { IStateGroupIcon, STATE_GROUP_COLORS } from "./helper";
const iconComponents = {
backlog: BacklogGroupIcon,
@@ -19,17 +20,18 @@ export const StateGroupIcon: React.FC<IStateGroupIcon> = ({
className = "",
color,
stateGroup,
height = "12px",
width = "12px",
size = EIconSize.SM,
percentage,
}) => {
const StateIconComponent = iconComponents[stateGroup] || UnstartedGroupIcon;
return (
<StateIconComponent
height={height}
width={width}
height={STATE_GROUP_SIZES[size]}
width={STATE_GROUP_SIZES[size]}
color={color ?? STATE_GROUP_COLORS[stateGroup]}
className={`flex-shrink-0 ${className}`}
percentage={percentage}
/>
);
};

View File

@@ -1,21 +1,51 @@
import * as React from "react";
import { ISvgIcons } from "../type";
import { DashedCircle } from "./dashed-circle";
import { ProgressCircle } from "./progress-circle";
// StateIcon component implementation
export const UnstartedGroupIcon: React.FC<ISvgIcons> = ({
width = "20",
height = "20",
className,
color = "#3a3a3a",
}) => (
<svg
height={height}
width={width}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8" cy="8" r="7.4" stroke={color} strokeWidth="1.2" />
color = "#F59E0B",
percentage = 100,
}) => {
// Ensure percentage is between 0 and 100
const normalized =
typeof percentage === "number"
? percentage <= 1
? percentage * 100 // treat 0-1 as fraction
: percentage // already 0-100
: 100; // fallback
const validPercentage = Math.max(0, Math.min(100, normalized));
// SVG parameters
const viewBoxSize = 16;
const center = viewBoxSize / 2;
const radius = 6;
const strokeWidth = 1.5;
// Calculate the circumference of the circle
const circumference = 2 * Math.PI * radius;
// Calculate the dash offset based on percentage
const dashOffset = circumference * (1 - validPercentage / 100);
return (
<svg width={width} height={height} viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} className={className}>
<DashedCircle center={center} radius={radius} color={color} percentage={validPercentage} />
{/* Solid progress circle */}
<ProgressCircle
center={center}
radius={radius}
color={color}
strokeWidth={strokeWidth}
circumference={circumference}
dashOffset={dashOffset}
/>
</svg>
);
};

View File

@@ -1,3 +1,4 @@
export interface ISvgIcons extends React.SVGAttributes<SVGElement> {
className?: string | undefined;
percentage?: number;
}

View File

@@ -2,7 +2,8 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// ui
// plane imports
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/ui";
// hooks
import { useStates } from "@/hooks/store";
@@ -26,7 +27,7 @@ export const AppliedStateFilters: React.FC<Props> = observer((props) => {
return (
<div key={stateId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} height="12px" width="12px" />
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} size={EIconSize.SM} />
{stateDetails.name}
<button
type="button"

View File

@@ -3,7 +3,7 @@
import isNil from "lodash/isNil";
import { ContrastIcon } from "lucide-react";
// types
import { ISSUE_PRIORITIES } from "@plane/constants";
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
import {
GroupByColumnTypes,
IGroupByColumn,
@@ -117,7 +117,7 @@ const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefine
name: state.name,
icon: (
<div className="h-3.5 w-3.5 rounded-full">
<StateGroupIcon stateGroup={state.group} color={state.color} width="14" height="14" />
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.MD} />
</div>
),
payload: { state_id: state.id },

View File

@@ -2,6 +2,7 @@ import { Command } from "cmdk";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { Spinner, StateGroupIcon } from "@plane/ui";
// store hooks
import { useProjectState } from "@/hooks/store";
@@ -26,7 +27,12 @@ export const ChangeWorkItemStateList = observer((props: TChangeWorkItemStateList
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleStateChange(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<StateGroupIcon
stateGroup={state.group}
color={state.color}
size={EIconSize.LG}
percentage={state?.order}
/>
<p>{state.name}</p>
</div>
<div>{state.id === currentStateId && <Check className="h-3 w-3" />}</div>

View File

@@ -6,7 +6,7 @@ import { useParams } from "next/navigation";
// icons
import { ArchiveX } from "lucide-react";
// types
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel, EIconSize } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
// ui
@@ -42,7 +42,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
query: state.name,
content: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.LG} />
{state.name}
</div>
),
@@ -139,7 +139,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
</div>
</div>
<div className="flex w-full items-center justify-between gap-2 px-5 py-4">
<div className="ppy sm:py-10 flex w-full items-center justify-between gap-2 px-5 py-4">
<div className="w-1/2 text-sm font-medium">
{t("project_settings.automations.auto-close.auto_close_status")}
</div>
@@ -149,18 +149,12 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
label={
<div className="flex items-center gap-2">
{selectedOption ? (
<StateGroupIcon
stateGroup={selectedOption.group}
color={selectedOption.color}
height="16px"
width="16px"
/>
<StateGroupIcon stateGroup={selectedOption.group} color={selectedOption.color} size="lg" />
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
size={EIconSize.LG}
/>
) : (
<DoubleCircleIcon className="h-3.5 w-3.5 text-custom-text-200" />

View File

@@ -98,7 +98,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
query: `${state?.name}`,
content: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} className="h-3 w-3 flex-shrink-0" />
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
className="size-4 flex-shrink-0"
percentage={state?.order}
/>
<span className="flex-grow truncate text-left">{state?.name}</span>
</div>
),
@@ -179,7 +184,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={selectedState?.color ?? "rgba(var(--color-text-300))"}
className="h-3 w-3 flex-shrink-0"
className="size-4 flex-shrink-0"
percentage={selectedState?.order}
/>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (

View File

@@ -95,7 +95,12 @@ export const RecentIssue = observer((props: BlockProps) => {
<div className="flex gap-4">
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
<div>
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} className="h-4 w-4 my-auto" />
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
className="h-4 w-4 my-auto"
percentage={state?.order}
/>
</div>
</Tooltip>
<Tooltip tooltipHeading="Priority" tooltipContent={issueDetails?.priority ?? "Priority"}>

View File

@@ -3,6 +3,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { EIconSize } from "@plane/constants";
import { StateGroupIcon, Tag } from "@plane/ui";
// hooks
import { useProjectInbox, useProjectState } from "@/hooks/store";
@@ -30,7 +31,7 @@ export const InboxIssueAppliedFiltersState: FC = observer(() => {
return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
<StateGroupIcon color={optionDetail.color} stateGroup={optionDetail.group} height="12px" width="12px" />
<StateGroupIcon color={optionDetail.color} stateGroup={optionDetail.group} size={EIconSize.SM} />
</div>
<div className="text-xs truncate">{optionDetail?.name}</div>
<div

View File

@@ -2,6 +2,7 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { EIconSize } from "@plane/constants";
import { IState } from "@plane/types";
import { Loader, StateGroupIcon } from "@plane/ui";
// components
@@ -55,7 +56,14 @@ export const FilterState: FC<Props> = observer((props) => {
key={state?.id}
isChecked={filterValue?.includes(state?.id) ? true : false}
onClick={() => handleInboxIssueFilters("state", handleFilterValue(state.id))}
icon={<StateGroupIcon color={state.color} stateGroup={state.group} height="12px" width="12px" />}
icon={
<StateGroupIcon
color={state.color}
stateGroup={state.group}
size={EIconSize.SM}
percentage={state?.order}
/>
}
title={state.name}
/>
))}

View File

@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
import { EIconSize } from "@plane/constants";
import { TStateGroups } from "@plane/types";
import { StateGroupIcon } from "@plane/ui";
@@ -19,7 +20,7 @@ export const AppliedStateGroupFilters: React.FC<Props> = observer((props) => {
<>
{values.map((stateGroup) => (
<div key={stateGroup} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<StateGroupIcon stateGroup={stateGroup as TStateGroups} height="12px" width="12px" />
<StateGroupIcon stateGroup={stateGroup as TStateGroups} size={EIconSize.SM} />
{stateGroup}
<button
type="button"

View File

@@ -5,6 +5,7 @@ import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
import { IState } from "@plane/types";
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/ui";
// types
@@ -27,7 +28,12 @@ export const AppliedStateFilters: React.FC<Props> = observer((props) => {
return (
<div key={stateId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} height="12px" width="12px" />
<StateGroupIcon
color={stateDetails.color}
stateGroup={stateDetails.group}
size={EIconSize.SM}
percentage={stateDetails?.order}
/>
{stateDetails.name}
{editable && (
<button

View File

@@ -3,6 +3,7 @@
import React, { useMemo, useState } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { EIconSize } from "@plane/constants";
import { IState } from "@plane/types";
// components
import { Loader, StateGroupIcon } from "@plane/ui";
@@ -56,7 +57,14 @@ export const FilterState: React.FC<Props> = observer((props) => {
key={state.id}
isChecked={appliedFilters?.includes(state.id) ? true : false}
onClick={() => handleUpdate(state.id)}
icon={<StateGroupIcon stateGroup={state.group} color={state.color} />}
icon={
<StateGroupIcon
stateGroup={state.group}
color={state.color}
size={EIconSize.MD}
percentage={state?.order}
/>
}
title={state.name}
/>
))}

View File

@@ -11,7 +11,7 @@ import uniq from "lodash/uniq";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import { ContrastIcon } from "lucide-react";
// plane types
import { EIssuesStoreType, ISSUE_PRIORITIES, STATE_GROUPS } from "@plane/constants";
import { EIconSize, EIssuesStoreType, ISSUE_PRIORITIES, STATE_GROUPS } from "@plane/constants";
import {
GroupByColumnTypes,
IGroupByColumn,
@@ -198,8 +198,8 @@ const getStateColumns = (): IGroupByColumn[] | undefined => {
id: state.id,
name: state.name,
icon: (
<div className="h-3.5 w-3.5 rounded-full">
<StateGroupIcon stateGroup={state.group} color={state.color} width="14" height="14" />
<div className="size-4 rounded-full">
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.LG} percentage={state.order} />
</div>
),
payload: { state_id: state.id },
@@ -213,8 +213,8 @@ const getStateGroupColumns = (): IGroupByColumn[] => {
id: stateGroup.key,
name: stateGroup.label,
icon: (
<div className="h-3.5 w-3.5 rounded-full">
<StateGroupIcon stateGroup={stateGroup.key} width="14" height="14" />
<div className="size-4 rounded-full">
<StateGroupIcon stateGroup={stateGroup.key} size={EIconSize.LG} />
</div>
),
payload: {},

View File

@@ -4,6 +4,7 @@ import { FC, useState, useRef } from "react";
import { observer } from "mobx-react";
import { ChevronDown, Plus } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
import { StateGroupIcon } from "@plane/ui";
@@ -74,7 +75,7 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
<ChevronDown className="w-4 h-4" />
</div>
<div className="flex-shrink-0 w-6 h-6 rounded flex justify-center items-center overflow-hidden">
<StateGroupIcon stateGroup={groupKey} height="16px" width="16px" />
<StateGroupIcon stateGroup={groupKey} size={EIconSize.XL} />
</div>
<div className="text-base font-medium text-custom-text-200 capitalize px-1">{groupKey}</div>
</div>

View File

@@ -2,9 +2,11 @@ import { SetStateAction } from "react";
import { observer } from "mobx-react";
import { GripVertical, Pencil } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { IState, TStateOperationsCallbacks } from "@plane/types";
import { StateGroupIcon } from "@plane/ui";
// local imports
import { useProjectState } from "@/hooks/store";
import { StateDelete, StateMarksAsDefault } from "./options";
type TBaseStateItemTitleProps = {
@@ -28,6 +30,11 @@ export type TStateItemTitleProps = TEnabledStateItemTitleProps | TDisabledStateI
export const StateItemTitle = observer((props: TStateItemTitleProps) => {
const { stateCount, setUpdateStateModal, disabled, state, shouldShowDescription = true } = props;
// store hooks
const { getStatePercentageInGroup } = useProjectState();
// derived values
const statePercentage = getStatePercentageInGroup(state.id);
const percentage = statePercentage ? statePercentage / 100 : undefined;
return (
<div className="flex items-center gap-2 w-full justify-between">
@@ -40,7 +47,7 @@ export const StateItemTitle = observer((props: TStateItemTitleProps) => {
)}
{/* state icon */}
<div className="flex-shrink-0">
<StateGroupIcon stateGroup={state.group} color={state.color} className={"size-3.5"} />
<StateGroupIcon stateGroup={state.group} color={state.color} size={EIconSize.XL} percentage={percentage} />
</div>
{/* state title and description */}
<div className="text-sm px-2 min-h-5">

View File

@@ -45,6 +45,8 @@ export interface IStateStore {
stateId: string,
payload: Partial<IState>
) => Promise<void>;
getStatePercentageInGroup: (stateId: string | null | undefined) => number | undefined;
}
export class StateStore implements IStateStore {
@@ -303,4 +305,27 @@ export class StateStore implements IStateStore {
});
}
};
/**
* Returns the percentage position of a state within its group based on sequence
* @param stateId The ID of the state to find the percentage for
* @returns The percentage position of the state in its group (0-100), or -1 if not found
*/
getStatePercentageInGroup = computedFn((stateId: string | null | undefined) => {
if (!stateId || !this.stateMap[stateId]) return -1;
const state = this.stateMap[stateId];
const group = state.group;
if (!group || !this.groupedProjectStates || !this.groupedProjectStates[group]) return -1;
// Get all states in the same group
const statesInGroup = this.groupedProjectStates[group];
const stateIndex = statesInGroup.findIndex((s) => s.id === stateId);
if (stateIndex === -1) return undefined;
// Calculate percentage: ((index + 1) / totalLength) * 100
return ((stateIndex + 1) / statesInGroup.length) * 100;
});
}