feat(schema-diff): named config selector and mapping UX
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user