From 62e8515a48dd9ffca01744c736d0dd1cc4846b58 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Tue, 9 Jun 2026 22:40:40 +0400 Subject: [PATCH 1/5] refactor: update handler, router, service, components, drafts for API and ui --- api/internal/handler/project.go | 4 + api/internal/router/router.go | 2 +- api/internal/service/state.go | 59 +++++ ui/src/components/CreateWorkItemModal.tsx | 204 ++++++++++-------- .../drafts/DraftIssueRowProperties.tsx | 29 +-- ui/src/components/drafts/draftStateOptions.ts | 44 ++++ ui/src/components/work-item/Dropdown.tsx | 7 +- 7 files changed, 235 insertions(+), 114 deletions(-) create mode 100644 ui/src/components/drafts/draftStateOptions.ts diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go index d810e7ac..6c229835 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,9 @@ func (h *ProjectHandler) Create(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"}) return } + if h.State != nil { + _ = 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/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..7de3b67c 100644 --- a/api/internal/service/state.go +++ b/api/internal/service/state.go @@ -42,9 +42,68 @@ func (s *StateService) List(ctx context.Context, workspaceSlug string, projectID if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { return nil, err } + wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrProjectForbidden + } + if err := s.ensureDefaultStates(ctx, projectID, wrk.ID); 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: "cancelled"}, +} + +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.Create(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 { + if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { + return err + } + wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return ErrProjectForbidden + } + return s.ensureDefaultStates(ctx, projectID, wrk.ID) +} + 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 { return nil, err diff --git a/ui/src/components/CreateWorkItemModal.tsx b/ui/src/components/CreateWorkItemModal.tsx index 9a73ff12..9ef16191 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 = Boolean(selectedProject?.module_view); + const showCycles = Boolean(selectedProject?.cycle_view); 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}