Files
tusk/src/components/connections/ConnectionDialog.tsx
A.Shakhmatov 47b040fadf feat: add DSN input mode to connection dialog
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>
2026-02-11 22:19:38 +03:00

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>
);
}