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') ? : @@ -502,6 +510,7 @@ export function CreateWorkItemModal({ icon={} displayValue={stateName || 'Backlog'} compact + allowDismissInsideDialog panelClassName="flex min-w-[120px] max-h-52 flex-col rounded border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" >
@@ -514,25 +523,30 @@ export function CreateWorkItemModal({ />
- {filteredStates.map((s) => ( - - ))} + {filteredStates.length === 0 ? ( +
No states
+ ) : ( + filteredStates.map((s) => ( + + )) + )}
} displayValue={ @@ -561,6 +575,7 @@ export function CreateWorkItemModal({ id="assignees" openId={openDropdown} onOpen={setOpenDropdown} + allowDismissInsideDialog label="Assignees" icon={} displayValue={assigneeNames || 'Add assignees'} @@ -607,6 +622,7 @@ export function CreateWorkItemModal({ id="labels" openId={openDropdown} onOpen={setOpenDropdown} + allowDismissInsideDialog label="Labels" icon={} displayValue={labelNames} @@ -672,96 +688,102 @@ export function CreateWorkItemModal({ onChange={setDueDate} placeholder="Due date" /> - } - displayValue={cycleName || 'No cycle'} - compact - panelClassName="flex min-w-[120px] max-h-52 flex-col rounded border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" - > -
- setCycleSearch(e.target.value)} - className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs placeholder:text-(--txt-placeholder) focus:outline-none focus:border-(--border-strong)" - /> -
-
- - {filteredCycles.map((c) => ( + {showCycles ? ( + } + displayValue={cycleName || 'No cycle'} + compact + panelClassName="flex min-w-[120px] max-h-52 flex-col rounded border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" + > +
+ setCycleSearch(e.target.value)} + className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs placeholder:text-(--txt-placeholder) focus:outline-none focus:border-(--border-strong)" + /> +
+
- ))} -
-
- } - displayValue={moduleName ?? ''} - compact - panelClassName="flex min-w-[120px] max-h-52 flex-col rounded border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" - > -
- setModuleSearch(e.target.value)} - className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs placeholder:text-(--txt-placeholder) focus:outline-none focus:border-(--border-strong)" - /> -
-
- - {filteredModules.map((m) => ( + {filteredCycles.map((c) => ( + + ))} +
+
+ ) : null} + {showModules ? ( + } + displayValue={moduleName ?? ''} + compact + panelClassName="flex min-w-[120px] max-h-52 flex-col rounded border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" + > +
+ setModuleSearch(e.target.value)} + className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs placeholder:text-(--txt-placeholder) focus:outline-none focus:border-(--border-strong)" + /> +
+
- ))} -
-
+ {filteredModules.map((m) => ( + + ))} +
+
+ ) : null}