feat(schema-diff): support selecting tables to compare
This commit is contained in:
1 parent
0facea2740
commit
b3b6d29099
21 files changed
+933
-69
No files matched your search
@@ -19,6 +19,7 @@ import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
|
||||
|
||||
type CompareColumn = ColumnInfo;
|
||||
@@ -89,7 +90,6 @@ const sourceTable = ref("");
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
const sourceSchemas = ref<string[]>([]);
|
||||
const sourceTables = ref<string[]>([]);
|
||||
const sourceTableSearch = ref("");
|
||||
const selectedSourceTables = ref<Set<string>>(new Set());
|
||||
|
||||
const targetConnectionId = ref("");
|
||||
@@ -122,12 +122,11 @@ let syncPlanRequestId = 0;
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
const filteredSourceTables = computed(() => {
|
||||
const query = sourceTableSearch.value.trim().toLowerCase();
|
||||
if (!query) return sourceTables.value;
|
||||
return sourceTables.value.filter((table) => table.toLowerCase().includes(query));
|
||||
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
|
||||
const sourceTableSelection = computed<string[]>({
|
||||
get: () => [...selectedSourceTables.value],
|
||||
set: (value: string[]) => resetSelectedSourceTables(value),
|
||||
});
|
||||
const allFilteredTablesSelected = computed(() => filteredSourceTables.value.length > 0 && filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)));
|
||||
const compareTasksPreview = computed(() =>
|
||||
selectedSourceTableNames.value.map((table) => {
|
||||
const target = isBatchCompare.value ? table : targetTable.value || table;
|
||||
@@ -221,23 +220,6 @@ function resetSelectedSourceTables(nextTables: Iterable<string>) {
|
||||
selectedSourceTables.value = new Set(nextTables);
|
||||
}
|
||||
|
||||
function toggleSourceTable(table: string) {
|
||||
const next = new Set(selectedSourceTables.value);
|
||||
if (next.has(table)) next.delete(table);
|
||||
else next.add(table);
|
||||
resetSelectedSourceTables(next);
|
||||
}
|
||||
|
||||
function toggleSelectAllSourceTables() {
|
||||
const next = new Set(selectedSourceTables.value);
|
||||
if (allFilteredTablesSelected.value) {
|
||||
filteredSourceTables.value.forEach((table) => next.delete(table));
|
||||
} else {
|
||||
filteredSourceTables.value.forEach((table) => next.add(table));
|
||||
}
|
||||
resetSelectedSourceTables(next);
|
||||
}
|
||||
|
||||
function buildCompareTasks(): DataCompareTableTask[] {
|
||||
if (!selectedSourceTableNames.value.length) return [];
|
||||
if (!isBatchCompare.value) {
|
||||
@@ -804,7 +786,6 @@ watch(sourceConnectionId, (id) => {
|
||||
sourceSchemas.value = [];
|
||||
sourceTables.value = [];
|
||||
sourceTable.value = "";
|
||||
sourceTableSearch.value = "";
|
||||
resetSelectedSourceTables([]);
|
||||
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
@@ -823,7 +804,6 @@ watch(sourceDatabase, () => {
|
||||
sourceSchemas.value = [];
|
||||
sourceTables.value = [];
|
||||
sourceTable.value = "";
|
||||
sourceTableSearch.value = "";
|
||||
resetSelectedSourceTables([]);
|
||||
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
@@ -839,7 +819,6 @@ watch(sourceSchema, () => {
|
||||
clearResult();
|
||||
sourceTables.value = [];
|
||||
sourceTable.value = "";
|
||||
sourceTableSearch.value = "";
|
||||
resetSelectedSourceTables([]);
|
||||
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
@@ -947,41 +926,13 @@ watch(
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
|
||||
<div class="space-y-2 rounded-lg border p-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs font-medium">{{ t("dataCompare.sourceTables") }}</Label>
|
||||
<div v-if="sourceTables.length" class="text-[11px] text-muted-foreground">
|
||||
{{
|
||||
t("dataCompare.selectedTables", {
|
||||
selected: selectedSourceTableNames.length,
|
||||
total: sourceTables.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input v-if="sourceTables.length > 5" v-model="sourceTableSearch" class="h-7 text-xs" :placeholder="t('dataCompare.searchTables')" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="sourceTables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="toggleSelectAllSourceTables">
|
||||
{{ allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-3 text-center">
|
||||
{{ t("dataCompare.selectSourceTables") }}
|
||||
</div>
|
||||
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-3 text-center">
|
||||
{{ t("dataCompare.noTables") }}
|
||||
</div>
|
||||
<div v-else class="max-h-40 overflow-auto rounded border">
|
||||
<button v-for="table in filteredSourceTables" :key="table" type="button" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50" @click="toggleSourceTable(table)">
|
||||
<CheckSquare v-if="selectedSourceTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
<span class="truncate">{{ table }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TableMultiSelect
|
||||
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
|
||||
v-model="sourceTableSelection"
|
||||
:tables="sourceTables"
|
||||
:title="t('dataCompare.sourceTables')"
|
||||
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch";
|
||||
import { createSchemaDiffTableListCoordinator, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableListLoader, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -28,6 +31,8 @@ const props = defineProps<{
|
||||
targetSchema: string;
|
||||
ignoreComments: boolean;
|
||||
options: SchemaDiffCompareOptions;
|
||||
selectedTables?: string[];
|
||||
tableListLoader: SchemaDiffTableListLoader;
|
||||
loading: boolean;
|
||||
recentConfigs: SchemaDiffConfig[];
|
||||
}>();
|
||||
@@ -49,6 +54,7 @@ const emit = defineEmits<{
|
||||
(e: "swap"): void;
|
||||
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
|
||||
(e: "deleteHistoryConfig", configId: string): void;
|
||||
(e: "update:selectedTables", value?: string[]): void;
|
||||
}>();
|
||||
|
||||
const sourceDatabases = ref<string[]>([]);
|
||||
@@ -68,8 +74,94 @@ const targetDbType = computed(() => targetConfig.value?.db_type || "");
|
||||
const showFieldMapping = computed(() => sourceDbType.value && targetDbType.value && sourceDbType.value !== targetDbType.value);
|
||||
const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []);
|
||||
|
||||
// ---- Visual (explicit) table selection ----
|
||||
const sourceTableList = ref<string[]>([]);
|
||||
const targetTableList = ref<string[]>([]);
|
||||
const restrictTables = ref(false);
|
||||
const localSelectedTables = ref<string[]>([]);
|
||||
|
||||
const matchResult = computed(() => buildSameNameTableMatches(localSelectedTables.value, targetTableList.value));
|
||||
const missingTargetTables = computed(() => matchResult.value.missing);
|
||||
|
||||
// Keep the visual restriction in sync with the (persisted) config options.
|
||||
// `undefined` = not restricted (compare all tables, then regex filter);
|
||||
// an array = explicitly restricted to exactly those tables.
|
||||
watch(
|
||||
() => props.selectedTables,
|
||||
(value) => {
|
||||
restrictTables.value = Array.isArray(value);
|
||||
localSelectedTables.value = value && Array.isArray(value) ? [...value] : [];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleToggleRestrict(enabled: boolean) {
|
||||
restrictTables.value = enabled;
|
||||
emit("update:selectedTables", enabled ? [...localSelectedTables.value] : undefined);
|
||||
}
|
||||
|
||||
function handleUpdateSelectedTables(value: string[]) {
|
||||
localSelectedTables.value = value;
|
||||
if (restrictTables.value) emit("update:selectedTables", [...value]);
|
||||
}
|
||||
|
||||
function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
|
||||
return side === "source" ? { connectionId: props.sourceConnectionId, database: props.sourceDatabase, schema: props.sourceSchema } : { connectionId: props.targetConnectionId, database: props.targetDatabase, schema: props.targetSchema };
|
||||
}
|
||||
|
||||
function isTableIdentityReady(side: SchemaDiffTableSide): boolean {
|
||||
const identity = getTableIdentity(side);
|
||||
const config = side === "source" ? sourceConfig.value : targetConfig.value;
|
||||
return !!identity.connectionId && !!identity.database && (!isSchemaAware(config?.db_type) || !!identity.schema);
|
||||
}
|
||||
|
||||
function setTableList(side: SchemaDiffTableSide, tables: Array<{ name?: string }>) {
|
||||
const names = tables.map((entry) => entry.name ?? "").filter(Boolean);
|
||||
if (side === "source") sourceTableList.value = names;
|
||||
else targetTableList.value = names;
|
||||
}
|
||||
|
||||
function reconcileSelectedTablesAfterSuccessfulLoad(tables: Array<{ name?: string }>) {
|
||||
if (!restrictTables.value || !Array.isArray(props.selectedTables)) return;
|
||||
|
||||
const availableTables = tables.map((entry) => entry.name ?? "").filter(Boolean);
|
||||
const nextSelectedTables = reconcileSchemaDiffSelectedTables(props.selectedTables, availableTables);
|
||||
if (nextSelectedTables.length === props.selectedTables.length && nextSelectedTables.every((table, index) => table === props.selectedTables?.[index])) return;
|
||||
|
||||
localSelectedTables.value = nextSelectedTables;
|
||||
emit("update:selectedTables", nextSelectedTables);
|
||||
}
|
||||
|
||||
const tableListCoordinator = createSchemaDiffTableListCoordinator({
|
||||
loader: props.tableListLoader,
|
||||
getIdentity: getTableIdentity,
|
||||
setTables: setTableList,
|
||||
onSourceTablesLoaded: reconcileSelectedTablesAfterSuccessfulLoad,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [restrictTables.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value],
|
||||
() => {
|
||||
const shouldLoad = shouldLoadSchemaDiffTableList("source", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("source"));
|
||||
tableListCoordinator.refresh("source", shouldLoad).catch(() => {});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [restrictTables.value, localSelectedTables.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value],
|
||||
() => {
|
||||
const shouldLoad = shouldLoadSchemaDiffTableList("target", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("target"));
|
||||
tableListCoordinator.refresh("target", shouldLoad).catch(() => {});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const canConfigureTableSelection = computed(() => isTableIdentityReady("source"));
|
||||
|
||||
const canCompare = computed(() => {
|
||||
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
||||
const hasSelectedTables = props.selectedTables === undefined || props.selectedTables.length > 0;
|
||||
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
||||
});
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
@@ -175,6 +267,25 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// A visual selection belongs to the current source connection/database/schema. Clear it
|
||||
// when that identity changes so a saved table name cannot silently restrict a different
|
||||
// source. If the source and selected tables change together, it is a saved-config load;
|
||||
// let the selectedTables watcher restore that config instead of clearing it again.
|
||||
watch(
|
||||
() => ({
|
||||
source: [props.sourceConnectionId, props.sourceDatabase, props.sourceSchema] as const,
|
||||
selectedTables: props.selectedTables,
|
||||
}),
|
||||
(current, previous) => {
|
||||
if (current.source.every((value, index) => value === previous.source[index])) return;
|
||||
if (current.selectedTables !== previous.selectedTables) return;
|
||||
if (current.selectedTables === undefined && !restrictTables.value) return;
|
||||
restrictTables.value = false;
|
||||
localSelectedTables.value = [];
|
||||
emit("update:selectedTables", undefined);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.targetDatabase,
|
||||
async (db) => {
|
||||
@@ -324,6 +435,20 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Source Table Selection -->
|
||||
<div class="mt-3 space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.tableSelection") }}</Label>
|
||||
<Button v-if="restrictTables || canConfigureTableSelection" variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="handleToggleRestrict(!restrictTables)">
|
||||
{{ restrictTables ? t("diff.compareAllTables") : t("diff.chooseTables") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
|
||||
{{ t("diff.tableSelectionUnrestricted") }}
|
||||
</div>
|
||||
<TableMultiSelect v-if="restrictTables" :key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`" :model-value="localSelectedTables" @update:model-value="handleUpdateSelectedTables" :tables="sourceTableList" :title="t('diff.sourceTables')" :empty-text="t('dataCompare.noTables')" />
|
||||
</div>
|
||||
|
||||
<!-- Source Info -->
|
||||
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
||||
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
|
||||
@@ -409,6 +534,17 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Target Same-Name Match -->
|
||||
<div v-if="restrictTables && localSelectedTables.length" class="mt-3 space-y-1.5 rounded-lg border p-3 text-xs">
|
||||
<div class="font-medium">{{ t("diff.autoMatchHint") }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }}
|
||||
</div>
|
||||
<div v-if="missingTargetTables.length" class="text-destructive">
|
||||
{{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Info -->
|
||||
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
||||
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue
|
||||
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
|
||||
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
|
||||
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, schemaDiffMetadataLoadPlan } from "@/lib/schema/schemaDiffMetadataLoad";
|
||||
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
|
||||
import type { ObjectSourceKind, TableInfo } from "@/types/database";
|
||||
@@ -56,6 +57,10 @@ const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const store = useConnectionStore();
|
||||
const schemaDiffTableListLoader = createSchemaDiffTableListLoader({
|
||||
ensureConnected: (connectionId) => store.ensureConnected(connectionId),
|
||||
listTables: (connectionId, database, schema) => api.listTables(connectionId, database, schema),
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
@@ -326,6 +331,12 @@ function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectedTablesUpdate(value?: string[]) {
|
||||
if (activeConfig.value) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, selectedTables: value }, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
|
||||
if (activeConfig.value) {
|
||||
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
|
||||
@@ -396,11 +407,13 @@ async function handleCompare() {
|
||||
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
|
||||
const tableFilter = compileSchemaDiffTableFilter(opts);
|
||||
|
||||
await store.ensureConnected(sourceConnectionId.value);
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
|
||||
const [srcTables, tgtTables] = await Promise.all([api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value)]);
|
||||
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts);
|
||||
const sourceTableIdentity: SchemaDiffTableIdentity = { connectionId: sourceConnectionId.value, database: sourceDatabase.value, schema: sourceSchema.value };
|
||||
const targetTableIdentity: SchemaDiffTableIdentity = { connectionId: targetConnectionId.value, database: targetDatabase.value, schema: targetSchema.value };
|
||||
const [srcTables, tgtTables] = await Promise.all([schemaDiffTableListLoader.load(sourceTableIdentity), schemaDiffTableListLoader.load(targetTableIdentity)]);
|
||||
// Explicit (visual) table selection is applied here, BEFORE any per-table
|
||||
// metadata details are loaded, so metadata requests only happen for the
|
||||
// final table set. `undefined`/empty means no restriction (legacy path).
|
||||
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts, opts.selectedTables);
|
||||
|
||||
const sourceDetails = await loadSchemaDetails(sourceTables, {
|
||||
connectionId: sourceConnectionId.value,
|
||||
@@ -871,6 +884,8 @@ const targetConnectionInfo = computed(() => {
|
||||
:configs="configs"
|
||||
:active-config-id="activeConfigId"
|
||||
:options="activeConfig?.options"
|
||||
:selected-tables="activeConfig?.options?.selectedTables"
|
||||
:table-list-loader="schemaDiffTableListLoader"
|
||||
:loading="loading"
|
||||
:recent-configs="recentConfigs"
|
||||
@compare="handleCompare"
|
||||
@@ -880,6 +895,7 @@ const targetConnectionInfo = computed(() => {
|
||||
@load-history-config="handleLoadHistoryConfig"
|
||||
@delete-history-config="handleDeleteHistoryConfig"
|
||||
@update:field-mappings="handleFieldMappingsUpdate"
|
||||
@update:selected-tables="handleSelectedTablesUpdate"
|
||||
@open-field-mapping="showFieldMappingDialog = true"
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckSquare, Square } from "@lucide/vue";
|
||||
import { filterTableNames, isEveryFilteredSelected, toggleSelectFiltered, toggleTableName } from "@/lib/diff/tableMultiSelect";
|
||||
|
||||
/**
|
||||
* Shared, searchable multi-select for a set of table names. Used by both Data
|
||||
* Compare and Schema Diff. It only knows about "a list of table names to
|
||||
* multi-select" — no connection/database/diff-engine concepts.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** All available table names. */
|
||||
tables: string[];
|
||||
/** Currently selected table names (v-model). */
|
||||
modelValue: string[];
|
||||
/** Optional header label; falls back to `tableMultiSelect.tables`. */
|
||||
title?: string;
|
||||
/** Show the search box (only rendered when there are more than 5 tables). */
|
||||
searchable?: boolean;
|
||||
/** Text shown when `tables` is empty. */
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
searchable: true,
|
||||
emptyText: "",
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: "update:modelValue", value: string[]): void }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const search = ref("");
|
||||
|
||||
const filteredTables = computed(() => filterTableNames(props.tables, search.value));
|
||||
const selectedSet = computed(() => new Set(props.modelValue));
|
||||
const allFilteredSelected = computed(() => isEveryFilteredSelected(props.modelValue, filteredTables.value));
|
||||
const showSearch = computed(() => props.searchable && props.tables.length > 5);
|
||||
|
||||
function toggle(table: string) {
|
||||
if (props.disabled) return;
|
||||
emit("update:modelValue", toggleTableName(props.modelValue, table));
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (props.disabled) return;
|
||||
emit("update:modelValue", toggleSelectFiltered(props.modelValue, filteredTables.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 rounded-lg border p-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs font-medium">{{ title || t("tableMultiSelect.tables") }}</Label>
|
||||
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
|
||||
{{ t("tableMultiSelect.selected", { selected: modelValue.length, total: tables.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="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">
|
||||
{{ allFilteredSelected ? t("tableMultiSelect.deselectAll") : t("tableMultiSelect.selectAll") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="tables.length === 0" class="py-3 text-center text-xs text-muted-foreground">
|
||||
{{ emptyText || t("tableMultiSelect.noTables") }}
|
||||
</div>
|
||||
<div v-else class="max-h-40 overflow-auto rounded border">
|
||||
<button v-for="table in filteredTables" :key="table" type="button" :disabled="disabled" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50 disabled:opacity-50" @click="toggle(table)">
|
||||
<CheckSquare v-if="selectedSet.has(table)" class="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<Square v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
||||
<span class="truncate">{{ table }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user