diff --git a/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts new file mode 100644 index 0000000000..f1d3c7b061 --- /dev/null +++ b/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts @@ -0,0 +1,331 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + connection: { + id: "structure-charset-test", + name: "MySQL", + db_type: "mysql", + driver_label: "MySQL", + }, + ensureConnected: vi.fn(), + listDataTypes: vi.fn(), + buildTableStructureChangeSql: vi.fn(), + updateEditorSettings: vi.fn(), + loadObjectDdl: vi.fn(), + invalidateObjectDdl: vi.fn(), + loadObjectMetadataFacet: vi.fn(), + invalidateTableMetadataCache: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") }); + return { + AlertTriangle: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronUp: Icon, + Copy: Icon, + Database: Icon, + Info: Icon, + KeyRound: Icon, + ListChevronsUpDown: Icon, + Loader2: Icon, + Maximize2: Icon, + Plus: Icon, + RefreshCw: Icon, + Save: Icon, + Search: Icon, + Settings: Icon, + SlidersHorizontal: Icon, + Trash2: Icon, + X: Icon, + }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + name: "Button", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + name: "Input", + inheritAttrs: false, + props: { modelValue: { type: [String, Number], default: "" } }, + emits: ["update:modelValue"], + setup: + (props, { attrs, emit }) => + () => + h("input", { + ...attrs, + value: props.modelValue, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), + }), + }; +}); +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + name: "Badge", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const Button = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }); + return { Tabs: Div, TabsContent: Div, TabsList: Div, TabsTrigger: Button }; +}); +vi.mock("@/components/ui/dropdown-menu", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const Button = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }); + return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div }; +}); +vi.mock("@/components/ui/popover", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div }; +}); +vi.mock("@/components/ui/searchable-select", async () => { + const { defineComponent, h } = await import("vue"); + return { + SearchableSelect: defineComponent({ + name: "SearchableSelect", + inheritAttrs: false, + props: { + modelValue: { type: String, default: "" }, + options: { type: Array, default: () => [] }, + allowCustom: { type: Boolean, default: false }, + }, + emits: ["update:modelValue"], + setup: + (props, { attrs, emit }) => + () => + h("button", { + ...attrs, + type: "button", + "data-searchable-select": "true", + "data-model-value": props.modelValue, + "data-options": JSON.stringify(props.options), + "data-allow-custom": String(props.allowCustom), + onClick: () => emit("update:modelValue", "custom_domain"), + }), + }), + }; +}); +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div }; +}); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined), + }), +})); +vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) })); +vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [] }, + updateEditorSettings: mocks.updateEditorSettings, + }), +})); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) })); +vi.mock("@/lib/metadata/objectDdlCache", () => ({ + loadObjectDdl: mocks.loadObjectDdl, + invalidateObjectDdl: mocks.invalidateObjectDdl, +})); +vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet })); +vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache })); +vi.mock("@/lib/backend/api", () => ({ + listDataTypes: mocks.listDataTypes, + buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, +})); + +import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; + +const mountedApps: App[] = []; + +function draft() { + return { + initialized: true, + activeTab: "columns" as const, + newTableName: "", + tableComment: "", + originalTableComment: "", + columns: [ + { + id: "existing:id", + name: "id", + dataType: "VARCHAR", + isNullable: true, + defaultValue: "", + comment: "", + isPrimaryKey: false, + characterSet: "utf8mb3", + collation: "utf8mb3_uca1400_ai_ci", + extra: "", + original: { + name: "id", + data_type: "VARCHAR", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: null, + }, + originalPosition: 0, + markedForDrop: false, + }, + ], + indexes: [], + foreignKeys: [], + triggers: [], + }; +} + +async function mountEditor() { + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.listDataTypes.mockResolvedValue([]); + mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); + + const root = document.createElement("div"); + document.body.append(root); + const app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + schema: "test", + tableName: "users", + draft: draft(), + }); + mountedApps.push(app); + app.mount(root); + await nextTick(); + await Promise.resolve(); + await nextTick(); + return root; +} + +function searchableSelectInColumn(root: HTMLElement, header: string): HTMLButtonElement { + const headerIndex = Array.from(root.querySelectorAll("thead th")).findIndex((cell) => cell.textContent?.trim() === header); + if (headerIndex < 0) throw new Error(`Missing ${header} column`); + const row = root.querySelector('[data-column-row-index="0"]'); + const cell = row?.querySelectorAll("td")[headerIndex]; + const select = cell?.querySelector('[data-searchable-select="true"]'); + if (!select) throw new Error(`Missing ${header} searchable select`); + return select; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.connection.db_type = "mysql"; + mocks.connection.name = "MySQL"; + mocks.connection.driver_label = "MySQL"; + mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id varchar(255))", cacheStatus: "remote" }); + mocks.invalidateObjectDdl.mockResolvedValue(undefined); + mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" }); +}); + +afterEach(() => { + vi.useRealTimers(); + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("TableStructureEditor charset/collation column width", () => { + it("lets the charset trigger fill its resizable column instead of a fixed width cap", async () => { + const root = await mountEditor(); + const charsetSelect = searchableSelectInColumn(root, "structureEditor.characterSet"); + + const triggerClass = charsetSelect.getAttribute("trigger-class") ?? ""; + expect(triggerClass.split(",")).toContain("w-full"); + expect(triggerClass.split(",")).not.toEqual(expect.arrayContaining(["w-20"])); + }); + + it("lets the collation trigger fill its resizable column instead of a fixed width cap", async () => { + const root = await mountEditor(); + const collationSelect = searchableSelectInColumn(root, "structureEditor.collation"); + + const triggerClass = collationSelect.getAttribute("trigger-class") ?? ""; + expect(triggerClass.split(",")).toContain("w-full"); + expect(triggerClass.split(",")).not.toEqual(expect.arrayContaining(["w-28"])); + }); +}); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 9cdc091b99..0d1be6ab2f 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -2946,7 +2946,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { :empty-text="t('structureEditor.noMatchingType')" :allow-custom="true" :disabled="isColumnCharsetDisabled(column)" - :trigger-class="[structureMonoControlClass, 'w-20']" + :trigger-class="[structureMonoControlClass, 'w-full']" @update:model-value="(v: string) => onCharsetChange(column, v)" /> @@ -2959,7 +2959,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { :empty-text="t('structureEditor.noMatchingType')" :allow-custom="true" :disabled="isColumnCharsetDisabled(column)" - :trigger-class="[structureMonoControlClass, 'w-28']" + :trigger-class="[structureMonoControlClass, 'w-full']" @update:model-value="(v: string) => (column.collation = v)" />