fix(structure): improve mysql auto-increment editing and field shortcuts
This commit is contained in:
17 files changed
+285
-78
No files matched your search
+56
-2
@@ -45,6 +45,7 @@ vi.mock("@lucide/vue", async () => {
|
||||
ListChevronsUpDown: Icon,
|
||||
Loader2: Icon,
|
||||
Maximize2: Icon,
|
||||
Pencil: Icon,
|
||||
Plus: Icon,
|
||||
RefreshCw: Icon,
|
||||
Save: Icon,
|
||||
@@ -277,7 +278,7 @@ function draft(autoIncrement = false, counter?: { value?: string; originalValue?
|
||||
};
|
||||
}
|
||||
|
||||
async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }) {
|
||||
async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }, draftOverride?: ReturnType<typeof draft>) {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.listDataTypes.mockResolvedValue([]);
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
|
||||
@@ -289,7 +290,7 @@ async function mountEditor(autoIncrement = false, counter?: { value?: string; or
|
||||
database: "test",
|
||||
schema: "test",
|
||||
tableName: "users",
|
||||
draft: draft(autoIncrement, counter),
|
||||
draft: draftOverride ?? draft(autoIncrement, counter),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
mountedEditor = app.mount(root) as unknown as { applyChanges: () => Promise<boolean> };
|
||||
@@ -357,6 +358,59 @@ describe("TableStructureEditor MySQL AUTO_INCREMENT counter", () => {
|
||||
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
|
||||
expect(input?.value).toBe("10");
|
||||
expect(input?.disabled).toBe(false);
|
||||
expect(input?.classList.contains("structure-grid-control")).toBe(false);
|
||||
expect(input?.classList.contains("font-mono")).toBe(true);
|
||||
expect(input?.closest("td")).not.toBeNull();
|
||||
expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")?.getAttribute("title")).toContain("structureEditor.editMysqlAutoIncrementValue");
|
||||
});
|
||||
|
||||
it("loads an editable blank counter when auto-increment is checked before saving", async () => {
|
||||
mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null);
|
||||
const root = await mountEditor(false);
|
||||
expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")).toBeNull();
|
||||
|
||||
const autoIncrementLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("structureEditor.autoIncrement"));
|
||||
const checkbox = autoIncrementLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
expect(checkbox).toBeTruthy();
|
||||
checkbox!.checked = true;
|
||||
checkbox!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement).toHaveBeenCalledWith("structure-charset-test", "test", "users"));
|
||||
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
|
||||
await vi.waitFor(() => expect(input?.disabled).toBe(false));
|
||||
expect(input?.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a restored dirty blank-counter draft when the server has no counter", async () => {
|
||||
mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null);
|
||||
const restoredDraft = draft(false, { value: "500", originalValue: "" });
|
||||
restoredDraft.columns[0].extra.autoIncrement = true;
|
||||
const root = await mountEditor(false, undefined, restoredDraft);
|
||||
|
||||
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
|
||||
await vi.waitFor(() => expect(input?.value).toBe("500"));
|
||||
});
|
||||
|
||||
it("accepts unsigned bigint digits and rejects non-decimal input without rewriting it", async () => {
|
||||
const root = await mountEditor(true);
|
||||
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]")!;
|
||||
await vi.waitFor(() => expect(input.value).toBe("10"));
|
||||
expect(input.getAttribute("inputmode")).toBe("numeric");
|
||||
expect(input.getAttribute("pattern")).toBe("[0-9]*");
|
||||
|
||||
const maxUnsignedBigint = "18446744073709551615";
|
||||
input.value = maxUnsignedBigint;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: maxUnsignedBigint })));
|
||||
mocks.buildMysqlAutoIncrementSql.mockClear();
|
||||
|
||||
for (const invalidValue of ["abc", "-1", "+1", "1.5", "1e3"]) {
|
||||
input.value = invalidValue;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(input.value).toBe(maxUnsignedBigint);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes a restored clean counter draft from the server", async () => {
|
||||
|
||||
+74
-8
@@ -631,16 +631,25 @@ describe("TableStructureEditor data type options", () => {
|
||||
});
|
||||
|
||||
describe("TableStructureEditor action column", () => {
|
||||
it("shows the field shortcut hints in the toolbar", async () => {
|
||||
it("moves delayed shortcut hints onto the add, copy, and delete controls", async () => {
|
||||
const root = await mountEditor("dameng");
|
||||
|
||||
expect(root.textContent).toContain("settings.shortcutsTab");
|
||||
expect(root.textContent).toContain("Shift+Enter");
|
||||
expect(root.textContent).toContain("⌘/Ctrl+D");
|
||||
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("flex-col");
|
||||
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("w-56");
|
||||
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("!bg-secondary");
|
||||
expect(root.querySelector("[data-field-shortcut-hints] kbd")?.className).toContain("text-primary");
|
||||
expect(root.textContent).not.toContain("settings.shortcutsTab");
|
||||
expect(root.querySelector("[data-field-shortcut-hints]")).toBeNull();
|
||||
|
||||
const addTooltip = root.querySelector<HTMLElement>("[data-add-column-shortcut-tooltip]");
|
||||
const copyTooltip = root.querySelector<HTMLElement>("[data-copy-column-shortcut-tooltip]");
|
||||
const deleteTooltip = root.querySelector<HTMLElement>("[data-delete-column-shortcut-tooltip]");
|
||||
expect(addTooltip?.getAttribute("delay-duration")).toBe("500");
|
||||
expect(copyTooltip?.getAttribute("delay-duration")).toBe("500");
|
||||
expect(deleteTooltip?.getAttribute("delay-duration")).toBe("500");
|
||||
expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).toBe("Shift+Enter");
|
||||
expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).toBe("⌘/Ctrl+D");
|
||||
expect(root.querySelector("[data-delete-column-shortcut-content]")?.textContent?.trim()).toBe("⌘/Ctrl+Del");
|
||||
expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).not.toContain("structureEditor.addColumn");
|
||||
expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).not.toContain("structureEditor.copyColumn");
|
||||
expect(copyTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false);
|
||||
expect(deleteTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds a field below the focused input on Shift+Enter", async () => {
|
||||
@@ -671,6 +680,63 @@ describe("TableStructureEditor action column", () => {
|
||||
expect(document.activeElement).toBe(copiedInput);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "Ctrl+Delete", event: { key: "Delete", ctrlKey: true } },
|
||||
{ label: "Cmd+Delete", event: { key: "Backspace", metaKey: true } },
|
||||
])("removes a focused unsaved field on $label", async ({ event }) => {
|
||||
const root = await mountEditor("dameng");
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull());
|
||||
const addedInput = root.querySelector<HTMLInputElement>('[data-column-row-index="1"] [data-column-name-input]');
|
||||
if (!addedInput) throw new Error("Missing added column name input");
|
||||
|
||||
addedInput.focus();
|
||||
addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ...event }));
|
||||
|
||||
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).toBeNull());
|
||||
expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks a focused persisted field for deletion on Mod+Delete", async () => {
|
||||
const root = await mountEditor("dameng");
|
||||
const sourceInput = root.querySelector<HTMLInputElement>('[data-column-row-index="0"] [data-column-name-input]');
|
||||
if (!sourceInput) throw new Error("Missing source column name input");
|
||||
|
||||
sourceInput.focus();
|
||||
sourceInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete", ctrlKey: true }));
|
||||
|
||||
await vi.waitFor(() => expect(root.querySelector('button[aria-label="structureEditor.restore"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it("keeps Ctrl+Backspace available for editing field text", async () => {
|
||||
const root = await mountEditor("dameng");
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull());
|
||||
const addedInput = root.querySelector<HTMLInputElement>('[data-column-row-index="1"] [data-column-name-input]');
|
||||
if (!addedInput) throw new Error("Missing added column name input");
|
||||
|
||||
addedInput.focus();
|
||||
addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", ctrlKey: true }));
|
||||
|
||||
await nextTick();
|
||||
expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Cmd+Backspace available for editing non-empty field text", async () => {
|
||||
const root = await mountEditor("dameng");
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull());
|
||||
const addedInput = root.querySelector<HTMLInputElement>('[data-column-row-index="1"] [data-column-name-input]');
|
||||
if (!addedInput) throw new Error("Missing added column name input");
|
||||
|
||||
addedInput.value = "name";
|
||||
addedInput.focus();
|
||||
addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", metaKey: true }));
|
||||
|
||||
await nextTick();
|
||||
expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("widens the ordinal indicator for a two-digit primary-key row", async () => {
|
||||
const root = await mountEditor("dameng");
|
||||
const addColumn = buttonWithText(root, "structureEditor.addColumn");
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, Keyboard, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Pencil, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
|
||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -334,6 +334,7 @@ const structureDensityValues: StructureEditorDensity[] = ["compact", "standard",
|
||||
const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths";
|
||||
const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths";
|
||||
const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed";
|
||||
const FIELD_SHORTCUT_TOOLTIP_DELAY_MS = 500;
|
||||
const STRUCTURE_COLUMN_WIDTH_COUNT = 12;
|
||||
const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 9;
|
||||
const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]);
|
||||
@@ -944,6 +945,24 @@ const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.va
|
||||
const canEditMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrementCounter(connection.value, isCreateMode.value, columns.value));
|
||||
const canBuildMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrement.value && !mysqlAutoIncrementLoading.value && !mysqlAutoIncrementLoadError.value && originalMysqlAutoIncrementValue.value !== undefined);
|
||||
const supportsMysqlEngine = computed(() => supportsMysqlTableEngine(connection.value));
|
||||
const hasPersistedMysqlAutoIncrementColumn = computed(() => columns.value.some((column) => !column.markedForDrop && (column.original?.extra ?? "").toLowerCase().includes("auto_increment")));
|
||||
function isMysqlAutoIncrementCounterColumn(column: EditableStructureColumn): boolean {
|
||||
return canEditMysqlAutoIncrement.value && !column.markedForDrop && column.extra.autoIncrement === true;
|
||||
}
|
||||
function setMysqlAutoIncrement(column: EditableStructureColumn, checked: boolean) {
|
||||
column.extra.autoIncrement = checked;
|
||||
if (checked && originalMysqlAutoIncrementValue.value === undefined && !mysqlAutoIncrementLoading.value) {
|
||||
void loadMysqlAutoIncrementCounter(true);
|
||||
}
|
||||
}
|
||||
function onMysqlAutoIncrementInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (/^\d*$/.test(input.value)) {
|
||||
mysqlAutoIncrementValue.value = input.value;
|
||||
return;
|
||||
}
|
||||
input.value = mysqlAutoIncrementValue.value ?? "";
|
||||
}
|
||||
const tableOwnerOptions = computed(() => {
|
||||
const owner = tableOwner.value;
|
||||
if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value;
|
||||
@@ -1661,7 +1680,8 @@ async function loadMysqlAutoIncrementCounter(preserveDraft = false) {
|
||||
await store.ensureConnected(props.connectionId);
|
||||
const value = await api.getMysqlTableAutoIncrement(props.connectionId, props.database, props.tableName);
|
||||
if (requestId !== mysqlAutoIncrementLoadRequestId) return;
|
||||
const draft = refreshMysqlAutoIncrementCounterDraft(value, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft);
|
||||
const server = value === null && !hasPersistedMysqlAutoIncrementColumn.value ? "" : value;
|
||||
const draft = refreshMysqlAutoIncrementCounterDraft(server, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft);
|
||||
originalMysqlAutoIncrementValue.value = draft.originalValue;
|
||||
mysqlAutoIncrementValue.value = draft.value;
|
||||
} catch (error: any) {
|
||||
@@ -3383,6 +3403,15 @@ function isShiftEnterShortcut(event: KeyboardEvent): boolean {
|
||||
return !event.isComposing && event.key === "Enter" && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||
}
|
||||
|
||||
function isPlainModDeleteShortcut(event: KeyboardEvent): boolean {
|
||||
if (isPlainModShortcut(event, "delete")) return true;
|
||||
if (!event.metaKey || event.ctrlKey || !isPlainModShortcut(event, "backspace")) return false;
|
||||
// ⌘⌫ is macOS "delete to beginning of line" while editing text; only treat it as a
|
||||
// field delete when the focused input is empty so normal text editing keeps working.
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target.value === "" : true;
|
||||
}
|
||||
|
||||
function onStructureEditorKeydown(event: KeyboardEvent) {
|
||||
const focusedColumn = activeTab.value === "columns" ? focusedEditableColumn(event.target) : undefined;
|
||||
if (focusedColumn && isShiftEnterShortcut(event) && canAddColumn.value) {
|
||||
@@ -3397,6 +3426,13 @@ function onStructureEditorKeydown(event: KeyboardEvent) {
|
||||
void copyColumn(focusedColumn);
|
||||
return;
|
||||
}
|
||||
if (focusedColumn && isPlainModDeleteShortcut(event) && (!focusedColumn.original || canDropColumn(focusedColumn))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (focusedColumn.original) toggleDropColumn(focusedColumn);
|
||||
else removeNewColumn(focusedColumn);
|
||||
return;
|
||||
}
|
||||
if (isPlainModShortcut(event, "f")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -3750,26 +3786,6 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="canEditMysqlAutoIncrement" class="flex shrink-0 items-center gap-2">
|
||||
<label class="shrink-0 font-medium text-muted-foreground">AUTO_INCREMENT</label>
|
||||
<Input
|
||||
v-model="mysqlAutoIncrementValue"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
data-mysql-auto-increment-counter
|
||||
:placeholder="mysqlAutoIncrementLoading ? t('common.loading') : '—'"
|
||||
:title="mysqlAutoIncrementLoadError || undefined"
|
||||
:class="[structureMonoControlClass, 'max-w-[220px]']"
|
||||
:disabled="mysqlAutoIncrementLoading || !!mysqlAutoIncrementLoadError || originalMysqlAutoIncrementValue === undefined || saving"
|
||||
/>
|
||||
<Tooltip v-if="mysqlAutoIncrementLoadError">
|
||||
<TooltipTrigger as-child>
|
||||
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-destructive']" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ mysqlAutoIncrementLoadError }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsTableOwner" class="flex shrink-0 items-center gap-2">
|
||||
<label class="flex shrink-0 items-center gap-1 font-medium text-muted-foreground">
|
||||
<UserRound :class="structureIconClass" />
|
||||
@@ -3874,31 +3890,14 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
{{ columnSearchMatchCount }}
|
||||
</button>
|
||||
</div>
|
||||
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
|
||||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addColumn") }}
|
||||
</Button>
|
||||
<Tooltip v-if="activeTab === 'columns'" :delay-duration="0">
|
||||
<Tooltip v-if="activeTab === 'columns'" :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-add-column-shortcut-tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button type="button" size="sm" variant="ghost" :class="[structureToolbarButtonClass, 'text-muted-foreground']" :aria-label="t('settings.shortcutsTab')">
|
||||
<Keyboard :class="structureIconClass" />
|
||||
{{ t("settings.shortcutsTab") }}
|
||||
<Button size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
|
||||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addColumn") }}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
data-field-shortcut-hints
|
||||
side="bottom"
|
||||
class="flex w-56 max-w-none flex-col items-stretch gap-1.5 border border-primary/30 !bg-secondary !text-secondary-foreground shadow-lg data-open:!animate-none data-[state=delayed-open]:!animate-none data-closed:!animate-none [&>svg]:!bg-secondary [&>svg]:!fill-secondary"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="whitespace-nowrap"><span class="text-primary">↓</span> {{ t("structureEditor.addColumn") }}</span>
|
||||
<kbd class="min-w-5 shrink-0 whitespace-nowrap rounded border border-primary/30 bg-background px-1.5 py-0.5 text-center font-mono text-[11px] leading-none text-primary shadow-xs">Shift+Enter</kbd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="whitespace-nowrap"><span class="text-primary">↓</span> {{ t("structureEditor.copyColumn") }}</span>
|
||||
<kbd class="min-w-5 shrink-0 whitespace-nowrap rounded border border-primary/30 bg-background px-1.5 py-0.5 text-center font-mono text-[11px] leading-none text-primary shadow-xs">⌘/Ctrl+D</kbd>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
<TooltipContent side="bottom" class="font-mono font-medium" data-add-column-shortcut-content>Shift+Enter</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button v-if="activeTab === 'columns'" size="sm" variant="outline" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="openCopyColumnsDialog">
|
||||
<Copy :class="structureIconClass" />
|
||||
@@ -4005,25 +4004,28 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
>
|
||||
<ListChevronsUpDown :class="structureIconClass" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :title="t('structureEditor.copyColumn')" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
|
||||
<Copy :class="structureIconClass" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="column.original"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="structureActionButtonClass"
|
||||
:disabled="!canDropColumn(column)"
|
||||
:title="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
|
||||
:aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
|
||||
@click.stop="toggleDropColumn(column)"
|
||||
>
|
||||
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
|
||||
<Trash2 v-else :class="structureIconClass" />
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
|
||||
<X :class="structureIconClass" />
|
||||
</Button>
|
||||
<Tooltip :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-copy-column-shortcut-tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
|
||||
<Copy :class="structureIconClass" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="font-mono font-medium" data-copy-column-shortcut-content>⌘/Ctrl+D</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-delete-column-shortcut-tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button v-if="column.original" variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canDropColumn(column)" :aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')" @click.stop="toggleDropColumn(column)">
|
||||
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
|
||||
<Trash2 v-else :class="structureIconClass" />
|
||||
</Button>
|
||||
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
|
||||
<X :class="structureIconClass" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="font-mono font-medium" data-delete-column-shortcut-content>
|
||||
{{ column.markedForDrop ? t("structureEditor.restore") : "⌘/Ctrl+Del" }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -4208,9 +4210,43 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
|
||||
<template v-else-if="structureDialect === 'mysql'">
|
||||
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
|
||||
<input v-model="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" />
|
||||
<input :checked="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" @change="setMysqlAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
|
||||
<span>{{ t("structureEditor.autoIncrement") }}</span>
|
||||
</label>
|
||||
<Popover v-if="isMysqlAutoIncrementCounterColumn(column)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="[structureIconButtonClass, 'mr-1 shrink-0']"
|
||||
:title="t('structureEditor.editMysqlAutoIncrementValue', { value: mysqlAutoIncrementValue || '—' })"
|
||||
:aria-label="t('structureEditor.editMysqlAutoIncrementValue', { value: mysqlAutoIncrementValue || '—' })"
|
||||
data-mysql-auto-increment-editor-trigger
|
||||
>
|
||||
<Loader2 v-if="mysqlAutoIncrementLoading" :class="[structureIconClass, 'animate-spin text-muted-foreground']" />
|
||||
<AlertTriangle v-else-if="mysqlAutoIncrementLoadError" :class="[structureIconClass, 'text-destructive']" />
|
||||
<Pencil v-else :class="structureIconClass" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-80 space-y-2 p-3">
|
||||
<label class="block text-xs font-medium text-foreground">{{ t("structureEditor.mysqlAutoIncrementNextValue") }}</label>
|
||||
<Input
|
||||
:model-value="mysqlAutoIncrementValue"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="off"
|
||||
data-mysql-auto-increment-counter
|
||||
:aria-label="t('structureEditor.mysqlAutoIncrementNextValue')"
|
||||
:placeholder="mysqlAutoIncrementLoading ? t('common.loading') : '—'"
|
||||
:title="mysqlAutoIncrementLoadError || undefined"
|
||||
class="w-full font-mono"
|
||||
:disabled="mysqlAutoIncrementLoading || !!mysqlAutoIncrementLoadError || originalMysqlAutoIncrementValue === undefined || saving"
|
||||
@input.capture="onMysqlAutoIncrementInput"
|
||||
/>
|
||||
<p v-if="mysqlAutoIncrementLoadError" class="text-xs text-destructive">{{ mysqlAutoIncrementLoadError }}</p>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.mysqlAutoIncrementNonemptyHint") }}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<label :class="[structurePropertyLabelClass, 'flex-1 basis-0']" :title="t('structureEditor.onUpdateCurrentTimestamp')">
|
||||
<input v-model="column.extra.onUpdateCurrentTimestamp" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" />
|
||||
<span class="min-w-0 truncate">{{ t("structureEditor.onUpdateCurrentTimestamp") }}</span>
|
||||
|
||||
@@ -3988,6 +3988,8 @@ export default {
|
||||
sqliteRebuildNotice: "SQLite retains an inert data snapshot as a backup, then rebuilds the table in one transaction and forcibly CASTs changed columns. Unrepresentable values may become 0/0.0; failures roll back.",
|
||||
extendedProperties: "Extended",
|
||||
autoIncrement: "Auto Increment",
|
||||
mysqlAutoIncrementNextValue: "Next auto-increment value",
|
||||
editMysqlAutoIncrementValue: "Edit next auto-increment value (current: {value})",
|
||||
onUpdateCurrentTimestamp: "Auto Update Time",
|
||||
identity: "Identity",
|
||||
identityGeneration: "Generation",
|
||||
|
||||
@@ -3803,6 +3803,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "SQLite conserva una instantánea de datos inerte como copia de seguridad, reconstruye la tabla en una sola transacción y fuerza CAST en las columnas modificadas. Los valores no representables pueden convertirse en 0/0.0; si ocurre un error, se revierte todo.",
|
||||
extendedProperties: "Extendido",
|
||||
autoIncrement: "Auto Incremento",
|
||||
mysqlAutoIncrementNextValue: "Siguiente valor de autoincremento",
|
||||
editMysqlAutoIncrementValue: "Editar el siguiente valor de autoincremento (actual: {value})",
|
||||
onUpdateCurrentTimestamp: "Actualización Auto",
|
||||
identity: "Identidad",
|
||||
identityGeneration: "Generación",
|
||||
|
||||
@@ -3801,6 +3801,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "SQLite conserva uno snapshot di dati inerte come backup, ricostruisce la tabella in un'unica transazione e forza CAST sulle colonne modificate. I valori non rappresentabili possono diventare 0/0.0; in caso di errore, tutte le modifiche vengono annullate.",
|
||||
extendedProperties: "Esteso",
|
||||
autoIncrement: "Auto Incremento",
|
||||
mysqlAutoIncrementNextValue: "Valore di auto incremento successivo",
|
||||
editMysqlAutoIncrementValue: "Modifica il valore di auto incremento successivo (attuale: {value})",
|
||||
onUpdateCurrentTimestamp: "Aggiorna Ora Automaticamente",
|
||||
identity: "Identità",
|
||||
identityGeneration: "Generazione",
|
||||
|
||||
@@ -3863,6 +3863,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "SQLite は制約が動作しないデータスナップショットをバックアップとして保持し、1 つのトランザクション内でテーブルを再構築して、変更された列を CAST で強制変換します。表現できない値は 0/0.0 になる場合があり、失敗時はすべてロールバックされます。",
|
||||
extendedProperties: "拡張",
|
||||
autoIncrement: "自動採番",
|
||||
mysqlAutoIncrementNextValue: "次の自動採番値",
|
||||
editMysqlAutoIncrementValue: "次の自動採番値を編集(現在:{value})",
|
||||
onUpdateCurrentTimestamp: "自動更新時刻",
|
||||
identity: "ID列",
|
||||
identityGeneration: "生成",
|
||||
|
||||
@@ -3752,6 +3752,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "SQLite는 백업으로 비활성 데이터 스냅샷을 유지한 다음 하나의 트랜잭션에서 테이블을 재구성하고 변경된 컬럼을 강제로 CAST합니다. 표현할 수 없는 값은 0/0.0이 될 수 있으며, 실패 시 롤백됩니다.",
|
||||
extendedProperties: "확장",
|
||||
autoIncrement: "자동 증가",
|
||||
mysqlAutoIncrementNextValue: "다음 자동 증가 값",
|
||||
editMysqlAutoIncrementValue: "다음 자동 증가 값 편집(현재: {value})",
|
||||
onUpdateCurrentTimestamp: "수정 시간 자동 갱신",
|
||||
identity: "ID",
|
||||
identityGeneration: "생성",
|
||||
|
||||
@@ -3803,6 +3803,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "O SQLite mantém um snapshot de dados inerte como backup, reconstrói a tabela em uma única transação e força CAST nas colunas alteradas. Valores não representáveis podem se tornar 0/0.0; em caso de falha, todas as alterações são revertidas.",
|
||||
extendedProperties: "Estendidas",
|
||||
autoIncrement: "Auto incremento",
|
||||
mysqlAutoIncrementNextValue: "Próximo valor de incremento automático",
|
||||
editMysqlAutoIncrementValue: "Editar o próximo valor de incremento automático (atual: {value})",
|
||||
onUpdateCurrentTimestamp: "Atualizar hora automaticamente",
|
||||
identity: "Identidade",
|
||||
identityGeneration: "Geração",
|
||||
|
||||
@@ -3973,6 +3973,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "SQLite 会保留一张不带活动约束的数据快照作为备份表,再在同一事务内重建原表,使用 CAST 强制转换类型已修改的字段;无法表示的值可能变为 0/0.0,执行失败则整体回滚。",
|
||||
extendedProperties: "扩展属性",
|
||||
autoIncrement: "自增",
|
||||
mysqlAutoIncrementNextValue: "下一个自增值",
|
||||
editMysqlAutoIncrementValue: "编辑下一个自增值(当前:{value})",
|
||||
onUpdateCurrentTimestamp: "自动更新时间",
|
||||
identity: "标识列",
|
||||
identityGeneration: "生成方式",
|
||||
|
||||
@@ -3543,6 +3543,8 @@ export default withEnglishFallback({
|
||||
sqliteRebuildNotice: "SQLite 會保留一張不含作用中約束的資料快照作為備份表,再於同一交易內重建原表,並使用 CAST 強制轉換已修改型別的欄位;無法表示的值可能變成 0/0.0,執行失敗則整體回復。",
|
||||
extendedProperties: "擴充屬性",
|
||||
autoIncrement: "自動遞增",
|
||||
mysqlAutoIncrementNextValue: "下一個自動遞增值",
|
||||
editMysqlAutoIncrementValue: "編輯下一個自動遞增值(目前:{value})",
|
||||
onUpdateCurrentTimestamp: "自動更新時間",
|
||||
identity: "識別欄位",
|
||||
identityGeneration: "產生方式",
|
||||
|
||||
@@ -37,9 +37,10 @@ describe("MySQL table AUTO_INCREMENT counter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("is editable only for existing native-MySQL tables with an active existing auto-increment column", () => {
|
||||
it("is editable only for existing native-MySQL tables with an active auto-increment column draft", () => {
|
||||
const nativeMysql = { db_type: "mysql", driver_profile: "mysql" } as any;
|
||||
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [existingColumn(true)])).toBe(true);
|
||||
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [{ ...existingColumn(true), original: undefined }])).toBe(true);
|
||||
expect(canEditMysqlAutoIncrementCounter(nativeMysql, true, [existingColumn(true)])).toBe(false);
|
||||
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [existingColumn(false)])).toBe(false);
|
||||
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [{ ...existingColumn(true), markedForDrop: true }])).toBe(false);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getMysqlTableAutoIncrement } from "@/lib/backend/http";
|
||||
|
||||
describe("MySQL AUTO_INCREMENT web API", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads the table counter from the schema endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue("42"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getMysqlTableAutoIncrement("connection 1", "sales/db", "order items")).resolves.toBe("42");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/schema/mysql/auto-increment?connection_id=connection+1&database=sales%2Fdb&table=order+items");
|
||||
});
|
||||
});
|
||||
@@ -847,8 +847,8 @@ export async function getTableComment(_connectionId: string, _database: string,
|
||||
throw new Error("Table comment lookup is not available in the web backend");
|
||||
}
|
||||
|
||||
export async function getMysqlTableAutoIncrement(_connectionId: string, _database: string, _table: string): Promise<string | null> {
|
||||
throw new Error("MySQL AUTO_INCREMENT metadata is not available in the web backend");
|
||||
export async function getMysqlTableAutoIncrement(connectionId: string, database: string, table: string): Promise<string | null> {
|
||||
return get(`/api/schema/mysql/auto-increment?${qs({ connection_id: connectionId, database, table })}`);
|
||||
}
|
||||
|
||||
export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string, tableNameFilter?: TableNameFilter): Promise<ObjectInfo[]> {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function refreshMysqlAutoIncrementCounterDraft(serverValue: string | null
|
||||
|
||||
export function canEditMysqlAutoIncrementCounter(connection: Pick<ConnectionConfig, "db_type" | "driver_profile"> | undefined, isCreateMode: boolean, columns: readonly EditableStructureColumn[]): boolean {
|
||||
if (isCreateMode || !supportsNativeMysqlAutoIncrement(connection)) return false;
|
||||
return columns.some((column) => !!column.original && !column.markedForDrop && column.extra.autoIncrement === true);
|
||||
return columns.some((column) => !column.markedForDrop && column.extra.autoIncrement === true);
|
||||
}
|
||||
|
||||
export interface BuildMysqlAutoIncrementCounterStatementOptions extends Omit<MysqlAutoIncrementSqlOptions, "databaseType" | "value"> {
|
||||
|
||||
Reference in new issue
Block a user