feat(postgres): list user-defined types with details and DDL for PG-family databases

This commit is contained in:
amwps290 authored and GitHub committed 2026-08-11 15:00:24 +08:00
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">
@@ -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");
});
});