feat(projects): auto-create default Development environment on project create

This commit is contained in:
KodekaTeam committed 2026-07-09 14:23:01 +07:00
1 parent 7ebdaca2e5
commit 8a27446643
2 files changed
+211 -125

No files matched your search

+194 -125
View File
@@ -2,11 +2,14 @@
import { useMemo, useState } from "react";
import { FolderArchive, Loader2, LucideRocket, X } from "lucide-react";
import type { ProjectCreateBody, ProjectRecord } from "@/lib/api";
import type { ProjectCreateBody, ProjectRecord, Server } from "@/lib/api";
import SearchableSelect from "@/components/SearchableSelect";
import type { SearchableSelectOption } from "@/components/SearchableSelect";
interface ProjectFormModalProps {
project?: ProjectRecord;
submitting: boolean;
servers?: Server[];
onClose: () => void;
onSubmit: (payload: ProjectCreateBody) => Promise<void>;
}
@@ -14,21 +17,41 @@ interface ProjectFormModalProps {
export default function ProjectFormModal({
project,
submitting,
servers,
onClose,
onSubmit,
}: ProjectFormModalProps) {
const isEditing = Boolean(project);
const [name, setName] = useState(project?.name ?? "");
const [description, setDescription] = useState(project?.description ?? "");
const [serverId, setServerId] = useState("");
const [error, setError] = useState("");
const canSubmit = useMemo(() => name.trim().length >= 2, [name]);
const canSubmit = useMemo(
() => name.trim().length >= 5 && (isEditing || serverId),
[name, isEditing, serverId],
);
const serverOptions: SearchableSelectOption[] = useMemo(
() =>
(servers ?? []).map((s) => ({
value: s.id,
label: s.name,
keywords: `${s.ip} ${s.os ?? ""}`,
description: `${s.ip} — ${s.status}`,
})),
[servers],
);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!canSubmit) {
setError("Lengkapi nama project minimal 2 karakter.");
setError(
isEditing
? "Complete the project name (minimum 5 characters)."
: "Select a server and enter the project name.",
);
return;
}
@@ -36,7 +59,17 @@ export default function ProjectFormModal({
const payload = {
name: name.trim(),
description: description.trim(),
...(isEditing ? {} : { environments: [] }),
...(isEditing
? {}
: {
environments: [
{
name: "Development",
kind: "DEVELOPMENT" as const,
serverId,
},
],
}),
};
await onSubmit(payload);
@@ -67,134 +100,170 @@ export default function ProjectFormModal({
padding: 28,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 12,
marginBottom: 20,
paddingRight: 36,
}}
>
<div>
<h3
style={{
display: "flex",
alignItems: "center",
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 16,
}}
>
<FolderArchive size={18} style={{ marginRight: 8 }} />
{isEditing ? "Project Detail/Ops" : "Create New Project"}
</h3>
<p
style={{ marginTop: 2, color: "var(--text-muted)", fontSize: 12 }}
>
{isEditing
? "Update project information used across environments and deployments."
: "Create a new project to manage your environments and deployments."}
</p>
</div>
</div>
<div style={{ display: "grid", gap: 14, marginBottom: 20 }}>
<div>
<label
style={{
display: "block",
marginBottom: 5,
fontSize: 12,
color: "var(--text-muted)",
}}
>
Project Name
</label>
<input
className="input"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Project name, e.g. Doktainer App"
minLength={5}
maxLength={35}
style={{ width: "100%" }}
/>
</div>
<div>
<label
style={{
display: "block",
marginBottom: 5,
fontSize: 12,
color: "var(--text-muted)",
}}
>
Description
</label>
<textarea
className="input"
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
placeholder="Description of your project..."
style={{ width: "100%", resize: "vertical" }}
/>
</div>
</div>
{error ? (
<div
style={{
marginTop: 16,
borderRadius: 12,
padding: 12,
color: "#fecaca",
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
fontSize: 13,
display: "flex",
alignItems: "flex-start",
gap: 12,
marginBottom: 20,
paddingRight: 36,
}}
>
{error}
<div>
<h3
style={{
display: "flex",
alignItems: "center",
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 16,
}}
>
<FolderArchive size={18} style={{ marginRight: 8 }} />
{isEditing ? "Project Detail/Ops" : "Create New Project"}
</h3>
<p
style={{
marginTop: 2,
color: "var(--text-muted)",
fontSize: 12,
}}
>
{isEditing
? "Update project information used across environments and deployments."
: "Create a new project to manage your environments and deployments."}
</p>
</div>
</div>
) : null}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 2fr",
gap: 5,
marginTop: 20,
}}
>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="btn btn-ghost"
<div style={{ display: "grid", gap: 14, marginBottom: 20 }}>
<div>
<label
style={{
display: "block",
marginBottom: 5,
fontSize: 12,
color: "var(--text-muted)",
}}
>
Project Name
</label>
<input
className="input"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Project name, e.g. Doktainer App"
minLength={5}
maxLength={35}
style={{ width: "100%" }}
/>
</div>
{!isEditing && serverOptions.length >= 0 ? (
<div>
<label
style={{
display: "block",
marginBottom: 5,
fontSize: 12,
color: "var(--text-muted)",
}}
>
Default Server{" "}
<span style={{ color: "var(--danger)" }}>*</span>
</label>
<SearchableSelect
value={serverId}
options={serverOptions}
onChange={setServerId}
placeholder="Select a server…"
searchPlaceholder="Search servers…"
emptyText="No servers available"
/>
<p
style={{
marginTop: 4,
fontSize: 11,
color: "var(--text-muted)",
}}
>
An environment "Development" will be created on this server.
</p>
</div>
) : null}
<div>
<label
style={{
display: "block",
marginBottom: 5,
fontSize: 12,
color: "var(--text-muted)",
}}
>
Description
</label>
<textarea
className="input"
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
placeholder="Description of your project..."
style={{ width: "100%", resize: "vertical" }}
/>
</div>
</div>
{error ? (
<div
style={{
marginTop: 16,
borderRadius: 12,
padding: 12,
color: "#fecaca",
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
fontSize: 13,
}}
>
{error}
</div>
) : null}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 2fr",
gap: 5,
marginTop: 20,
}}
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !canSubmit}
className="btn btn-primary"
>
{submitting ? (
<Loader2 size={18} style={{ marginRight: 0 }} />
) : (
<LucideRocket size={18} style={{ marginRight: 0 }} />
)}
{submitting
? isEditing
? "Saving..."
: "Creating..."
: isEditing
? "Save Changes"
: "Create Project"}
</button>
</div>
</form>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="btn btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !canSubmit}
className="btn btn-primary"
>
{submitting ? (
<Loader2 size={18} style={{ marginRight: 0 }} />
) : (
<LucideRocket size={18} style={{ marginRight: 0 }} />
)}
{submitting
? isEditing
? "Saving..."
: "Creating..."
: isEditing
? "Save Changes"
: "Create Project"}
</button>
</div>
</form>
</div>
</div>
);
+17
View File
@@ -12,7 +12,9 @@ import ProjectsToolbar from "@/app/projects/components/ProjectsToolbar";
import {
type ProjectCreateBody,
type ProjectRecord,
type Server,
projectsApi,
servers as serversApi,
} from "@/lib/api";
import { useToastManager } from "@/lib/use-toast-manager";
@@ -36,6 +38,7 @@ export default function ProjectsPage() {
);
const [creatingProject, setCreatingProject] = useState(false);
const [updatingProject, setUpdatingProject] = useState(false);
const [servers, setServers] = useState<Server[]>([]);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
null,
);
@@ -86,6 +89,19 @@ export default function ProjectsPage() {
return () => window.cancelAnimationFrame(frame);
}, [load]);
const loadServers = useCallback(async () => {
try {
const response = await serversApi.list();
setServers(response.data ?? []);
} catch {
// servers are non-critical for existing page loads
}
}, []);
useEffect(() => {
void loadServers();
}, [loadServers]);
const summary = useMemo(() => {
const environmentCount = projects.reduce(
(total, project) => total + project.environments.length,
@@ -311,6 +327,7 @@ export default function ProjectsPage() {
<ProjectFormModal
project={editingProject ?? undefined}
submitting={editingProject ? updatingProject : creatingProject}
servers={servers}
onClose={() => {
setShowProjectModal(false);
setEditingProject(null);