diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go index a4c620b..68c81b7 100644 --- a/api/internal/handler/project.go +++ b/api/internal/handler/project.go @@ -84,8 +84,22 @@ func (h *ProjectHandler) Create(c *gin.Context) { } slug := c.Param("slug") var body struct { - Name string `json:"name" binding:"required"` - Identifier string `json:"identifier"` + Name string `json:"name" binding:"required"` + Identifier string `json:"identifier"` + Description *string `json:"description"` + Timezone *string `json:"timezone"` + CoverImage *string `json:"cover_image"` + Emoji *string `json:"emoji"` + IconProp map[string]interface{} `json:"icon_prop"` + ProjectLeadID *string `json:"project_lead_id"` + DefaultAssigneeID *string `json:"default_assignee_id"` + GuestViewAllFeatures *bool `json:"guest_view_all_features"` + ModuleView *bool `json:"module_view"` + CycleView *bool `json:"cycle_view"` + IssueViewsView *bool `json:"issue_views_view"` + PageView *bool `json:"page_view"` + IntakeView *bool `json:"intake_view"` + IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) @@ -100,6 +114,106 @@ func (h *ProjectHandler) Create(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"}) return } + + // If additional fields were provided, immediately apply them using the same logic as Update. + if body.Description != nil || + body.Timezone != nil || + body.CoverImage != nil || + body.Emoji != nil || + len(body.IconProp) > 0 || + body.ProjectLeadID != nil || + body.DefaultAssigneeID != nil || + body.GuestViewAllFeatures != nil || + body.ModuleView != nil || + body.CycleView != nil || + body.IssueViewsView != nil || + body.PageView != nil || + body.IntakeView != nil || + body.IsTimeTrackingEnabled != nil { + + // Reuse Update's field handling by calling the service directly. + var ( + description, timezone *string + coverImage *string + iconProp *model.JSONMap + projectLeadIDPtr *uuid.UUID + defaultAssigneeIDPtr *uuid.UUID + ) + + if body.Description != nil { + description = body.Description + } + if body.Timezone != nil { + timezone = body.Timezone + } + if body.CoverImage != nil { + coverImage = body.CoverImage + } + if body.Emoji != nil && *body.Emoji != "" { + empty := model.JSONMap{} + iconProp = &empty + } else if len(body.IconProp) > 0 { + ip := model.JSONMap(body.IconProp) + iconProp = &ip + } + projectLeadSet := false + if body.ProjectLeadID != nil { + projectLeadSet = true + if *body.ProjectLeadID != "" { + id, parseErr := uuid.Parse(*body.ProjectLeadID) + if parseErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project_lead_id", "detail": "must be a valid UUID"}) + return + } + projectLeadIDPtr = &id + } + } + defaultAssigneeSet := false + if body.DefaultAssigneeID != nil { + defaultAssigneeSet = true + if *body.DefaultAssigneeID != "" { + id, parseErr := uuid.Parse(*body.DefaultAssigneeID) + if parseErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid default_assignee_id", "detail": "must be a valid UUID"}) + return + } + defaultAssigneeIDPtr = &id + } + } + + updated, err := h.Project.Update( + c.Request.Context(), + slug, + p.ID, + user.ID, + nil, // name + nil, // identifier + description, + timezone, + coverImage, + body.Emoji, + iconProp, + projectLeadSet, + projectLeadIDPtr, + defaultAssigneeSet, + defaultAssigneeIDPtr, + body.GuestViewAllFeatures, + body.ModuleView, + body.CycleView, + body.IssueViewsView, + body.PageView, + body.IntakeView, + body.IsTimeTrackingEnabled, + ) + if err != nil { + // If the follow-up update fails, still return the base project creation result. + c.JSON(http.StatusCreated, p) + return + } + c.JSON(http.StatusCreated, updated) + return + } + c.JSON(http.StatusCreated, p) } diff --git a/api/migrations/000001_init_schema.up.sql b/api/migrations/000001_init_schema.up.sql index f67069f..8094f19 100644 --- a/api/migrations/000001_init_schema.up.sql +++ b/api/migrations/000001_init_schema.up.sql @@ -1424,6 +1424,8 @@ CREATE TABLE user_favorites ( updated_by_id UUID REFERENCES users (id) ON DELETE SET NULL, parent_id UUID REFERENCES user_favorites (id) ON DELETE CASCADE ); +CREATE UNIQUE INDEX idx_user_fav_entity + ON user_favorites (user_id, entity_type, entity_identifier); CREATE TABLE user_recent_visits ( id UUID PRIMARY KEY , workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ceee086..19a209f 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -57,6 +57,20 @@ export interface InviteByTokenResponse { export interface CreateProjectRequest { name: string; identifier?: string; + description?: string; + timezone?: string; + cover_image?: string; + emoji?: string; + icon_prop?: ProjectIconProp | null; + project_lead_id?: string; + default_assignee_id?: string; + guest_view_all_features?: boolean; + module_view?: boolean; + cycle_view?: boolean; + issue_views_view?: boolean; + page_view?: boolean; + intake_view?: boolean; + is_time_tracking_enabled?: boolean; } /** Project icon_prop from API (name + optional color) */ diff --git a/ui/src/components/CreateProjectModal.tsx b/ui/src/components/CreateProjectModal.tsx index 6d2728e..2d20dbd 100644 --- a/ui/src/components/CreateProjectModal.tsx +++ b/ui/src/components/CreateProjectModal.tsx @@ -1,8 +1,21 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Button, Input } from "./ui"; +import { CoverImageModal } from "./CoverImageModal"; +import { + ProjectIconDisplay, + ProjectIconModal, + type ProjectIconSelection, +} from "./ProjectIconModal"; import { projectService } from "../services/projectService"; -import type { ProjectApiResponse } from "../api/types"; +import type { + ProjectApiResponse, + WorkspaceMemberApiResponse, +} from "../api/types"; +import { useAuth } from "../contexts/AuthContext"; +import { workspaceService } from "../services/workspaceService"; +import { ProjectNetworkSelect } from "./ProjectNetworkSelect"; +import { ProjectLeadSelect } from "./ProjectLeadSelect"; export interface CreateProjectModalProps { open: boolean; @@ -19,43 +32,6 @@ const COVER_GRADIENTS = [ "linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)", ]; -const IconGlobe = () => ( - - - - - -); - -const IconUsers = () => ( - - - - - - -); - const IconInfo = () => ( (null); + const [iconProp, setIconProp] = + useState(null); + const [coverImage, setCoverImage] = useState(null); + const [network, setNetwork] = useState<"public" | "private">("public"); + const [projectLeadId, setProjectLeadId] = useState(null); + const [workspaceMembers, setWorkspaceMembers] = useState< + WorkspaceMemberApiResponse[] + >([]); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); - const [coverIndex, setCoverIndex] = useState(0); + const [coverModalOpen, setCoverModalOpen] = useState(false); + const [iconModalOpen, setIconModalOpen] = useState(false); const handleClose = () => { setName(""); setIdentifier(""); setDescription(""); + setEmoji(null); + setIconProp(null); + setCoverImage(null); + setNetwork("public"); + setProjectLeadId(null); setError(""); onClose(); }; @@ -121,10 +113,18 @@ export function CreateProjectModal({ } setSubmitting(true); try { - const project = await projectService.create(workspaceSlug, { + const payload: Parameters[1] = { name: name.trim(), identifier: identifier.trim() || undefined, - }); + description: description.trim() || undefined, + cover_image: coverImage || undefined, + emoji: emoji ?? undefined, + icon_prop: iconProp ?? undefined, + guest_view_all_features: network === "public" ? true : undefined, + project_lead_id: projectLeadId ?? undefined, + }; + + const project = await projectService.create(workspaceSlug, payload); onSuccess?.(project); handleClose(); } catch (err) { @@ -138,22 +138,39 @@ export function CreateProjectModal({ useEffect(() => { if (!open) return; + const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleEscape); document.body.style.overflow = "hidden"; + + // Load workspace members for project lead dropdown + workspaceService + .listMembers(workspaceSlug) + .then((members) => setWorkspaceMembers(members)) + .catch(() => { + // ignore for create modal; dropdown will just be empty + }); + return () => { document.removeEventListener("keydown", handleEscape); document.body.style.overflow = ""; }; - }, [open, onClose]); + }, [open, onClose, workspaceSlug]); if (!open) return null; - const coverStyle = { - background: COVER_GRADIENTS[coverIndex % COVER_GRADIENTS.length], - }; + const coverStyle = + coverImage != null + ? { + backgroundImage: `url(${coverImage})`, + backgroundSize: "cover", + backgroundPosition: "center", + } + : { + background: COVER_GRADIENTS[0], + }; return createPortal(
e.stopPropagation()} > {/* Cover + Close */} -
+
- {/* Icon placeholder overlapping cover */} + {/* Icon overlapping cover */}
-
- 📁 -
+
{/* Form */} -
-
-
+ +
+
setName(e.target.value)} placeholder="Project name" @@ -215,9 +240,8 @@ export function CreateProjectModal({ className="w-full" />
-
+
setIdentifier( @@ -237,9 +261,6 @@ export function CreateProjectModal({
-