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[] {
+
{{ 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();
+ });
+});