import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { X, Link, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import { buildObjectDdl } from "../../../lib/objectCrud";
import type { SchemaGraph } from "../../../lib/types";
// Panel selects: transparent, fill the available width (no surface bg like the grid).
const panelSelect =
"w-full bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer";
export interface FkColumn {
name: string;
data_type: string;
}
export interface FkStagedPair {
localCol: string;
refType: string;
}
export interface FkDefinition {
columns: string[];
ref_schema: string;
ref_table: string;
ref_columns: string[];
on_delete?: string;
on_update?: string;
deferrable?: boolean;
initially_deferred?: boolean;
}
interface FkPair {
localCol: string;
refCol: string;
}
interface Props {
connectionId: string;
schema: string;
table: string;
column: string | null;
/** Columns of the table being edited (from the form grid — works even when the table isn't created yet). */
localColumns: FkColumn[];
mode: "create" | "edit";
onClose: () => void;
/** Called after staging: pairs (for type auto-match); in create mode the FK definition is returned
* so the form can inline it into the CREATE TABLE instead of a separate ALTER change. */
onStaged?: (result: { pairs: FkStagedPair[]; fk?: FkDefinition }) => void;
}
const FK_ACTIONS = [
"NO ACTION",
"RESTRICT",
"CASCADE",
"SET NULL",
"SET DEFAULT",
];
function Section({
label,
children,
flush = false,
}: {
label: string;
children: ReactNode;
flush?: boolean;
}) {
return (
);
}
export function FkPanel({
connectionId,
schema,
table,
column,
localColumns,
mode,
onClose,
onStaged,
}: Props) {
const [graph, setGraph] = useState({
tables: [],
relationships: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [staging, setStaging] = useState(false);
const [pairs, setPairs] = useState(() =>
column
? [{ localCol: column, refCol: "" }]
: [{ localCol: "", refCol: "" }],
);
const [refSchema, setRefSchema] = useState(schema);
const [refTable, setRefTable] = useState("");
const [onDelete, setOnDelete] = useState("NO ACTION");
const [onUpdate, setOnUpdate] = useState("NO ACTION");
useEffect(() => {
let active = true;
setLoading(true);
cmd.getSchemaGraph(connectionId, undefined)
.then((g) => {
if (!active) return;
setGraph(g);
setLoading(false);
})
.catch((e) => {
if (!active) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
active = false;
};
}, [connectionId]);
const localNames = useMemo(
() => localColumns.map((c) => c.name),
[localColumns],
);
const schemas = useMemo(
() => Array.from(new Set(graph.tables.map((t) => t.schema))).sort(),
[graph],
);
const tablesInSchema = useMemo(
() =>
graph.tables
.filter((t) => t.schema === refSchema)
.sort((a, b) => a.name.localeCompare(b.name)),
[graph, refSchema],
);
const refTableInfo = useMemo(
() => tablesInSchema.find((t) => t.name === refTable),
[tablesInSchema, refTable],
);
const refColumns = useMemo(() => {
if (!refTableInfo) return [];
return [...refTableInfo.columns].sort((a, b) => {
if (a.is_pk && !b.is_pk) return -1;
if (!a.is_pk && b.is_pk) return 1;
return a.name.localeCompare(b.name);
});
}, [refTableInfo]);
// Defaults: keep selections valid as the graph loads / inputs change.
useEffect(() => {
if (schemas.length > 0 && !schemas.includes(refSchema)) {
setRefSchema(schemas[0] ?? "");
}
}, [schemas, refSchema]);
useEffect(() => {
if (
tablesInSchema.length > 0 &&
!tablesInSchema.some((t) => t.name === refTable)
) {
setRefTable(tablesInSchema[0]?.name ?? "");
} else if (tablesInSchema.length === 0) {
setRefTable("");
}
}, [tablesInSchema, refTable]);
useEffect(() => {
if (localNames.length === 0) return;
setPairs((prev) =>
prev.map((p) =>
localNames.includes(p.localCol)
? p
: { ...p, localCol: localNames[0] ?? "" },
),
);
}, [localNames]);
useEffect(() => {
setPairs((prev) =>
prev.map((p) =>
p.refCol && refColumns.some((c) => c.name === p.refCol)
? p
: { ...p, refCol: refColumns[0]?.name ?? "" },
),
);
}, [refColumns]);
const setPair = (i: number, key: keyof FkPair, value: string) =>
setPairs((prev) =>
prev.map((p, j) => (j === i ? { ...p, [key]: value } : p)),
);
const addPair = () =>
setPairs((prev) => [
...prev,
{
localCol: localNames[0] ?? "",
refCol: refColumns[0]?.name ?? "",
},
]);
const removePair = (i: number) =>
setPairs((prev) =>
prev.length > 1 ? prev.filter((_, j) => j !== i) : prev,
);
const typeMappings = pairs
.map((p) => ({
localType:
localColumns.find((c) => c.name === p.localCol)?.data_type ??
"",
refType:
refTableInfo?.columns.find((c) => c.name === p.refCol)
?.data_type ?? "",
}))
.filter((m) => m.localType !== "" || m.refType !== "");
const tableLabel = table.trim() === "" ? "unnamed table" : table;
const addFk = async () => {
const cols = pairs.map((p) => p.localCol);
const refCols = pairs.map((p) => p.refCol);
if (
cols.length === 0 ||
cols.some((c) => c === "") ||
refCols.some((c) => c === "")
)
return;
setStaging(true);
setError(null);
const refTypes: Record = {};
for (const c of refTableInfo?.columns ?? []) refTypes[c.name] = c.data_type;
const pairsResult = pairs.map((p) => ({
localCol: p.localCol,
refType: refTypes[p.refCol] ?? "",
}));
const fk: FkDefinition = {
columns: cols,
ref_schema: refSchema,
ref_table: refTable,
ref_columns: refCols,
on_delete: onDelete,
on_update: onUpdate,
deferrable: false,
initially_deferred: false,
};
try {
if (mode === "create") {
// Inline the FK into the CREATE TABLE — no separate ALTER change.
onStaged?.({ pairs: pairsResult, fk });
onClose();
return;
}
const sqls = await buildObjectDdl(connectionId, "constraint", {
schema,
table,
name: "",
action: {
op: "foreign_key",
columns: cols,
ref_schema: refSchema,
ref_table: refTable,
ref_columns: refCols,
on_delete: onDelete,
on_update: onUpdate,
},
});
sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description:
sqls.length > 1
? `Add FK ${cols.join(", ")} → ${refSchema}.${refTable} (${i + 1}/${sqls.length})`
: `Add FK ${cols.join(", ")} → ${refSchema}.${refTable}`,
}),
);
onStaged?.({ pairs: pairsResult });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setStaging(false);
}
};
return (
{table.trim() === "" && (
Enter a table name in the form first — a foreign key
needs a named table.
)}
{loading && (
Loading schema graph…
)}
{!loading && graph.tables.length === 0 && (
No tables available for foreign key reference.
)}
{!loading && graph.tables.length > 0 && (
<>
{refSchema && refTable && (
<>
{schema}.{tableLabel}
{refSchema}.{refTable || "—"}
{pairs.map((pair, i) => (
{pairs.length > 1 && (
)}
))}
{typeMappings.length > 0 && (
{typeMappings.map((m, i) => (
-
{m.localType || "?"}
→
{" "}
{m.refType || "?"}
))}
)}
>
)}
>
)}
{error && (
{error}
)}
);
}