feat(postgres): list user-defined types with details and DDL for PG-family databases
This commit is contained in:
1 parent
0c6c1fada2
commit
154c460657
59 files changed
+6538
-150
No files matched your search
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Copy, RefreshCw, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import type { ConnectionConfig, CustomTypeDetails, CustomTypeKind } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
database: string;
|
||||
schema: string;
|
||||
name: string;
|
||||
catalog?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const toast = useToast();
|
||||
const guard = createSidePanelRequestGuard();
|
||||
const details = ref<CustomTypeDetails | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const tab = ref<"members" | "properties" | "ddl">("members");
|
||||
|
||||
const kindLabel = computed(() => (details.value ? t(`customType.kinds.${details.value.kind}`) : ""));
|
||||
const hasMemberTab = computed(() => details.value?.kind === "composite" || details.value?.kind === "enum");
|
||||
const tabs = computed(() => {
|
||||
const items: Array<{ id: "members" | "properties" | "ddl"; label: string }> = [];
|
||||
if (hasMemberTab.value) items.push({ id: "members", label: t("customType.tabs.members") });
|
||||
items.push({ id: "properties", label: t("customType.tabs.properties") });
|
||||
items.push({ id: "ddl", label: t("customType.tabs.ddl") });
|
||||
return items;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
// start() bumps the epoch so an in-flight request from a previously selected
|
||||
// type can never overwrite the current panel state.
|
||||
const epoch = guard.start();
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
details.value = null;
|
||||
tab.value = "members";
|
||||
try {
|
||||
const result = await api.getCustomTypeDetails(props.connection.id, props.database, props.schema, props.name);
|
||||
if (guard.isStale(epoch)) return;
|
||||
details.value = result;
|
||||
if (tab.value === "members" && result.kind !== "composite" && result.kind !== "enum") {
|
||||
tab.value = "properties";
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (guard.isStale(epoch)) return;
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (guard.isFresh(epoch)) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.connection.id, props.database, props.schema, props.name] as const,
|
||||
() => load(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const ddlText = computed(() => details.value?.ddl?.sql ?? "");
|
||||
|
||||
async function copyDdl() {
|
||||
if (!ddlText.value) return;
|
||||
try {
|
||||
await copyToClipboard(ddlText.value);
|
||||
toast.toast(t("contextMenu.ddlCopied"));
|
||||
} catch (error) {
|
||||
toast.toast(t("grid.copyFailed", { message: translateBackendError(t, error) }));
|
||||
}
|
||||
}
|
||||
|
||||
const propertyRows = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const p = details.value?.properties;
|
||||
if (!p) return [];
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
const push = (label: string, value: string | null | undefined) => {
|
||||
if (value != null && value !== "") rows.push({ label, value: String(value) });
|
||||
};
|
||||
push(t("customType.properties.baseType"), p.baseType);
|
||||
if (p.notNull) push(t("customType.properties.notNull"), "true");
|
||||
push(t("customType.properties.default"), p.default);
|
||||
push(t("customType.properties.collation"), p.collation);
|
||||
for (const constraint of p.domainConstraints) {
|
||||
rows.push({
|
||||
label: constraint.name ? `${t("customType.properties.domainConstraint")} · ${constraint.name}` : t("customType.properties.domainConstraint"),
|
||||
value: constraint.definition,
|
||||
});
|
||||
}
|
||||
push(t("customType.properties.rangeSubtype"), p.rangeSubtype);
|
||||
push(t("customType.properties.rangeMultirange"), p.rangeMultirangeName);
|
||||
return rows;
|
||||
});
|
||||
|
||||
function isFieldKind(kind: CustomTypeKind | undefined): boolean {
|
||||
return kind === "composite";
|
||||
}
|
||||
|
||||
function isEnumKind(kind: CustomTypeKind | undefined): boolean {
|
||||
return kind === "enum";
|
||||
}
|
||||
|
||||
function selectTab(next: "members" | "properties" | "ddl") {
|
||||
tab.value = next;
|
||||
}
|
||||
|
||||
defineExpose({ selectTab });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex items-center gap-2 border-b px-3 py-1.5 bg-muted/20 h-9 shrink-0">
|
||||
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ name }}</span>
|
||||
<span class="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground shrink-0">{{ kindLabel }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<div class="text-xs text-destructive break-words max-w-full">{{ error }}</div>
|
||||
<Button variant="outline" size="sm" class="h-6 text-xs" @click="load">
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
{{ t("common.retry") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="details">
|
||||
<div class="flex items-center gap-1 border-b px-2 py-1 shrink-0">
|
||||
<button v-for="item in tabs" :key="item.id" type="button" class="rounded px-2 py-1 text-xs" :class="tab === item.id ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'" @click="tab = item.id">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="tab === 'ddl' && ddlText" variant="ghost" size="sm" class="h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyDdl">
|
||||
<Copy class="w-3 h-3" />
|
||||
<span class="table-info-action-label">{{ t("grid.copyDdl") }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<!-- Members: empty state for composite/enum -->
|
||||
<div v-if="tab === 'members' && details.members.length === 0" class="flex flex-col items-center justify-center gap-1 px-4 py-8 text-center">
|
||||
<span class="text-xs text-muted-foreground">{{ t("customType.members.empty") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Members: composite fields -->
|
||||
<table v-else-if="tab === 'members' && isFieldKind(details.kind)" class="w-full text-[11px]">
|
||||
<thead class="sticky top-0 bg-muted/40 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th class="px-3 py-1.5 font-medium">#</th>
|
||||
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.name") }}</th>
|
||||
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.type") }}</th>
|
||||
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.nullable") }}</th>
|
||||
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.default") }}</th>
|
||||
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.comment") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in details.members" :key="member.ordinal" class="border-t border-border/60">
|
||||
<td class="px-3 py-1.5 text-muted-foreground">{{ member.ordinal }}</td>
|
||||
<td class="px-3 py-1.5 font-mono">{{ member.name }}</td>
|
||||
<td class="px-3 py-1.5 font-mono">{{ member.dataType }}</td>
|
||||
<td class="px-3 py-1.5">{{ member.nullable == null ? "—" : member.nullable ? "✓" : "✗" }}</td>
|
||||
<td class="px-3 py-1.5 font-mono">{{ member.default ?? "—" }}</td>
|
||||
<td class="px-3 py-1.5">{{ member.comment ?? "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Members: enum values -->
|
||||
<ul v-else-if="tab === 'members' && isEnumKind(details.kind) && details.members.length > 0" class="divide-y divide-border/60">
|
||||
<li v-for="member in details.members" :key="member.ordinal" class="flex items-center gap-3 px-3 py-1.5 text-[11px]">
|
||||
<span class="w-6 text-muted-foreground text-right">{{ member.ordinal }}</span>
|
||||
<span class="font-mono">{{ member.enumValue }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Properties -->
|
||||
<table v-else-if="tab === 'properties'" class="w-full text-[11px]">
|
||||
<tbody>
|
||||
<tr v-if="details.comment" class="border-b border-border/60">
|
||||
<td class="px-3 py-1.5 text-muted-foreground w-44">{{ t("customType.members.comment") }}</td>
|
||||
<td class="px-3 py-1.5">{{ details.comment }}</td>
|
||||
</tr>
|
||||
<tr v-for="row in propertyRows" :key="row.label" class="border-b border-border/60">
|
||||
<td class="px-3 py-1.5 text-muted-foreground w-44 align-top">{{ row.label }}</td>
|
||||
<td class="px-3 py-1.5 font-mono whitespace-pre-wrap break-all">{{ row.value }}</td>
|
||||
</tr>
|
||||
<tr v-if="propertyRows.length === 0 && !details.comment" class="border-b border-border/60">
|
||||
<td class="px-3 py-2 text-xs text-muted-foreground" colspan="2">{{ t("customType.properties.empty") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- DDL -->
|
||||
<div v-else class="flex h-full flex-col">
|
||||
<div v-if="details.ddl && !details.ddl.complete" class="border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5">
|
||||
<div v-for="warning in details.ddl.warnings" :key="warning" class="text-[11px] text-amber-600 dark:text-amber-400">{{ warning }}</div>
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed">{{ ddlText || t("customType.ddl.empty") }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Eraser,
|
||||
Eye,
|
||||
FileCode,
|
||||
Info,
|
||||
GripVertical,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
@@ -55,6 +56,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
|
||||
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
|
||||
@@ -118,6 +120,7 @@ import {
|
||||
type ObjectBrowserSortKey,
|
||||
} from "@/lib/table/objectBrowserRows";
|
||||
import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
|
||||
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
|
||||
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
|
||||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||||
@@ -173,7 +176,7 @@ const sourceCanEdit = ref(true);
|
||||
// --- Right-side panel state ---
|
||||
// Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.)
|
||||
const sidePanelRow = ref<ObjectBrowserRow | null>(null);
|
||||
const sidePanelMode = ref<"table-info" | "source">("source");
|
||||
const sidePanelMode = ref<"table-info" | "source" | "type-info">("source");
|
||||
// Table info panel state
|
||||
const tableInfoTab = ref<TableInfoTab>("ddl");
|
||||
const tableColumns = ref<ColumnInfo[]>([]);
|
||||
@@ -196,6 +199,7 @@ let sidePanelResizeStartX = 0;
|
||||
let sidePanelResizeStartWidth = 0;
|
||||
const isResizingSidePanel = ref(false);
|
||||
const sidePanelGuard = createSidePanelRequestGuard();
|
||||
const sidePanelRef = ref<InstanceType<typeof CustomTypeInfoPanel> | null>(null);
|
||||
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
|
||||
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||||
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
|
||||
@@ -822,6 +826,9 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
|
||||
case "table-info":
|
||||
void openTableInfo(row);
|
||||
break;
|
||||
case "type-info":
|
||||
void openTypeInfo(row);
|
||||
break;
|
||||
case "open-table":
|
||||
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
|
||||
break;
|
||||
@@ -833,7 +840,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
|
||||
|
||||
function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
|
||||
const activation = settingsStore.editorSettings.sidebarActivation;
|
||||
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation);
|
||||
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value);
|
||||
// Double click: cancel any pending single-click and fire immediately
|
||||
if (isDouble) {
|
||||
if (singleClickTimer) {
|
||||
@@ -1107,6 +1114,28 @@ function closeSidePanel() {
|
||||
sidePanelGuard.bump();
|
||||
}
|
||||
|
||||
async function openTypeInfo(row: ObjectBrowserRow) {
|
||||
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "type-info") {
|
||||
closeSidePanel();
|
||||
return;
|
||||
}
|
||||
sidePanelRow.value = row;
|
||||
sidePanelMode.value = "type-info";
|
||||
sidePanelGuard.bump();
|
||||
}
|
||||
|
||||
function openTypeDdl(row: ObjectBrowserRow) {
|
||||
sidePanelRow.value = row;
|
||||
sidePanelMode.value = "type-info";
|
||||
sidePanelGuard.bump();
|
||||
// DDL tab is selected by the panel once details load; switching the tab is
|
||||
// deferred via a dedicated request so the panel can focus it.
|
||||
nextTick(() => {
|
||||
const panel = sidePanelRef.value;
|
||||
panel?.selectTab("ddl");
|
||||
});
|
||||
}
|
||||
|
||||
const canOpenTableStructureEditor = computed(() => sidePanelRow.value?.type === "TABLE" && canOpenStructureEditor.value);
|
||||
|
||||
function openTableStructureEditor() {
|
||||
@@ -2737,9 +2766,32 @@ function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
const items: ContextMenuItem[] = [];
|
||||
const capabilities = customTypeCapabilities(effectiveDatabaseType.value);
|
||||
// Verified PG-family types open the read-only details panel; Xugu keeps its
|
||||
// “view source” entry for TYPE/TYPE_BODY rows.
|
||||
if (supportsTypeObjectSource(effectiveDatabaseType.value)) {
|
||||
items.push({ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 });
|
||||
}
|
||||
if (capabilities.details) {
|
||||
items.push({ label: t("contextMenu.viewDetails"), action: () => void openTypeInfo(item), icon: Info });
|
||||
if (capabilities.ddl) {
|
||||
items.push({ label: t("contextMenu.viewDdl"), action: () => openTypeDdl(item), icon: FileCode });
|
||||
}
|
||||
}
|
||||
// Only separate when an action precedes copy-name.
|
||||
if (items.length > 0) {
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
items.push({ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy });
|
||||
return items;
|
||||
}
|
||||
|
||||
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
if (item.type === "TABLE") return getTableMenuItems(item);
|
||||
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
|
||||
if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(item);
|
||||
if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item);
|
||||
return getProcFuncMenuItems(item);
|
||||
}
|
||||
@@ -3233,6 +3285,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- Type info mode (read-only user-defined type details) -->
|
||||
<template v-else-if="sidePanelMode === 'type-info'">
|
||||
<CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" />
|
||||
</template>
|
||||
<!-- Source mode (views, procedures, functions, sequences) -->
|
||||
<template v-else>
|
||||
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
|
||||
import type { ConnectionConfig, CustomTypeDetails } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getCustomTypeDetails: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
getCustomTypeDetails: (...args: unknown[]) => mocks.getCustomTypeDetails(...args),
|
||||
}));
|
||||
vi.mock("@/lib/common/clipboard", () => ({
|
||||
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
function passthrough(tag: string): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h(tag, attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@lucide/vue", () => {
|
||||
const Icon = passthrough("span");
|
||||
return {
|
||||
Copy: Icon,
|
||||
RefreshCw: Icon,
|
||||
X: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
|
||||
const connection: ConnectionConfig = {
|
||||
id: "pg-1",
|
||||
name: "pg",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "test",
|
||||
password: "secret",
|
||||
database: Some("demo"),
|
||||
visible_databases: null,
|
||||
visible_schemas: null,
|
||||
show_system_schemas: false,
|
||||
attached_databases: [],
|
||||
init_script: null,
|
||||
color: null,
|
||||
transport_layers: [],
|
||||
connection_string: null,
|
||||
driver_profile: null,
|
||||
driver_label: null,
|
||||
note: "",
|
||||
url_params: null,
|
||||
agent_java_options: [],
|
||||
} as unknown as ConnectionConfig;
|
||||
|
||||
function Some<T>(value: T): T | null {
|
||||
return value;
|
||||
}
|
||||
|
||||
function enumDetails(overrides: Partial<CustomTypeDetails> = {}): CustomTypeDetails {
|
||||
return {
|
||||
name: "status",
|
||||
schema: "app",
|
||||
kind: "enum",
|
||||
members: [
|
||||
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
|
||||
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
|
||||
],
|
||||
properties: { domainConstraints: [] },
|
||||
ddl: { sql: "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');", complete: true, warnings: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
let root: HTMLDivElement | undefined;
|
||||
|
||||
function mountPanel(props: Record<string, unknown>) {
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(CustomTypeInfoPanel, props);
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = undefined;
|
||||
root = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function clickButton(text: string) {
|
||||
const button = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent?.trim() === text);
|
||||
button?.dispatchEvent(new MouseEvent("click"));
|
||||
return button;
|
||||
}
|
||||
|
||||
describe("CustomTypeInfoPanel", () => {
|
||||
it("shows loading while details are being fetched", async () => {
|
||||
let release!: () => void;
|
||||
mocks.getCustomTypeDetails.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
|
||||
await nextTick();
|
||||
expect(root!.textContent).toContain("common.loading");
|
||||
release();
|
||||
});
|
||||
|
||||
it("renders enum values and complete DDL", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
|
||||
await flush();
|
||||
expect(root!.textContent).toContain("draft");
|
||||
expect(root!.textContent).toContain("published");
|
||||
});
|
||||
|
||||
it("shows the explanatory empty state for an enum with no values", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ members: [] }));
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
|
||||
await flush();
|
||||
expect(root!.textContent).toContain("customType.members.empty");
|
||||
});
|
||||
|
||||
it("shows the explanatory empty state for a composite with no fields", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ kind: "composite", members: [], ddl: { sql: "CREATE TYPE ...;", complete: true, warnings: [] } }));
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "address" });
|
||||
await flush();
|
||||
expect(root!.textContent).toContain("customType.members.empty");
|
||||
});
|
||||
|
||||
it("shows DDL warnings when the DDL is incomplete", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(
|
||||
enumDetails({
|
||||
ddl: { sql: "", complete: false, warnings: ["multirange companion; no standalone CREATE"] },
|
||||
}),
|
||||
);
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "_price_range" });
|
||||
await flush();
|
||||
const ddlButton = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent === "customType.tabs.ddl");
|
||||
ddlButton?.dispatchEvent(new MouseEvent("click"));
|
||||
await nextTick();
|
||||
expect(root!.textContent).toContain("multirange companion; no standalone CREATE");
|
||||
});
|
||||
|
||||
it("hides kernel-level type implementation attributes", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(
|
||||
enumDetails({
|
||||
kind: "base",
|
||||
members: [],
|
||||
properties: {
|
||||
domainConstraints: [],
|
||||
inputFunction: "base_in",
|
||||
alignment: "i",
|
||||
storage: "x",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "base_type" });
|
||||
await flush();
|
||||
expect(clickButton("customType.tabs.members")).toBeUndefined();
|
||||
expect(root!.textContent).toContain("customType.tabs.properties");
|
||||
expect(root!.textContent).not.toContain("base_in");
|
||||
expect(root!.textContent).not.toContain("customType.properties.alignment");
|
||||
expect(root!.textContent).not.toContain("customType.properties.storage");
|
||||
});
|
||||
|
||||
it("renders an error state with a working retry button", async () => {
|
||||
mocks.getCustomTypeDetails.mockRejectedValueOnce(new Error("catalog unavailable"));
|
||||
mocks.getCustomTypeDetails.mockResolvedValueOnce(enumDetails());
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
|
||||
await flush();
|
||||
expect(root!.textContent).toContain("catalog unavailable");
|
||||
clickButton("common.retry");
|
||||
await vi.waitFor(() => expect(root!.textContent).toContain("draft"));
|
||||
});
|
||||
|
||||
it("reports copy success through the toast", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
|
||||
mocks.copyToClipboard.mockResolvedValue(undefined);
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
|
||||
await flush();
|
||||
clickButton("customType.tabs.ddl");
|
||||
await flush();
|
||||
clickButton("grid.copyDdl");
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');"));
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.ddlCopied");
|
||||
});
|
||||
|
||||
it("reports copy failure through the toast", async () => {
|
||||
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
|
||||
mocks.copyToClipboard.mockRejectedValue(new Error("denied"));
|
||||
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
|
||||
await flush();
|
||||
clickButton("customType.tabs.ddl");
|
||||
await flush();
|
||||
clickButton("grid.copyDdl");
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed"));
|
||||
});
|
||||
|
||||
it("ignores a stale response when the type is switched quickly on the same instance", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
const first = new Promise<CustomTypeDetails>((resolve) => (releaseFirst = () => resolve(enumDetails({ name: "status" }))));
|
||||
const second = Promise.resolve(enumDetails({ name: "email", kind: "domain", members: [], properties: { baseType: "text", domainConstraints: [] } }));
|
||||
mocks.getCustomTypeDetails.mockReturnValueOnce(first).mockReturnValueOnce(second);
|
||||
|
||||
const hostName = ref("status");
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h(CustomTypeInfoPanel, { connection, database: "demo", schema: "app", name: hostName.value });
|
||||
},
|
||||
});
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(Host);
|
||||
app.mount(root);
|
||||
|
||||
await nextTick();
|
||||
hostName.value = "email";
|
||||
await nextTick();
|
||||
releaseFirst();
|
||||
await flush();
|
||||
|
||||
// The stale "status" response must not overwrite the "email" details.
|
||||
expect(root!.textContent).toContain("email");
|
||||
expect(root!.textContent).not.toContain("draft");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user