feat(structure): open editor from field and index nodes
This commit is contained in:
1 parent
564c5beeff
commit
92ef1ac3cf
13 files changed
+258
-25
No files matched your search
@@ -1412,6 +1412,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
||||
:table-name="activeTab.structureTableName || ''"
|
||||
:initial-tab="activeTab.structureInitialTab"
|
||||
:initial-tab-request-id="activeTab.structureInitialTabRequestId"
|
||||
:initial-target="activeTab.structureInitialTarget"
|
||||
:draft="activeTab.structureDraft"
|
||||
@update:draft="(draft) => (activeTab.structureDraft = draft)"
|
||||
@saved="(commentChanged) => emit('structureEditorSaved', commentChanged)"
|
||||
|
||||
@@ -3790,8 +3790,22 @@ function openTableImport() {
|
||||
|
||||
function openStructureEditor() {
|
||||
const node = props.node;
|
||||
if (node.type !== "table" || !node.connectionId || !node.database) return;
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.label);
|
||||
if (!node.connectionId || !node.database) return;
|
||||
if (node.type === "table") {
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.label);
|
||||
return;
|
||||
}
|
||||
if (node.type === "column" && node.tableName) {
|
||||
const columnName = tableChildDropObjectName(node).trim();
|
||||
if (!columnName) return;
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.tableName, "columns", { kind: "column", name: columnName });
|
||||
return;
|
||||
}
|
||||
if (node.type === "index" && node.tableName) {
|
||||
const indexName = tableChildDropObjectName(node).trim();
|
||||
if (!indexName) return;
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, node.tableName, "indexes", { kind: "index", name: indexName });
|
||||
}
|
||||
}
|
||||
|
||||
function openFieldLineage() {
|
||||
@@ -3835,7 +3849,8 @@ const canOpenTableImport = computed(() => {
|
||||
return props.node.type === "table" && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableImport(currentDatabaseType());
|
||||
});
|
||||
const canOpenStructureEditor = computed(() => {
|
||||
return props.node.type === "table" && !isSqlServerLinkedNode(props.node) && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType());
|
||||
const editableNode = props.node.type === "table" || ((props.node.type === "column" || props.node.type === "index") && !!props.node.tableName);
|
||||
return editableNode && !isSqlServerLinkedNode(props.node) && !!props.node.connectionId && !!props.node.database && supportsTableStructureEditing(currentTableStructureDatabaseType());
|
||||
});
|
||||
const canOpenFieldLineage = computed(() => {
|
||||
return props.node.type === "column" && !!props.node.database && !!props.node.tableName && supportsFieldLineage(currentDatabaseType());
|
||||
@@ -4756,9 +4771,16 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
// 7. Column
|
||||
if (node.type === "column") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
const columnActions: ContextMenuItem[] = [];
|
||||
if (canOpenStructureEditor.value) {
|
||||
columnActions.push({ label: t("contextMenu.editColumn"), action: openStructureEditor, icon: PencilRuler });
|
||||
}
|
||||
if (canOpenFieldLineage.value) {
|
||||
columnActions.push({ label: t("lineage.open"), action: openFieldLineage, icon: Network });
|
||||
}
|
||||
if (columnActions.length > 0) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("lineage.open"), action: openFieldLineage, icon: Network });
|
||||
items.push(...columnActions);
|
||||
}
|
||||
if (canDropTableChildObject.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
@@ -4775,6 +4797,10 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
|
||||
if (node.type === "index" || node.type === "fkey" || node.type === "trigger") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
if (node.type === "index" && canOpenStructureEditor.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.editIndex"), action: openStructureEditor, icon: PencilRuler });
|
||||
}
|
||||
if (node.type === "index" && canDropMongoIndex.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
|
||||
@@ -28,7 +28,7 @@ import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/li
|
||||
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||||
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
|
||||
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import type { TableInfoTab, TableStructureEditorDraft } from "@/types/database";
|
||||
import type { TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget } from "@/types/database";
|
||||
import {
|
||||
buildStructureTargetLabel,
|
||||
combineDataTypeForDatabase,
|
||||
@@ -85,6 +85,7 @@ const props = defineProps<{
|
||||
tableName: string;
|
||||
initialTab?: TableInfoTab;
|
||||
initialTabRequestId?: number;
|
||||
initialTarget?: TableStructureEditorTarget;
|
||||
draft?: TableStructureEditorDraft;
|
||||
}>();
|
||||
|
||||
@@ -494,8 +495,12 @@ const resizing = ref<{ col: number; startX: number; startW: number } | null>(nul
|
||||
const columnSearchInputRef = ref<InstanceType<typeof Input>>();
|
||||
const columnSearchText = ref("");
|
||||
const highlightedColumnId = ref<string | null>(null);
|
||||
const indexSearchInputRef = ref<InstanceType<typeof Input>>();
|
||||
const indexSearchText = ref("");
|
||||
const highlightedIndexId = ref<string | null>(null);
|
||||
const sqlPreviewCollapsed = ref(loadSqlPreviewCollapsed());
|
||||
let columnHighlightTimer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
let indexHighlightTimer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
|
||||
watch(
|
||||
structureDensity,
|
||||
@@ -689,6 +694,12 @@ const filteredColumnRowIds = computed(() => {
|
||||
);
|
||||
});
|
||||
const columnSearchMatchCount = computed(() => (columnSearchText.value.trim() ? filteredColumnRowIds.value.size : 0));
|
||||
const filteredIndexRowIds = computed(() => {
|
||||
const query = indexSearchText.value.trim().toLowerCase();
|
||||
if (!query) return new Set<string>();
|
||||
return new Set(indexes.value.filter((index) => indexMatchesSearch(index, query)).map((index) => index.id));
|
||||
});
|
||||
const indexSearchMatchCount = computed(() => (indexSearchText.value.trim() ? filteredIndexRowIds.value.size : 0));
|
||||
const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"];
|
||||
const triggerTimingOptions = ["BEFORE", "AFTER"];
|
||||
const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
|
||||
@@ -723,6 +734,10 @@ let restoringDraft = false;
|
||||
let syncingDraft = false;
|
||||
let draftHydrated = false;
|
||||
let hydratingRestoredDraft = false;
|
||||
// A context-menu target may arrive before metadata rows render, so search text
|
||||
// and row scrolling are tracked separately for each request.
|
||||
let appliedInitialTargetSearchKey = "";
|
||||
let appliedInitialTargetScrollKey = "";
|
||||
|
||||
function cloneDraftValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
@@ -977,6 +992,12 @@ function resetState() {
|
||||
newTableName.value = "";
|
||||
tableComment.value = "";
|
||||
originalTableComment.value = "";
|
||||
columnSearchText.value = "";
|
||||
highlightedColumnId.value = null;
|
||||
indexSearchText.value = "";
|
||||
highlightedIndexId.value = null;
|
||||
appliedInitialTargetSearchKey = "";
|
||||
appliedInitialTargetScrollKey = "";
|
||||
}
|
||||
|
||||
async function reloadStructureFromDatabase() {
|
||||
@@ -1441,6 +1462,76 @@ function onColumnSearchKeydown(event: KeyboardEvent) {
|
||||
scrollToColumnSearchMatch(event.shiftKey ? -1 : 1);
|
||||
}
|
||||
|
||||
function indexMatchesSearch(index: EditableStructureIndex, searchQuery = indexSearchText.value.trim().toLowerCase()): boolean {
|
||||
if (!searchQuery) return false;
|
||||
return [index.name, toColumnNames(index.columns), index.includedColumns.join(", "), index.indexType, index.filter, index.comment].some((value) =>
|
||||
String(value ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchQuery),
|
||||
);
|
||||
}
|
||||
|
||||
function indexFieldMatchesSearch(value: string | null | undefined): boolean {
|
||||
const query = indexSearchText.value.trim().toLowerCase();
|
||||
return (
|
||||
!!query &&
|
||||
String(value ?? "")
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
function indexRowClass(index: EditableStructureIndex) {
|
||||
const isSearchMatch = filteredIndexRowIds.value.has(index.id);
|
||||
return {
|
||||
"bg-destructive/5 opacity-60": index.markedForDrop,
|
||||
"structure-column-search-match": isSearchMatch,
|
||||
"structure-column-search-current": highlightedIndexId.value === index.id,
|
||||
};
|
||||
}
|
||||
|
||||
function indexSearchFieldClass(index: EditableStructureIndex, value: string | null | undefined) {
|
||||
const matches = indexFieldMatchesSearch(value);
|
||||
return {
|
||||
"!border-primary/60 !bg-primary/10": matches,
|
||||
"!border-primary !ring-2 !ring-primary/30": matches && highlightedIndexId.value === index.id,
|
||||
};
|
||||
}
|
||||
|
||||
function focusIndexSearch() {
|
||||
activeTab.value = "indexes";
|
||||
void nextTick(() => {
|
||||
const input = indexSearchInputRef.value?.$el as HTMLInputElement | undefined;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToIndexSearchMatch(direction: 1 | -1 = 1) {
|
||||
const query = indexSearchText.value.trim();
|
||||
if (!query) {
|
||||
focusIndexSearch();
|
||||
return;
|
||||
}
|
||||
const rows = Array.from(rootRef.value?.querySelectorAll<HTMLElement>("[data-index-row-index]") ?? []);
|
||||
const matches = indexes.value.map((index, rowIndex) => ({ index, rowIndex })).filter(({ index }) => indexMatchesSearch(index));
|
||||
if (!matches.length) return;
|
||||
const currentIndex = highlightedIndexId.value ? matches.findIndex(({ index }) => index.id === highlightedIndexId.value) : -1;
|
||||
const nextMatch = matches[(currentIndex + direction + matches.length) % matches.length] ?? matches[0];
|
||||
highlightedIndexId.value = nextMatch.index.id;
|
||||
rows[nextMatch.rowIndex]?.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer);
|
||||
indexHighlightTimer = window.setTimeout(() => {
|
||||
highlightedIndexId.value = null;
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function onIndexSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
scrollToIndexSearchMatch(event.shiftKey ? -1 : 1);
|
||||
}
|
||||
|
||||
function columnDragInsertionIndex(index: number, event: DragEvent): number {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLElement)) return index;
|
||||
@@ -1816,16 +1907,18 @@ function unregisterStructureEditorShortcuts() {
|
||||
onMounted(() => {
|
||||
resetState();
|
||||
applyInitialStructureTab();
|
||||
applyInitialStructureTarget();
|
||||
registerStructureEditorShortcuts();
|
||||
void loadDynamicDataTypeOptions();
|
||||
if (props.draft?.initialized) {
|
||||
restoreDraft(props.draft);
|
||||
applyInitialStructureTab();
|
||||
void hydrateRestoredDraftFromDatabase();
|
||||
applyInitialStructureTarget();
|
||||
void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget());
|
||||
} else if (isCreateMode.value) {
|
||||
markDraftHydratedAndSync();
|
||||
} else {
|
||||
void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true });
|
||||
void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1834,7 +1927,8 @@ onActivated(() => {
|
||||
void loadDynamicDataTypeOptions();
|
||||
if (props.draft?.initialized && !draftHydrated) {
|
||||
restoreDraft(props.draft);
|
||||
void hydrateRestoredDraftFromDatabase();
|
||||
applyInitialStructureTarget();
|
||||
void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget());
|
||||
}
|
||||
});
|
||||
onDeactivated(unregisterStructureEditorShortcuts);
|
||||
@@ -1843,6 +1937,7 @@ onBeforeUnmount(() => {
|
||||
unregisterStructureEditorShortcuts();
|
||||
clearSqlPreviewState();
|
||||
if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer);
|
||||
if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer);
|
||||
persistStructureDensity();
|
||||
});
|
||||
|
||||
@@ -1869,13 +1964,53 @@ function applyInitialStructureTab() {
|
||||
activeTab.value = resolveStructureMetadataTab(props.initialTab);
|
||||
}
|
||||
|
||||
function initialTargetKey(target: TableStructureEditorTarget): string {
|
||||
return `${props.initialTabRequestId ?? 0}:${target.kind}:${target.name}`;
|
||||
}
|
||||
|
||||
function applyInitialStructureTarget() {
|
||||
const target = props.initialTarget;
|
||||
const targetName = target?.name.trim();
|
||||
if (!target || !targetName) return;
|
||||
|
||||
const key = initialTargetKey(target);
|
||||
if (appliedInitialTargetSearchKey !== key) {
|
||||
if (target.kind === "column") {
|
||||
activeTab.value = resolveStructureMetadataTab("columns");
|
||||
columnSearchText.value = targetName;
|
||||
highlightedColumnId.value = null;
|
||||
} else {
|
||||
activeTab.value = resolveStructureMetadataTab("indexes");
|
||||
indexSearchText.value = targetName;
|
||||
highlightedIndexId.value = null;
|
||||
}
|
||||
appliedInitialTargetSearchKey = key;
|
||||
}
|
||||
|
||||
if (appliedInitialTargetScrollKey === key) return;
|
||||
const hasMatch = target.kind === "column" ? columns.value.some((column) => columnMatchesSearch(column)) : indexes.value.some((index) => indexMatchesSearch(index));
|
||||
if (!hasMatch) return;
|
||||
appliedInitialTargetScrollKey = key;
|
||||
void nextTick(() => {
|
||||
if (target.kind === "column") {
|
||||
scrollToColumnSearchMatch(1);
|
||||
} else {
|
||||
scrollToIndexSearchMatch(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(tableMetadataCapabilities, (capabilities) => {
|
||||
if (!isStructureMetadataTabSupported(activeTab.value, capabilities)) activeTab.value = firstStructureMetadataTab(capabilities);
|
||||
});
|
||||
|
||||
watch([() => props.initialTab, () => props.initialTabRequestId], () => {
|
||||
if (!props.initialTab) return;
|
||||
applyInitialStructureTab();
|
||||
watch([() => props.initialTab, () => props.initialTabRequestId, () => props.initialTarget], () => {
|
||||
if (props.initialTab) applyInitialStructureTab();
|
||||
applyInitialStructureTarget();
|
||||
});
|
||||
|
||||
watch([columns, indexes], () => {
|
||||
applyInitialStructureTarget();
|
||||
});
|
||||
|
||||
watch([() => props.connectionId, () => props.database, databaseType], () => {
|
||||
@@ -1893,6 +2028,7 @@ watch(
|
||||
|
||||
watch(activeTab, () => {
|
||||
highlightedColumnId.value = null;
|
||||
highlightedIndexId.value = null;
|
||||
syncDraftToParent();
|
||||
});
|
||||
|
||||
@@ -2036,6 +2172,19 @@ watch(activeTab, (tab) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("structureEditor.configureColumnTemplates") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div v-if="activeTab === 'indexes'" class="relative flex w-40 shrink-0 items-center">
|
||||
<Search :class="[structureIconClass, 'pointer-events-none absolute left-2 text-muted-foreground']" />
|
||||
<Input ref="indexSearchInputRef" v-model="indexSearchText" :placeholder="t('structureEditor.searchIndexes')" :class="[structureControlClass, 'pl-7 pr-14 text-[length:var(--structure-font-size)] placeholder:text-[length:var(--structure-font-size)]']" @keydown="onIndexSearchKeydown" />
|
||||
<button
|
||||
v-if="indexSearchText"
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 rounded px-1 text-[length:var(--structure-font-size)] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:title="t('structureEditor.nextIndexMatch')"
|
||||
@click="scrollToIndexSearchMatch(1)"
|
||||
>
|
||||
{{ indexSearchMatchCount }}
|
||||
</button>
|
||||
</div>
|
||||
<Button v-if="activeTab === 'indexes'" size="sm" :class="structureToolbarButtonClass" :disabled="!structureCapabilities.createIndex || indexesLoading" @click="addIndex">
|
||||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addIndex") }}
|
||||
@@ -2345,9 +2494,9 @@ watch(activeTab, (tab) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-index-row="!index.original ? 'true' : undefined">
|
||||
<tr v-for="(index, rowIndex) in indexes" :key="index.id" :class="indexRowClass(index)" :data-new-index-row="!index.original ? 'true' : undefined" :data-index-row-index="rowIndex">
|
||||
<td :class="structureCellClass">
|
||||
<Input :model-value="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input @update:model-value="(value: string | number) => onIndexNameInput(index, value)" />
|
||||
<Input :model-value="index.name" :class="[structureControlClass, indexSearchFieldClass(index, index.name)]" :disabled="!canEditIndexDraft(index)" data-index-name-input @update:model-value="(value: string | number) => onIndexNameInput(index, value)" />
|
||||
</td>
|
||||
<td :class="[structureCellClass, 'overflow-hidden']">
|
||||
<DropdownMenu v-if="canEditIndexDraft(index)">
|
||||
@@ -2405,10 +2554,10 @@ watch(activeTab, (tab) => {
|
||||
<span v-else class="text-[length:var(--structure-font-size)] text-muted-foreground">{{ index.includedColumns.join(", ") }}</span>
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input v-model="index.filter" :class="structureMonoControlClass" :placeholder="index.original?.filter || ''" :disabled="!canEditIndexFilter(index)" />
|
||||
<Input v-model="index.filter" :class="[structureMonoControlClass, indexSearchFieldClass(index, index.filter)]" :placeholder="index.original?.filter || ''" :disabled="!canEditIndexFilter(index)" />
|
||||
</td>
|
||||
<td :class="structureCellClass">
|
||||
<Input v-model="index.comment" :class="structureControlClass" :disabled="!canEditIndexComment(index)" />
|
||||
<Input v-model="index.comment" :class="[structureControlClass, indexSearchFieldClass(index, index.comment)]" :disabled="!canEditIndexComment(index)" />
|
||||
</td>
|
||||
<td :class="structureLastCellClass">
|
||||
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>
|
||||
|
||||
Reference in new issue
Block a user