Files
gridline/src/components/connections/NewConnectionScreen.tsx
T
adrianbonpin 0c74d77e75 v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
* [P1-T1] Change::Ddl Rust variant + execute_change arm

* [P1-T2] Frontend ddl change type + objectCrud capability

* [P1-T3] use_keychain data model + migration v8

* [P2-T1] object_crud skeleton + validators + build_ddl dispatch

* [P2-T2] Sequence builders

* [P2-T3] Enum builders (no value removal)

* [P2-T4] View / matview / extension builders

* [P2-T5] Index + constraint builders

* [P2-T6] Function / procedure + trigger builders

* [P2-T7] build_object_ddl + get_available_extensions commands + wrappers

* [P3-T1] conditional keychain + session passwords

* [P3-T2] keychain-off password prompt on connect

* [P3-T3] default-ON keychain opt-out + tooltip + modal conditional

* [P3-T4] ddl queue card + after-commit refetch

* [P4-T1] ObjectCrudDialog shell

* [P4-T2] SequenceForm

* [P4-T3] EnumForm with no-removal note

* [P4-T4] ExtensionForm with available-extensions picker

* [P4-T5] ViewForm (view + materialized view)

* [P5-T1] IndexForm with column picker

* [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker

* [P5-T3] FunctionForm (function + procedure)

* [P5-T4] TriggerForm with trigger-function picker

* [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring

* [P6-T1] object tab type + openObjectTab dedup

* [P6-T2] extract ObjectDetail for object tabs

* [P6-T3] Objects view two-pane sidebar + workspace

* [P6-T4] object-tab content + per-type tab icons

* [P7-T1] Version bump 0.7.5 -> 0.7.6

* [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6

* [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6

* fix(ui): object tab icon stacks above name (preflight svg block)

* fix(ui): optically center object tab icon with name

* fix(ui): object tab icon matches query/table icon handling

* [UI-POLISH-1] objectForm tab type + openFormTab store action

* [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle

* [UI-POLISH-3] route create/edit through form tabs; remove modal

* docs: create/edit now open as form tabs (AGENTS sync)

* [FB-1] follow app styling patterns + schema dropdown in forms

* [FB-2] Monaco editor for function body + view definition

* [FB-3] form tabs styled like viewers + in-cell editing

* [FB-5] focus outline scoped to input area (label excluded)

* [FB-6] no amber focus outline on Monaco body/definition rows

* [FB-7] header dedupe + schema default + full edit prefill

* [FB-8] SQL view in read-only Monaco editor

* docs: roadmap — table create/edit + relationships (next)

* docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
2026-08-06 22:56:49 +08:00

340 lines
12 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { ConnectionFormShell } from "./ConnectionFormShell";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
import {
parseConnectionString,
detectProviderFromHost,
} from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils";
import { testConnection } from "../../lib/commands";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionInput, Folder, Tag } from "../../lib/types";
import type { ConnectionFormData } from "./connectionFormData";
interface NewConnectionScreenProps {
defaultFolderId?: string | null;
prefilledConnectionString?: string;
folders?: Folder[];
tags?: Tag[];
onSaved?: () => void;
onCancel?: () => void;
}
type Stage = "entry" | "configured";
const FALLBACK_PORTS: Record<string, number> = {
postgresql: 5432,
mysql: 3306,
redis: 6379,
};
function createEmptyForm(
defaultFolderId: string | null = null,
defaultPorts?: Record<string, number | null>,
): ConnectionFormData {
return {
name: "",
environment: null,
folder_id: defaultFolderId,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: defaultPorts?.postgresql ?? 5432,
username: null,
password: null,
database: null,
use_keychain: true,
ssh_password: null,
};
}
function getDefaultPort(dbType: string): number {
return (
useSettingsStore.getState().settings?.default_ports?.[dbType] ??
FALLBACK_PORTS[dbType] ??
5432
);
}
export function NewConnectionScreen({
defaultFolderId = null,
prefilledConnectionString = "",
folders: _folders,
tags: _tags,
onSaved,
onCancel,
}: NewConnectionScreenProps) {
const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState<
"supabase" | "neon" | null
>(null);
const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm(
defaultFolderId,
useSettingsStore.getState().settings?.default_ports,
),
);
const [testLoading, setTestLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify);
const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
setStage("configured");
},
[],
);
const handleConnectionStringChange = useCallback((value: string) => {
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}));
} else {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
}
}, []);
useEffect(() => {
if (
stage === "entry" &&
form.connection_string &&
parseConnectionString(form.connection_string)
) {
setStage("configured");
}
}, [form.connection_string, stage]);
useEffect(() => {
if (prefilledConnectionString) {
handleConnectionStringChange(prefilledConnectionString);
}
}, [prefilledConnectionString, handleConnectionStringChange]);
const handleSelectProvider = useCallback(
(id: ProviderId) => {
const provider = getProviderById(id)!;
setManagedPreset(
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
);
revealConfigured({
db_type: provider.dbType,
port:
provider.dbType === "sqlite"
? null
: getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}),
});
},
[revealConfigured],
);
const updateForm = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
const value = updates.connection_string;
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
return {
...prev,
...updates,
db_type: parsed.db_type,
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
}
return { ...prev, ...updates };
}
return { ...prev, ...updates };
});
},
[],
);
const buildPayload = useCallback((): ConnectionInput => {
return {
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
folder_id: form.folder_id,
tag_ids: form.tag_ids,
connection_string: form.connection_string,
environment: form.environment,
password: form.password,
database: form.database,
use_keychain: form.use_keychain,
ssh_host: form.ssh_host ?? null,
ssh_port: form.ssh_port ?? null,
ssh_user: form.ssh_user ?? null,
ssh_auth_method: form.ssh_auth_method ?? null,
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null,
};
}, [form]);
const validate = useCallback((): string | null => {
const result = validateConnectionInput(buildPayload());
return result.ok ? null : result.error;
}, [buildPayload]);
const handleSave = useCallback(async () => {
const error = validate();
if (error) {
notify(error, "error");
return;
}
setSaveLoading(true);
try {
await createConnection(buildPayload());
notify("Connection saved", "success");
onSaved?.();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
notify(`Failed to save connection: ${message}`, "error");
} finally {
setSaveLoading(false);
}
}, [validate, notify, createConnection, buildPayload, onSaved]);
const handleTest = useCallback(async () => {
const error = validate();
if (error) {
notify(error, "error");
return;
}
setTestLoading(true);
try {
const result = await testConnection(buildPayload());
if (result.ok) {
notify("Connection successful", "success");
} else {
notify(result.error ?? "Connection failed", "error");
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
notify(`Connection test failed: ${message}`, "error");
} finally {
setTestLoading(false);
}
}, [validate, notify, testConnection, buildPayload]);
const isEntry = stage === "entry";
const showEntryUri = isEntry || form.db_type !== "sqlite";
return (
<ConnectionFormShell
onBack={() => onCancel?.()}
onTest={handleTest}
onSave={handleSave}
testLoading={testLoading}
saveLoading={saveLoading}
>
<div>
{isEntry && showEntryUri && (
<label className="block text-sm text-text mb-1.5">
Connection URI
</label>
)}
{isEntry && form.db_type === "sqlite" ? (
<div>
<label className="block text-sm text-text mb-1.5">
File Path
</label>
<input
value={form.host}
onChange={(e) =>
setForm((prev) => ({
...prev,
host: e.target.value,
}))
}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
</div>
) : (
<textarea
value={form.connection_string}
onChange={(e) =>
handleConnectionStringChange(e.target.value)
}
placeholder="postgresql://user:password@host:5432/database"
aria-label={isEntry ? "Connection URI" : undefined}
rows={1}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors resize-none overflow-x-auto whitespace-nowrap ${
isEntry ? "" : "sr-only"
}`}
/>
)}
{isEntry && showEntryUri && (
<p className="text-xs text-text-muted mt-1.5">
Paste a connection string to auto-detect, or pick a
provider below.
</p>
)}
</div>
{isEntry ? (
<>
<div className="flex items-center gap-3 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<ProviderTabsGrid onSelect={handleSelectProvider} />
</>
) : (
<>
{managedPreset && (
<ProviderSetupGuide provider={managedPreset} />
)}
<DetailedConnectionForm
form={form}
onChange={updateForm}
/>
</>
)}
</ConnectionFormShell>
);
}