feat: add custom state group ordering (Fixes #8743)#8745
feat: add custom state group ordering (Fixes #8743)#8745Aesthetic002 wants to merge 2 commits intomakeplane:previewfrom
Conversation
📝 WalkthroughWalkthroughThis change introduces the ability to customize the display order of project states. It adds a Changes
Sequence DiagramsequenceDiagram
participant User as User
participant UI as GroupList/GroupItem
participant Store as State Store
participant API as Backend API
participant DB as Database
User->>UI: Drag state group to reorder
UI->>UI: Update local drag state (isDragging, closestEdge)
UI->>UI: Render DropIndicator at drop position
User->>UI: Drop state group
UI->>Store: Call updateProject with new state_group_order
Store->>API: POST/PATCH request with state_group_order
API->>DB: Save new state_group_order to Project
DB-->>API: Confirm save
API-->>Store: Return updated Project
Store->>Store: Update groupedProjectStates with custom order
Store-->>UI: Notify state change
UI->>UI: Re-render states in new order
UI-->>User: Display reordered states
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds per-project customization of the display order for state groups, persisted on the backend and applied by the web app when grouping states (including drag-and-drop reordering in Project Settings → States).
Changes:
- Add
state_group_orderto the Project model + migration and expose it via project serializers/types. - Apply
state_group_orderwhen grouping project states in the frontend store. - Add drag-and-drop reordering of state groups in the project states settings UI and apply ordering in issue layout utilities.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/types/src/project/projects.ts | Extends IProject with state_group_order for typed consumption in web. |
| apps/web/core/store/state.store.ts | Uses project state_group_order to build an ordered groupedProjectStates map. |
| apps/web/core/components/project-states/group-list.tsx | Adds global DnD monitoring to persist group reordering via updateProject. |
| apps/web/core/components/project-states/group-item.tsx | Makes each group draggable/droppable and shows drop indicators. |
| apps/web/core/components/issues/issue-layouts/utils.tsx | Sorts state columns using the project’s state_group_order when present. |
| apps/api/plane/db/models/project.py | Adds state_group_order JSONField with a default order. |
| apps/api/plane/db/migrations/0121_project_state_group_order.py | Migration to add state_group_order column to projects. |
| apps/api/plane/api/serializers/project.py | Exposes state_group_order via create/update serializers. |
You can also share your feedback on Copilot code review. Take the survey.
| const sourceId = source.data.id as string; | ||
| const destinationId = destination.data.id as string; | ||
| const edge = extractClosestEdge(destination.data); |
There was a problem hiding this comment.
monitorForElements only checks the drag source type, but onDrop assumes the first dropTarget is a STATE_GROUP and blindly reads destination.data.id. If a STATE_GROUP is dropped over a nested/non-group drop target (e.g. a state row), destination.data.id will be a state id or undefined and the reorder calculation will produce an incorrect newOrder (or splice at -1). Add a guard to ensure the destination drop target has data.type === "STATE_GROUP" (and a valid id) before computing the new order.
| const sourceId = source.data.id as string; | |
| const destinationId = destination.data.id as string; | |
| const edge = extractClosestEdge(destination.data); | |
| const destinationData = destination.data; | |
| if ( | |
| !destinationData || | |
| destinationData.type !== "STATE_GROUP" || | |
| !destinationData.id | |
| ) { | |
| return; | |
| } | |
| const sourceId = source.data.id as string; | |
| const destinationId = destinationData.id as string; | |
| const edge = extractClosestEdge(destinationData); |
|
|
||
| // API call | ||
| if (workspaceSlug && projectId) { | ||
| updateProject(workspaceSlug.toString(), projectId.toString(), { state_group_order: newOrder as TStateGroups[] }); |
There was a problem hiding this comment.
updateProject(...) returns a Promise and is currently fired-and-forgotten inside onDrop. If the request fails, this can lead to an unhandled promise rejection and no user feedback. Consider explicitly handling the promise (e.g., void updateProjectPromise.catch(...) / setPromiseToast(...)) so failures are surfaced and don’t pollute the console.
| updateProject(workspaceSlug.toString(), projectId.toString(), { state_group_order: newOrder as TStateGroups[] }); | |
| const updatePromise = updateProject( | |
| workspaceSlug.toString(), | |
| projectId.toString(), | |
| { state_group_order: newOrder as TStateGroups[] } | |
| ); | |
| void updatePromise.catch((error) => { | |
| console.error("Failed to update project state group order", error); | |
| }); |
| 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; |
There was a problem hiding this comment.
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.
| class Meta: | ||
| model = Project | ||
| fields = [ | ||
| "name", | ||
| "description", | ||
| "project_lead", | ||
| "default_assignee", | ||
| "identifier", | ||
| "icon_prop", | ||
| "emoji", | ||
| "cover_image", | ||
| "module_view", | ||
| "cycle_view", | ||
| "issue_views_view", | ||
| "page_view", | ||
| "intake_view", | ||
| "guest_view_all_features", | ||
| "archive_in", | ||
| "close_in", | ||
| "timezone", | ||
| "external_source", | ||
| "external_id", | ||
| "is_issue_type_enabled", | ||
| "is_time_tracking_enabled", | ||
| "state_group_order", | ||
| ] |
There was a problem hiding this comment.
state_group_order is now writable via ProjectCreateSerializer/ProjectUpdateSerializer, but there’s no validation to ensure it’s a list of allowed state group keys, contains no duplicates, and (optionally) includes all required groups. Without this, clients can persist arbitrary JSON that will later break ordering logic in the UI. Add a validate_state_group_order (or extend validate) to enforce the allowed set (backlog/unstarted/started/completed/cancelled) and normalize/fill missing values if needed.
There was a problem hiding this comment.
🧹 Nitpick comments (5)
apps/web/core/components/project-states/group-list.tsx (2)
70-108: Potential edge case when destination not found in order.If
destinationIdis not innewOrder(e.g., due to a race condition or stale state),indexOfreturns-1. Withedge === "bottom", this becomes0, and the source is inserted at the beginning. While unlikely, this could produce unexpected ordering.Consider adding a guard:
Suggested guard for edge case
// Find insert index let insertIndex = newOrder.indexOf(destinationId); +if (insertIndex === -1) return; // Destination not found, abort reorder if (edge === "bottom") { insertIndex += 1; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/core/components/project-states/group-list.tsx` around lines 70 - 108, The onDrop handler in monitorForElements can insert the source at an incorrect place if destinationId is missing from newOrder; inside the onDrop (in group-list.tsx) check the result of newOrder.indexOf(destinationId) and handle -1 explicitly (either abort the reorder or append to the end) before adjusting for edge === "bottom" and calling updateProject; update the logic around insertIndex/newOrder (references: onDrop, sourceId, destinationId, newOrder, insertIndex, edge, updateProject) to guard against stale/missing destination IDs.
103-105: No error handling for updateProject failure.If the
updateProjectAPI call fails, the UI will have already visually reordered (via the computed property reading from groupedStates), but the backend state won't match. Consider adding error handling to show a toast or revert the visual state on failure.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/core/components/project-states/group-list.tsx` around lines 103 - 105, The UI reorders locally using groupedStates but doesn’t handle failures from updateProject; wrap the call to updateProject (the place where workspaceSlug, projectId and newOrder as TStateGroups[] are used) in an async try/catch, await the promise, and on error revert the local reorder (reset groupedStates to previous order or trigger a refetch) and show a user-facing error (e.g., toast.error) so backend/state stay consistent; ensure you capture the previous order before mutating so you can restore it on failure.apps/api/plane/api/serializers/project.py (1)
95-95: Consider adding validation forstate_group_orderin the serializer.The field is exposed without input validation. Malformed payloads (e.g., invalid group names, duplicates, wrong data types) could be persisted. Consider adding a
validate_state_group_ordermethod to ensure only valid state group values are accepted.Example validation approach
VALID_STATE_GROUPS = {"backlog", "unstarted", "started", "completed", "cancelled"} def validate_state_group_order(self, value): if not isinstance(value, list): raise serializers.ValidationError("state_group_order must be a list") if not all(isinstance(item, str) and item in VALID_STATE_GROUPS for item in value): raise serializers.ValidationError(f"state_group_order must contain only valid state groups: {VALID_STATE_GROUPS}") return value🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/plane/api/serializers/project.py` at line 95, Add a field-level validator method validate_state_group_order to the serializer class that ensures state_group_order is a list of strings, each string is one of the allowed groups (e.g., VALID_STATE_GROUPS = {"backlog","unstarted","started","completed","cancelled"}), rejects duplicates, and raises serializers.ValidationError with a clear message on type/invalid value/duplicate detection; implement this method on the serializer that declares the "state_group_order" field so Django REST Framework will invoke it during validation.apps/api/plane/db/models/project.py (1)
118-118: Consider adding validation for state_group_order values.The JSONField accepts any JSON-serializable data. If invalid state group names are saved (e.g., typos or non-existent groups), the frontend sorting logic will silently ignore them. You may want to add model-level or serializer-level validation to ensure only valid state groups (
backlog,unstarted,started,completed,cancelled) are stored.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/plane/db/models/project.py` at line 118, Add validation to ensure state_group_order contains only allowed group names by validating before save: in the Project model (where state_group_order = models.JSONField(default=get_default_state_group_order)) add a model-level clean() or a JSONField validator that checks the value is a list/array and every entry is one of the allowed set {'backlog','unstarted','started','completed','cancelled'} and raise django.core.exceptions.ValidationError on violation; alternatively add equivalent validation in the Project serializer (e.g., ProjectSerializer.validate_state_group_order) so invalid entries are rejected before persisting or returning.apps/web/core/store/state.store.ts (1)
312-318: Verify if pessimistic delete is intentional.The
deleteStatemethod now waits for the API call to succeed before removing the state from local store. This differs fromupdateState(lines 287-304), which uses optimistic updates with rollback on failure. If the API call is slow or fails, the UI will not reflect the deletion until completion/error.Consider whether this should follow the same optimistic pattern for consistency:
Optimistic delete with rollback pattern
deleteState = async (workspaceSlug: string, projectId: string, stateId: string) => { if (!this.stateMap?.[stateId]) return; + const originalState = this.stateMap[stateId]; + try { + runInAction(() => { + delete this.stateMap[stateId]; + }); await this.stateService.deleteState(workspaceSlug, projectId, stateId); - runInAction(() => { - delete this.stateMap[stateId]; - }); + } catch (error) { + runInAction(() => { + this.stateMap[stateId] = originalState; + }); + throw error; + } };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/core/store/state.store.ts` around lines 312 - 318, deleteState currently performs a pessimistic delete (waits for stateService.deleteState to succeed) which is inconsistent with updateState's optimistic pattern; change deleteState to perform an optimistic removal by storing a backup of this.stateMap[stateId], calling runInAction to delete this.stateMap[stateId] before awaiting stateService.deleteState(workspaceSlug, projectId, stateId), and on catch/failed API call restore the backup inside runInAction (optionally logging the error) so the store rolls back on failure; keep the early-return guard on !this.stateMap?.[stateId] and ensure errors are rethrown or handled consistently with updateState.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@apps/api/plane/api/serializers/project.py`:
- Line 95: Add a field-level validator method validate_state_group_order to the
serializer class that ensures state_group_order is a list of strings, each
string is one of the allowed groups (e.g., VALID_STATE_GROUPS =
{"backlog","unstarted","started","completed","cancelled"}), rejects duplicates,
and raises serializers.ValidationError with a clear message on type/invalid
value/duplicate detection; implement this method on the serializer that declares
the "state_group_order" field so Django REST Framework will invoke it during
validation.
In `@apps/api/plane/db/models/project.py`:
- Line 118: Add validation to ensure state_group_order contains only allowed
group names by validating before save: in the Project model (where
state_group_order = models.JSONField(default=get_default_state_group_order)) add
a model-level clean() or a JSONField validator that checks the value is a
list/array and every entry is one of the allowed set
{'backlog','unstarted','started','completed','cancelled'} and raise
django.core.exceptions.ValidationError on violation; alternatively add
equivalent validation in the Project serializer (e.g.,
ProjectSerializer.validate_state_group_order) so invalid entries are rejected
before persisting or returning.
In `@apps/web/core/components/project-states/group-list.tsx`:
- Around line 70-108: The onDrop handler in monitorForElements can insert the
source at an incorrect place if destinationId is missing from newOrder; inside
the onDrop (in group-list.tsx) check the result of
newOrder.indexOf(destinationId) and handle -1 explicitly (either abort the
reorder or append to the end) before adjusting for edge === "bottom" and calling
updateProject; update the logic around insertIndex/newOrder (references: onDrop,
sourceId, destinationId, newOrder, insertIndex, edge, updateProject) to guard
against stale/missing destination IDs.
- Around line 103-105: The UI reorders locally using groupedStates but doesn’t
handle failures from updateProject; wrap the call to updateProject (the place
where workspaceSlug, projectId and newOrder as TStateGroups[] are used) in an
async try/catch, await the promise, and on error revert the local reorder (reset
groupedStates to previous order or trigger a refetch) and show a user-facing
error (e.g., toast.error) so backend/state stay consistent; ensure you capture
the previous order before mutating so you can restore it on failure.
In `@apps/web/core/store/state.store.ts`:
- Around line 312-318: deleteState currently performs a pessimistic delete
(waits for stateService.deleteState to succeed) which is inconsistent with
updateState's optimistic pattern; change deleteState to perform an optimistic
removal by storing a backup of this.stateMap[stateId], calling runInAction to
delete this.stateMap[stateId] before awaiting
stateService.deleteState(workspaceSlug, projectId, stateId), and on catch/failed
API call restore the backup inside runInAction (optionally logging the error) so
the store rolls back on failure; keep the early-return guard on
!this.stateMap?.[stateId] and ensure errors are rethrown or handled consistently
with updateState.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: feb7afd5-a5a7-4e87-a7cd-e409d2086806
📒 Files selected for processing (8)
apps/api/plane/api/serializers/project.pyapps/api/plane/db/migrations/0121_project_state_group_order.pyapps/api/plane/db/models/project.pyapps/web/core/components/issues/issue-layouts/utils.tsxapps/web/core/components/project-states/group-item.tsxapps/web/core/components/project-states/group-list.tsxapps/web/core/store/state.store.tspackages/types/src/project/projects.ts
Description
This PR implements the ability to customize the display order of state groups within a project.
A new field
state_group_orderhas been added to theProjectmodel to persist the order of state groups. The frontend state store now reads this value and applies the custom ordering when grouping project states.Drag-and-drop functionality has been added to the Project Settings → States page to allow users to reorder state groups. The updated order is persisted using the project update API and reflected across the application, including the Kanban board.
Type of Change
Screenshots and Media (if applicable)
N/A
Test Scenarios
pnpm dev.References
Fixes #8743
Before changing the order
Video
HEHE.-.Work.items.-.Google.Chrome.2026-03-10.23-45-56.mp4
Summary by CodeRabbit
Release Notes
New Features
Improvements