diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 5ceb06a63..038d4faec 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -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", }, diff --git a/apiserver/plane/app/serializers/state.py b/apiserver/plane/app/serializers/state.py index 61af5cab7..29d8cf302 100644 --- a/apiserver/plane/app/serializers/state.py +++ b/apiserver/plane/app/serializers/state.py @@ -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"] diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 46290d7a5..31cbd8330 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -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", }, diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 419cd5a35..b735659c5 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -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) diff --git a/apiserver/plane/app/views/workspace/state.py b/apiserver/plane/app/views/workspace/state.py index c00044cff..08bc2be28 100644 --- a/apiserver/plane/app/views/workspace/state.py +++ b/apiserver/plane/app/views/workspace/state.py @@ -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) diff --git a/packages/constants/src/icon.ts b/packages/constants/src/icon.ts new file mode 100644 index 000000000..3ee66e31e --- /dev/null +++ b/packages/constants/src/icon.ts @@ -0,0 +1,7 @@ +export enum EIconSize { + XS = "xs", + SM = "sm", + MD = "md", + LG = "lg", + XL = "xl", +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index f974dd64b..057627fcd 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -32,3 +32,4 @@ export * from "./dashboard"; export * from "./page"; export * from "./emoji"; export * from "./subscription"; +export * from "./icon"; diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index d28194dc9..38d0abe12 100644 --- a/packages/types/src/state.d.ts +++ b/packages/types/src/state.d.ts @@ -12,6 +12,7 @@ export interface IState { project_id: string; sequence: number; workspace_id: string; + order: number; } export interface IStateLite { diff --git a/packages/ui/src/icons/state/backlog-group-icon.tsx b/packages/ui/src/icons/state/backlog-group-icon.tsx index fce4f0024..ebd0d05c4 100644 --- a/packages/ui/src/icons/state/backlog-group-icon.tsx +++ b/packages/ui/src/icons/state/backlog-group-icon.tsx @@ -1,39 +1,27 @@ import * as React from "react"; import { ISvgIcons } from "../type"; +import { DashedCircle } from "./dashed-circle"; export const BacklogGroupIcon: React.FC = ({ width = "20", height = "20", className, - color = "#a3a3a3", -}) => ( - - - - - - - - - - -); + color = "#60646C", +}) => { + // SVG parameters + const viewBoxSize = 16; + const center = viewBoxSize / 2; + const radius = 6; + return ( + + + + ); +}; diff --git a/packages/ui/src/icons/state/cancelled-group-icon.tsx b/packages/ui/src/icons/state/cancelled-group-icon.tsx index c18a2570a..fb802523e 100644 --- a/packages/ui/src/icons/state/cancelled-group-icon.tsx +++ b/packages/ui/src/icons/state/cancelled-group-icon.tsx @@ -4,7 +4,7 @@ import { ISvgIcons } from "../type"; export const CancelledGroupIcon: React.FC = ({ className = "", - color = "#ef4444", + color = "#9AA4BC", height = "20", width = "20", ...rest @@ -17,16 +17,11 @@ export const CancelledGroupIcon: React.FC = ({ xmlns="http://www.w3.org/2000/svg" {...rest} > - - - - - - - - + ); diff --git a/packages/ui/src/icons/state/completed-group-icon.tsx b/packages/ui/src/icons/state/completed-group-icon.tsx index b53292687..c4a15f15f 100644 --- a/packages/ui/src/icons/state/completed-group-icon.tsx +++ b/packages/ui/src/icons/state/completed-group-icon.tsx @@ -4,7 +4,7 @@ import { ISvgIcons } from "../type"; export const CompletedGroupIcon: React.FC = ({ className = "", - color = "#16a34a", + color = "#46A758", height = "20", width = "20", ...rest @@ -18,7 +18,9 @@ export const CompletedGroupIcon: React.FC = ({ {...rest} > diff --git a/packages/ui/src/icons/state/dashed-circle.tsx b/packages/ui/src/icons/state/dashed-circle.tsx new file mode 100644 index 000000000..4ee64cdf0 --- /dev/null +++ b/packages/ui/src/icons/state/dashed-circle.tsx @@ -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 = ({ 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( + + + + ); + } + } + return segments; + }; + + return {generateDashedCircle()}; +}; diff --git a/packages/ui/src/icons/state/helper.tsx b/packages/ui/src/icons/state/helper.tsx index 0f1fa1231..fda3eff34 100644 --- a/packages/ui/src/icons/state/helper.tsx +++ b/packages/ui/src/icons/state/helper.tsx @@ -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", }; diff --git a/packages/ui/src/icons/state/progress-circle.tsx b/packages/ui/src/icons/state/progress-circle.tsx new file mode 100644 index 000000000..470a560fc --- /dev/null +++ b/packages/ui/src/icons/state/progress-circle.tsx @@ -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 = ({ + center, + radius, + color, + strokeWidth, + circumference, + dashOffset, +}) => ( + +); diff --git a/packages/ui/src/icons/state/started-group-icon.tsx b/packages/ui/src/icons/state/started-group-icon.tsx index 77f1d1609..d924c0d6c 100644 --- a/packages/ui/src/icons/state/started-group-icon.tsx +++ b/packages/ui/src/icons/state/started-group-icon.tsx @@ -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 = ({ width = "20", height = "20", className, - color = "#f39e1f", -}) => ( - - - - - - - - - - - -); + 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 ( + + {/* Dashed background circle with segments that disappear with progress */} + + + {/* render smaller circle in the middle */} + + + {/* Solid progress circle */} + + + ); +}; diff --git a/packages/ui/src/icons/state/state-group-icon.tsx b/packages/ui/src/icons/state/state-group-icon.tsx index e771fff5e..b5647ae05 100644 --- a/packages/ui/src/icons/state/state-group-icon.tsx +++ b/packages/ui/src/icons/state/state-group-icon.tsx @@ -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 = ({ className = "", color, stateGroup, - height = "12px", - width = "12px", + size = EIconSize.SM, + percentage, }) => { const StateIconComponent = iconComponents[stateGroup] || UnstartedGroupIcon; return ( ); }; diff --git a/packages/ui/src/icons/state/unstarted-group-icon.tsx b/packages/ui/src/icons/state/unstarted-group-icon.tsx index 5c62b1f12..9f57b698f 100644 --- a/packages/ui/src/icons/state/unstarted-group-icon.tsx +++ b/packages/ui/src/icons/state/unstarted-group-icon.tsx @@ -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 = ({ width = "20", height = "20", className, - color = "#3a3a3a", -}) => ( - - - -); + 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 ( + + + + {/* Solid progress circle */} + + + ); +}; diff --git a/packages/ui/src/icons/type.ts b/packages/ui/src/icons/type.ts index 4a04c948b..09e605629 100644 --- a/packages/ui/src/icons/type.ts +++ b/packages/ui/src/icons/type.ts @@ -1,3 +1,4 @@ export interface ISvgIcons extends React.SVGAttributes { className?: string | undefined; + percentage?: number; } diff --git a/space/core/components/issues/filters/applied-filters/state.tsx b/space/core/components/issues/filters/applied-filters/state.tsx index 23bfc87e6..4166dabfb 100644 --- a/space/core/components/issues/filters/applied-filters/state.tsx +++ b/space/core/components/issues/filters/applied-filters/state.tsx @@ -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 = observer((props) => { return (
- + {stateDetails.name}
), payload: { state_id: state.id }, diff --git a/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx b/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx index 9f59d226b..1ec015bbd 100644 --- a/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx +++ b/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx @@ -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) => ( handleStateChange(state.id)} className="focus:outline-none">
- +

{state.name}

{state.id === currentStateId && }
diff --git a/web/core/components/automation/auto-close-automation.tsx b/web/core/components/automation/auto-close-automation.tsx index 833e3bfde..0adf1387d 100644 --- a/web/core/components/automation/auto-close-automation.tsx +++ b/web/core/components/automation/auto-close-automation.tsx @@ -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 = observer((props) => { query: state.name, content: (
- + {state.name}
), @@ -139,7 +139,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { -
+
{t("project_settings.automations.auto-close.auto_close_status")}
@@ -149,18 +149,12 @@ export const AutoCloseAutomation: React.FC = observer((props) => { label={
{selectedOption ? ( - + ) : currentDefaultState ? ( ) : ( diff --git a/web/core/components/dropdowns/state.tsx b/web/core/components/dropdowns/state.tsx index 9af77dd7e..bdc754260 100644 --- a/web/core/components/dropdowns/state.tsx +++ b/web/core/components/dropdowns/state.tsx @@ -98,7 +98,12 @@ export const StateDropdown: React.FC = observer((props) => { query: `${state?.name}`, content: (
- + {state?.name}
), @@ -179,7 +184,8 @@ export const StateDropdown: React.FC = observer((props) => { )} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( diff --git a/web/core/components/home/widgets/recents/issue.tsx b/web/core/components/home/widgets/recents/issue.tsx index 63bd732b3..d4be2f7f9 100644 --- a/web/core/components/home/widgets/recents/issue.tsx +++ b/web/core/components/home/widgets/recents/issue.tsx @@ -95,7 +95,12 @@ export const RecentIssue = observer((props: BlockProps) => {
- +
diff --git a/web/core/components/inbox/inbox-filter/applied-filters/state.tsx b/web/core/components/inbox/inbox-filter/applied-filters/state.tsx index 8a1f0d0ad..a237c910f 100644 --- a/web/core/components/inbox/inbox-filter/applied-filters/state.tsx +++ b/web/core/components/inbox/inbox-filter/applied-filters/state.tsx @@ -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 (
- +
{optionDetail?.name}
= observer((props) => { key={state?.id} isChecked={filterValue?.includes(state?.id) ? true : false} onClick={() => handleInboxIssueFilters("state", handleFilterValue(state.id))} - icon={} + icon={ + + } title={state.name} /> ))} diff --git a/web/core/components/issues/issue-layouts/filters/applied-filters/state-group.tsx b/web/core/components/issues/issue-layouts/filters/applied-filters/state-group.tsx index 521408cd9..807a5b233 100644 --- a/web/core/components/issues/issue-layouts/filters/applied-filters/state-group.tsx +++ b/web/core/components/issues/issue-layouts/filters/applied-filters/state-group.tsx @@ -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 = observer((props) => { <> {values.map((stateGroup) => (
- + {stateGroup}