Add Fields/DSN toggle in New Connection dialog. DSN mode accepts a PostgreSQL connection string (postgresql://user:pass@host:port/db?sslmode=...) and auto-parses it into individual fields. Switching between modes syncs the data bidirectionally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
|
|
import { toast } from "sonner";
|
|
import type { ConnectionConfig } from "@/types";
|
|
import { ENVIRONMENTS } from "@/lib/environment";
|
|
import { Loader2, X } from "lucide-react";
|
|
|
|
type InputMode = "fields" | "dsn";
|
|
|
|
function parseDsn(dsn: string): Partial<ConnectionConfig> | null {
|
|
try {
|
|
const trimmed = dsn.trim();
|
|
// Support both postgres:// and postgresql://
|
|
const match = trimmed.match(
|
|
/^(?:postgres(?:ql)?):\/\/(?:([^:@]*?)(?::([^@]*?))?@)?([^/:?]+)(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/
|
|
);
|
|
if (!match) return null;
|
|
|
|
const [, user, password, host, port, database, queryStr] = match;
|
|
|
|
const result: Partial<ConnectionConfig> = {};
|
|
if (host) result.host = decodeURIComponent(host);
|
|
if (port) result.port = parseInt(port, 10);
|
|
if (user) result.user = decodeURIComponent(user);
|
|
if (password !== undefined) result.password = decodeURIComponent(password);
|
|
if (database) result.database = decodeURIComponent(database);
|
|
|
|
if (queryStr) {
|
|
const params = new URLSearchParams(queryStr);
|
|
const ssl = params.get("sslmode");
|
|
if (ssl) result.ssl_mode = ssl;
|
|
}
|
|
|
|
return result;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildDsn(config: ConnectionConfig): string {
|
|
const ssl = config.ssl_mode ?? "prefer";
|
|
const user = encodeURIComponent(config.user);
|
|
const pass = config.password ? `:${encodeURIComponent(config.password)}` : "";
|
|
const auth = config.user ? `${user}${pass}@` : "";
|
|
return `postgresql://${auth}${config.host}:${config.port}/${encodeURIComponent(config.database)}?sslmode=${ssl}`;
|
|
}
|
|
|
|
const CONNECTION_COLORS = [
|
|
{ name: "Red", value: "#ef4444" },
|
|
{ name: "Orange", value: "#f97316" },
|
|
{ name: "Yellow", value: "#eab308" },
|
|
{ name: "Green", value: "#22c55e" },
|
|
{ name: "Cyan", value: "#06b6d4" },
|
|
{ name: "Blue", value: "#3b82f6" },
|
|
{ name: "Purple", value: "#a855f7" },
|
|
{ name: "Pink", value: "#ec4899" },
|
|
];
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
connection?: ConnectionConfig | null;
|
|
}
|
|
|
|
const emptyConfig: ConnectionConfig = {
|
|
id: "",
|
|
name: "",
|
|
host: "localhost",
|
|
port: 5432,
|
|
user: "postgres",
|
|
password: "",
|
|
database: "postgres",
|
|
ssl_mode: "prefer",
|
|
color: undefined,
|
|
environment: undefined,
|
|
};
|
|
|
|
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
|
const [form, setForm] = useState<ConnectionConfig>(emptyConfig);
|
|
const [mode, setMode] = useState<InputMode>("fields");
|
|
const [dsn, setDsn] = useState("");
|
|
const [dsnError, setDsnError] = useState<string | null>(null);
|
|
const saveMutation = useSaveConnection();
|
|
const testMutation = useTestConnection();
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
|
|
setForm(config);
|
|
setMode("fields");
|
|
setDsn(connection ? buildDsn(config) : "");
|
|
setDsnError(null);
|
|
}
|
|
}, [open, connection]);
|
|
|
|
const update = (field: keyof ConnectionConfig, value: string | number) => {
|
|
setForm((f) => ({ ...f, [field]: value }));
|
|
};
|
|
|
|
const handleTest = () => {
|
|
testMutation.mutate(form, {
|
|
onSuccess: (version) => {
|
|
toast.success("Connection successful", { description: version });
|
|
},
|
|
onError: (err) => {
|
|
toast.error("Connection failed", { description: String(err) });
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!form.name.trim()) {
|
|
toast.error("Name is required");
|
|
return;
|
|
}
|
|
saveMutation.mutate(form, {
|
|
onSuccess: () => {
|
|
toast.success("Connection saved");
|
|
onOpenChange(false);
|
|
},
|
|
onError: (err) => {
|
|
toast.error("Save failed", { description: String(err) });
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[480px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{connection ? "Edit Connection" : "New Connection"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3 py-4">
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Name
|
|
</label>
|
|
<Input
|
|
className="col-span-3"
|
|
value={form.name}
|
|
onChange={(e) => update("name", e.target.value)}
|
|
placeholder="My Database"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Mode
|
|
</label>
|
|
<div className="col-span-3 flex gap-1 rounded-md border p-0.5">
|
|
<button
|
|
type="button"
|
|
className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
|
|
mode === "fields"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => {
|
|
if (mode === "dsn") {
|
|
const parsed = parseDsn(dsn);
|
|
if (parsed) {
|
|
setForm((f) => ({ ...f, ...parsed }));
|
|
setDsnError(null);
|
|
}
|
|
}
|
|
setMode("fields");
|
|
}}
|
|
>
|
|
Fields
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
|
|
mode === "dsn"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => {
|
|
setDsn(buildDsn(form));
|
|
setDsnError(null);
|
|
setMode("dsn");
|
|
}}
|
|
>
|
|
DSN
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{mode === "dsn" ? (
|
|
<div className="grid grid-cols-4 items-start gap-3">
|
|
<label className="text-right text-sm text-muted-foreground pt-2">
|
|
DSN
|
|
</label>
|
|
<div className="col-span-3 space-y-1.5">
|
|
<Input
|
|
className="font-mono text-xs"
|
|
value={dsn}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setDsn(value);
|
|
if (value.trim()) {
|
|
const parsed = parseDsn(value);
|
|
if (parsed) {
|
|
setForm((f) => ({ ...f, ...parsed }));
|
|
setDsnError(null);
|
|
} else {
|
|
setDsnError("Invalid DSN format");
|
|
}
|
|
} else {
|
|
setDsnError(null);
|
|
}
|
|
}}
|
|
placeholder="postgresql://user:password@host:5432/database?sslmode=prefer"
|
|
/>
|
|
{dsnError && (
|
|
<p className="text-xs text-destructive">{dsnError}</p>
|
|
)}
|
|
<p className="text-[11px] text-muted-foreground">
|
|
postgresql://user:password@host:port/database?sslmode=prefer
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Host
|
|
</label>
|
|
<Input
|
|
className="col-span-3"
|
|
value={form.host}
|
|
onChange={(e) => update("host", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Port
|
|
</label>
|
|
<Input
|
|
className="col-span-3"
|
|
type="number"
|
|
value={form.port}
|
|
onChange={(e) => update("port", parseInt(e.target.value) || 5432)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
User
|
|
</label>
|
|
<Input
|
|
className="col-span-3"
|
|
value={form.user}
|
|
onChange={(e) => update("user", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Password
|
|
</label>
|
|
<Input
|
|
className="col-span-3"
|
|
type="password"
|
|
value={form.password}
|
|
onChange={(e) => update("password", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Database
|
|
</label>
|
|
<Input
|
|
className="col-span-3"
|
|
value={form.database}
|
|
onChange={(e) => update("database", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
SSL Mode
|
|
</label>
|
|
<Select
|
|
value={form.ssl_mode ?? "prefer"}
|
|
onValueChange={(v) => update("ssl_mode", v)}
|
|
>
|
|
<SelectTrigger className="col-span-3">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="disable">Disable</SelectItem>
|
|
<SelectItem value="prefer">Prefer</SelectItem>
|
|
<SelectItem value="require">Require</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Environment
|
|
</label>
|
|
<Select
|
|
value={form.environment ?? "__none__"}
|
|
onValueChange={(v) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
environment: v === "__none__" ? undefined : v,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger className="col-span-3">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">None</SelectItem>
|
|
{ENVIRONMENTS.map((env) => (
|
|
<SelectItem key={env.value} value={env.value}>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: env.color }}
|
|
/>
|
|
{env.label}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-3">
|
|
<label className="text-right text-sm text-muted-foreground">
|
|
Color
|
|
</label>
|
|
<div className="col-span-3 flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
className={`flex h-6 w-6 items-center justify-center rounded-full border-2 ${
|
|
!form.color ? "border-primary" : "border-transparent"
|
|
} bg-muted`}
|
|
onClick={() => setForm((f) => ({ ...f, color: undefined }))}
|
|
title="No color"
|
|
>
|
|
<X className="h-3 w-3 text-muted-foreground" />
|
|
</button>
|
|
{CONNECTION_COLORS.map((c) => (
|
|
<button
|
|
key={c.value}
|
|
type="button"
|
|
className={`h-6 w-6 rounded-full border-2 ${
|
|
form.color === c.value
|
|
? "border-primary"
|
|
: "border-transparent"
|
|
}`}
|
|
style={{ backgroundColor: c.value }}
|
|
onClick={() => setForm((f) => ({ ...f, color: c.value }))}
|
|
title={c.name}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleTest}
|
|
disabled={testMutation.isPending}
|
|
>
|
|
{testMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Test
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
|
{saveMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|