From 557208e3fd48f2bce0f16f49a8ef94273cf6945e Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 03:37:59 +0400 Subject: [PATCH 1/9] feat: ListDraftsByWorkspaceID returns draft work items (is_draft) --- api/internal/handler/issue.go | 32 +++++++++++++++++++++-- api/internal/router/router.go | 2 ++ api/internal/service/issue.go | 48 +++++++++++++++++++++++++++++++++-- api/internal/store/issue.go | 17 +++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/api/internal/handler/issue.go b/api/internal/handler/issue.go index 56ba3e4..d95f05a 100644 --- a/api/internal/handler/issue.go +++ b/api/internal/handler/issue.go @@ -29,6 +29,32 @@ func issueID(c *gin.Context) (uuid.UUID, bool) { return id, true } +// ListWorkspaceDrafts returns draft work items for the workspace (all projects). +// GET /api/workspaces/:slug/draft-issues/ +func (h *IssueHandler) ListWorkspaceDrafts(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + if limit <= 0 || limit > 100 { + limit = 50 + } + list, err := h.Issue.ListDraftsForWorkspace(c.Request.Context(), slug, user.ID, limit, offset) + if err != nil { + if err == service.ErrWorkspaceForbidden { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list draft issues"}) + return + } + c.JSON(http.StatusOK, list) +} + // List returns issues for the project. // GET /api/workspaces/:slug/projects/:projectId/issues/ func (h *IssueHandler) List(c *gin.Context) { @@ -114,6 +140,7 @@ func (h *IssueHandler) Create(c *gin.Context) { TargetDate *string `json:"target_date"` AssigneeIDs []uuid.UUID `json:"assignee_ids"` LabelIDs []uuid.UUID `json:"label_ids"` + IsDraft bool `json:"is_draft"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) @@ -137,7 +164,7 @@ func (h *IssueHandler) Create(c *gin.Context) { } } - issue, err := h.Issue.Create(c.Request.Context(), slug, projectID, user.ID, body.Name, body.Description, body.Priority, body.StateID, body.AssigneeIDs, body.LabelIDs, startDate, targetDate, body.ParentID) + issue, err := h.Issue.Create(c.Request.Context(), slug, projectID, user.ID, body.Name, body.Description, body.Priority, body.StateID, body.AssigneeIDs, body.LabelIDs, startDate, targetDate, body.ParentID, body.IsDraft) if err != nil { if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) @@ -177,6 +204,7 @@ func (h *IssueHandler) Update(c *gin.Context) { TargetDate *string `json:"target_date"` AssigneeIDs []uuid.UUID `json:"assignee_ids"` LabelIDs []uuid.UUID `json:"label_ids"` + IsDraft *bool `json:"is_draft"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) @@ -222,7 +250,7 @@ func (h *IssueHandler) Update(c *gin.Context) { } } - issue, err := h.Issue.Update(c.Request.Context(), slug, projectID, issueID, user.ID, name, priority, description, body.StateID, assigneeIDs, labelIDs, startDate, targetDate, body.ParentID) + issue, err := h.Issue.Update(c.Request.Context(), slug, projectID, issueID, user.ID, name, priority, description, body.StateID, assigneeIDs, labelIDs, startDate, targetDate, body.ParentID, body.IsDraft) if err != nil { if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Issue not found"}) diff --git a/api/internal/router/router.go b/api/internal/router/router.go index 8991393..df44598 100644 --- a/api/internal/router/router.go +++ b/api/internal/router/router.go @@ -170,6 +170,8 @@ func New(cfg Config) *gin.Engine { api.GET("/users/me/workspaces/:slug/projects/invitations/", projectHandler.ListUserProjectInvitations) api.POST("/workspaces/:slug/projects/join/", projectHandler.JoinByToken) + api.GET("/workspaces/:slug/draft-issues/", issueHandler.ListWorkspaceDrafts) + api.GET("/workspaces/:slug/projects/", projectHandler.List) api.POST("/workspaces/:slug/projects/", projectHandler.Create) api.GET("/workspaces/:slug/projects/:projectId/", projectHandler.Get) diff --git a/api/internal/service/issue.go b/api/internal/service/issue.go index 2bfca0c..cbc6917 100644 --- a/api/internal/service/issue.go +++ b/api/internal/service/issue.go @@ -42,6 +42,46 @@ func (s *IssueService) ensureProjectAccess(ctx context.Context, workspaceSlug st return nil } +func (s *IssueService) ensureWorkspaceAccess(ctx context.Context, workspaceSlug string, userID uuid.UUID) (*model.Workspace, error) { + wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceForbidden + } + ok, _ := s.ws.IsMember(ctx, wrk.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + return wrk, nil +} + +// ListDraftsForWorkspace returns draft issues for all projects in the workspace the user can access. +func (s *IssueService) ListDraftsForWorkspace(ctx context.Context, workspaceSlug string, userID uuid.UUID, limit, offset int) ([]model.Issue, error) { + wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID) + if err != nil { + return nil, err + } + list, err := s.is.ListDraftsByWorkspaceID(ctx, wrk.ID, limit, offset) + if err != nil { + return nil, err + } + for i := range list { + issueID := list[i].ID + if ids, err := s.is.ListAssigneesForIssue(ctx, issueID); err == nil { + list[i].AssigneeIDs = ids + } + if ids, err := s.is.ListLabelsForIssue(ctx, issueID); err == nil { + list[i].LabelIDs = ids + } + if ids, err := s.is.ListCycleIDsForIssue(ctx, issueID); err == nil { + list[i].CycleIDs = ids + } + if ids, err := s.is.ListModuleIDsForIssue(ctx, issueID); err == nil { + list[i].ModuleIDs = ids + } + } + return list, nil +} + func (s *IssueService) List(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, limit, offset int) ([]model.Issue, error) { if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { return nil, err @@ -97,7 +137,7 @@ func (s *IssueService) GetByID(ctx context.Context, workspaceSlug string, projec return issue, nil } -func (s *IssueService) Create(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, description, priority string, stateID *uuid.UUID, assigneeIDs []uuid.UUID, labelIDs []uuid.UUID, startDate, targetDate *time.Time, parentID *uuid.UUID) (*model.Issue, error) { +func (s *IssueService) Create(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, description, priority string, stateID *uuid.UUID, assigneeIDs []uuid.UUID, labelIDs []uuid.UUID, startDate, targetDate *time.Time, parentID *uuid.UUID, isDraft bool) (*model.Issue, error) { if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { return nil, err } @@ -107,6 +147,7 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project ProjectID: projectID, WorkspaceID: wrk.ID, CreatedByID: &userID, + IsDraft: isDraft, } if description != "" { issue.DescriptionHTML = description @@ -145,7 +186,7 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project return issue, nil } -func (s *IssueService) Update(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, name, priority, description *string, stateID *uuid.UUID, assigneeIDs, labelIDs *[]uuid.UUID, startDate, targetDate *time.Time, parentID *uuid.UUID) (*model.Issue, error) { +func (s *IssueService) Update(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, name, priority, description *string, stateID *uuid.UUID, assigneeIDs, labelIDs *[]uuid.UUID, startDate, targetDate *time.Time, parentID *uuid.UUID, isDraft *bool) (*model.Issue, error) { issue, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID) if err != nil { return nil, err @@ -171,6 +212,9 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project if parentID != nil { issue.ParentID = parentID } + if isDraft != nil { + issue.IsDraft = *isDraft + } issue.UpdatedByID = &userID if err := s.is.Update(ctx, issue); err != nil { return nil, err diff --git a/api/internal/store/issue.go b/api/internal/store/issue.go index 38cb225..056e570 100644 --- a/api/internal/store/issue.go +++ b/api/internal/store/issue.go @@ -73,6 +73,23 @@ func (s *IssueStore) ListByProjectID(ctx context.Context, projectID uuid.UUID, l return list, err } +// ListDraftsByWorkspaceID returns draft work items (is_draft) across all projects in the workspace. +func (s *IssueStore) ListDraftsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID, limit, offset int) ([]model.Issue, error) { + var list []model.Issue + q := s.db.WithContext(ctx).Where( + "workspace_id = ? AND is_draft = ? AND deleted_at IS NULL", + workspaceID, true, + ).Order("updated_at DESC") + if limit > 0 { + q = q.Limit(limit) + } + if offset > 0 { + q = q.Offset(offset) + } + err := q.Find(&list).Error + return list, err +} + func (s *IssueStore) Update(ctx context.Context, i *model.Issue) error { return s.db.WithContext(ctx).Save(i).Error } From 0889545b4a07aef1a0f5dc84c3727c352f1e4eb4 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 03:39:03 +0400 Subject: [PATCH 2/9] feat: draftspage loads workspace projects, if no project shows an empty state with a link to {slug}/projects --- api/internal/store/issue.go | 1 - ui/src/api/types.ts | 1 + ui/src/components/CreateWorkItemModal.tsx | 11 +- ui/src/components/PageDescriptionEditor.tsx | 160 ++++++++ ui/src/pages/DraftsPage.tsx | 392 +++++++++++++++++++- ui/src/pages/IssueDetailPage.tsx | 1 + ui/src/pages/IssueListPage.tsx | 2 + ui/src/pages/ModuleDetailPage.tsx | 2 + ui/src/pages/NewPagePage.tsx | 124 +++++++ ui/src/pages/PageDetailPage.tsx | 196 ++++++++++ ui/src/pages/ViewDetailPage.tsx | 2 + ui/src/services/issueService.ts | 15 +- 12 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/PageDescriptionEditor.tsx create mode 100644 ui/src/pages/NewPagePage.tsx create mode 100644 ui/src/pages/PageDetailPage.tsx diff --git a/api/internal/store/issue.go b/api/internal/store/issue.go index 056e570..b676042 100644 --- a/api/internal/store/issue.go +++ b/api/internal/store/issue.go @@ -73,7 +73,6 @@ func (s *IssueStore) ListByProjectID(ctx context.Context, projectID uuid.UUID, l return list, err } -// ListDraftsByWorkspaceID returns draft work items (is_draft) across all projects in the workspace. func (s *IssueStore) ListDraftsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID, limit, offset int) ([]model.Issue, error) { var list []model.Issue q := s.db.WithContext(ctx).Where( diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 056f27f..dd2457c 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -195,6 +195,7 @@ export interface CreateIssueRequest { start_date?: string | null; target_date?: string | null; parent_id?: string | null; + is_draft?: boolean; } /** GET /api/instance/setup-status/ */ diff --git a/ui/src/components/CreateWorkItemModal.tsx b/ui/src/components/CreateWorkItemModal.tsx index 4f2aa58..4d32b4a 100644 --- a/ui/src/components/CreateWorkItemModal.tsx +++ b/ui/src/components/CreateWorkItemModal.tsx @@ -102,6 +102,8 @@ export interface CreateWorkItemModalProps { defaultProjectId?: string; defaultModuleId?: string | null; createError?: string | null; + /** When true, creates a workspace draft (always sends is_draft). Title copy matches Plane drafts flow. */ + draftOnly?: boolean; onSave?: (data: { title: string; description: string; @@ -116,7 +118,8 @@ export interface CreateWorkItemModalProps { cycleId?: string | null; moduleId?: string | null; parentId?: string | null; - }) => void; + isDraft?: boolean; + }) => void | Promise; } export function CreateWorkItemModal({ @@ -127,6 +130,7 @@ export function CreateWorkItemModal({ defaultProjectId, defaultModuleId, createError, + draftOnly = false, onSave, }: CreateWorkItemModalProps) { const [title, setTitle] = useState(''); @@ -316,7 +320,7 @@ export function CreateWorkItemModal({ title, description, projectId, - stateId: stateId || undefined, + stateId: draftOnly ? undefined : stateId || undefined, priority: priority !== 'none' ? priority : undefined, assigneeIds: assigneeIds.length ? assigneeIds : undefined, assigneeId: assigneeIds[0] ?? undefined, @@ -326,6 +330,7 @@ export function CreateWorkItemModal({ cycleId: cycleId ?? undefined, moduleId: moduleId ?? undefined, parentId: parentId ?? undefined, + isDraft: draftOnly ? true : undefined, }); if (!createMore) onClose(); else { @@ -407,7 +412,7 @@ export function CreateWorkItemModal({ >

- Create new work item + {draftOnly ? 'Create draft work item' : 'Create new work item'}

string; + isEmpty: () => boolean; + focus: () => void; + setHtml: (html: string) => void; +}; + +export type PageDescriptionEditorProps = { + initialHtml?: string; + placeholder?: string; + autoFocus?: boolean; + readOnly?: boolean; + className?: string; + /** + * Optional keyboard shortcut handler. + * If provided, pressing `Ctrl/Cmd + S` triggers it (default: prevent browser save dialog). + */ + onSaveShortcut?: () => void; +}; + +export const PageDescriptionEditor = forwardRef( + ( + { initialHtml, placeholder, autoFocus, readOnly, className, onSaveShortcut }: PageDescriptionEditorProps, + ref, + ) => { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + bulletList: { keepMarks: true, keepAttributes: true }, + orderedList: { keepMarks: true, keepAttributes: true }, + codeBlock: {}, + }), + Underline, + Placeholder.configure({ + placeholder: placeholder ?? 'Write something…', + }), + ], + content: initialHtml ?? '', + editable: !readOnly, + autofocus: autoFocus ? 'end' : false, + }); + + useEffect(() => { + if (!editor) return; + // keep editor content in sync when parent loads data + if (initialHtml !== undefined) { + editor.commands.setContent(initialHtml || ''); + } + }, [editor, initialHtml]); + + useImperativeHandle( + ref, + () => ({ + getHtml: () => editor?.getHTML() ?? '', + isEmpty: () => { + const html = (editor?.getHTML() ?? '').trim(); + return html === '' || html === '

'; + }, + focus: () => editor?.commands.focus(), + setHtml: (html: string) => editor?.commands.setContent(html ?? ''), + }), + [editor], + ); + + if (!editor) return null; + + const buttonBase = + 'inline-flex h-8 w-8 items-center justify-center rounded border border-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-icon-secondary) disabled:opacity-40'; + + return ( +
+
+ + + + + + + {onSaveShortcut && ( + + Ctrl/Cmd + S to save + + )} +
+
+ { + if (!onSaveShortcut) return; + const key = event.key?.toLowerCase?.() ?? ''; + if ((event.metaKey || event.ctrlKey) && key === 's') { + event.preventDefault(); + onSaveShortcut(); + } + }} + /> +
+
+ ); + }, +); + +PageDescriptionEditor.displayName = 'PageDescriptionEditor'; + diff --git a/ui/src/pages/DraftsPage.tsx b/ui/src/pages/DraftsPage.tsx index 7635466..a7ad530 100644 --- a/ui/src/pages/DraftsPage.tsx +++ b/ui/src/pages/DraftsPage.tsx @@ -1,13 +1,393 @@ -import { useParams } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { Badge, Button } from '../components/ui'; +import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; +import { workspaceService } from '../services/workspaceService'; +import { projectService } from '../services/projectService'; +import { issueService } from '../services/issueService'; +import { cycleService } from '../services/cycleService'; +import { moduleService } from '../services/moduleService'; +import type { WorkspaceApiResponse, ProjectApiResponse, IssueApiResponse } from '../api/types'; +import type { Priority } from '../types'; + +const PAGE_SIZE = 50; + +const priorityVariant: Record = { + urgent: 'danger', + high: 'danger', + medium: 'warning', + low: 'default', + none: 'neutral', +}; + +const IconFolderPlus = () => ( + + + + + +); + +const IconFileDraft = () => ( + + + + + + +); export function DraftsPage() { const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + const [workspace, setWorkspace] = useState(null); + const [projects, setProjects] = useState([]); + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [listLoading, setListLoading] = useState(false); + const [error, setError] = useState(null); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [createError, setCreateError] = useState(null); + const [rowBusy, setRowBusy] = useState(null); + + const projectById = useMemo(() => { + const m = new Map(); + for (const p of projects) m.set(p.id, p); + return m; + }, [projects]); + + const loadDrafts = useCallback( + async (reset: boolean) => { + if (!workspaceSlug) return; + const nextOffset = reset ? 0 : offset; + if (reset) setListLoading(true); + try { + const batch = await issueService.listWorkspaceDrafts(workspaceSlug, { + limit: PAGE_SIZE + 1, + offset: nextOffset, + }); + const more = batch.length > PAGE_SIZE; + const slice = more ? batch.slice(0, PAGE_SIZE) : batch; + setDrafts((prev) => (reset ? slice : [...prev, ...slice])); + setHasMore(more); + setOffset(nextOffset + slice.length); + } catch { + if (reset) setDrafts([]); + setError('Could not load drafts.'); + } finally { + if (reset) setListLoading(false); + } + }, + [workspaceSlug, offset], + ); + + useEffect(() => { + if (!workspaceSlug) { + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + Promise.all([ + workspaceService.getBySlug(workspaceSlug), + projectService.list(workspaceSlug), + ]) + .then(([w, plist]) => { + if (cancelled) return; + setWorkspace(w ?? null); + setProjects(plist ?? []); + }) + .catch(() => { + if (!cancelled) { + setWorkspace(null); + setProjects([]); + setError('Could not load workspace.'); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); + + useEffect(() => { + if (!workspaceSlug || !workspace) return; + let cancelled = false; + setOffset(0); + setHasMore(false); + setListLoading(true); + issueService + .listWorkspaceDrafts(workspaceSlug, { limit: PAGE_SIZE + 1, offset: 0 }) + .then((batch) => { + if (cancelled) return; + const more = batch.length > PAGE_SIZE; + const slice = more ? batch.slice(0, PAGE_SIZE) : batch; + setDrafts(slice); + setHasMore(more); + setOffset(slice.length); + }) + .catch(() => { + if (!cancelled) { + setDrafts([]); + setError('Could not load drafts.'); + } + }) + .finally(() => { + if (!cancelled) setListLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, workspace]); + + const handleCreateSave = async (data: { + title: string; + description?: string; + projectId: string; + stateId?: string; + priority?: Priority; + assigneeIds?: string[]; + labelIds?: string[]; + startDate?: string; + dueDate?: string; + cycleId?: string | null; + moduleId?: string | null; + parentId?: string | null; + isDraft?: boolean; + }) => { + if (!workspaceSlug || !data.title.trim()) return; + setCreateError(null); + try { + const created = await issueService.create(workspaceSlug, data.projectId, { + name: data.title.trim(), + description: data.description || undefined, + state_id: data.stateId || undefined, + priority: data.priority || undefined, + assignee_ids: data.assigneeIds?.length ? data.assigneeIds : undefined, + label_ids: data.labelIds?.length ? data.labelIds : undefined, + start_date: data.startDate || undefined, + target_date: data.dueDate || undefined, + parent_id: data.parentId || undefined, + is_draft: data.isDraft === true ? true : undefined, + }); + if (created?.id) { + if (data.cycleId) { + await cycleService.addIssue(workspaceSlug, data.projectId, data.cycleId, created.id); + } + if (data.moduleId) { + await moduleService.addIssue(workspaceSlug, data.projectId, data.moduleId, created.id); + } + } + setCreateOpen(false); + await loadDrafts(true); + } catch (err) { + setCreateError(err instanceof Error ? err.message : 'Failed to create draft.'); + } + }; + + const handlePublish = async (issue: IssueApiResponse) => { + if (!workspaceSlug) return; + setRowBusy(issue.id); + try { + await issueService.update(workspaceSlug, issue.project_id, issue.id, { is_draft: false }); + setDrafts((prev) => prev.filter((i) => i.id !== issue.id)); + } catch { + setError('Could not publish draft.'); + } finally { + setRowBusy(null); + } + }; + + const handleDelete = async (issue: IssueApiResponse) => { + if (!workspaceSlug) return; + if (!window.confirm(`Delete draft “${issue.name}”?`)) return; + setRowBusy(issue.id); + try { + await issueService.delete(workspaceSlug, issue.project_id, issue.id); + setDrafts((prev) => prev.filter((i) => i.id !== issue.id)); + } catch { + setError('Could not delete draft.'); + } finally { + setRowBusy(null); + } + }; + + if (loading) { + return ( +
+ Loading… +
+ ); + } + + if (!workspaceSlug || !workspace) { + return
Workspace not found.
; + } + + const base = `/${workspace.slug}`; + + if (projects.length === 0) { + return ( +
+ +

No projects yet

+

+ Create a project in this workspace before you can add draft work items. +

+ + Create project + +
+ ); + } + return ( -
-

Drafts

-

- Draft work items. (Placeholder for {workspaceSlug}) -

+
+
+

Drafts

+

+ Draft work items stay out of the main backlog until you publish them. Open a row to edit + details, or use Publish to move the item onto the project board. +

+
+ + {error && ( +

+ {error} +

+ )} + + {listLoading && drafts.length === 0 ? ( +
Loading drafts…
+ ) : drafts.length === 0 ? ( +
+ +

No draft work items

+

+ Capture ideas as drafts and publish them into a project when you are ready. +

+ +
+ ) : ( +
+
    + {drafts.map((issue) => { + const proj = projectById.get(issue.project_id); + const ident = proj?.identifier ?? '—'; + const seq = issue.sequence_id ?? '—'; + const busy = rowBusy === issue.id; + const pri = (issue.priority ?? 'none') as Priority; + return ( +
  • +
    +
    + +
    + + {ident}-{seq} + + + {issue.name} + + {issue.priority && issue.priority !== 'none' ? ( + + {issue.priority} + + ) : null} +
    + + {proj ? ( +

    {proj.name}

    + ) : null} +
    +
    + + +
    +
    +
  • + ); + })} +
+ {hasMore ? ( +
+ +
+ ) : null} +
+ )} + +
+ +
+ + { + setCreateOpen(false); + setCreateError(null); + }} + workspaceSlug={workspace.slug} + projects={projects} + defaultProjectId={projects[0]?.id} + draftOnly + createError={createError} + onSave={handleCreateSave} + />
); } diff --git a/ui/src/pages/IssueDetailPage.tsx b/ui/src/pages/IssueDetailPage.tsx index a128f41..8bfb69c 100644 --- a/ui/src/pages/IssueDetailPage.tsx +++ b/ui/src/pages/IssueDetailPage.tsx @@ -897,6 +897,7 @@ export function IssueDetailPage() { start_date: data.startDate || undefined, target_date: data.dueDate || undefined, parent_id: issue.id, + is_draft: data.isDraft === true ? true : undefined, }); if (data.cycleId) { await cycleService diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx index 21199e2..e43d8a6 100644 --- a/ui/src/pages/IssueListPage.tsx +++ b/ui/src/pages/IssueListPage.tsx @@ -511,6 +511,7 @@ export function IssueListPage() { cycleId?: string | null; moduleId?: string | null; parentId?: string | null; + isDraft?: boolean; }) => { if (!workspaceSlug || !data.title.trim()) return; setCreateError(null); @@ -525,6 +526,7 @@ export function IssueListPage() { start_date: data.startDate || undefined, target_date: data.dueDate || undefined, parent_id: data.parentId || undefined, + is_draft: data.isDraft === true ? true : undefined, }); if (created?.id) { if (data.cycleId) { diff --git a/ui/src/pages/ModuleDetailPage.tsx b/ui/src/pages/ModuleDetailPage.tsx index 2353411..d3b07a7 100644 --- a/ui/src/pages/ModuleDetailPage.tsx +++ b/ui/src/pages/ModuleDetailPage.tsx @@ -344,6 +344,7 @@ export function ModuleDetailPage() { cycleId?: string | null; moduleId?: string | null; parentId?: string | null; + isDraft?: boolean; }) => { if (!workspaceSlug || !data.title.trim() || !resolvedModuleId) return; setCreateError(null); @@ -358,6 +359,7 @@ export function ModuleDetailPage() { start_date: data.startDate || undefined, target_date: data.dueDate || undefined, parent_id: data.parentId || undefined, + is_draft: data.isDraft === true ? true : undefined, }); if (created?.id) { await moduleService.addIssue(workspaceSlug, data.projectId, resolvedModuleId, created.id); diff --git a/ui/src/pages/NewPagePage.tsx b/ui/src/pages/NewPagePage.tsx new file mode 100644 index 0000000..55590c6 --- /dev/null +++ b/ui/src/pages/NewPagePage.tsx @@ -0,0 +1,124 @@ +import { useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Button, Input } from '../components/ui'; +import { pageService } from '../services/pageService'; +import type { PageDescriptionEditorHandle } from '../components/PageDescriptionEditor'; +import { PageDescriptionEditor } from '../components/PageDescriptionEditor'; + +export function NewPagePage() { + const navigate = useNavigate(); + const { workspaceSlug, projectId } = useParams<{ + workspaceSlug: string; + projectId: string; + }>(); + const [searchParams] = useSearchParams(); + + const pageType = searchParams.get('type'); + const initialAccess = useMemo(() => { + if (pageType === 'private') return 1; + // archived pages can't be created from this route in our backend; default to public + return 0; + }, [pageType]); + + const [access, setAccess] = useState(initialAccess); + const [name, setName] = useState('Untitled page'); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const editorRef = useRef(null); + + const baseUrl = useMemo(() => { + if (!workspaceSlug || !projectId) return ''; + return `/${workspaceSlug}/projects/${projectId}`; + }, [workspaceSlug, projectId]); + + if (!workspaceSlug || !projectId) { + return
Project not found.
; + } + + const canSave = name.trim().length > 0 && !isSaving; + + const handleCreate = async () => { + setError(null); + if (!workspaceSlug || !projectId) return; + const editorHtml = editorRef.current?.getHtml() ?? '

'; + + if (!name.trim()) { + setError('Please enter a page name.'); + return; + } + + setIsSaving(true); + try { + const created = await pageService.create(workspaceSlug, { + name: name.trim(), + description_html: editorHtml, + project_id: projectId, + access, + }); + navigate(`${baseUrl}/pages/${created.id}`); + } catch (e) { + setError((e as Error)?.message ?? 'Failed to create page.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+ setName(e.target.value)} + placeholder="Untitled page" + /> + +
+ {[ + { key: 0, label: 'Public' }, + { key: 1, label: 'Private' }, + ].map((t) => ( + + ))} +
+
+ +
+ + +
+
+ + + + {error &&
{error}
} +
+ ); +} + diff --git a/ui/src/pages/PageDetailPage.tsx b/ui/src/pages/PageDetailPage.tsx new file mode 100644 index 0000000..1c24134 --- /dev/null +++ b/ui/src/pages/PageDetailPage.tsx @@ -0,0 +1,196 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Button, Input } from '../components/ui'; +import { Modal } from '../components/ui/Modal'; +import { PageDescriptionEditor, type PageDescriptionEditorHandle } from '../components/PageDescriptionEditor'; +import { pageService } from '../services/pageService'; + +export function PageDetailPage() { + const navigate = useNavigate(); + const { workspaceSlug, projectId, pageId } = useParams<{ + workspaceSlug: string; + projectId: string; + pageId: string; + }>(); + + const baseUrl = useMemo(() => { + if (!workspaceSlug || !projectId) return ''; + return `/${workspaceSlug}/projects/${projectId}`; + }, [workspaceSlug, projectId]); + + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const [name, setName] = useState(''); + const [access, setAccess] = useState(0); + const [descriptionHtml, setDescriptionHtml] = useState('

'); + + const editorRef = useRef(null); + const [deleteOpen, setDeleteOpen] = useState(false); + + useEffect(() => { + if (!workspaceSlug || !pageId) { + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + pageService + .get(workspaceSlug, pageId) + .then((p) => { + if (cancelled) return; + setName(p.name ?? ''); + setAccess(typeof p.access === 'number' ? p.access : 0); + setDescriptionHtml(p.description_html ?? '

'); + }) + .catch(() => { + if (!cancelled) setError('Page not found.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [workspaceSlug, pageId]); + + const canSave = name.trim().length > 0 && !isSaving; + + const handleSave = async () => { + if (!workspaceSlug || !pageId) return; + setError(null); + + const editorHtml = editorRef.current?.getHtml() ?? descriptionHtml ?? '

'; + if (!name.trim()) return; + + setIsSaving(true); + try { + const updated = await pageService.update(workspaceSlug, pageId, { + name: name.trim(), + description_html: editorHtml, + access, + }); + setName(updated.name ?? name); + setAccess(typeof updated.access === 'number' ? updated.access : access); + setDescriptionHtml(updated.description_html ?? editorHtml); + } catch (e) { + setError((e as Error)?.message ?? 'Failed to save page.'); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!workspaceSlug || !pageId) return; + setError(null); + setIsSaving(true); + try { + await pageService.delete(workspaceSlug, pageId); + navigate(`${baseUrl}/pages`); + } catch (e) { + setError((e as Error)?.message ?? 'Failed to delete page.'); + } finally { + setIsSaving(false); + setDeleteOpen(false); + } + }; + + if (loading) { + return
Loading…
; + } + + if (!workspaceSlug || !projectId || !pageId || !baseUrl) { + return
Project not found.
; + } + + return ( +
+
+
+ setName(e.target.value)} + placeholder="Page title" + /> + +
+ {[ + { key: 0, label: 'Public' }, + { key: 1, label: 'Private' }, + ].map((t) => ( + + ))} +
+
+ +
+ + + +
+
+ + void handleSave()} + /> + + {error &&
{error}
} + + setDeleteOpen(false)} + title="Delete page" + footer={ + <> + + + + } + > +
+ This will permanently remove the page. +
+
+
+ ); +} + diff --git a/ui/src/pages/ViewDetailPage.tsx b/ui/src/pages/ViewDetailPage.tsx index a28a46c..6a514f1 100644 --- a/ui/src/pages/ViewDetailPage.tsx +++ b/ui/src/pages/ViewDetailPage.tsx @@ -750,6 +750,7 @@ export function ViewDetailPage() { cycleId?: string | null; moduleId?: string | null; parentId?: string | null; + isDraft?: boolean; }) => { if (!workspaceSlug || !data.title.trim()) return; setCreateError(null); @@ -764,6 +765,7 @@ export function ViewDetailPage() { start_date: data.startDate || undefined, target_date: data.dueDate || undefined, parent_id: data.parentId || undefined, + is_draft: data.isDraft === true ? true : undefined, }); if (created?.id) { if (data.cycleId) { diff --git a/ui/src/services/issueService.ts b/ui/src/services/issueService.ts index 413c073..f0b70f5 100644 --- a/ui/src/services/issueService.ts +++ b/ui/src/services/issueService.ts @@ -7,6 +7,19 @@ export interface ListIssuesParams { } export const issueService = { + async listWorkspaceDrafts( + workspaceSlug: string, + params?: ListIssuesParams, + ): Promise { + const searchParams = new URLSearchParams(); + if (params?.limit != null) searchParams.set('limit', String(params.limit)); + if (params?.offset != null) searchParams.set('offset', String(params.offset)); + const qs = searchParams.toString(); + const url = `/api/workspaces/${encodeURIComponent(workspaceSlug)}/draft-issues/${qs ? `?${qs}` : ''}`; + const { data } = await apiClient.get(url); + return data; + }, + async list( workspaceSlug: string, projectId: string, @@ -44,7 +57,7 @@ export const issueService = { workspaceSlug: string, projectId: string, issueId: string, - payload: Partial, + payload: Partial, ): Promise { const { data } = await apiClient.patch( `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/issues/${encodeURIComponent(issueId)}/`, From 4571d09a53395f2f054909c4bad372ccdc25a291 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 04:04:22 +0400 Subject: [PATCH 3/9] [REFACTOR] Update pages for ui --- ui/src/pages/DraftsPage.tsx | 530 +++++++++++++++++++++++++++++++----- 1 file changed, 465 insertions(+), 65 deletions(-) diff --git a/ui/src/pages/DraftsPage.tsx b/ui/src/pages/DraftsPage.tsx index a7ad530..742d7c8 100644 --- a/ui/src/pages/DraftsPage.tsx +++ b/ui/src/pages/DraftsPage.tsx @@ -1,24 +1,59 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { Badge, Button } from '../components/ui'; +import { Button, Avatar } from '../components/ui'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; import { issueService } from '../services/issueService'; import { cycleService } from '../services/cycleService'; import { moduleService } from '../services/moduleService'; -import type { WorkspaceApiResponse, ProjectApiResponse, IssueApiResponse } from '../api/types'; +import { stateService } from '../services/stateService'; +import { labelService } from '../services/labelService'; +import type { + WorkspaceApiResponse, + ProjectApiResponse, + IssueApiResponse, + StateApiResponse, + LabelApiResponse, + WorkspaceMemberApiResponse, +} from '../api/types'; import type { Priority } from '../types'; +import type { StateGroup } from '../types/workspaceViewFilters'; +import { + PRIORITY_ICONS, + PRIORITY_LABELS, + STATE_GROUP_ICONS, +} from '../components/workspace-views/WorkspaceViewsFiltersData'; +import { findWorkspaceMemberByUserId, getImageUrl } from '../lib/utils'; const PAGE_SIZE = 50; -const priorityVariant: Record = { - urgent: 'danger', - high: 'danger', - medium: 'warning', - low: 'default', - none: 'neutral', -}; +const PRIORITIES: Priority[] = ['urgent', 'high', 'medium', 'low', 'none']; + +function formatShortDate(iso: string | null | undefined): string | null { + if (!iso?.trim()) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +/** Plane-style key + sequence (e.g. LOGI1). Never use placeholder em-dashes for the key. */ +function projectIssueKey(proj: ProjectApiResponse | undefined, issue: IssueApiResponse): string { + const raw = proj?.identifier?.trim(); + if (raw && raw.length > 0) return raw.toUpperCase(); + const name = proj?.name?.trim() ?? ''; + const letters = name.replace(/[^a-zA-Z0-9]/g, ''); + if (letters.length >= 4) return letters.slice(0, 4).toUpperCase(); + if (letters.length > 0) return letters.toUpperCase().padEnd(4, 'X').slice(0, 4); + const idPart = (issue.project_id || '').replace(/-/g, ''); + return (idPart.slice(0, 4) || 'ITEM').toUpperCase(); +} + +function draftDisplayId(proj: ProjectApiResponse | undefined, issue: IssueApiResponse): string { + const key = projectIssueKey(proj, issue); + const seq = issue.sequence_id; + return seq != null ? `${key}${seq}` : key; +} const IconFolderPlus = () => ( ( ); +const IconCalendar = () => ( + + + + + + +); + +const IconUser = () => ( + + + + +); + +const IconTag = () => ( + + + +); + +const IconEye = () => ( + + + + +); + +const IconCircleSlash = () => ( + + + + +); + +const IconMoreVertical = () => ( + + + + + +); + +const IconLayoutGrid = () => ( + + + + + + +); + +function stateGroupIcon(group: string | undefined) { + const g = (group ?? 'backlog').toLowerCase() as StateGroup; + const icon = STATE_GROUP_ICONS[g]; + return icon ?? STATE_GROUP_ICONS.backlog; +} + export function DraftsPage() { const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState([]); + const [members, setMembers] = useState([]); const [drafts, setDrafts] = useState([]); + const [statesByProject, setStatesByProject] = useState>(new Map()); + const [labelsByProject, setLabelsByProject] = useState>(new Map()); const [loading, setLoading] = useState(true); const [listLoading, setListLoading] = useState(false); const [error, setError] = useState(null); @@ -68,6 +213,7 @@ export function DraftsPage() { const [createOpen, setCreateOpen] = useState(false); const [createError, setCreateError] = useState(null); const [rowBusy, setRowBusy] = useState(null); + const [menuOpenId, setMenuOpenId] = useState(null); const projectById = useMemo(() => { const m = new Map(); @@ -75,6 +221,50 @@ export function DraftsPage() { return m; }, [projects]); + const projectIdsKey = useMemo( + () => [...new Set(drafts.map((d) => d.project_id))].sort().join(','), + [drafts], + ); + + useEffect(() => { + if (!workspaceSlug || !projectIdsKey) { + setStatesByProject(new Map()); + setLabelsByProject(new Map()); + return; + } + const ids = projectIdsKey.split(',').filter(Boolean); + let cancelled = false; + Promise.all( + ids.map(async (pid) => { + const [states, labels] = await Promise.all([ + stateService.list(workspaceSlug, pid), + labelService.list(workspaceSlug, pid), + ]); + return { pid, states, labels }; + }), + ) + .then((rows) => { + if (cancelled) return; + const sm = new Map(); + const lm = new Map(); + for (const { pid, states, labels } of rows) { + sm.set(pid, states ?? []); + lm.set(pid, labels ?? []); + } + setStatesByProject(sm); + setLabelsByProject(lm); + }) + .catch(() => { + if (!cancelled) { + setStatesByProject(new Map()); + setLabelsByProject(new Map()); + } + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectIdsKey]); + const loadDrafts = useCallback( async (reset: boolean) => { if (!workspaceSlug) return; @@ -111,16 +301,19 @@ export function DraftsPage() { Promise.all([ workspaceService.getBySlug(workspaceSlug), projectService.list(workspaceSlug), + workspaceService.listMembers(workspaceSlug), ]) - .then(([w, plist]) => { + .then(([w, plist, mems]) => { if (cancelled) return; setWorkspace(w ?? null); setProjects(plist ?? []); + setMembers(mems ?? []); }) .catch(() => { if (!cancelled) { setWorkspace(null); setProjects([]); + setMembers([]); setError('Could not load workspace.'); } }) @@ -162,6 +355,30 @@ export function DraftsPage() { }; }, [workspaceSlug, workspace]); + const getUser = (userId: string | null) => { + if (!userId) return null; + const m = findWorkspaceMemberByUserId(members, userId); + const display = m?.member_display_name?.trim() ?? ''; + const emailUser = m?.member_email?.trim().split('@')[0]?.trim() ?? ''; + const name = display !== '' ? display : emailUser !== '' ? emailUser : userId.slice(0, 8); + const raw = m?.member_avatar?.trim(); + const avatarUrl = raw ? raw : null; + return { id: userId, name, avatarUrl }; + }; + + const handlePatch = async (issue: IssueApiResponse, payload: Record) => { + if (!workspaceSlug) return; + setRowBusy(issue.id); + try { + const updated = await issueService.update(workspaceSlug, issue.project_id, issue.id, payload); + setDrafts((prev) => prev.map((i) => (i.id === issue.id ? { ...i, ...updated } : i))); + } catch { + setError('Could not update draft.'); + } finally { + setRowBusy(null); + } + }; + const handleCreateSave = async (data: { title: string; description?: string; @@ -209,6 +426,7 @@ export function DraftsPage() { const handlePublish = async (issue: IssueApiResponse) => { if (!workspaceSlug) return; + setMenuOpenId(null); setRowBusy(issue.id); try { await issueService.update(workspaceSlug, issue.project_id, issue.id, { is_draft: false }); @@ -223,6 +441,7 @@ export function DraftsPage() { const handleDelete = async (issue: IssueApiResponse) => { if (!workspaceSlug) return; if (!window.confirm(`Delete draft “${issue.name}”?`)) return; + setMenuOpenId(null); setRowBusy(issue.id); try { await issueService.delete(workspaceSlug, issue.project_id, issue.id); @@ -258,7 +477,7 @@ export function DraftsPage() {

Create project @@ -268,12 +487,17 @@ export function DraftsPage() { return (
-
-

Drafts

-

- Draft work items stay out of the main backlog until you publish them. Open a row to edit - details, or use Publish to move the item onto the project board. -

+
+
+

Drafts

+

+ Draft work items stay out of the main backlog until you publish them. Use the row controls + like on the project board, or open the work item to edit in full. +

+
+
{error && ( @@ -292,7 +516,7 @@ export function DraftsPage() { Capture ideas as drafts and publish them into a project when you are ready.

) : ( @@ -300,55 +524,237 @@ export function DraftsPage() {
    {drafts.map((issue) => { const proj = projectById.get(issue.project_id); - const ident = proj?.identifier ?? '—'; - const seq = issue.sequence_id ?? '—'; + const displayId = draftDisplayId(proj, issue); const busy = rowBusy === issue.id; const pri = (issue.priority ?? 'none') as Priority; + const states = statesByProject.get(issue.project_id) ?? []; + const labels = labelsByProject.get(issue.project_id) ?? []; + const currentState = states.find((s) => s.id === issue.state_id); + const stateName = currentState?.name ?? 'Backlog'; + const primaryAssigneeId = + issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; + const assignee = getUser(primaryAssigneeId); + const labelNames = (issue.label_ids ?? []) + .map((id) => labels.find((l) => l.id === id)?.name) + .filter((n): n is string => Boolean(n)); + const startStr = formatShortDate(issue.start_date); + const dueStr = formatShortDate(issue.target_date); + const issueUrl = `${base}/projects/${issue.project_id}/issues/${issue.id}`; + return (
  • -
    -
    - + + {displayId} + + {issue.name} + + + +
    e.stopPropagation()} + > + {/* State (Plane-style dashed control) */} + + + -
    - - {ident}-{seq} - - - {issue.name} + + + + + {labelNames.length > 0 ? ( + + ) : ( + + - {issue.priority && issue.priority !== 'none' ? ( - - {issue.priority} - - ) : null} -
    - - {proj ? ( -

    {proj.name}

    - ) : null} -
    -
    - - + + + {PRIORITY_ICONS[pri] ?? } + +
    + + + + + + + + + +
    + + {menuOpenId === issue.id ? ( +
    + + +
    + ) : null} +
  • @@ -369,12 +775,6 @@ export function DraftsPage() {
)} -
- -
- { From f81794687bde02f01beec7c986c38a9dfc1c0779 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 04:21:33 +0400 Subject: [PATCH 4/9] drafts design fixture --- .../drafts/DraftIssueRowProperties.tsx | 596 ++++++++++++++++++ ui/src/components/work-item/Dropdown.tsx | 3 + ui/src/pages/DraftsPage.tsx | 439 +++---------- 3 files changed, 684 insertions(+), 354 deletions(-) create mode 100644 ui/src/components/drafts/DraftIssueRowProperties.tsx diff --git a/ui/src/components/drafts/DraftIssueRowProperties.tsx b/ui/src/components/drafts/DraftIssueRowProperties.tsx new file mode 100644 index 0000000..14a5255 --- /dev/null +++ b/ui/src/components/drafts/DraftIssueRowProperties.tsx @@ -0,0 +1,596 @@ +import { useRef } from 'react'; +import { Dropdown } from '../work-item/Dropdown'; +import { Avatar } from '../ui'; +import type { + IssueApiResponse, + ProjectApiResponse, + StateApiResponse, + LabelApiResponse, + ModuleApiResponse, + WorkspaceMemberApiResponse, +} from '../../api/types'; +import type { Priority } from '../../types'; +import type { StateGroup } from '../../types/workspaceViewFilters'; +import { + PRIORITY_ICONS, + PRIORITY_LABELS, + STATE_GROUP_ICONS, +} from '../workspace-views/WorkspaceViewsFiltersData'; +import { findWorkspaceMemberByUserId, getImageUrl } from '../../lib/utils'; + +const PRIORITIES: Priority[] = ['urgent', 'high', 'medium', 'low', 'none']; + +/** Plane-style start date: calendar + clock accent (simplified). */ +function IconStartDateProperty() { + return ( + + + + + + + ); +} + +function IconDueDateProperty() { + return ( + + + + + + ); +} + +const IconChevronDown = () => ( + + + +); + +const IconCircleSlash = () => ( + + + + +); + +const IconTag = () => ( + + + +); + +const IconUser = () => ( + + + + +); + +const IconLayoutGrid = () => ( + + + + + + +); + +const IconEye = () => ( + + + + +); + +const IconMoreHorizontal = () => ( + + + + + +); + +function stateGroupIcon(group: string | undefined) { + const g = (group ?? 'backlog').toLowerCase() as StateGroup; + return STATE_GROUP_ICONS[g] ?? STATE_GROUP_ICONS.backlog; +} + +const propBtnSquare = + 'relative flex size-7 shrink-0 items-center justify-center rounded border border-(--border-subtle) bg-(--bg-surface-1) text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) disabled:pointer-events-none disabled:opacity-40'; + +export interface DraftIssueRowPropertiesProps { + issue: IssueApiResponse; + project: ProjectApiResponse | undefined; + states: StateApiResponse[]; + labels: LabelApiResponse[]; + modules: ModuleApiResponse[]; + members: WorkspaceMemberApiResponse[]; + busy: boolean; + openDropdownId: string | null; + setOpenDropdownId: (id: string | null) => void; + onPatch: (issue: IssueApiResponse, payload: Record) => Promise; + onModuleChange: (issue: IssueApiResponse, moduleId: string | null) => Promise; + onToggleRowMenu: () => void; + rowMenuOpen: boolean; + onPublish: () => void; + onDelete: () => void; +} + +export function DraftIssueRowProperties({ + issue, + project, + states, + labels, + modules, + members, + busy, + openDropdownId, + setOpenDropdownId, + onPatch, + onModuleChange, + onToggleRowMenu, + rowMenuOpen, + onPublish, + onDelete, +}: DraftIssueRowPropertiesProps) { + const startInputRef = useRef(null); + const dueInputRef = useRef(null); + + const pri = (issue.priority ?? 'none') as Priority; + const currentState = states.find((s) => s.id === issue.state_id); + const stateName = currentState?.name ?? 'Backlog'; + const primaryAssigneeId = + issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; + const assigneeMember = findWorkspaceMemberByUserId(members, primaryAssigneeId); + const assigneeName = + assigneeMember?.member_display_name?.trim() || + assigneeMember?.member_email?.split('@')[0] || + (primaryAssigneeId ? primaryAssigneeId.slice(0, 8) : ''); + const assigneeAvatar = assigneeMember?.member_avatar?.trim(); + const labelNames = (issue.label_ids ?? []) + .map((id) => labels.find((l) => l.id === id)?.name) + .filter((n): n is string => Boolean(n)); + const currentModuleId = issue.module_ids?.[0] ?? null; + + const toggleLabel = (labelId: string) => { + const cur = issue.label_ids ?? []; + const next = cur.includes(labelId) ? cur.filter((x) => x !== labelId) : [...cur, labelId]; + void onPatch(issue, { label_ids: next }); + }; + + const panelClass = + 'max-h-64 min-w-[180px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)'; + + const showModules = Boolean(project?.module_view); + + return ( +
e.stopPropagation()} + > + {/* State — StateDropdown border-with-text + dashed */} + } + displayValue="" + align="right" + disabled={busy} + triggerClassName="inline-flex h-7 max-w-[10rem] min-w-0 items-center gap-1 rounded border border-dashed border-(--border-subtle) bg-(--bg-surface-1) px-2 text-[12px] font-medium text-(--txt-primary) hover:bg-(--bg-layer-1-hover) disabled:opacity-40" + triggerContent={ + <> + + {stateGroupIcon(currentState?.group)} + + {stateName} + + + } + panelClassName={panelClass} + > + {states.length === 0 ? ( +
No states
+ ) : ( + <> + + {states.map((s) => ( + + ))} + + )} +
+ + {/* Blocked — visual parity (no API yet) */} + + + {/* Labels */} + } + displayValue="" + align="right" + disabled={busy} + triggerClassName={propBtnSquare} + triggerContent={ + + + + } + panelClassName={panelClass} + > + {labels.length === 0 ? ( +
No labels
+ ) : ( + labels.map((l) => { + const on = (issue.label_ids ?? []).includes(l.id); + return ( + + ); + }) + )} +
+ + {/* Start date — icon-only + hidden date input (Plane DateDropdown border-without-text) */} +
+ + { + const v = e.target.value; + void onPatch(issue, { start_date: v || null }); + }} + /> +
+ + {/* Due date */} +
+ + { + const v = e.target.value; + void onPatch(issue, { target_date: v || null }); + }} + /> +
+ + {/* Assignee — icon / avatar + chevron in bordered control */} + } + displayValue="" + align="right" + disabled={busy} + triggerClassName="inline-flex h-7 items-center gap-0.5 rounded border border-(--border-subtle) bg-(--bg-surface-1) pl-1 pr-1 hover:bg-(--bg-layer-1-hover) disabled:opacity-40" + triggerContent={ + <> + + {primaryAssigneeId ? ( + + ) : ( + + + + )} + + + + } + panelClassName={panelClass} + > + + {members.map((m) => { + const uid = m.member_id ?? m.id; + const nm = + m.member_display_name?.trim() || m.member_email?.split('@')[0] || uid.slice(0, 8); + return ( + + ); + })} + + + {/* Modules — Plane ModuleDropdown icon */} + {showModules ? ( + } + displayValue="" + align="right" + disabled={busy} + triggerClassName={propBtnSquare} + triggerContent={ + + + + } + panelClassName={panelClass} + > + + {modules.map((mod) => ( + + ))} + + ) : null} + + {/* Priority — Plane PriorityDropdown border-without-text */} + } + displayValue="" + align="right" + disabled={busy} + triggerClassName={propBtnSquare} + triggerContent={ + + {PRIORITY_ICONS[pri] ?? PRIORITY_ICONS.none} + + } + panelClassName={panelClass} + > + {PRIORITIES.map((p) => ( + + ))} + + + {/* Visibility — no border (Plane-style) */} + + + {/* More — no border; menu aligned like Plane quick actions */} +
+ + {rowMenuOpen ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/ui/src/components/work-item/Dropdown.tsx b/ui/src/components/work-item/Dropdown.tsx index 81e788d..26a2800 100644 --- a/ui/src/components/work-item/Dropdown.tsx +++ b/ui/src/components/work-item/Dropdown.tsx @@ -19,6 +19,7 @@ export interface DropdownProps { triggerClassName?: string; /** Optional custom trigger content (when set, icon and displayValue are ignored and this is rendered inside the trigger). */ triggerContent?: React.ReactNode; + disabled?: boolean; } export function Dropdown({ @@ -34,6 +35,7 @@ export function Dropdown({ align = 'left', triggerClassName, triggerContent, + disabled = false, }: DropdownProps) { const triggerRef = useRef(null); const panelRef = useRef(null); @@ -87,6 +89,7 @@ export function Dropdown({ - {menuOpenId === issue.id ? ( -
- - -
- ) : null} -
-
+ + setMenuOpenId((id) => { + const next = id === issue.id ? null : issue.id; + if (next) setPropDropdownId(null); + return next; + }) + } + onPublish={() => void handlePublish(issue)} + onDelete={() => void handleDelete(issue)} + />
); From c47c9ea42a0e147815f9b739a9dd0c011ffa6e5d Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 04:37:42 +0400 Subject: [PATCH 5/9] lint and format fixtures --- ui/src/api/types.ts | 15 ++++++++ ui/src/components/PageDescriptionEditor.tsx | 19 ++++++++-- .../drafts/DraftIssueRowProperties.tsx | 18 +-------- ui/src/pages/DraftsPage.tsx | 38 ++++++++++++++----- ui/src/pages/NewPagePage.tsx | 1 - ui/src/pages/PageDetailPage.tsx | 16 +++++--- ui/src/services/pageService.ts | 35 ++++++++++++++++- 7 files changed, 105 insertions(+), 37 deletions(-) diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index dd2457c..29a9385 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -422,6 +422,21 @@ export interface PageApiResponse { updated_at: string; } +export interface CreatePageRequest { + name: string; + description_html?: string; + project_id?: string | null; + /** 0 public, 1 private */ + access?: number; +} + +export interface UpdatePageRequest { + name?: string; + description_html?: string; + /** 0 public, 1 private */ + access?: number; +} + /** Notification as returned by the API */ export interface NotificationApiResponse { id: string; diff --git a/ui/src/components/PageDescriptionEditor.tsx b/ui/src/components/PageDescriptionEditor.tsx index ecbb237..df11d97 100644 --- a/ui/src/components/PageDescriptionEditor.tsx +++ b/ui/src/components/PageDescriptionEditor.tsx @@ -25,9 +25,19 @@ export type PageDescriptionEditorProps = { onSaveShortcut?: () => void; }; -export const PageDescriptionEditor = forwardRef( +export const PageDescriptionEditor = forwardRef< + PageDescriptionEditorHandle, + PageDescriptionEditorProps +>( ( - { initialHtml, placeholder, autoFocus, readOnly, className, onSaveShortcut }: PageDescriptionEditorProps, + { + initialHtml, + placeholder, + autoFocus, + readOnly, + className, + onSaveShortcut, + }: PageDescriptionEditorProps, ref, ) => { const editor = useEditor({ @@ -75,7 +85,9 @@ export const PageDescriptionEditor = forwardRef +
@@ -427,7 +438,9 @@ export function DraftsPage() { )} {listLoading && drafts.length === 0 ? ( -
Loading drafts…
+
+ Loading drafts… +
) : drafts.length === 0 ? (
@@ -435,7 +448,12 @@ export function DraftsPage() {

Capture ideas as drafts and publish them into a project when you are ready.

-
@@ -458,7 +476,9 @@ export function DraftsPage() { to={issueUrl} className="group flex min-w-0 flex-1 items-center gap-2 truncate text-[13px] no-underline" > - {displayId} + + {displayId} + {issue.name} diff --git a/ui/src/pages/NewPagePage.tsx b/ui/src/pages/NewPagePage.tsx index 55590c6..66fa6ea 100644 --- a/ui/src/pages/NewPagePage.tsx +++ b/ui/src/pages/NewPagePage.tsx @@ -121,4 +121,3 @@ export function NewPagePage() {
); } - diff --git a/ui/src/pages/PageDetailPage.tsx b/ui/src/pages/PageDetailPage.tsx index 1c24134..f303a84 100644 --- a/ui/src/pages/PageDetailPage.tsx +++ b/ui/src/pages/PageDetailPage.tsx @@ -2,7 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Button, Input } from '../components/ui'; import { Modal } from '../components/ui/Modal'; -import { PageDescriptionEditor, type PageDescriptionEditorHandle } from '../components/PageDescriptionEditor'; +import { + PageDescriptionEditor, + type PageDescriptionEditorHandle, +} from '../components/PageDescriptionEditor'; import { pageService } from '../services/pageService'; export function PageDetailPage() { @@ -101,7 +104,11 @@ export function PageDetailPage() { }; if (loading) { - return
Loading…
; + return ( +
+ Loading… +
+ ); } if (!workspaceSlug || !projectId || !pageId || !baseUrl) { @@ -186,11 +193,8 @@ export function PageDetailPage() { } > -
- This will permanently remove the page. -
+
This will permanently remove the page.
); } - diff --git a/ui/src/services/pageService.ts b/ui/src/services/pageService.ts index b2dc8b8..b15862b 100644 --- a/ui/src/services/pageService.ts +++ b/ui/src/services/pageService.ts @@ -1,5 +1,5 @@ import { apiClient } from '../api/client'; -import type { PageApiResponse } from '../api/types'; +import type { CreatePageRequest, PageApiResponse, UpdatePageRequest } from '../api/types'; export const pageService = { async list(workspaceSlug: string, projectId?: string | null): Promise { @@ -9,4 +9,37 @@ export const pageService = { const { data } = await apiClient.get(url); return data; }, + + async create(workspaceSlug: string, payload: CreatePageRequest): Promise { + const { data } = await apiClient.post( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/pages/`, + payload, + ); + return data; + }, + + async get(workspaceSlug: string, pageId: string): Promise { + const { data } = await apiClient.get( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/pages/${encodeURIComponent(pageId)}/`, + ); + return data; + }, + + async update( + workspaceSlug: string, + pageId: string, + payload: UpdatePageRequest, + ): Promise { + const { data } = await apiClient.patch( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/pages/${encodeURIComponent(pageId)}/`, + payload, + ); + return data; + }, + + async delete(workspaceSlug: string, pageId: string): Promise { + await apiClient.delete( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/pages/${encodeURIComponent(pageId)}/`, + ); + }, }; From 269562d5d925470aa9750f8442fc26f4b5b61f72 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 14:54:34 +0400 Subject: [PATCH 6/9] [REFACTOR] Update components, drafts, pages for ui --- ui/src/components/CreateWorkItemModal.tsx | 8 +- .../drafts/DraftIssueRowProperties.tsx | 265 +++++++++++++++--- ui/src/components/layout/PageHeader.tsx | 17 ++ ui/src/pages/DraftsPage.tsx | 89 +++--- ui/src/pages/NewPagePage.tsx | 123 -------- ui/src/pages/PageDetailPage.tsx | 200 ------------- 6 files changed, 297 insertions(+), 405 deletions(-) delete mode 100644 ui/src/pages/NewPagePage.tsx delete mode 100644 ui/src/pages/PageDetailPage.tsx diff --git a/ui/src/components/CreateWorkItemModal.tsx b/ui/src/components/CreateWorkItemModal.tsx index 4d32b4a..4a826cf 100644 --- a/ui/src/components/CreateWorkItemModal.tsx +++ b/ui/src/components/CreateWorkItemModal.tsx @@ -102,7 +102,13 @@ export interface CreateWorkItemModalProps { defaultProjectId?: string; defaultModuleId?: string | null; createError?: string | null; - /** When true, creates a workspace draft (always sends is_draft). Title copy matches Plane drafts flow. */ + /** + * When true, configures the modal for the workspace drafts flow: + * - Draft-specific title copy + * - `onSave` receives `isDraft: true` + * + * Callers are still responsible for mapping `isDraft` to the API payload (e.g. `is_draft`). + */ draftOnly?: boolean; onSave?: (data: { title: string; diff --git a/ui/src/components/drafts/DraftIssueRowProperties.tsx b/ui/src/components/drafts/DraftIssueRowProperties.tsx index c88d55a..d412c4c 100644 --- a/ui/src/components/drafts/DraftIssueRowProperties.tsx +++ b/ui/src/components/drafts/DraftIssueRowProperties.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Dropdown } from '../work-item/Dropdown'; import { Avatar } from '../ui'; import type { @@ -6,6 +6,7 @@ import type { ProjectApiResponse, StateApiResponse, LabelApiResponse, + CycleApiResponse, ModuleApiResponse, WorkspaceMemberApiResponse, } from '../../api/types'; @@ -19,6 +20,17 @@ import { import { findWorkspaceMemberByUserId, getImageUrl } from '../../lib/utils'; const PRIORITIES: Priority[] = ['urgent', 'high', 'medium', 'low', 'none']; +const PRIORITY_TILE: Record = { + urgent: 'border-red-200 bg-red-50 text-red-600', + high: 'border-orange-200 bg-orange-50 text-orange-600', + medium: 'border-yellow-200 bg-yellow-50 text-yellow-700', + low: 'border-blue-200 bg-blue-50 text-blue-600', + none: 'border-(--border-subtle) bg-(--bg-layer-1) text-(--txt-icon-tertiary)', +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(' '); +} /** Plane-style start date: calendar + clock accent (simplified). */ function IconStartDateProperty() { @@ -145,6 +157,21 @@ const IconLayoutGrid = () => ( ); +const IconCycle = () => ( + + + + +); + const IconEye = () => ( void; onPatch: (issue: IssueApiResponse, payload: Record) => Promise; onModuleChange: (issue: IssueApiResponse, moduleId: string | null) => Promise; + onCycleChange: (issue: IssueApiResponse, cycleId: string | null) => Promise; onToggleRowMenu: () => void; rowMenuOpen: boolean; onPublish: () => void; @@ -200,12 +229,14 @@ export function DraftIssueRowProperties({ states, labels, modules, + cycles, members, busy, openDropdownId, setOpenDropdownId, onPatch, onModuleChange, + onCycleChange, onToggleRowMenu, rowMenuOpen, onPublish, @@ -213,6 +244,9 @@ export function DraftIssueRowProperties({ }: DraftIssueRowPropertiesProps) { const startInputRef = useRef(null); const dueInputRef = useRef(null); + const [stateSearch, setStateSearch] = useState(''); + const [prioritySearch, setPrioritySearch] = useState(''); + const [labelSearch, setLabelSearch] = useState(''); const pri = (issue.priority ?? 'none') as Priority; const currentState = states.find((s) => s.id === issue.state_id); @@ -229,6 +263,47 @@ export function DraftIssueRowProperties({ .map((id) => labels.find((l) => l.id === id)?.name) .filter((n): n is string => Boolean(n)); const currentModuleId = issue.module_ids?.[0] ?? null; + const moduleCount = issue.module_ids?.length ?? 0; + const currentCycleId = issue.cycle_ids?.[0] ?? null; + const cycleName = currentCycleId ? cycles.find((c) => c.id === currentCycleId)?.name : ''; + const stateOptions = useMemo(() => { + const byGroup = new Map(); + for (const s of states) { + const g = (s.group ?? '').toLowerCase(); + if (!g) continue; + if (!byGroup.has(g)) byGroup.set(g, s); + } + // Plane drafts shows these groups in this order + const ORDER: Array<{ group: string; label: string }> = [ + { group: 'backlog', label: 'Backlog' }, + { group: 'todo', label: 'Todo' }, + { group: 'in_progress', label: 'In Progress' }, + { group: 'done', label: 'Done' }, + { group: 'cancelled', label: 'Cancelled' }, + ]; + return ORDER.map(({ group, label }) => { + const st = byGroup.get(group); + return { group, label, id: st?.id ?? null }; + }); + }, [states]); + + const filteredStateOptions = useMemo(() => { + const q = stateSearch.trim().toLowerCase(); + if (!q) return stateOptions; + return stateOptions.filter((o) => o.label.toLowerCase().includes(q)); + }, [stateOptions, stateSearch]); + + const filteredPriorities = useMemo(() => { + const q = prioritySearch.trim().toLowerCase(); + if (!q) return PRIORITIES; + return PRIORITIES.filter((p) => PRIORITY_LABELS[p].toLowerCase().includes(q)); + }, [prioritySearch]); + + const filteredLabels = useMemo(() => { + const q = labelSearch.trim().toLowerCase(); + if (!q) return labels; + return labels.filter((l) => l.name.toLowerCase().includes(q)); + }, [labels, labelSearch]); const toggleLabel = (labelId: string) => { const cur = issue.label_ids ?? []; @@ -240,10 +315,18 @@ export function DraftIssueRowProperties({ 'max-h-64 min-w-[180px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)'; const showModules = Boolean(project?.module_view); + const showCycles = Boolean(project?.cycle_view); + + const moduleLabel = + moduleCount > 1 + ? `${moduleCount} Modules` + : moduleCount === 1 + ? (modules.find((m) => m.id === currentModuleId)?.name ?? '1 Module') + : 'No module'; return (
e.stopPropagation()} > {/* State — StateDropdown border-with-text + dashed */} @@ -256,7 +339,7 @@ export function DraftIssueRowProperties({ displayValue="" align="right" disabled={busy} - triggerClassName="inline-flex h-7 max-w-[10rem] min-w-0 items-center gap-1 rounded border border-dashed border-(--border-subtle) bg-(--bg-surface-1) px-2 text-[12px] font-medium text-(--txt-primary) hover:bg-(--bg-layer-1-hover) disabled:opacity-40" + triggerClassName="inline-flex h-7 max-w-[10rem] min-w-0 items-center gap-1 rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 text-[12px] font-medium text-(--txt-primary) hover:bg-(--bg-layer-1-hover) disabled:opacity-40" triggerContent={ <> @@ -268,33 +351,46 @@ export function DraftIssueRowProperties({ } panelClassName={panelClass} > - {states.length === 0 ? ( +
+ setStateSearch(e.target.value)} + className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> +
+ {filteredStateOptions.length === 0 ? (
No states
) : ( <> - - {states.map((s) => ( - - ))} + {filteredStateOptions.map((opt) => { + const currentGroup = (currentState?.group ?? 'backlog').toLowerCase(); + const isSelected = + currentGroup === opt.group || + (!!opt.id && issue.state_id === opt.id) || + (!opt.id && !issue.state_id && opt.group === 'backlog'); + return ( + + ); + })} )} @@ -322,16 +418,28 @@ export function DraftIssueRowProperties({ disabled={busy} triggerClassName={propBtnSquare} triggerContent={ - + } panelClassName={panelClass} > - {labels.length === 0 ? ( -
No labels
+
+ setLabelSearch(e.target.value)} + className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> +
+ {filteredLabels.length === 0 ? ( +
Type to add a new label
) : ( - labels.map((l) => { + filteredLabels.map((l) => { const on = (issue.label_ids ?? []).includes(l.id); return ( + {cycles.map((cy) => ( + + ))} + + ) : null} + {/* Priority — Plane PriorityDropdown border-without-text */} + {PRIORITY_ICONS[pri] ?? PRIORITY_ICONS.none} } panelClassName={panelClass} > - {PRIORITIES.map((p) => ( +
+ setPrioritySearch(e.target.value)} + className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> +
+ {filteredPriorities.map((p) => ( ))}
@@ -555,7 +734,7 @@ export function DraftIssueRowProperties({ {rowMenuOpen ? (
- ))} -
-
- -
- - -
- - - - - {error &&
{error}
} - - ); -} diff --git a/ui/src/pages/PageDetailPage.tsx b/ui/src/pages/PageDetailPage.tsx deleted file mode 100644 index f303a84..0000000 --- a/ui/src/pages/PageDetailPage.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { Button, Input } from '../components/ui'; -import { Modal } from '../components/ui/Modal'; -import { - PageDescriptionEditor, - type PageDescriptionEditorHandle, -} from '../components/PageDescriptionEditor'; -import { pageService } from '../services/pageService'; - -export function PageDetailPage() { - const navigate = useNavigate(); - const { workspaceSlug, projectId, pageId } = useParams<{ - workspaceSlug: string; - projectId: string; - pageId: string; - }>(); - - const baseUrl = useMemo(() => { - if (!workspaceSlug || !projectId) return ''; - return `/${workspaceSlug}/projects/${projectId}`; - }, [workspaceSlug, projectId]); - - const [loading, setLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - - const [name, setName] = useState(''); - const [access, setAccess] = useState(0); - const [descriptionHtml, setDescriptionHtml] = useState('

'); - - const editorRef = useRef(null); - const [deleteOpen, setDeleteOpen] = useState(false); - - useEffect(() => { - if (!workspaceSlug || !pageId) { - setLoading(false); - return; - } - - let cancelled = false; - setLoading(true); - setError(null); - - pageService - .get(workspaceSlug, pageId) - .then((p) => { - if (cancelled) return; - setName(p.name ?? ''); - setAccess(typeof p.access === 'number' ? p.access : 0); - setDescriptionHtml(p.description_html ?? '

'); - }) - .catch(() => { - if (!cancelled) setError('Page not found.'); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [workspaceSlug, pageId]); - - const canSave = name.trim().length > 0 && !isSaving; - - const handleSave = async () => { - if (!workspaceSlug || !pageId) return; - setError(null); - - const editorHtml = editorRef.current?.getHtml() ?? descriptionHtml ?? '

'; - if (!name.trim()) return; - - setIsSaving(true); - try { - const updated = await pageService.update(workspaceSlug, pageId, { - name: name.trim(), - description_html: editorHtml, - access, - }); - setName(updated.name ?? name); - setAccess(typeof updated.access === 'number' ? updated.access : access); - setDescriptionHtml(updated.description_html ?? editorHtml); - } catch (e) { - setError((e as Error)?.message ?? 'Failed to save page.'); - } finally { - setIsSaving(false); - } - }; - - const handleDelete = async () => { - if (!workspaceSlug || !pageId) return; - setError(null); - setIsSaving(true); - try { - await pageService.delete(workspaceSlug, pageId); - navigate(`${baseUrl}/pages`); - } catch (e) { - setError((e as Error)?.message ?? 'Failed to delete page.'); - } finally { - setIsSaving(false); - setDeleteOpen(false); - } - }; - - if (loading) { - return ( -
- Loading… -
- ); - } - - if (!workspaceSlug || !projectId || !pageId || !baseUrl) { - return
Project not found.
; - } - - return ( -
-
-
- setName(e.target.value)} - placeholder="Page title" - /> - -
- {[ - { key: 0, label: 'Public' }, - { key: 1, label: 'Private' }, - ].map((t) => ( - - ))} -
-
- -
- - - -
-
- - void handleSave()} - /> - - {error &&
{error}
} - - setDeleteOpen(false)} - title="Delete page" - footer={ - <> - - - - } - > -
This will permanently remove the page.
-
-
- ); -} From 90d7e709339f85e8ebc0893ca17eef7bf9b3ce85 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 15:10:17 +0400 Subject: [PATCH 7/9] fix: modules page spaces removed - design fixture --- ui/src/components/layout/AppShell.tsx | 5 ++++- ui/src/components/layout/PageHeader.tsx | 22 +++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx index 2a277f4..53e8890 100644 --- a/ui/src/components/layout/AppShell.tsx +++ b/ui/src/components/layout/AppShell.tsx @@ -9,6 +9,7 @@ export function AppShell() { const { pathname } = useLocation(); const isViewsRoute = pathname.includes('/views'); const isCyclesPage = pathname.endsWith('/cycles'); + const isModulesRoute = pathname.includes('/modules'); return ( @@ -20,7 +21,9 @@ export function AppShell() {
diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 75531af..366fce9 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -165,6 +165,22 @@ const IconChevronDown = () => ( ); +const IconPencil = () => ( + + + +); + const IconChevronUp = () => ( {s.label} @@ -1514,7 +1530,7 @@ function ProjectSectionHeader({ : [...prev, p.key], ); }} - className="rounded border-[var(--border-subtle)]" + className="rounded border-(--border-subtle)" /> {p.label} @@ -1575,7 +1591,7 @@ function ProjectSectionHeader({ : [...prev, p.key], ); }} - className="rounded border-[var(--border-subtle)]" + className="rounded border-(--border-subtle)" /> {p.label} From 455707413af92980da11546a6b8db3e761a1f97c Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Thu, 26 Mar 2026 15:25:57 +0400 Subject: [PATCH 8/9] refactor: removed extra top padding for the Drafts route --- ui/src/components/layout/AppShell.tsx | 9 +++-- ui/src/components/layout/PageHeader.tsx | 8 ++++- ui/src/pages/DraftsPage.tsx | 46 ++++++++++--------------- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx index 53e8890..5044135 100644 --- a/ui/src/components/layout/AppShell.tsx +++ b/ui/src/components/layout/AppShell.tsx @@ -10,20 +10,23 @@ export function AppShell() { const isViewsRoute = pathname.includes('/views'); const isCyclesPage = pathname.endsWith('/cycles'); const isModulesRoute = pathname.includes('/modules'); + const isDraftsRoute = pathname.includes('/drafts'); return (
-
+
diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 366fce9..5993114 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -779,7 +779,13 @@ function DraftsHeader() { Drafts
-
+
+ + + +
); } diff --git a/ui/src/pages/DraftsPage.tsx b/ui/src/pages/DraftsPage.tsx index a2e20b1..87b3191 100644 --- a/ui/src/pages/DraftsPage.tsx +++ b/ui/src/pages/DraftsPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '../components/ui'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; import { DraftIssueRowProperties } from '../components/drafts/DraftIssueRowProperties'; @@ -80,6 +80,7 @@ const IconFileDraft = () => ( export function DraftsPage() { const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState([]); const [members, setMembers] = useState([]); @@ -236,6 +237,11 @@ export function DraftsPage() { void loadDrafts(true); }, [workspaceSlug, workspace, loadDrafts]); + useEffect(() => { + const shouldOpen = searchParams.get('create') === '1'; + if (shouldOpen) setCreateOpen(true); + }, [searchParams]); + const setPropDropdownOpen = useCallback((id: string | null) => { setPropDropdownId(id); if (id) setMenuOpenId(null); @@ -421,33 +427,15 @@ export function DraftsPage() { } return ( -
-
-
-

Drafts

-

- Draft work items stay out of the main backlog until you publish them. Use the row - controls like on the project board, or open the work item to edit in full. -

-
- -
- +
{error && ( -

+

{error}

)} {listLoading && drafts.length === 0 ? ( -
+
Loading drafts…
) : drafts.length === 0 ? ( @@ -467,7 +455,7 @@ export function DraftsPage() {
) : ( -
+
    {drafts.map((issue) => { const proj = projectById.get(issue.project_id); @@ -480,12 +468,12 @@ export function DraftsPage() { const issueUrl = `${base}/projects/${issue.project_id}/issues/${issue.id}`; return ( -
  • -
    +
  • +
    navigate(issueUrl)} aria-label={`Open draft ${issue.name}`} > @@ -526,7 +514,7 @@ export function DraftsPage() { })}
{hasMore ? ( -
+
+
+
+ + + + + setPrioritySearch(e.target.value)} + className="w-full bg-transparent text-[13px] text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + /> +
+
+
+ {filteredPriorities.map((p) => { + const selected = pri === p; + return ( + + ); + })} +
+ {/* Labels */} setLabelSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && canCreateLabel && !createLabelLoading) { + e.preventDefault(); + void handleCreateLabel(); + } + }} className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" />
{filteredLabels.length === 0 ? ( -
Type to add a new label
+
+ {canCreateLabel ? 'Press Enter to create label' : 'Type to add a new label'} +
) : ( filteredLabels.map((l) => { const on = (issue.label_ids ?? []).includes(l.id); @@ -454,6 +726,24 @@ export function DraftIssueRowProperties({ ); }) )} + {canCreateLabel ? ( + <> +
+ + {createLabelError ? ( +
+ {createLabelError} +
+ ) : null} + + ) : null} {/* Start date — icon-only + hidden date input (Plane DateDropdown border-without-text) */} @@ -656,71 +946,7 @@ export function DraftIssueRowProperties({ ) : null} - {/* Priority — Plane PriorityDropdown border-without-text */} - } - displayValue="" - align="right" - disabled={busy} - triggerClassName={propBtnSquare} - triggerContent={ - - {PRIORITY_ICONS[pri] ?? PRIORITY_ICONS.none} - - } - panelClassName={panelClass} - > -
- setPrioritySearch(e.target.value)} - className="w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-xs text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" - /> -
- {filteredPriorities.map((p) => ( - - ))} -
- - {/* Visibility — no border (Plane-style) */} - - - {/* More — no border; menu aligned like Plane quick actions */} + {/* Quick actions — ⋯ menu (Plane: Edit, Make a copy, Move to issues, Delete) */}
+ +
diff --git a/ui/src/components/work-item/Dropdown.tsx b/ui/src/components/work-item/Dropdown.tsx index 26a2800..5847408 100644 --- a/ui/src/components/work-item/Dropdown.tsx +++ b/ui/src/components/work-item/Dropdown.tsx @@ -19,6 +19,10 @@ export interface DropdownProps { triggerClassName?: string; /** Optional custom trigger content (when set, icon and displayValue are ignored and this is rendered inside the trigger). */ triggerContent?: React.ReactNode; + /** Optional tooltip (native title) for trigger button. */ + triggerTitle?: string; + /** Optional accessible name for trigger button. */ + triggerAriaLabel?: string; disabled?: boolean; } @@ -35,6 +39,8 @@ export function Dropdown({ align = 'left', triggerClassName, triggerContent, + triggerTitle, + triggerAriaLabel, disabled = false, }: DropdownProps) { const triggerRef = useRef(null); @@ -92,6 +98,8 @@ export function Dropdown({ disabled={disabled} onClick={() => onOpen(open ? null : id)} className={triggerClassName ?? defaultTriggerClass} + title={triggerTitle} + aria-label={triggerAriaLabel} > {triggerContent ?? ( <> @@ -107,7 +115,7 @@ export function Dropdown({ ref={panelRef} className={ panelClassName ?? - 'max-h-60 min-w-[140px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)' + 'max-h-60 min-w-35 overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)' } style={{ position: 'fixed', diff --git a/ui/src/pages/DraftsPage.tsx b/ui/src/pages/DraftsPage.tsx index 87b3191..9848118 100644 --- a/ui/src/pages/DraftsPage.tsx +++ b/ui/src/pages/DraftsPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '../components/ui'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; +import type { WorkItemInitialValues } from '../components/CreateWorkItemModal'; import { DraftIssueRowProperties } from '../components/drafts/DraftIssueRowProperties'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; @@ -104,6 +105,8 @@ export function DraftsPage() { const [hasMore, setHasMore] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [createError, setCreateError] = useState(null); + const [editingIssueId, setEditingIssueId] = useState(null); + const [modalInitialValues, setModalInitialValues] = useState(); const [rowBusy, setRowBusy] = useState(null); const [menuOpenId, setMenuOpenId] = useState(null); const [propDropdownId, setPropDropdownId] = useState(null); @@ -336,30 +339,49 @@ export function DraftsPage() { if (!workspaceSlug || !data.title.trim()) return; setCreateError(null); try { - const created = await issueService.create(workspaceSlug, data.projectId, { - name: data.title.trim(), - description: data.description || undefined, - state_id: data.stateId || undefined, - priority: data.priority || undefined, - assignee_ids: data.assigneeIds?.length ? data.assigneeIds : undefined, - label_ids: data.labelIds?.length ? data.labelIds : undefined, - start_date: data.startDate || undefined, - target_date: data.dueDate || undefined, - parent_id: data.parentId || undefined, - is_draft: data.isDraft === true ? true : undefined, - }); - if (created?.id) { - if (data.cycleId) { - await cycleService.addIssue(workspaceSlug, data.projectId, data.cycleId, created.id); + if (editingIssueId) { + const existing = drafts.find((d) => d.id === editingIssueId); + if (existing) { + await issueService.update(workspaceSlug, existing.project_id, editingIssueId, { + name: data.title.trim(), + description: data.description || undefined, + state_id: data.stateId || undefined, + priority: data.priority || undefined, + assignee_ids: data.assigneeIds?.length ? data.assigneeIds : [], + label_ids: data.labelIds?.length ? data.labelIds : [], + start_date: data.startDate || null, + target_date: data.dueDate || null, + parent_id: data.parentId || null, + }); } - if (data.moduleId) { - await moduleService.addIssue(workspaceSlug, data.projectId, data.moduleId, created.id); + } else { + const created = await issueService.create(workspaceSlug, data.projectId, { + name: data.title.trim(), + description: data.description || undefined, + state_id: data.stateId || undefined, + priority: data.priority || undefined, + assignee_ids: data.assigneeIds?.length ? data.assigneeIds : undefined, + label_ids: data.labelIds?.length ? data.labelIds : undefined, + start_date: data.startDate || undefined, + target_date: data.dueDate || undefined, + parent_id: data.parentId || undefined, + is_draft: data.isDraft === true ? true : undefined, + }); + if (created?.id) { + if (data.cycleId) { + await cycleService.addIssue(workspaceSlug, data.projectId, data.cycleId, created.id); + } + if (data.moduleId) { + await moduleService.addIssue(workspaceSlug, data.projectId, data.moduleId, created.id); + } } } setCreateOpen(false); + setEditingIssueId(null); + setModalInitialValues(undefined); await loadDrafts(true); } catch (err) { - setCreateError(err instanceof Error ? err.message : 'Failed to create draft.'); + setCreateError(err instanceof Error ? err.message : 'Failed to save draft.'); } }; @@ -394,6 +416,40 @@ export function DraftsPage() { } }; + const issueToInitialValues = (issue: IssueApiResponse): WorkItemInitialValues => ({ + title: issue.name, + description: issue.description_html ?? '', + projectId: issue.project_id, + stateId: issue.state_id ?? undefined, + priority: (issue.priority as Priority) ?? undefined, + assigneeIds: issue.assignee_ids ?? [], + labelIds: issue.label_ids ?? [], + startDate: issue.start_date?.slice(0, 10) ?? undefined, + dueDate: issue.target_date?.slice(0, 10) ?? undefined, + cycleId: issue.cycle_ids?.[0] ?? null, + moduleId: issue.module_ids?.[0] ?? null, + parentId: issue.parent_id ?? null, + }); + + const handleEdit = (issue: IssueApiResponse) => { + setMenuOpenId(null); + setPropDropdownId(null); + setEditingIssueId(issue.id); + setModalInitialValues(issueToInitialValues(issue)); + setCreateOpen(true); + }; + + const handleDuplicate = (issue: IssueApiResponse) => { + setMenuOpenId(null); + setPropDropdownId(null); + setEditingIssueId(null); + setModalInitialValues({ + ...issueToInitialValues(issue), + title: `${issue.name} (copy)`, + }); + setCreateOpen(true); + }; + if (loading) { return (
@@ -484,6 +540,7 @@ export function DraftsPage() {
void handlePublish(issue)} + onEdit={() => handleEdit(issue)} + onDuplicate={() => handleDuplicate(issue)} + onMoveToIssues={() => void handlePublish(issue)} onDelete={() => void handleDelete(issue)} />
@@ -532,6 +591,8 @@ export function DraftsPage() { onClose={() => { setCreateOpen(false); setCreateError(null); + setEditingIssueId(null); + setModalInitialValues(undefined); if (searchParams.get('create') === '1') { searchParams.delete('create'); setSearchParams(searchParams, { replace: true }); @@ -540,6 +601,7 @@ export function DraftsPage() { workspaceSlug={workspace.slug} projects={projects} defaultProjectId={projects[0]?.id} + initialValues={modalInitialValues} draftOnly createError={createError} onSave={handleCreateSave}