fix(structure): fill resizable charset and collation columns
This commit is contained in:
1 parent
f9fd2d4fa8
commit
5b40487753
2 files changed
+333
-2
No files matched your search
+331
@@ -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<HTMLElement>('[data-column-row-index="0"]');
|
||||
const cell = row?.querySelectorAll("td")[headerIndex];
|
||||
const select = cell?.querySelector<HTMLButtonElement>('[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"]));
|
||||
});
|
||||
});
|
||||
@@ -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)"
|
||||
/>
|
||||
</td>
|
||||
@@ -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)"
|
||||
/>
|
||||
</td>
|
||||
|
||||
Reference in new issue
Block a user