Release / publish (, ubuntu-22.04) (push) Failing after 14m39s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Release / publish (, windows-latest) (push) Canceled after 0s
* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1) * feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2) * feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3) * feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1) * feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2) * feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3) * feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4) * feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5) * feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1) * feat(store): roles slice + openFormTab table/role dedup (Task 3.2) * feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1) * feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2) * feat(ui): wire roles into object registry + explorer (Task 4.3) * feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4) * chore: bump version to 0.7.7 (Task 5.1) * docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2) * test: v0.7.7 manual smoke checklist (Task 5.3) * chore: sync Cargo.lock to v0.7.7 * style(ui): match TableForm visual layout to shared object-form rows (remove stray padding) * feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls * style(ui): table-creator columns grid — bordered cells like the data table view * feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row * feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints * style(ui): table creator — constraints column sized to content (no clip), overscroll containment * style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2 * feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema * style(ui): table creator — columns grid fills container width, scrolls only when too narrow * feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family * feat(ui): FK panel — animate in/out from the right, flush create-table-style rows * fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid * feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs * fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs * style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker * style(ui): FK panel — vertical divider between local/ref columns * style(ui): FK panel — section title padding py-2 * style(ui): FK panel — remove row margins + select/text padding in column picker * style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells * style(ui): FK panel — cell padding + row borders on column picker (user polish) * feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class * style(ui): hide FK icon on PK columns; fix grid header/row column alignment * feat(ui): SQL preview in table creator uses read-only Monaco editor * feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions * style(ui): FK relation row — label, icon, table on one line * style(ui): FormRow selects transparent like FK panel; test matches FK relation label * style(ui): form-row focus outline amber-400/20 (subtle) * style(ui): SQL view — remove px-4 py-3 wrapper padding * feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card * fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64 * fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed) * feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more * feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all * chore(test): drop unused fireEvent import in RoleDetail test * style(ui): hide chevron when section has 5 or fewer entries * docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins) * docs(readme): collapse Recent changes and Key features into details blocks * docs(readme): move hero caption under image; align download + comparison table columns * docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible * docs(readme): per-subsection collapsibles with meaningful summary labels
567 lines
23 KiB
TypeScript
567 lines
23 KiB
TypeScript
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 (
|
|
<div className="border-b border-border">
|
|
<div className="border-b border-border px-4 py-2 text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
|
{label}
|
|
</div>
|
|
<div className={flush ? "" : "px-2 py-2"}>{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FkPanel({
|
|
connectionId,
|
|
schema,
|
|
table,
|
|
column,
|
|
localColumns,
|
|
mode,
|
|
onClose,
|
|
onStaged,
|
|
}: Props) {
|
|
const [graph, setGraph] = useState<SchemaGraph>({
|
|
tables: [],
|
|
relationships: [],
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [staging, setStaging] = useState(false);
|
|
|
|
const [pairs, setPairs] = useState<FkPair[]>(() =>
|
|
column
|
|
? [{ localCol: column, refCol: "" }]
|
|
: [{ localCol: "", refCol: "" }],
|
|
);
|
|
const [refSchema, setRefSchema] = useState<string>(schema);
|
|
const [refTable, setRefTable] = useState<string>("");
|
|
const [onDelete, setOnDelete] = useState<string>("NO ACTION");
|
|
const [onUpdate, setOnUpdate] = useState<string>("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<string, string> = {};
|
|
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 (
|
|
<motion.div
|
|
initial={{ x: "100%" }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: "100%" }}
|
|
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
|
|
style={{ willChange: "transform" }}
|
|
className="fixed top-0 right-0 h-full w-[420px] bg-canvas border-l border-border z-40 flex flex-col"
|
|
>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="flex items-center gap-2 text-text">
|
|
<Link size={14} />
|
|
<span className="text-sm font-semibold">Foreign key</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="text-text-muted hover:text-text"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="flex-1 overflow-auto"
|
|
style={{ overscrollBehavior: "none" }}
|
|
>
|
|
{table.trim() === "" && (
|
|
<p className="border-b border-border px-4 py-2 text-xs text-amber-400">
|
|
Enter a table name in the form first — a foreign key
|
|
needs a named table.
|
|
</p>
|
|
)}
|
|
{loading && (
|
|
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
|
Loading schema graph…
|
|
</p>
|
|
)}
|
|
{!loading && graph.tables.length === 0 && (
|
|
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
|
No tables available for foreign key reference.
|
|
</p>
|
|
)}
|
|
|
|
{!loading && graph.tables.length > 0 && (
|
|
<>
|
|
<Section label="Select a Schema">
|
|
<select
|
|
aria-label="Schema"
|
|
value={refSchema}
|
|
onChange={(e) => setRefSchema(e.target.value)}
|
|
className={panelSelect}
|
|
>
|
|
{schemas.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Section>
|
|
|
|
<Section label="Select a Table to reference to">
|
|
<select
|
|
aria-label="Table"
|
|
value={refTable}
|
|
onChange={(e) => setRefTable(e.target.value)}
|
|
className={panelSelect}
|
|
>
|
|
{tablesInSchema.map((t) => (
|
|
<option key={t.name} value={t.name}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Section>
|
|
|
|
{refSchema && refTable && (
|
|
<>
|
|
<Section
|
|
flush
|
|
label={`Select columns from ${schema}.${tableLabel} to reference to`}
|
|
>
|
|
<div className="grid grid-cols-2 text-xs text-text-muted border-b border-border">
|
|
<div className="border-r border-border px-4 py-1">
|
|
<span className="truncate">
|
|
{schema}.{tableLabel}
|
|
</span>
|
|
</div>
|
|
<div className="px-4 py-1">
|
|
<span className="truncate">
|
|
{refSchema}.{refTable || "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{pairs.map((pair, i) => (
|
|
<div
|
|
key={i}
|
|
className="grid grid-cols-2 items-center border-b border-border"
|
|
>
|
|
<div className="border-r border-border px-2 py-1">
|
|
<select
|
|
aria-label={`Local column ${i + 1}`}
|
|
value={pair.localCol}
|
|
onChange={(e) =>
|
|
setPair(
|
|
i,
|
|
"localCol",
|
|
e.target.value,
|
|
)
|
|
}
|
|
className={panelSelect}
|
|
>
|
|
{localColumns.map((c) => (
|
|
<option
|
|
key={c.name}
|
|
value={c.name}
|
|
>
|
|
{c.name} (
|
|
{c.data_type})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-1 px-2 py-1">
|
|
<select
|
|
aria-label={`Referenced column ${i + 1}`}
|
|
value={pair.refCol}
|
|
onChange={(e) =>
|
|
setPair(
|
|
i,
|
|
"refCol",
|
|
e.target.value,
|
|
)
|
|
}
|
|
className={panelSelect}
|
|
>
|
|
{refColumns.map((c) => (
|
|
<option
|
|
key={c.name}
|
|
value={c.name}
|
|
>
|
|
{c.name} (
|
|
{c.data_type})
|
|
{c.is_pk
|
|
? " (PK)"
|
|
: ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{pairs.length > 1 && (
|
|
<button
|
|
type="button"
|
|
aria-label={`Remove pair ${i + 1}`}
|
|
onClick={() =>
|
|
removePair(i)
|
|
}
|
|
className="shrink-0 text-text-muted hover:text-red-400"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
aria-label="Add another column"
|
|
onClick={addPair}
|
|
className="text-xs text-accent hover:text-accent-hover px-4 py-2 cursor-pointer"
|
|
>
|
|
<Plus size={12} className="inline" />{" "}
|
|
Add another column
|
|
</button>
|
|
</Section>
|
|
|
|
{typeMappings.length > 0 && (
|
|
<Section label="Types will be updated">
|
|
<ul className="space-y-0.5">
|
|
{typeMappings.map((m, i) => (
|
|
<li
|
|
key={i}
|
|
className="px-2 font-mono text-xs text-text"
|
|
>
|
|
<span className="text-text-muted mr-2">
|
|
{m.localType || "?"}
|
|
</span>
|
|
<span className="text-accent">
|
|
→
|
|
</span>{" "}
|
|
<span className="">
|
|
{m.refType || "?"}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Section>
|
|
)}
|
|
|
|
<Section label="Action if referenced row is updated">
|
|
<select
|
|
aria-label="On update"
|
|
value={onUpdate}
|
|
onChange={(e) =>
|
|
setOnUpdate(e.target.value)
|
|
}
|
|
className={panelSelect}
|
|
>
|
|
{FK_ACTIONS.map((a) => (
|
|
<option key={a} value={a}>
|
|
{a}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Section>
|
|
|
|
<Section label="Action if referenced row is removed">
|
|
<select
|
|
aria-label="On delete"
|
|
value={onDelete}
|
|
onChange={(e) =>
|
|
setOnDelete(e.target.value)
|
|
}
|
|
className={panelSelect}
|
|
>
|
|
{FK_ACTIONS.map((a) => (
|
|
<option key={a} value={a}>
|
|
{a}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Section>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="border-b border-border px-4 py-2 text-xs text-red-400">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-3 py-1.5 text-xs font-medium text-text hover:text-text"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={addFk}
|
|
disabled={
|
|
staging ||
|
|
table.trim() === "" ||
|
|
pairs.length === 0 ||
|
|
pairs.some((p) => p.localCol === "" || p.refCol === "")
|
|
}
|
|
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
>
|
|
Add FK
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|