feat(projects): enable editing project info from project cards
This commit is contained in:
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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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")] },
|
||||
|
||||
Reference in new issue
Block a user