diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go
index d810e7ac..4c38f690 100644
--- a/api/internal/handler/project.go
+++ b/api/internal/handler/project.go
@@ -13,6 +13,7 @@ import (
// ProjectHandler serves project and project-member/invite endpoints.
type ProjectHandler struct {
Project *service.ProjectService
+ State *service.StateService
}
func projectID(c *gin.Context) (uuid.UUID, bool) {
@@ -118,6 +119,10 @@ func (h *ProjectHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
+ if h.State != nil {
+ // Best-effort: project is already created; state listing also seeds on-demand.
+ _ = h.State.EnsureDefaultStates(c.Request.Context(), slug, p.ID, user.ID)
+ }
// If additional fields were provided, immediately apply them using the same logic as Update.
if body.Description != nil ||
diff --git a/api/internal/handler/project_test.go b/api/internal/handler/project_test.go
index d28032b3..67c9149c 100644
--- a/api/internal/handler/project_test.go
+++ b/api/internal/handler/project_test.go
@@ -39,6 +39,20 @@ func TestProject_Create_Success(t *testing.T) {
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
body := testutil.MustJSONMap(t, rr)
assert.Equal(t, "My Project", body["name"])
+
+ projectID, _ := body["id"].(string)
+ require.NotEmpty(t, projectID)
+
+ statesRR := ts.GET("/api/workspaces/"+ws.Slug+"/projects/"+projectID+"/states/", session)
+ require.Equal(t, http.StatusOK, statesRR.Code, "body=%s", statesRR.Body.String())
+ states := testutil.DecodeJSON[[]map[string]any](t, statesRR)
+ require.Len(t, states, 5)
+ names := make([]string, len(states))
+ for i, st := range states {
+ name, _ := st["name"].(string)
+ names[i] = name
+ }
+ assert.ElementsMatch(t, []string{"Backlog", "Todo", "In Progress", "Done", "Cancelled"}, names)
}
func TestProject_Create_RequiresMembership(t *testing.T) {
diff --git a/api/internal/handler/state_test.go b/api/internal/handler/state_test.go
index 0d0df90b..80ba6c40 100644
--- a/api/internal/handler/state_test.go
+++ b/api/internal/handler/state_test.go
@@ -46,6 +46,27 @@ func TestState_CRUD(t *testing.T) {
require.Equal(t, http.StatusNoContent, rr4.Code)
}
+func TestState_List_SeedsDefaultStates(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ user := testutil.CreateUser(t, ts.DB)
+ ws := testutil.CreateWorkspace(t, ts.DB, user.ID)
+ session := testutil.LoginAs(t, ts.DB, user)
+ project := testutil.CreateProject(t, ts.DB, ws.ID, user.ID)
+
+ base := "/api/workspaces/" + ws.Slug + "/projects/" + project.ID.String() + "/states/"
+ rr := ts.GET(base, session)
+ require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
+
+ list := testutil.DecodeJSON[[]map[string]any](t, rr)
+ require.Len(t, list, 5)
+ names := make([]string, len(list))
+ for i, st := range list {
+ name, _ := st["name"].(string)
+ names[i] = name
+ }
+ assert.ElementsMatch(t, []string{"Backlog", "Todo", "In Progress", "Done", "Cancelled"}, names)
+}
+
func TestState_NonMember404(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index 27257147..5c54cc7d 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -202,7 +202,7 @@ func New(cfg Config) *gin.Engine {
Queue: cfg.Queue,
AppBaseURL: appBaseURL,
}
- projectHandler := &handler.ProjectHandler{Project: projectSvc}
+ projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc}
favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore}
stateHandler := &handler.StateHandler{State: stateSvc}
labelHandler := &handler.LabelHandler{Label: labelSvc}
diff --git a/api/internal/service/state.go b/api/internal/service/state.go
index 9a37dd13..ac374b25 100644
--- a/api/internal/service/state.go
+++ b/api/internal/service/state.go
@@ -11,6 +11,9 @@ import (
var ErrStateNotFound = errors.New("state not found")
+// DefaultProjectStateNames are seeded for new projects (Plane parity, without triage).
+var DefaultProjectStateNames = []string{"Backlog", "Todo", "In Progress", "Done", "Cancelled"}
+
// StateService handles state (workflow) business logic.
type StateService struct {
ss *store.StateStore
@@ -22,34 +25,94 @@ func NewStateService(ss *store.StateStore, ps *store.ProjectStore, ws *store.Wor
return &StateService{ss: ss, ps: ps, ws: ws}
}
-func (s *StateService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error {
+func (s *StateService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) (uuid.UUID, error) {
wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
if err != nil {
- return ErrProjectForbidden
+ return uuid.Nil, ErrProjectForbidden
}
ok, _ := s.ws.IsMember(ctx, wrk.ID, userID)
if !ok {
- return ErrProjectForbidden
+ return uuid.Nil, ErrProjectForbidden
}
inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, wrk.ID)
if !inWorkspace {
- return ErrProjectNotFound
+ return uuid.Nil, ErrProjectNotFound
}
- return nil
+ return wrk.ID, nil
}
func (s *StateService) List(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) ([]model.State, error) {
- if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
+ workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
+ if err != nil {
+ return nil, err
+ }
+ list, err := s.ss.ListByProjectID(ctx, projectID)
+ if err != nil {
+ return nil, err
+ }
+ if len(list) > 0 {
+ return list, nil
+ }
+ if err := s.ensureDefaultStates(ctx, projectID, workspaceID); err != nil {
return nil, err
}
return s.ss.ListByProjectID(ctx, projectID)
}
+// defaultProjectStates mirrors Plane's DEFAULT_STATES (without triage).
+var defaultProjectStates = []struct {
+ name string
+ color string
+ sequence float64
+ group string
+ isDefault bool
+}{
+ {name: "Backlog", color: "#60646C", sequence: 15000, group: "backlog", isDefault: true},
+ {name: "Todo", color: "#60646C", sequence: 25000, group: "unstarted"},
+ {name: "In Progress", color: "#F59E0B", sequence: 35000, group: "started"},
+ {name: "Done", color: "#46A758", sequence: 45000, group: "completed"},
+ {name: "Cancelled", color: "#9AA4BC", sequence: 55000, group: "canceled"},
+}
+
+func (s *StateService) ensureDefaultStates(ctx context.Context, projectID, workspaceID uuid.UUID) error {
+ list, err := s.ss.ListByProjectID(ctx, projectID)
+ if err != nil {
+ return err
+ }
+ if len(list) > 0 {
+ return nil
+ }
+ for _, def := range defaultProjectStates {
+ st := &model.State{
+ Name: def.name,
+ Color: def.color,
+ Sequence: def.sequence,
+ Group: def.group,
+ Default: def.isDefault,
+ ProjectID: projectID,
+ WorkspaceID: workspaceID,
+ }
+ if err := s.ss.RestoreOrCreateByNameAndProject(ctx, st); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// EnsureDefaultStates seeds workflow states for a project that has none (Plane parity).
+func (s *StateService) EnsureDefaultStates(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error {
+ workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
+ if err != nil {
+ return err
+ }
+ return s.ensureDefaultStates(ctx, projectID, workspaceID)
+}
+
func (s *StateService) Create(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, color, group string) (*model.State, error) {
- if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
+ workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
+ if err != nil {
return nil, err
}
- wrk, _ := s.ws.GetBySlug(ctx, workspaceSlug)
if color == "" {
color = "#0d0d0d"
}
@@ -61,7 +124,7 @@ func (s *StateService) Create(ctx context.Context, workspaceSlug string, project
Color: color,
Group: group,
ProjectID: projectID,
- WorkspaceID: wrk.ID,
+ WorkspaceID: workspaceID,
}
if err := s.ss.Create(ctx, st); err != nil {
return nil, err
@@ -70,7 +133,7 @@ func (s *StateService) Create(ctx context.Context, workspaceSlug string, project
}
func (s *StateService) GetByID(ctx context.Context, workspaceSlug string, projectID, stateID uuid.UUID, userID uuid.UUID) (*model.State, error) {
- if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
+ if _, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil {
return nil, err
}
st, err := s.ss.GetByID(ctx, stateID)
diff --git a/api/internal/store/state.go b/api/internal/store/state.go
index c9dc9d81..34705602 100644
--- a/api/internal/store/state.go
+++ b/api/internal/store/state.go
@@ -2,10 +2,12 @@ package store
import (
"context"
+ "errors"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// StateStore handles state persistence.
@@ -17,6 +19,34 @@ func (s *StateStore) Create(ctx context.Context, st *model.State) error {
return s.db.WithContext(ctx).Create(st).Error
}
+// RestoreOrCreateByNameAndProject inserts a default state, restoring a soft-deleted row
+// with the same (name, project_id) when present so UNIQUE constraints do not block reseeding.
+func (s *StateStore) RestoreOrCreateByNameAndProject(ctx context.Context, st *model.State) error {
+ var existing model.State
+ err := s.db.WithContext(ctx).Unscoped().
+ Where("name = ? AND project_id = ?", st.Name, st.ProjectID).
+ First(&existing).Error
+ if err == nil {
+ if !existing.DeletedAt.Valid {
+ return nil
+ }
+ existing.Color = st.Color
+ existing.Sequence = st.Sequence
+ existing.Group = st.Group
+ existing.Default = st.Default
+ existing.WorkspaceID = st.WorkspaceID
+ existing.DeletedAt = gorm.DeletedAt{}
+ return s.db.WithContext(ctx).Unscoped().Save(&existing).Error
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ return s.db.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "name"}, {Name: "project_id"}},
+ DoNothing: true,
+ }).Create(st).Error
+}
+
func (s *StateStore) GetByID(ctx context.Context, id uuid.UUID) (*model.State, error) {
var st model.State
err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&st).Error
diff --git a/ui/src/components/CreateWorkItemModal.tsx b/ui/src/components/CreateWorkItemModal.tsx
index 9a73ff12..ad182277 100644
--- a/ui/src/components/CreateWorkItemModal.tsx
+++ b/ui/src/components/CreateWorkItemModal.tsx
@@ -274,7 +274,9 @@ export function CreateWorkItemModal({
};
}, [workspaceSlug]);
- const stateName = stateId ? states.find((s) => s.id === stateId)?.name : '';
+ const stateName = stateId ? (states.find((s) => s.id === stateId)?.name ?? '') : '';
+ const showModules = selectedProject?.module_view ?? true;
+ const showCycles = selectedProject?.cycle_view ?? true;
const assigneeNames =
assigneeIds
.map((id) => members.find((m) => m.id === id)?.name ?? id.slice(0, 8))
@@ -299,6 +301,11 @@ export function CreateWorkItemModal({
const filteredCycles = cycles.filter((c) => q(c.name).includes(q(cycleSearch)));
const filteredModules = modules.filter((m) => q(m.name).includes(q(moduleSearch)));
+ useEffect(() => {
+ if (!showCycles) setCycleId(null);
+ if (!showModules) setModuleId(null);
+ }, [showCycles, showModules]);
+
useEffect(() => {
if (open) {
const iv = initialValues;
@@ -345,7 +352,7 @@ export function CreateWorkItemModal({
title,
description,
projectId,
- stateId: draftOnly ? undefined : stateId || undefined,
+ stateId: stateId || undefined,
priority: priority !== 'none' ? priority : undefined,
assigneeIds: assigneeIds.length ? assigneeIds : undefined,
assigneeId: assigneeIds[0] ?? undefined,
@@ -444,6 +451,7 @@ export function CreateWorkItemModal({
id="project"
openId={openDropdown}
onOpen={setOpenDropdown}
+ allowDismissInsideDialog
label="Select project"
icon={
selectedProject?.name.includes('Logistics') ?