diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index 644b5ba1076..303fd38ecd9 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -92,6 +92,7 @@ class Meta: "external_id", "is_issue_type_enabled", "is_time_tracking_enabled", + "state_group_order", ] read_only_fields = [ diff --git a/apps/api/plane/db/migrations/0121_project_state_group_order.py b/apps/api/plane/db/migrations/0121_project_state_group_order.py new file mode 100644 index 00000000000..fbffc09ff37 --- /dev/null +++ b/apps/api/plane/db/migrations/0121_project_state_group_order.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.29 on 2026-03-10 11:34 + +from django.db import migrations, models +import plane.db.models.project + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0120_issueview_archived_at'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='state_group_order', + field=models.JSONField(default=plane.db.models.project.get_default_state_group_order), + ), + ] diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 4039b1d2903..2bd26cdb73d 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -65,6 +65,10 @@ def get_default_preferences(): return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}} +def get_default_state_group_order(): + return ["backlog", "unstarted", "started", "completed", "cancelled"] + + class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) name = models.CharField(max_length=255, verbose_name="Project Name") @@ -111,6 +115,7 @@ class Project(BaseModel): close_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) logo_props = models.JSONField(default=dict) default_state = models.ForeignKey("db.State", on_delete=models.SET_NULL, null=True, related_name="default_state") + state_group_order = models.JSONField(default=get_default_state_group_order) archived_at = models.DateTimeField(null=True) # timezone TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) diff --git a/apps/web/core/components/issues/issue-layouts/utils.tsx b/apps/web/core/components/issues/issue-layouts/utils.tsx index c1a1a89f358..d622b766984 100644 --- a/apps/web/core/components/issues/issue-layouts/utils.tsx +++ b/apps/web/core/components/issues/issue-layouts/utils.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import type { CSSProperties, FC } from "react"; +import type { CSSProperties } from "react"; import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { clone, isNil, pull, uniq, concat } from "lodash-es"; import scrollIntoView from "smooth-scroll-into-view-if-needed"; @@ -70,9 +70,7 @@ export const isWorkspaceLevel = (type: EIssuesStoreType) => EIssuesStoreType.TEAM_VIEW, EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS, EIssuesStoreType.WORKSPACE_DRAFT, - ].includes(type) - ? true - : false; + ].includes(type); type TGetGroupByColumns = { groupBy: GroupByColumnTypes | null; @@ -88,7 +86,7 @@ type TGetGroupByColumns = { export const getGroupByColumns = ({ groupBy, includeNone, - isWorkspaceLevel, + isWorkspaceLevel: isWorkspaceLevelParam, isEpic = false, projectId, }: TGetGroupByColumns): IGroupByColumn[] | undefined => { @@ -125,7 +123,7 @@ export const getGroupByColumns = ({ }; // Get and return the columns for the specified group by option - return groupByColumnMap[groupBy]?.({ isWorkspaceLevel, projectId }); + return groupByColumnMap[groupBy]?.({ isWorkspaceLevel: isWorkspaceLevelParam, projectId }); }; const getProjectColumns = (): IGroupByColumn[] | undefined => { @@ -212,8 +210,27 @@ const getStateColumns = ({ projectId }: TGetColumns): IGroupByColumn[] | undefin const { getProjectStates, projectStates } = store.state; const _states = projectId ? getProjectStates(projectId) : projectStates; if (!_states) return; + + // Get the project's custom state group order if available + const currentProjectId = projectId || store.projectRoot.project.currentProjectDetails?.id; + const project = currentProjectId ? store.projectRoot.project.getProjectById(currentProjectId) : undefined; + const stateGroupOrder = project?.state_group_order; + + // If there's a custom order, re-sort states by group order + let sortedStates = _states; + if (stateGroupOrder && stateGroupOrder.length > 0) { + sortedStates = [..._states].sort((a, b) => { + const aGroupIndex = stateGroupOrder.indexOf(a.group); + const bGroupIndex = stateGroupOrder.indexOf(b.group); + if (aGroupIndex !== bGroupIndex) { + return aGroupIndex - bGroupIndex; + } + return a.sequence - b.sequence; + }); + } + // map project states to group by columns - return _states.map((state) => ({ + return sortedStates.map((state) => ({ id: state.id, name: state.name, icon: ( @@ -251,11 +268,11 @@ const getPriorityColumns = (): IGroupByColumn[] => { })); }; -const getLabelsColumns = ({ isWorkspaceLevel }: TGetColumns): IGroupByColumn[] => { +const getLabelsColumns = ({ isWorkspaceLevel: isWorkspaceLevelParam }: TGetColumns): IGroupByColumn[] => { const { workspaceLabels, projectLabels } = store.label; // map labels to group by columns const labels = [ - ...(isWorkspaceLevel ? workspaceLabels || [] : projectLabels || []), + ...(isWorkspaceLevelParam ? workspaceLabels || [] : projectLabels || []), { id: "None", name: "None", color: "#666" }, ]; // map labels to group by columns @@ -269,11 +286,14 @@ const getLabelsColumns = ({ isWorkspaceLevel }: TGetColumns): IGroupByColumn[] = })); }; -const getAssigneeColumns = ({ isWorkspaceLevel, projectId }: TGetColumns): IGroupByColumn[] | undefined => { +const getAssigneeColumns = ({ + isWorkspaceLevel: isWorkspaceLevelParam, + projectId, +}: TGetColumns): IGroupByColumn[] | undefined => { // store values const { getUserDetails } = store.memberRoot; // derived values - const { memberIds, includeNone } = getScopeMemberIds({ isWorkspaceLevel, projectId }); + const { memberIds, includeNone } = getScopeMemberIds({ isWorkspaceLevel: isWorkspaceLevelParam, projectId }); const assigneeColumns: IGroupByColumn[] = []; if (!memberIds) return []; @@ -452,8 +472,8 @@ const handleSortOrder = ( if (destinationIssues && destinationIssues.length > 0) { if (destinationIndex === 0) { - const destinationIssueId = destinationIssues[0]; - const destinationIssue = getIssueById(destinationIssueId); + const firstIssueId = destinationIssues[0]; + const destinationIssue = getIssueById(firstIssueId); if (!destinationIssue) return currentIssueState; currentIssueState = { @@ -461,8 +481,8 @@ const handleSortOrder = ( sort_order: destinationIssue.sort_order - sortOrderDefaultValue, }; } else if (destinationIndex === destinationIssues.length) { - const destinationIssueId = destinationIssues[destinationIssues.length - 1]; - const destinationIssue = getIssueById(destinationIssueId); + const lastIssueId = destinationIssues[destinationIssues.length - 1]; + const destinationIssue = getIssueById(lastIssueId); if (!destinationIssue) return currentIssueState; currentIssueState = { @@ -731,7 +751,7 @@ export const isDisplayFiltersApplied = (filters: Partial): boolea (key) => !filters.displayProperties?.[key as keyof IIssueDisplayProperties] ); - const isDisplayFiltersApplied = Object.keys(filters.displayFilters ?? {}).some((key) => { + const hasDisplayFiltersApplied = Object.keys(filters.displayFilters ?? {}).some((key) => { const value = filters.displayFilters?.[key as keyof IIssueDisplayFilterOptions]; if (!value) return false; // -create_at is the default order @@ -741,7 +761,7 @@ export const isDisplayFiltersApplied = (filters: Partial): boolea return true; }); - return isDisplayPropertiesApplied || isDisplayFiltersApplied; + return isDisplayPropertiesApplied || hasDisplayFiltersApplied; }; /** diff --git a/apps/web/core/components/project-states/group-item.tsx b/apps/web/core/components/project-states/group-item.tsx index f5c438ac122..902502e0e20 100644 --- a/apps/web/core/components/project-states/group-item.tsx +++ b/apps/web/core/components/project-states/group-item.tsx @@ -4,12 +4,16 @@ * See the LICENSE file for details. */ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect, Fragment } from "react"; import { observer } from "mobx-react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; // plane imports import { EIconSize, STATE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { DropIndicator } from "@plane/ui"; import { PlusIcon, StateGroupIcon, ChevronDownIcon } from "@plane/propel/icons"; import type { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types"; import { cn } from "@plane/utils"; @@ -50,86 +54,139 @@ export const GroupItem = observer(function GroupItem(props: TGroupItem) { const { t } = useTranslation(); // state const [createState, setCreateState] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState(false); + const [closestEdge, setClosestEdge] = useState(null); + // derived values const currentStateExpanded = groupsExpanded.includes(groupKey); const shouldShowEmptyState = states.length === 0 && currentStateExpanded && !createState; + useEffect(() => { + const elementRef = dropElementRef.current; + if (!elementRef || !isEditable) return; + + return combine( + draggable({ + element: elementRef, + getInitialData: () => ({ type: "STATE_GROUP", id: groupKey }), + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + dropTargetForElements({ + element: elementRef, + canDrop: ({ source }) => source.data.type === "STATE_GROUP" && source.data.id !== groupKey, + getData: ({ input, element }) => + attachClosestEdge( + { type: "STATE_GROUP", id: groupKey }, + { + input, + element, + allowedEdges: ["top", "bottom"], + } + ), + onDragEnter: (args) => { + setIsDraggedOver(true); + setClosestEdge(extractClosestEdge(args.self.data)); + }, + onDragLeave: () => { + setIsDraggedOver(false); + setClosestEdge(null); + }, + onDrop: () => { + setIsDraggedOver(false); + setClosestEdge(null); + }, + }) + ); + }, [groupKey, isEditable]); + return ( -
-
-
(!currentStateExpanded ? handleExpand(groupKey) : handleGroupCollapse(groupKey))} - > -
+ +
+
+ +
-
- -
-
{groupKey}
+ +
- -
- {shouldShowEmptyState && ( -
-
{t("project_settings.states.empty_state.title", { groupKey })}
- {isEditable &&
{t("project_settings.states.empty_state.description")}
} -
- )} + {shouldShowEmptyState && ( +
+
{t("project_settings.states.empty_state.title", { groupKey })}
+ {isEditable &&
{t("project_settings.states.empty_state.description")}
} +
+ )} - {currentStateExpanded && ( -
- -
- )} + {currentStateExpanded && ( +
+ +
+ )} - {isEditable && createState && ( -
- setCreateState(false)} - createStateCallback={stateOperationsCallbacks.createState} - shouldTrackEvents={shouldTrackEvents} - /> -
- )} -
+ {isEditable && createState && ( +
+ setCreateState(false)} + createStateCallback={stateOperationsCallbacks.createState} + shouldTrackEvents={shouldTrackEvents} + /> +
+ )} +
+ + ); }); diff --git a/apps/web/core/components/project-states/group-list.tsx b/apps/web/core/components/project-states/group-list.tsx index eec413d79bf..ab083ad47f7 100644 --- a/apps/web/core/components/project-states/group-list.tsx +++ b/apps/web/core/components/project-states/group-list.tsx @@ -4,13 +4,18 @@ * See the LICENSE file for details. */ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { observer } from "mobx-react"; +import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { useParams } from "next/navigation"; // plane imports import type { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types"; import { cn } from "@plane/utils"; // components import { GroupItem } from "@/components/project-states"; +// hooks +import { useProject } from "@/hooks/store/use-project"; type TGroupList = { groupedStates: Record; @@ -58,6 +63,49 @@ export const GroupList = observer(function GroupList(props: TGroupList) { return [...prev, groupKey]; }); }; + + const { updateProject } = useProject(); + const { workspaceSlug, projectId } = useParams(); + + useEffect(() => { + if (!isEditable) return; + + return monitorForElements({ + canMonitor({ source }) { + return source.data.type === "STATE_GROUP"; + }, + onDrop({ source, location }) { + const destination = location.current.dropTargets[0]; + if (!destination) return; + + const sourceId = source.data.id as string; + const destinationId = destination.data.id as string; + const edge = extractClosestEdge(destination.data); + + if (sourceId === destinationId) return; + + // Current order based on keys in groupedStates (which are sorted by computed) + const currentOrder = Object.keys(groupedStates); + + // Remove source + const newOrder = currentOrder.filter((id) => id !== sourceId); + + // Find insert index + let insertIndex = newOrder.indexOf(destinationId); + if (edge === "bottom") { + insertIndex += 1; + } + + // Insert source + newOrder.splice(insertIndex, 0, sourceId); + + // API call + if (workspaceSlug && projectId) { + updateProject(workspaceSlug.toString(), projectId.toString(), { state_group_order: newOrder as TStateGroups[] }); + } + }, + }); + }, [groupedStates, isEditable, workspaceSlug, projectId, updateProject]); return (
{Object.entries(groupedStates).map(([key, value]) => { diff --git a/apps/web/core/store/state.store.ts b/apps/web/core/store/state.store.ts index 1e2ad567321..6ed6432ce8b 100644 --- a/apps/web/core/store/state.store.ts +++ b/apps/web/core/store/state.store.ts @@ -114,23 +114,27 @@ export class StateStore implements IStateStore { return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId)); } - /** - * Returns the stateMap belongs to a specific project grouped by group - */ get groupedProjectStates() { if (!this.router.projectId) return; + const project = this.rootStore.projectRoot.project.getProjectById(this.router.projectId); + const stateGroupOrder = project?.state_group_order || Object.keys(STATE_GROUPS); + // First group the existing states const groupedStates = groupBy(this.projectStates, "group") as Record; - // Ensure all STATE_GROUPS are present - const allGroups = Object.keys(STATE_GROUPS).reduce( - (acc, group) => ({ - ...acc, - [group]: groupedStates[group] || [], - }), - {} as Record - ); + // Ensure all STATE_GROUPS are present based on stateGroupOrder + const allGroups: Record = {}; + stateGroupOrder.forEach((group) => { + allGroups[group as string] = groupedStates[group as string] || []; + }); + + // In case there are missing groups in the stored array + Object.keys(STATE_GROUPS).forEach((group) => { + if (!allGroups[group]) { + allGroups[group] = groupedStates[group] || []; + } + }); return allGroups; } @@ -307,10 +311,9 @@ export class StateStore implements IStateStore { */ deleteState = async (workspaceSlug: string, projectId: string, stateId: string) => { if (!this.stateMap?.[stateId]) return; - await this.stateService.deleteState(workspaceSlug, projectId, stateId).then(() => { - runInAction(() => { - delete this.stateMap[stateId]; - }); + await this.stateService.deleteState(workspaceSlug, projectId, stateId); + runInAction(() => { + delete this.stateMap[stateId]; }); }; diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index 3cdbed1deb4..a3a003e51ad 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -59,6 +59,7 @@ export interface IProject extends IPartialProject { members?: string[]; timezone?: string; next_work_item_sequence?: number; + state_group_order?: TStateGroups[]; } export type TProjectAnalyticsCountParams = { @@ -96,17 +97,17 @@ export type TProjectMembership = { member: string; role: TUserPermissions | EUserProjectRoles; } & ( - | { + | { id: string; original_role: EUserProjectRoles; created_at: string; } - | { + | { id: null; original_role: null; created_at: null; } -); + ); export interface IProjectBulkAddFormData { members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[];