feat(structure): insert column after selected
This commit is contained in:
1 parent
b6581d3dc9
commit
3b54fa56df
4 files changed
+108
-19
No files matched your search
@@ -66,6 +66,7 @@ import {
|
||||
mysqlEnumDataType,
|
||||
parseExtraToColumnExtra,
|
||||
rehydrateColumnDraftsFromMetadata,
|
||||
resolveInsertColumnIndex,
|
||||
restoreDamengLengthUnitsAfterSave,
|
||||
splitDataType,
|
||||
toColumnNames,
|
||||
@@ -548,6 +549,7 @@ const indexColWidths = ref(loadStructureIndexColumnWidths(structureDensity.value
|
||||
const resizing = ref<{ col: number; startX: number; startW: number } | null>(null);
|
||||
const columnSearchInputRef = ref<InstanceType<typeof Input>>();
|
||||
const columnSearchText = ref("");
|
||||
const selectedColumnId = ref<string | null>(null);
|
||||
const highlightedColumnId = ref<string | null>(null);
|
||||
const indexSearchInputRef = ref<InstanceType<typeof Input>>();
|
||||
const indexSearchText = ref("");
|
||||
@@ -1191,6 +1193,7 @@ function resetState() {
|
||||
sqliteSchemaRevision.value = undefined;
|
||||
foreignKeys.value = [];
|
||||
triggers.value = [];
|
||||
selectedColumnId.value = null;
|
||||
ddlContent.value = "";
|
||||
ddlFetched.value = false;
|
||||
newTableName.value = "";
|
||||
@@ -1270,6 +1273,7 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL
|
||||
const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value);
|
||||
const hydratedColumnDrafts = databaseType.value === "dameng" && options.damengLengthUnitsAfterSave ? restoreDamengLengthUnitsAfterSave(nextColumnDrafts, options.damengLengthUnitsAfterSave) : nextColumnDrafts;
|
||||
columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts);
|
||||
if (!options.preserveDraft) selectedColumnId.value = null;
|
||||
}
|
||||
|
||||
const nextTableComment = await tableCommentPromise;
|
||||
@@ -1325,6 +1329,20 @@ async function refreshStructureAfterSave(scope: StructureRefreshScope, damengLen
|
||||
}
|
||||
}
|
||||
|
||||
async function focusColumnNameInput(columnId: string) {
|
||||
await nextTick();
|
||||
const row = Array.from(rootRef.value?.querySelectorAll<HTMLElement>("[data-column-row-index]") ?? []).find((element) => element.dataset.columnId === columnId);
|
||||
const input = row?.querySelector<HTMLInputElement>("[data-column-name-input]");
|
||||
row?.scrollIntoView({ block: "nearest" });
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}
|
||||
|
||||
function onColumnRowActivate(column: EditableStructureColumn) {
|
||||
if (column.markedForDrop || !columns.value.some((item) => item.id === column.id)) return;
|
||||
selectedColumnId.value = column.id;
|
||||
}
|
||||
|
||||
async function addColumn() {
|
||||
if (!canAddColumn.value) return;
|
||||
activeTab.value = "columns";
|
||||
@@ -1343,14 +1361,11 @@ async function addColumn() {
|
||||
extra: {},
|
||||
markedForDrop: false,
|
||||
};
|
||||
columns.value.push(column);
|
||||
await nextTick();
|
||||
const newRows = rootRef.value?.querySelectorAll<HTMLElement>('[data-new-column-row="true"]');
|
||||
const row = newRows?.[newRows.length - 1];
|
||||
const input = row?.querySelector<HTMLInputElement>("[data-column-name-input]");
|
||||
row?.scrollIntoView({ block: "nearest" });
|
||||
input?.focus();
|
||||
input?.select();
|
||||
const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value);
|
||||
columns.value.splice(insertAt, 0, column);
|
||||
selectedColumnId.value = column.id;
|
||||
if (usesLocalTableColumnOrder.value) persistLocalColumnOrder();
|
||||
await focusColumnNameInput(column.id);
|
||||
}
|
||||
|
||||
function applyColumnTemplate(templateId: string) {
|
||||
@@ -1364,11 +1379,15 @@ function applyColumnTemplate(templateId: string) {
|
||||
createId: uuid,
|
||||
});
|
||||
if (!templateColumns.length) return;
|
||||
columns.value.push(...templateColumns);
|
||||
const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value);
|
||||
columns.value.splice(insertAt, 0, ...templateColumns);
|
||||
selectedColumnId.value = templateColumns[templateColumns.length - 1]?.id ?? selectedColumnId.value;
|
||||
if (usesLocalTableColumnOrder.value) persistLocalColumnOrder();
|
||||
}
|
||||
|
||||
function removeNewColumn(column: EditableStructureColumn) {
|
||||
columns.value = columns.value.filter((item) => item.id !== column.id);
|
||||
if (selectedColumnId.value === column.id) selectedColumnId.value = null;
|
||||
}
|
||||
|
||||
type ColumnDragState = {
|
||||
@@ -1741,10 +1760,12 @@ function onColumnDragEnd() {
|
||||
function columnRowClass(column: EditableStructureColumn, index: number) {
|
||||
const dragState = columnDragState.value;
|
||||
const isSearchMatch = filteredColumnRowIds.value.has(column.id);
|
||||
const isSelected = selectedColumnId.value === column.id && !column.markedForDrop;
|
||||
return {
|
||||
"bg-destructive/5 opacity-60": column.markedForDrop,
|
||||
"structure-column-search-match": isSearchMatch,
|
||||
"structure-column-search-current": highlightedColumnId.value === column.id,
|
||||
// Reuse the existing search-current highlight for the active/selected row.
|
||||
"structure-column-search-current": highlightedColumnId.value === column.id || isSelected,
|
||||
"opacity-55": dragState?.columnId === column.id,
|
||||
"bg-primary/5": dragState && (dragState.insertionIndex === index || dragState.insertionIndex === index + 1),
|
||||
"[&>td]:border-t-2 [&>td]:border-t-primary": dragState?.insertionIndex === index,
|
||||
@@ -1894,6 +1915,9 @@ function columnDragInsertionIndex(index: number, event: DragEvent): number {
|
||||
function toggleDropColumn(column: EditableStructureColumn) {
|
||||
if (!canDropColumn(column)) return;
|
||||
column.markedForDrop = !column.markedForDrop;
|
||||
if (column.markedForDrop && selectedColumnId.value === column.id) {
|
||||
selectedColumnId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isColumnNameDisabled(column: EditableStructureColumn): boolean {
|
||||
@@ -2431,12 +2455,23 @@ watch(
|
||||
);
|
||||
|
||||
watch(activeTab, () => {
|
||||
selectedColumnId.value = null;
|
||||
highlightedColumnId.value = null;
|
||||
highlightedIndexId.value = null;
|
||||
restoreStructureScrollPosition();
|
||||
syncDraftToParent();
|
||||
});
|
||||
|
||||
watch(
|
||||
columns,
|
||||
(items) => {
|
||||
if (selectedColumnId.value && !items.some((column) => column.id === selectedColumnId.value)) {
|
||||
selectedColumnId.value = null;
|
||||
}
|
||||
},
|
||||
{ deep: false },
|
||||
);
|
||||
|
||||
watch(secondaryMetadataLoading, (value) => {
|
||||
if (value || !deferredSqlPreviewRefresh) return;
|
||||
scheduleSqlPreviewRefresh();
|
||||
@@ -2636,7 +2671,18 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(column, index) in columns" :key="column.id" :class="columnRowClass(column, index)" :data-new-column-row="!column.original ? 'true' : undefined" :data-column-row-index="index" @dragover="onColumnDragOver(index, $event)" @drop="onColumnDrop(index, $event)">
|
||||
<tr
|
||||
v-for="(column, index) in columns"
|
||||
:key="column.id"
|
||||
:class="columnRowClass(column, index)"
|
||||
:data-new-column-row="!column.original ? 'true' : undefined"
|
||||
:data-column-row-index="index"
|
||||
:data-column-id="column.id"
|
||||
@click="onColumnRowActivate(column)"
|
||||
@focusin="onColumnRowActivate(column)"
|
||||
@dragover="onColumnDragOver(index, $event)"
|
||||
@drop="onColumnDrop(index, $event)"
|
||||
>
|
||||
<td :class="[structureCellClass, 'text-muted-foreground']">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ index + 1 }}</span>
|
||||
@@ -2965,12 +3011,12 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
:disabled="!canDropColumn(column)"
|
||||
:title="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
|
||||
:aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
|
||||
@click="toggleDropColumn(column)"
|
||||
@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="removeNewColumn(column)">
|
||||
<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>
|
||||
</div>
|
||||
@@ -3263,20 +3309,27 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --primary is rgb/oklch; use color-mix like DataGrid, not hsl(var(--primary)). */
|
||||
.structure-column-search-match > td:first-child {
|
||||
box-shadow: inset 3px 0 0 hsl(var(--primary) / 0.55);
|
||||
box-shadow: inset 3px 0 0 color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.structure-column-search-current > td {
|
||||
background-color: color-mix(in oklab, var(--primary) 8%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--primary) / 0.55),
|
||||
inset 0 -1px 0 hsl(var(--primary) / 0.55);
|
||||
inset 0 1px 0 color-mix(in oklab, var(--primary) 55%, transparent),
|
||||
inset 0 -1px 0 color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.structure-column-search-current > td:first-child {
|
||||
box-shadow:
|
||||
inset 3px 0 0 hsl(var(--primary)),
|
||||
inset 0 1px 0 hsl(var(--primary) / 0.55),
|
||||
inset 0 -1px 0 hsl(var(--primary) / 0.55);
|
||||
inset 3px 0 0 var(--primary),
|
||||
inset 0 1px 0 color-mix(in oklab, var(--primary) 55%, transparent),
|
||||
inset 0 -1px 0 color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Inputs are bg-transparent; give them a solid surface on the selected row so fields stay readable. */
|
||||
.structure-column-search-current :is(input, button, [role="combobox"], [data-slot="select-trigger"]) {
|
||||
background-color: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mysqlEnumDataType,
|
||||
parseExtraToColumnExtra,
|
||||
rehydrateColumnDraftsFromMetadata,
|
||||
resolveInsertColumnIndex,
|
||||
restoreDamengLengthUnitsAfterSave,
|
||||
splitDataType,
|
||||
} from "@/lib/table/tableStructureEditorState";
|
||||
@@ -248,6 +249,19 @@ describe("tableStructureEditorState", () => {
|
||||
expect(hasExistingColumnTypeChange([column])).toBe(false);
|
||||
});
|
||||
|
||||
it("inserts new columns after the selected row or appends when none is selected", () => {
|
||||
const columns = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
|
||||
expect(resolveInsertColumnIndex(columns, null)).toBe(3);
|
||||
expect(resolveInsertColumnIndex(columns, undefined)).toBe(3);
|
||||
expect(resolveInsertColumnIndex(columns, "a")).toBe(1);
|
||||
expect(resolveInsertColumnIndex(columns, "b")).toBe(2);
|
||||
expect(resolveInsertColumnIndex(columns, "c")).toBe(3);
|
||||
expect(resolveInsertColumnIndex(columns, "missing")).toBe(3);
|
||||
expect(resolveInsertColumnIndex([], "a")).toBe(0);
|
||||
expect(resolveInsertColumnIndex([{ id: "a", markedForDrop: true }, { id: "b" }], "a")).toBe(2);
|
||||
});
|
||||
|
||||
it("strips SQL Server metadata parentheses from editable defaults", () => {
|
||||
const drafts = createColumnDrafts(
|
||||
[
|
||||
|
||||
@@ -1066,6 +1066,14 @@ export function defaultNewColumnDataType(dbType: DatabaseType | undefined, dataT
|
||||
return dbType === "sqlite" ? "text" : "varchar(255)";
|
||||
}
|
||||
|
||||
/** Index at which to insert a new column (after the selected row, or append when none). */
|
||||
export function resolveInsertColumnIndex(columns: readonly { id: string; markedForDrop?: boolean }[], selectedColumnId: string | null | undefined): number {
|
||||
if (!selectedColumnId) return columns.length;
|
||||
// Dropped rows are not valid insertion anchors.
|
||||
const index = columns.findIndex((column) => column.id === selectedColumnId && !column.markedForDrop);
|
||||
return index >= 0 ? index + 1 : columns.length;
|
||||
}
|
||||
|
||||
function isMysqlDeprecatedDefaultParameterType(baseType: string): boolean {
|
||||
const typeName = baseType.split(/\s+/)[0];
|
||||
return ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "float", "double", "real"].includes(typeName ?? "");
|
||||
|
||||
Reference in new issue
Block a user