feat: implement metadata catalog database management
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import type { RefObject, ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Save,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type {
|
||||
CatalogDatabase,
|
||||
CatalogSecretName,
|
||||
CatalogSyncScope,
|
||||
CatalogSyncRun,
|
||||
DatabaseBinding,
|
||||
DatabaseTransport,
|
||||
} from "../../api/catalog-databases";
|
||||
import type {
|
||||
DatabaseBusyAction,
|
||||
DatabaseFormDraft,
|
||||
DatabaseFormMode,
|
||||
} from "./model";
|
||||
import { statusLabel } from "./model";
|
||||
import { DatabaseSyncMenu } from "./DatabaseSyncMenu";
|
||||
|
||||
const inputClass = "h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 read-only:bg-muted/40 read-only:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground";
|
||||
const labelClass = "grid min-w-0 gap-1.5 text-xs font-medium text-foreground";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: FieldProps) {
|
||||
return (
|
||||
<label className={labelClass}>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="font-normal leading-4 text-muted-foreground">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface SecretFieldProps {
|
||||
label: string;
|
||||
name: CatalogSecretName;
|
||||
row: CatalogDatabase;
|
||||
draft: DatabaseFormDraft;
|
||||
readOnly: boolean;
|
||||
canManageSecrets: boolean;
|
||||
onChange: (name: CatalogSecretName, value: string) => void;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
function SecretField({
|
||||
label,
|
||||
name,
|
||||
row,
|
||||
draft,
|
||||
readOnly,
|
||||
canManageSecrets,
|
||||
onChange,
|
||||
multiline = false,
|
||||
}: SecretFieldProps) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Field label={label} hint="Secret values are never displayed.">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={row.secrets[name] ? "Configured" : "Not configured"}
|
||||
readOnly
|
||||
aria-label={label}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const permissionHint = canManageSecrets
|
||||
? undefined
|
||||
: "You do not have permission to replace secret values.";
|
||||
|
||||
return (
|
||||
<Field label={label} hint={permissionHint}>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${inputClass} min-h-24 resize-y py-2 font-mono text-xs leading-5`}
|
||||
aria-label={label}
|
||||
autoComplete="off"
|
||||
value={draft.secrets[name] ?? ""}
|
||||
disabled={!canManageSecrets}
|
||||
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Paste value"}
|
||||
onChange={(event) => onChange(name, event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={inputClass}
|
||||
type="password"
|
||||
aria-label={label}
|
||||
autoComplete="new-password"
|
||||
value={draft.secrets[name] ?? ""}
|
||||
disabled={!canManageSecrets}
|
||||
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Not configured"}
|
||||
onChange={(event) => onChange(name, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface DatabaseFormProps {
|
||||
mode: DatabaseFormMode;
|
||||
row: CatalogDatabase;
|
||||
availableWorkspaces: CatalogDatabase[];
|
||||
workspaceLocked: boolean;
|
||||
draft: DatabaseFormDraft;
|
||||
dirty: boolean;
|
||||
canManage: boolean;
|
||||
canManageSecrets: boolean;
|
||||
busyAction: DatabaseBusyAction;
|
||||
stale: boolean;
|
||||
staleBannerOpen: boolean;
|
||||
partialSecretFailure: string | null;
|
||||
headingRef: RefObject<HTMLHeadingElement>;
|
||||
onBack: () => void;
|
||||
onWorkspaceChange: (workspaceId: string) => void;
|
||||
onFieldChange: (field: "databaseName" | "schema", value: string) => void;
|
||||
onTransportChange: (transport: DatabaseTransport) => void;
|
||||
onBindingChange: <K extends keyof DatabaseBinding>(key: K, value: DatabaseBinding[K]) => void;
|
||||
onSecretChange: (name: CatalogSecretName, value: string) => void;
|
||||
onSave: () => void;
|
||||
onTest: () => void;
|
||||
onDelete: () => void;
|
||||
onReloadLatest: () => void;
|
||||
onKeepEditing: () => void;
|
||||
onRetrySecrets: () => void;
|
||||
onOpenTables: (origin: HTMLButtonElement) => void;
|
||||
onOpenRelationships: () => void;
|
||||
onSync: (scope: CatalogSyncScope) => void;
|
||||
onOpenSync: () => void;
|
||||
activeSyncRun?: CatalogSyncRun;
|
||||
}
|
||||
|
||||
function formTitle(mode: DatabaseFormMode, workspaceLocked: boolean): string {
|
||||
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
|
||||
if (mode === "edit") return "Edit database";
|
||||
if (mode === "delete") return "Delete database";
|
||||
return "Database details";
|
||||
}
|
||||
|
||||
export function DatabaseForm({
|
||||
mode,
|
||||
row,
|
||||
availableWorkspaces,
|
||||
workspaceLocked,
|
||||
draft,
|
||||
dirty,
|
||||
canManage,
|
||||
canManageSecrets,
|
||||
busyAction,
|
||||
stale,
|
||||
staleBannerOpen,
|
||||
partialSecretFailure,
|
||||
headingRef,
|
||||
onBack,
|
||||
onWorkspaceChange,
|
||||
onFieldChange,
|
||||
onTransportChange,
|
||||
onBindingChange,
|
||||
onSecretChange,
|
||||
onSave,
|
||||
onTest,
|
||||
onDelete,
|
||||
onReloadLatest,
|
||||
onKeepEditing,
|
||||
onRetrySecrets,
|
||||
onOpenTables,
|
||||
onOpenRelationships,
|
||||
onSync,
|
||||
onOpenSync,
|
||||
activeSyncRun,
|
||||
}: DatabaseFormProps) {
|
||||
const title = formTitle(mode, workspaceLocked);
|
||||
const readOnly = mode === "view" || mode === "delete";
|
||||
const editable = mode === "add" || mode === "edit";
|
||||
const busy = busyAction !== null;
|
||||
const testDisabled = !canManage
|
||||
|| !row.configured
|
||||
|| !row.id
|
||||
|| dirty
|
||||
|| stale
|
||||
|| busy;
|
||||
const saveDisabled = !canManage
|
||||
|| stale
|
||||
|| busy
|
||||
|| (mode === "edit" && !dirty);
|
||||
const bindingReady = row.connectionStatus === "reachable" && row.testedVersion === row.version;
|
||||
|
||||
const form = (
|
||||
<form
|
||||
className="grid gap-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (editable) onSave();
|
||||
}}
|
||||
>
|
||||
{mode === "delete" ? (
|
||||
<div className="rounded-md border border-destructive/35 bg-destructive/8 p-4 text-sm text-foreground" role="alert">
|
||||
<p className="font-semibold text-destructive">This removes the local database configuration.</p>
|
||||
<p className="mt-1 leading-5 text-muted-foreground">
|
||||
{row.workspaceAvailable
|
||||
? "The repository workspace remains available and will return to the list as Not configured. "
|
||||
: "This orphaned catalog row will disappear from the list. "}
|
||||
Secret references associated with this configuration are removed too.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stale ? (
|
||||
staleBannerOpen ? (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
|
||||
<p className="font-semibold">A newer database configuration is available.</p>
|
||||
<p className="mt-1 leading-5">Reload the latest values before saving, testing, or deleting. Your draft is still intact.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={onReloadLatest}>
|
||||
<RefreshCw /> Reload latest
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onKeepEditing}>
|
||||
Keep editing
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="status">
|
||||
Reload the latest values before this configuration can be changed.
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{partialSecretFailure ? (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
|
||||
<p className="font-semibold">Database configuration saved; secret update could not be confirmed.</p>
|
||||
<p className="mt-1 leading-5">{partialSecretFailure} The values you entered are retained in this form.</p>
|
||||
<Button type="button" size="sm" variant="outline" className="mt-3" disabled={busy || stale} onClick={onRetrySecrets}>
|
||||
<RefreshCw /> {busyAction === "retry-secrets" ? "Retrying…" : "Retry secrets"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editable && !canManageSecrets ? (
|
||||
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground" role="note">
|
||||
Secret status is visible, but replacing secret values requires the workspace.secrets.manage permission.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!row.workspaceAvailable ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="alert">
|
||||
This configuration references a workspace that is no longer present in the repository YAML.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{mode === "add" ? (
|
||||
<Field label="Workspace">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Workspace"
|
||||
value={draft.workspaceId}
|
||||
disabled={workspaceLocked || busy}
|
||||
onChange={(event) => onWorkspaceChange(event.target.value)}
|
||||
>
|
||||
{availableWorkspaces.map((workspace) => (
|
||||
<option key={workspace.workspaceId} value={workspace.workspaceId}>
|
||||
{workspace.workspaceName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Workspace">
|
||||
<input className={inputClass} value={row.workspaceName} readOnly />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Workspace ID">
|
||||
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
|
||||
</Field>
|
||||
<Field label="Database engine">
|
||||
<input className={inputClass} value="PostgreSQL" readOnly />
|
||||
</Field>
|
||||
<Field label="Transport">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Transport"
|
||||
value={draft.binding.transport}
|
||||
disabled={readOnly || busy}
|
||||
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
|
||||
>
|
||||
<option value="postgres_direct">Direct PostgreSQL</option>
|
||||
<option value="rest_api">REST API</option>
|
||||
<option value="ssh_tunnel">SSH tunnel</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Database name">
|
||||
<input
|
||||
className={inputClass}
|
||||
required={editable}
|
||||
value={draft.databaseName}
|
||||
readOnly={readOnly}
|
||||
disabled={busy}
|
||||
onChange={(event) => onFieldChange("databaseName", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Schema">
|
||||
<input
|
||||
className={inputClass}
|
||||
required={editable}
|
||||
value={draft.schema}
|
||||
readOnly={readOnly}
|
||||
disabled={busy}
|
||||
onChange={(event) => onFieldChange("schema", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-5">
|
||||
<p className="thot-label mb-4">Installation binding</p>
|
||||
|
||||
{draft.binding.transport === "postgres_direct" ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Host">
|
||||
<input className={inputClass} required={editable} value={draft.binding.host ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("host", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Port">
|
||||
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.port ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("port", Number(event.target.value))} />
|
||||
</Field>
|
||||
<Field label="Username">
|
||||
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="TLS server name">
|
||||
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
|
||||
</Field>
|
||||
<SecretField label="Password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
||||
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.binding.transport === "rest_api" ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Base URL">
|
||||
<input className={inputClass} type="url" required={editable} placeholder="https://dwh.example/api" value={draft.binding.baseUrl ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("baseUrl", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Diagnostic endpoint" hint="This relative path is fixed by the runtime contract.">
|
||||
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label="Diagnostic endpoint" />
|
||||
</Field>
|
||||
<Field label="Authentication">
|
||||
<select className={inputClass} aria-label="Authentication" value={draft.binding.restAuth ?? "x-api-key"} disabled={readOnly || busy} onChange={(event) => onBindingChange("restAuth", event.target.value as DatabaseBinding["restAuth"])}>
|
||||
<option value="x-api-key">X-API-Key</option>
|
||||
<option value="bearer">Bearer token</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</Field>
|
||||
{draft.binding.restAuth !== "none" ? (
|
||||
<SecretField label="API key" name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.binding.transport === "ssh_tunnel" ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground md:col-span-2" role="note">
|
||||
SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.
|
||||
</div>
|
||||
<Field label="Database username">
|
||||
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
|
||||
</Field>
|
||||
<SecretField label="Database password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
||||
<Field label="SSH host">
|
||||
<input className={inputClass} required={editable} value={draft.binding.sshHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshHost", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="SSH port">
|
||||
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshPort ?? 22} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshPort", Number(event.target.value))} />
|
||||
</Field>
|
||||
<Field label="SSH username">
|
||||
<input className={inputClass} required={editable} value={draft.binding.sshUsername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshUsername", event.target.value)} />
|
||||
</Field>
|
||||
<SecretField label="SSH private key" name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
|
||||
<SecretField label="Private key passphrase" name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
||||
<SecretField label="SSH known hosts" name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
|
||||
<Field label="Target host">
|
||||
<input className={inputClass} required={editable} value={draft.binding.sshTargetHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetHost", event.target.value)} />
|
||||
</Field>
|
||||
<Field label="Target port">
|
||||
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshTargetPort ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetPort", Number(event.target.value))} />
|
||||
</Field>
|
||||
<Field label="TLS server name">
|
||||
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
|
||||
</Field>
|
||||
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{row.lastErrorMessage ? (
|
||||
<div className="rounded-md border border-destructive/25 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="status">
|
||||
Last connection error: {row.lastErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
|
||||
{mode === "view" || mode === "edit" ? (
|
||||
<Button type="button" variant="outline" disabled={testDisabled} onClick={onTest}>
|
||||
<TestTube2 /> {busyAction === "test" ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
) : null}
|
||||
{mode === "add" || mode === "edit" ? (
|
||||
<Button type="submit" disabled={saveDisabled}>
|
||||
<Save />
|
||||
{busyAction === "save"
|
||||
? "Saving…"
|
||||
: mode === "add"
|
||||
? workspaceLocked ? "Save database" : "Add database"
|
||||
: "Save changes"}
|
||||
</Button>
|
||||
) : null}
|
||||
{mode === "delete" ? (
|
||||
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
|
||||
<Trash2 /> {busyAction === "delete" ? "Deleting…" : "Delete database"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
<div className="mx-auto w-full max-w-[900px] px-4 py-5 sm:px-6 sm:py-6">
|
||||
<Button type="button" variant="ghost" className="-ml-2 mb-5" disabled={busy} onClick={onBack}>
|
||||
<ArrowLeft /> Back to list
|
||||
</Button>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div className="min-w-0">
|
||||
<p className="thot-label mb-1">Database management</p>
|
||||
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{row.workspaceName} · {row.workspaceId}
|
||||
</p>
|
||||
{row.lastTestedAt ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="rounded-full border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
|
||||
{statusLabel(row)}
|
||||
</span>
|
||||
{mode === "view" && activeSyncRun ? (
|
||||
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button>
|
||||
) : null}
|
||||
{mode === "view" ? (
|
||||
<DatabaseSyncMenu
|
||||
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
|
||||
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
|
||||
onSelect={onSync}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "view" ? (
|
||||
<nav aria-label="Database sections" className="mb-6 flex gap-1 border-b border-border" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground"
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
disabled={!row.configured || !row.id}
|
||||
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={(event) => onOpenTables(event.currentTarget)}
|
||||
>
|
||||
Tables
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
disabled={!row.configured || !row.id}
|
||||
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={onOpenRelationships}
|
||||
>
|
||||
Relationships
|
||||
</button>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{form}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user