feat(projects): auto-create default Development environment on project create
This commit is contained in:
1 parent
7ebdaca2e5
commit
8a27446643
2 files changed
+211
-125
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user