Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class Meta:
"external_id",
"is_issue_type_enabled",
"is_time_tracking_enabled",
"state_group_order",
]

read_only_fields = [
Expand Down
19 changes: 19 additions & 0 deletions apps/api/plane/db/migrations/0121_project_state_group_order.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
5 changes: 5 additions & 0 deletions apps/api/plane/db/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand Down
54 changes: 37 additions & 17 deletions apps/web/core/components/issues/issue-layouts/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -88,7 +86,7 @@ type TGetGroupByColumns = {
export const getGroupByColumns = ({
groupBy,
includeNone,
isWorkspaceLevel,
isWorkspaceLevel: isWorkspaceLevelParam,
isEpic = false,
projectId,
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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;
Comment on lines +222 to +228
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sort comparator uses stateGroupOrder.indexOf(...) directly. If state_group_order is ever missing a group (or contains an unknown group), indexOf returns -1 and those states will sort before all valid groups, producing a surprising order. Treat missing groups as “after all known groups” (or fall back to the default STATE_GROUPS order) so corrupted/partial data doesn’t break ordering.

Copilot uses AI. Check for mistakes.
});
}

// map project states to group by columns
return _states.map((state) => ({
return sortedStates.map((state) => ({
id: state.id,
name: state.name,
icon: (
Expand Down Expand Up @@ -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
Expand All @@ -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 [];
Expand Down Expand Up @@ -452,17 +472,17 @@ 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 = {
...currentIssueState,
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 = {
Expand Down Expand Up @@ -731,7 +751,7 @@ export const isDisplayFiltersApplied = (filters: Partial<IIssueFilters>): 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
Expand All @@ -741,7 +761,7 @@ export const isDisplayFiltersApplied = (filters: Partial<IIssueFilters>): boolea
return true;
});

return isDisplayPropertiesApplied || isDisplayFiltersApplied;
return isDisplayPropertiesApplied || hasDisplayFiltersApplied;
};

/**
Expand Down
Loading
Loading