feat(projects): enable editing project info from project cards

This commit is contained in:
KodekaTeam committed 2026-06-10 01:30:12 +07:00
1 parent 0f570955ef
commit 70fd3db19f
5 files changed
+232 -21

No files matched your search

@@ -2,21 +2,24 @@
import { useMemo, useState } from "react";
import { FolderArchive, Loader2, LucideRocket, X } from "lucide-react";
import type { ProjectCreateBody } from "@/lib/api";
import type { ProjectCreateBody, ProjectRecord } from "@/lib/api";
interface ProjectFormModalProps {
project?: ProjectRecord;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: ProjectCreateBody) => Promise<void>;
}
export default function ProjectFormModal({
project,
submitting,
onClose,
onSubmit,
}: ProjectFormModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const isEditing = Boolean(project);
const [name, setName] = useState(project?.name ?? "");
const [description, setDescription] = useState(project?.description ?? "");
const [error, setError] = useState("");
const canSubmit = useMemo(() => name.trim().length >= 2, [name]);
@@ -30,11 +33,13 @@ export default function ProjectFormModal({
}
setError("");
await onSubmit({
const payload = {
name: name.trim(),
description: description.trim(),
environments: [],
});
...(isEditing ? {} : { environments: [] }),
};
await onSubmit(payload);
};
return (
@@ -69,12 +74,14 @@ export default function ProjectFormModal({
}}
>
<FolderArchive size={18} style={{ marginRight: 8 }} />
Create New Project
{isEditing ? "Project Detail/Ops" : "Create New Project"}
</h3>
<p
style={{ marginTop: 2, color: "var(--text-muted)", fontSize: 12 }}
>
Create a new project to manage your environments and deployments.
{isEditing
? "Update project information used across environments and deployments."
: "Create a new project to manage your environments and deployments."}
</p>
</div>
<button
@@ -178,7 +185,13 @@ export default function ProjectFormModal({
) : (
<LucideRocket size={18} style={{ marginRight: 0 }} />
)}
{submitting ? "Creating..." : "Create Project"}
{submitting
? isEditing
? "Saving..."
: "Creating..."
: isEditing
? "Save Changes"
: "Create Project"}
</button>
</div>
</form>
+20 -5
View File
@@ -1,16 +1,18 @@
import Link from "next/link";
import { FolderTree, Layers3, Trash2 } from "lucide-react";
import { FolderTree, Layers3, Trash2, Wrench } from "lucide-react";
import type { ProjectRecord } from "@/lib/api";
interface ProjectsGridProps {
projects: ProjectRecord[];
deletingProjectId: string | null;
onEditProject: (project: ProjectRecord) => void;
onDeleteProject: (project: ProjectRecord) => void;
}
export default function ProjectsGrid({
projects,
deletingProjectId,
onEditProject,
onDeleteProject,
}: ProjectsGridProps) {
if (projects.length === 0) {
@@ -202,12 +204,25 @@ export default function ProjectsGrid({
))}
</div>
<div style={{ display: "flex", gap: 6 }}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<button
type="button"
className="btn btn-success"
onClick={() => onEditProject(project)}
style={{
flex: "1 1 120px",
fontSize: 11,
padding: "5px",
}}
>
<Wrench size={11} />
Detail/Ops
</button>
<Link
href={`/projects/${project.id}`}
className="btn btn-success"
className="btn btn-primary"
style={{
flex: 1,
flex: "1 1 120px",
fontSize: 11,
padding: "5px",
textDecoration: "none",
@@ -221,7 +236,7 @@ export default function ProjectsGrid({
className="btn btn-ghost"
onClick={() => onDeleteProject(project)}
disabled={deletingProjectId === project.id}
style={{ flex: 1, fontSize: 11, padding: "5px" }}
style={{ flex: "1 1 120px", fontSize: 11, padding: "5px" }}
>
<Trash2 size={11} />
{deletingProjectId === project.id ? "Deleting..." : "Remove"}
+61 -6
View File
@@ -31,7 +31,11 @@ export default function ProjectsPage() {
const [refreshing, setRefreshing] = useState(false);
const [search, setSearch] = useState("");
const [showProjectModal, setShowProjectModal] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectRecord | null>(
null,
);
const [creatingProject, setCreatingProject] = useState(false);
const [updatingProject, setUpdatingProject] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
null,
);
@@ -154,6 +158,46 @@ export default function ProjectsPage() {
[pushToast],
);
const handleUpdateProject = useCallback(
async (payload: ProjectCreateBody) => {
if (!editingProject) {
return;
}
setUpdatingProject(true);
try {
const response = await projectsApi.update(editingProject.id, {
name: payload.name,
description: payload.description,
});
const updatedProject = response.data;
if (updatedProject) {
setProjects((current) =>
current.map((project) =>
project.id === updatedProject.id ? updatedProject : project,
),
);
}
setEditingProject(null);
pushToast({
tone: "success",
title: "Project updated",
message: `Project ${payload.name} has been successfully updated.`,
});
} catch (error) {
pushToast({
tone: "error",
title: "Update failed",
message:
error instanceof Error ? error.message : "Project update failed.",
});
} finally {
setUpdatingProject(false);
}
},
[editingProject, pushToast],
);
const performDeleteProject = useCallback(
async (project: ProjectRecord) => {
setDeletingProjectId(project.id);
@@ -236,7 +280,10 @@ export default function ProjectsPage() {
addLabel="Create Project"
onSearchChange={setSearch}
onRefresh={() => load(true)}
onAdd={() => setShowProjectModal(true)}
onAdd={() => {
setEditingProject(null);
setShowProjectModal(true);
}}
/>
<ProjectsStatePanel
@@ -244,23 +291,31 @@ export default function ProjectsPage() {
isEmpty={filteredProjects.length === 0}
searchActive={Boolean(search.trim())}
canCreate={true}
onAdd={() => setShowProjectModal(true)}
onAdd={() => {
setEditingProject(null);
setShowProjectModal(true);
}}
/>
{!loading && filteredProjects.length > 0 ? (
<ProjectsGrid
projects={filteredProjects}
deletingProjectId={deletingProjectId}
onEditProject={setEditingProject}
onDeleteProject={handleDeleteProject}
/>
) : null}
</div>
{showProjectModal ? (
{showProjectModal || editingProject ? (
<ProjectFormModal
submitting={creatingProject}
onClose={() => setShowProjectModal(false)}
onSubmit={handleCreateProject}
project={editingProject ?? undefined}
submitting={editingProject ? updatingProject : creatingProject}
onClose={() => {
setShowProjectModal(false);
setEditingProject(null);
}}
onSubmit={editingProject ? handleUpdateProject : handleCreateProject}
/>
) : null}
</DashboardLayout>
+39
View File
@@ -251,6 +251,8 @@ export interface ProjectCreateBody {
environments?: ProjectEnvironmentCreateBody[];
}
export type ProjectUpdateBody = Pick<ProjectCreateBody, "name" | "description">;
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
@@ -383,6 +385,40 @@ export interface UpdateSettingsBody {
};
}
export type PanelAccessProxy = "NGINX" | "CADDY" | "TRAEFIK";
export interface PanelProxyCapability {
type: PanelAccessProxy;
label: string;
installed: boolean;
active: boolean;
available: boolean;
supportsProvisioning: boolean;
reason: string | null;
}
export interface PanelAccessCapabilities {
proxies: PanelProxyCapability[];
autoSsl: {
installed: boolean;
available: boolean;
reason: string | null;
};
defaultProxy: PanelAccessProxy | null;
upstream: string;
}
export interface PanelAccessProvisionResult {
domain: string;
panelUrl: string;
proxy: PanelAccessProxy;
configPath: string;
enabledPath: string;
reloadTarget: string;
sslEnabled: boolean;
message: string;
}
export type S3StorageProvider =
| "awsS3"
| "alibabaOss"
@@ -959,6 +995,9 @@ export const projectsApi = {
create: (body: ProjectCreateBody) =>
post<{ success: boolean; data: ProjectRecord }>("/projects", body),
update: (id: string, body: ProjectUpdateBody) =>
patch<{ success: boolean; data: ProjectRecord }>(`/projects/${id}`, body),
addEnvironment: (projectId: string, body: ProjectEnvironmentCreateBody) =>
post<{ success: boolean; data: ProjectEnvironmentRecord }>(
`/projects/${projectId}/environments`,
+90 -1
View File
@@ -28,6 +28,11 @@ const ProjectCreateSchema = z.object({
environments: z.array(ProjectEnvironmentInputSchema).max(12).default([]),
});
const ProjectUpdateSchema = ProjectCreateSchema.pick({
name: true,
description: true,
});
const EnvironmentCreateSchema = ProjectEnvironmentInputSchema;
const EnvironmentUpdateSchema = ProjectEnvironmentInputSchema;
@@ -54,6 +59,7 @@ async function generateUniqueProjectSlug(
db: DbClient,
organizationId: string,
name: string,
excludeProjectId?: string,
) {
const base = slugify(name);
let slug = base;
@@ -61,7 +67,11 @@ async function generateUniqueProjectSlug(
while (
await db.project.findFirst({
where: { organizationId, slug },
where: {
organizationId,
slug,
...(excludeProjectId ? { id: { not: excludeProjectId } } : {}),
},
select: { id: true },
})
) {
@@ -402,6 +412,85 @@ export async function projectsRoutes(app: FastifyInstance) {
},
);
app.patch(
"/:id",
{ preHandler: [requireRole("DEVELOPER")] },
async (req, reply) => {
const organizationId = req.organizationId;
if (!organizationId) {
return reply
.status(400)
.send({ success: false, error: "Active organization is required" });
}
const { id } = req.params as { id: string };
const body = ProjectUpdateSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const project = await prisma.project.findFirst({
where: { id, organizationId },
select: { id: true, name: true, slug: true },
});
if (!project) {
return reply
.status(404)
.send({ success: false, error: "Project not found" });
}
try {
await prisma.project.update({
where: { id: project.id },
data: {
name: body.data.name,
slug:
body.data.name === project.name
? project.slug
: await generateUniqueProjectSlug(
prisma,
organizationId,
body.data.name,
project.id,
),
description: body.data.description || null,
},
});
const freshProject = await getProjectByIdForOrganization(
project.id,
organizationId,
);
if (!freshProject) {
throw new Error("Project updated but could not be loaded");
}
await auditLog({
userId: req.userId,
organizationId,
action: "PROJECT_UPDATE",
category: "SYSTEM",
level: "INFO",
message: `Project \"${freshProject.name}\" updated`,
});
return reply.send({
success: true,
data: serializeProject(freshProject),
});
} catch (error) {
return reply.status(400).send({
success: false,
error:
error instanceof Error ? error.message : "Failed to update project",
});
}
},
);
app.post(
"/:id/environments",
{ preHandler: [requireRole("DEVELOPER")] },