feat(schema-diff): named config selector and mapping UX

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-14 12:37:39 +08:00
1 parent 2808996526
commit 18e34bf33a
15 files changed
+282 -15

No files matched your search

@@ -28,7 +28,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, Trash2, X } from "@lucide/vue";
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
@@ -166,6 +166,16 @@ function handleUpdateSelectedTables(value: string[]) {
reconcileTableMappings(value);
}
function handleRemoveSelectedTable(sourceTable: string) {
handleUpdateSelectedTables(localSelectedTables.value.filter((table) => table !== sourceTable));
}
function matchStatusClass(kind: SchemaDiffTableMatch["kind"]): string {
if (kind === "unmatched") return "text-destructive";
if (kind === "manual") return "text-amber-600 dark:text-amber-400";
return "text-muted-foreground";
}
function clearUnavailableTableSelection() {
sourceTableList.value = [];
targetTableList.value = [];
@@ -307,6 +317,10 @@ function handleUpdateSelectedRoutines(value: string[]) {
reconcileRoutineMappings(value);
}
function handleRemoveSelectedRoutine(sourceRoutine: string) {
handleUpdateSelectedRoutines(localSelectedRoutines.value.filter((routine) => routine !== sourceRoutine));
}
function clearUnavailableRoutineSelection() {
sourceRoutineList.value = [];
targetRoutineList.value = [];
@@ -861,6 +875,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceTables") }}</th>
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetTable") }}</th>
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
<th class="w-16 px-2 py-1.5 text-right font-medium">{{ t("common.actions") }}</th>
</tr>
</thead>
<tbody>
@@ -881,9 +896,14 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
content-class="w-[var(--reka-popover-trigger-width)]"
/>
</td>
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : match.kind === 'manual' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'">
<td class="px-2 py-1.5" :class="matchStatusClass(match.kind)">
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
</td>
<td class="px-2 py-1.5 text-right">
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" :aria-label="t('common.delete')" @click="handleRemoveSelectedTable(match.sourceTable)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</td>
</tr>
</tbody>
</table>
@@ -926,6 +946,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
@update:model-value="handleUpdateSelectedRoutines"
:tables="sourceRoutineList"
:title="t('diff.sourceRoutines')"
:search-placeholder="t('diff.searchSourceRoutines')"
:empty-text="t('diff.noRoutines')"
/>
</template>
@@ -946,6 +967,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceRoutines") }}</th>
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetRoutine") }}</th>
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
<th class="w-16 px-2 py-1.5 text-right font-medium">{{ t("common.actions") }}</th>
</tr>
</thead>
<tbody>
@@ -955,9 +977,14 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
<span v-if="match.targetRoutine">{{ match.targetRoutine }}</span>
<span v-else class="text-muted-foreground">—</span>
</td>
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : 'text-muted-foreground'">
<td class="px-2 py-1.5" :class="matchStatusClass(match.kind)">
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
</td>
<td class="px-2 py-1.5 text-right">
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" :aria-label="t('common.delete')" @click="handleRemoveSelectedRoutine(match.sourceRoutine)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</td>
</tr>
</tbody>
</table>
@@ -11,6 +11,7 @@ import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutio
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
import SchemaDiffConfigSelector from "@/components/diff/SchemaDiffConfigSelector.vue";
import FieldMappingDialog from "@/components/diff/FieldMappingDialog.vue";
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
import SchemaDiffRoutineList from "@/components/diff/SchemaDiffRoutineList.vue";
@@ -103,6 +104,7 @@ const ignoreComments = ref(false);
// Options panel
const showOptionsPanel = ref(false);
const showFieldMappingDialog = ref(false);
const showConfigSelector = ref(false);
const sourceDbType = computed(() => store.getConfig(sourceConnectionId.value)?.db_type ?? "");
const targetDbType = computed(() => store.getConfig(targetConnectionId.value)?.db_type ?? "");
@@ -254,6 +256,11 @@ function toggleMaximize() {
}
function handleDialogEscape(event: KeyboardEvent) {
if (showConfigSelector.value) {
event.preventDefault();
showConfigSelector.value = false;
return;
}
if (!showOptionsPanel.value) return;
event.preventDefault();
@@ -313,7 +320,7 @@ onBeforeUnmount(() => {
});
// Config management
const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfig, updateActiveConfigConnection, updateActiveConfigOptions, saveToHistory, deleteFromHistory } = useSchemaDiffConfig();
const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfig, createConfig, updateConfig, renameConfig, deleteConfig, duplicateConfig, importConfigs, updateActiveConfigConnection, updateActiveConfigOptions, saveToHistory, deleteFromHistory } = useSchemaDiffConfig();
const schemaDiffPanelOptions = computed(() => normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()));
const selectedObject = computed(() => {
@@ -1075,6 +1082,13 @@ async function copyRoutineDiffSide(side: TextDiffSide) {
}
}
function handleLoadHistoryConfig(config: SchemaDiffConfig) {
applyConfigToForm(config);
if (config.options) {
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(config.options, getDbType()));
}
}
function applyConfigToForm(config: SchemaDiffConfig) {
runWithoutTargetIdentityReset(() => {
sourceConnectionId.value = config.sourceConnectionId;
sourceDatabase.value = config.sourceDatabase;
@@ -1083,18 +1097,85 @@ function handleLoadHistoryConfig(config: SchemaDiffConfig) {
targetDatabase.value = config.targetDatabase;
targetSchema.value = config.targetSchema;
});
if (config.options) {
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(config.options, getDbType()));
}
}
function snapshotCurrentConfig(name: string): SchemaDiffConfig {
ensureDefaultConfig(getDbType());
const base = activeConfig.value;
return {
id: base?.id ?? "",
name,
createdAt: base?.createdAt ?? Date.now(),
updatedAt: Date.now(),
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
options: { ...schemaDiffPanelOptions.value },
};
}
function handleLoadConfig() {
ensureDefaultConfig(getDbType());
showConfigSelector.value = true;
}
function handleSelectActiveConfig(id: string) {
if (!id || id === activeConfigId.value) return;
activeConfigId.value = id;
const config = configs.value.find((entry) => entry.id === id);
if (config) applyConfigToForm(config);
}
function handleSaveConfig() {
if (activeConfig.value) {
const name = window.prompt(t("diff.saveConfigPrompt"), activeConfig.value.name || t("diff.defaultConfigName"));
if (name === null) return; // User cancelled
const configToSave = { ...activeConfig.value, name: name.trim() || t("diff.defaultConfigName") };
saveToHistory(configToSave);
ensureDefaultConfig(getDbType());
if (!activeConfig.value) return;
const name = window.prompt(t("diff.saveConfigPrompt"), activeConfig.value.name || t("diff.defaultConfigName"));
if (name === null) return;
const trimmed = name.trim() || t("diff.defaultConfigName");
const snapshot = snapshotCurrentConfig(trimmed);
updateConfig(activeConfig.value.id, {
name: trimmed,
sourceConnectionId: snapshot.sourceConnectionId,
sourceDatabase: snapshot.sourceDatabase,
sourceSchema: snapshot.sourceSchema,
targetConnectionId: snapshot.targetConnectionId,
targetDatabase: snapshot.targetDatabase,
targetSchema: snapshot.targetSchema,
options: snapshot.options,
});
saveToHistory({ ...snapshot, id: activeConfig.value.id });
toast(t("diff.configSaved"), 2000);
}
function handleCreateNamedConfig(name: string) {
const snapshot = snapshotCurrentConfig(name);
createConfig(name, snapshot, getDbType());
}
function handleRenameNamedConfig(id: string, name: string) {
renameConfig(id, name);
}
function handleDeleteNamedConfig(id: string) {
deleteConfig(id);
if (activeConfig.value) applyConfigToForm(activeConfig.value);
}
function handleDuplicateNamedConfig(id: string) {
duplicateConfig(id);
if (activeConfig.value) applyConfigToForm(activeConfig.value);
}
function handleImportNamedConfigs(jsonText: string) {
try {
importConfigs(jsonText, "merge");
if (activeConfig.value) applyConfigToForm(activeConfig.value);
toast(t("diff.configSaved"), 2000);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
}
}
@@ -1237,6 +1318,7 @@ const targetConnectionInfo = computed(() => {
@swap="handleSwap"
@show-options="showOptionsPanel = true"
@save-config="handleSaveConfig"
@load-config="handleLoadConfig"
@load-history-config="handleLoadHistoryConfig"
@delete-history-config="handleDeleteHistoryConfig"
@update:field-mappings="handleFieldMappingsUpdate"
@@ -1566,6 +1648,26 @@ const targetConnectionInfo = computed(() => {
</div>
</div>
<!-- Config selector overlay (save/load named configs) -->
<div v-if="showConfigSelector" class="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" @click.self="showConfigSelector = false">
<div class="max-h-[80vh] w-[720px] max-w-[calc(100vw-2rem)] overflow-auto rounded-lg border bg-card p-4 shadow-lg">
<div class="mb-4 flex items-center justify-between gap-2">
<h3 class="text-sm font-medium">{{ t("diff.loadConfig") }}</h3>
<Button variant="ghost" size="sm" :aria-label="t('common.close')" @click="showConfigSelector = false">✕</Button>
</div>
<SchemaDiffConfigSelector
:configs="configs"
:active-config-id="activeConfigId"
@update:active-config-id="handleSelectActiveConfig"
@create="handleCreateNamedConfig"
@rename="handleRenameNamedConfig"
@delete="handleDeleteNamedConfig"
@duplicate="handleDuplicateNamedConfig"
@import="handleImportNamedConfigs"
/>
</div>
</div>
<!-- Field Mapping Dialog Overlay -->
<FieldMappingDialog
:open="showFieldMappingDialog"
@@ -20,6 +20,8 @@ const props = withDefaults(
modelValue: string[];
/** Optional header label; falls back to `tableMultiSelect.tables`. */
title?: string;
/** Optional search input placeholder; falls back to `tableMultiSelect.search`. */
searchPlaceholder?: string;
/** Show the search box (only rendered when there are more than 5 tables). */
searchable?: boolean;
/** Text shown when `tables` is empty. */
@@ -28,6 +30,7 @@ const props = withDefaults(
}>(),
{
title: "",
searchPlaceholder: "",
searchable: true,
emptyText: "",
disabled: false,
@@ -64,7 +67,7 @@ function toggleSelectAll() {
</div>
</div>
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="t('tableMultiSelect.search')" :disabled="disabled" />
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="searchPlaceholder || t('tableMultiSelect.search')" :disabled="disabled" />
<div class="flex items-center gap-2">
<Button v-if="tables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="disabled" @click="toggleSelectAll">