From 5db779ecc13dffe210e37ac3a2c5cbf27e52fc07 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 17 Sep 2026 20:39:13 +0800 Subject: [PATCH] fix(objects): keep event creation visible for empty lists Closes #9436 --- .../src/components/objects/ObjectBrowser.vue | 2 +- ...bjectBrowser.eventCreate.component.spec.ts | 186 ++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 2048e71d4d..bb555d5b37 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -3519,7 +3519,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
{{ error }}
-
+
{{ t("objects.empty") }}
diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts new file mode 100644 index 0000000000..25271c445d --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.component.spec.ts @@ -0,0 +1,186 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, reactive, ref, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ObjectBrowser from "@/components/objects/ObjectBrowser.vue"; +import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; +import type { ConnectionConfig, ObjectInfo } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + listObjects: vi.fn(), + ensureConnected: vi.fn(), + editorInstances: 0, +})); + +vi.mock("@/lib/backend/api", () => ({ + listObjects: (...args: unknown[]) => mocks.listObjects(...args), + listSchemas: vi.fn().mockResolvedValue([]), + listObjectStatistics: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: () => connection, + orderByPinnedTreeNodes: (rows: unknown[]) => rows, + }), +})); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { refreshData: "F5" }, + objectBrowserViewMode: "grid", + objectBrowserShowCheckbox: false, + }, + }), +})); +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) })); +vi.mock("@/i18n", () => ({ default: { install: () => undefined } })); +vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("vue-virtual-scroller", () => ({ RecycleScroller: { render: () => null } })); +vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } })); +vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/ui/CustomContextMenu.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/MySqlEventEditor.vue", () => ({ + default: defineComponent({ + props: { name: String }, + emits: ["close", "saved"], + setup(props, { emit }) { + const draft = ref(""); + const instance = ++mocks.editorInstances; + return () => + h("section", { "data-event-editor": props.name ?? "create", "data-instance": instance }, [ + h("input", { + "data-event-draft": "", + value: draft.value, + onInput: (event: Event) => (draft.value = (event.target as HTMLInputElement).value), + }), + h("button", { "data-close-event": "", onClick: () => emit("close") }, "close"), + ]); + }, + }), +})); + +const connection = { + id: "mysql-events", + name: "MySQL", + db_type: "mysql", + database: "app", + driver_profile: null, + url_params: null, + transport_layers: [], +} as unknown as ConnectionConfig; +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.listObjects.mockResolvedValue([]); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.editorInstances = 0; + invalidateObjectBrowserRowsCache({}); +}); + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } + invalidateObjectBrowserRowsCache({}); +}); + +async function mountBrowser(overrides: Partial["$props"]> = {}) { + const props = reactive({ connection, database: "app", initialEventCreateRequestId: 1 as number | undefined, ...overrides }); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ setup: () => () => h(ObjectBrowser, props) }); + mountedApps.push({ app, host }); + app.mount(host); + await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledOnce()); + await nextTick(); + return { host, props }; +} + +describe("ObjectBrowser event editor rendering", () => { + it("renders the first event CREATE editor when the database has no objects", async () => { + const { host } = await mountBrowser(); + + expect(host.querySelector('[data-event-editor="create"]')).not.toBeNull(); + expect(host.textContent).not.toContain("objects.empty"); + }); + + it("keeps the ordinary empty state when no editor was requested", async () => { + const { host } = await mountBrowser({ initialEventCreateRequestId: undefined }); + + expect(host.textContent).toContain("objects.empty"); + expect(host.querySelector("[data-event-editor]")).toBeNull(); + }); + + it("returns to the empty state after closing the CREATE editor", async () => { + const { host } = await mountBrowser(); + const closeButton = host.querySelector("[data-close-event]"); + expect(closeButton).not.toBeNull(); + closeButton!.click(); + await nextTick(); + + expect(host.querySelector("[data-event-editor]")).toBeNull(); + expect(host.textContent).toContain("objects.empty"); + }); + + it("remounts a fresh CREATE editor for another request on the empty tab", async () => { + const { host, props } = await mountBrowser(); + const draft = host.querySelector("[data-event-draft]"); + expect(draft).not.toBeNull(); + draft!.value = "unsaved event"; + draft!.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + const firstInstance = host.querySelector("[data-event-editor]")?.getAttribute("data-instance"); + + props.initialEventCreateRequestId = 2; + await nextTick(); + + expect(host.querySelector("[data-event-editor]")?.getAttribute("data-instance")).not.toBe(firstInstance); + expect(host.querySelector("[data-event-draft]")?.value).toBe(""); + expect(mocks.listObjects).toHaveBeenCalledOnce(); + }); + + it("does not hide CREATE when an existing event is filtered out", async () => { + mocks.listObjects.mockResolvedValue([{ name: "existing_event", schema: "app", object_type: "EVENT" }]); + const { host } = await mountBrowser({ initialSearchQuery: "no-match" }); + + expect(host.querySelector('[data-event-editor="create"]')).not.toBeNull(); + expect(host.textContent).not.toContain("objects.empty"); + }); + + it("still opens an existing event in edit mode", async () => { + mocks.listObjects.mockResolvedValue([{ name: "existing_event", schema: "app", object_type: "EVENT" }]); + const { host } = await mountBrowser({ initialEventCreateRequestId: undefined, initialEventName: "existing_event", initialEventOpenRequestId: 1 }); + + expect(host.querySelector('[data-event-editor="existing_event"]')).not.toBeNull(); + }); + + it("keeps loading visible until the initial object request completes", async () => { + let resolveObjects!: (objects: ObjectInfo[]) => void; + mocks.listObjects.mockImplementation(() => new Promise((resolve) => (resolveObjects = resolve))); + const { host } = await mountBrowser(); + + expect(host.textContent).toContain("objects.loading"); + expect(host.querySelector("[data-event-editor]")).toBeNull(); + + resolveObjects([]); + await vi.waitFor(() => expect(host.querySelector('[data-event-editor="create"]')).not.toBeNull()); + }); + + it("preserves an object-load failure instead of hiding it with the editor", async () => { + mocks.listObjects.mockRejectedValue(new Error("objects unavailable")); + const { host } = await mountBrowser(); + + expect(host.textContent).toContain("objects unavailable"); + expect(host.querySelector("[data-event-editor]")).toBeNull(); + }); +});