dbx/apps/desktop/src/components/diff/SchemaDiffObjectTree.vue

246 lines
9.9 KiB
Vue

<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schema/schemaDiff";
import { Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap, ChevronDown, ChevronRight, ArrowRightLeft, PlusCircle, XCircle, MinusCircle } from "@lucide/vue";
const { t } = useI18n();
interface ObjectTypeGroup {
kind: DiffObjectKind;
label: string;
objects: SchemaDiffObject[];
expanded: boolean;
}
interface OperationGroup {
operationType: DiffOperationType;
label: string;
count: number;
selectedCount: number;
expanded: boolean;
typeGroups: ObjectTypeGroup[];
}
const props = defineProps<{
groups: OperationGroup[];
selectedObjectId?: string | null;
}>();
const emit = defineEmits<{
(e: "toggleGroup", operationType: DiffOperationType): void;
(e: "toggleTypeGroup", operationType: DiffOperationType, kind: DiffObjectKind): void;
(e: "toggleGroupSelection", operationType: DiffOperationType, selected: boolean): void;
(e: "toggleTypeSelection", operationType: DiffOperationType, kind: DiffObjectKind, selected: boolean): void;
(e: "toggleObjectSelection", objectId: string, selected: boolean): void;
(e: "selectObject", object: SchemaDiffObject): void;
}>();
const operationIcons: Record<DiffOperationType, any> = {
modify: ArrowRightLeft,
create: PlusCircle,
delete: XCircle,
none: MinusCircle,
};
const operationColors: Record<DiffOperationType, string> = {
modify: "text-blue-500",
create: "text-green-500",
delete: "text-red-500",
none: "text-muted-foreground",
};
const operationBgColors: Record<DiffOperationType, string> = {
modify: "bg-blue-500/10 border-blue-500/20",
create: "bg-green-500/10 border-green-500/20",
delete: "bg-red-500/10 border-red-500/20",
none: "bg-muted/30 border-muted",
};
function getObjectIcon(kind: DiffObjectKind) {
switch (kind) {
case "table":
return Table;
case "view":
return Eye;
case "function":
return FunctionSquare;
case "sequence":
return ListOrdered;
case "rule":
return ScrollText;
case "owner":
return UserCog;
case "index":
return ListTree;
case "foreignKey":
return Link2;
case "trigger":
return Zap;
default:
return Table;
}
}
function getObjectIconColor(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "text-amber-500";
case "view":
return "text-cyan-500";
case "function":
return "text-purple-500";
case "sequence":
return "text-orange-500";
case "rule":
return "text-pink-500";
case "owner":
return "text-indigo-500";
case "index":
return "text-teal-500";
case "foreignKey":
return "text-lime-500";
case "trigger":
return "text-rose-500";
default:
return "text-muted-foreground";
}
}
function getObjectTypeLabel(kind: DiffObjectKind): string {
switch (kind) {
case "table":
return "diff.objectKindLabel.table";
case "view":
return "diff.objectKindLabel.view";
case "function":
return "diff.objectKindLabel.function";
case "sequence":
return "diff.objectKindLabel.sequence";
case "rule":
return "diff.objectKindLabel.rule";
case "owner":
return "diff.objectKindLabel.owner";
case "index":
return "diff.objectKindLabel.index";
case "foreignKey":
return "diff.objectKindLabel.foreignKey";
case "trigger":
return "diff.objectKindLabel.trigger";
default:
return kind;
}
}
function isGroupFullySelected(group: OperationGroup): boolean {
return group.count > 0 && group.selectedCount === group.count;
}
function isGroupPartiallySelected(group: OperationGroup): boolean {
return group.selectedCount > 0 && group.selectedCount < group.count;
}
function isTypeGroupFullySelected(typeGroup: ObjectTypeGroup): boolean {
return typeGroup.objects.length > 0 && typeGroup.objects.every((o) => o.selected);
}
function isTypeGroupPartiallySelected(typeGroup: ObjectTypeGroup): boolean {
const selectedCount = typeGroup.objects.filter((o) => o.selected).length;
return selectedCount > 0 && selectedCount < typeGroup.objects.length;
}
function onGroupCheckboxChange(group: OperationGroup, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleGroupSelection", group.operationType, checked);
}
function onTypeCheckboxChange(group: OperationGroup, typeGroup: ObjectTypeGroup, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleTypeSelection", group.operationType, typeGroup.kind, checked);
}
function onObjectCheckboxChange(obj: SchemaDiffObject, event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit("toggleObjectSelection", obj.id, checked);
}
function formatObjectName(obj: SchemaDiffObject): string {
if (obj.objectKind === "function" && obj.arguments) {
return `${obj.name}(${obj.arguments})`;
}
return obj.name;
}
</script>
<template>
<div class="space-y-1">
<!-- Header -->
<div class="grid grid-cols-[1fr_60px_1fr] gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground border-b">
<div class="text-center">{{ t("diff.sourceObject") }}</div>
<div class="text-center">{{ t("diff.operation") }}</div>
<div class="text-center">{{ t("diff.targetObject") }}</div>
</div>
<!-- Operation Groups -->
<div v-for="group in groups" :key="group.operationType" class="border rounded-md overflow-hidden">
<!-- Operation Group Header -->
<button class="flex items-center gap-2 w-full px-3 py-2 text-sm font-medium transition-colors" :class="operationBgColors[group.operationType]" @click="$emit('toggleGroup', group.operationType)">
<ChevronDown v-if="group.expanded" class="w-4 h-4 shrink-0" />
<ChevronRight v-else class="w-4 h-4 shrink-0" />
<input type="checkbox" class="accent-primary shrink-0" :checked="isGroupFullySelected(group)" :indeterminate="isGroupPartiallySelected(group)" @click.stop @change="onGroupCheckboxChange(group, $event)" />
<component :is="operationIcons[group.operationType]" class="w-4 h-4 shrink-0" :class="operationColors[group.operationType]" />
<span :class="operationColors[group.operationType]">{{ t(group.label) }}</span>
<span class="text-xs text-muted-foreground ml-1"> ({{ t("diff.selectedCount", { selected: group.selectedCount, total: group.count }) }}) </span>
</button>
<!-- Type Groups -->
<div v-if="group.expanded" class="divide-y divide-border/30">
<div v-for="typeGroup in group.typeGroups" :key="typeGroup.kind" class="border-l-2 border-l-border/50 ml-2">
<!-- Type Group Header -->
<button class="flex items-center gap-2 w-full px-3 py-1.5 text-xs font-medium hover:bg-accent/20 transition-colors" @click="$emit('toggleTypeGroup', group.operationType, typeGroup.kind)">
<ChevronDown v-if="typeGroup.expanded" class="w-3.5 h-3.5 shrink-0" />
<ChevronRight v-else class="w-3.5 h-3.5 shrink-0" />
<input type="checkbox" class="accent-primary shrink-0" :checked="isTypeGroupFullySelected(typeGroup)" :indeterminate="isTypeGroupPartiallySelected(typeGroup)" @click.stop @change="onTypeCheckboxChange(group, typeGroup, $event)" />
<component :is="getObjectIcon(typeGroup.kind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(typeGroup.kind)" />
<span>{{ t(getObjectTypeLabel(typeGroup.kind)) }}</span>
<span class="text-xs text-muted-foreground">({{ typeGroup.objects.length }})</span>
</button>
<!-- Objects -->
<div v-if="typeGroup.expanded" class="divide-y divide-border/20">
<div v-for="obj in typeGroup.objects" :key="obj.id" class="grid grid-cols-[1fr_60px_1fr] gap-2 px-3 py-1 items-center hover:bg-accent/30 cursor-pointer ml-8" :class="{ 'bg-primary/10': selectedObjectId === obj.id }" @click="$emit('selectObject', obj)">
<!-- Source (hide for delete objects) -->
<div v-if="obj.operationType !== 'delete'" class="flex items-center gap-2 min-w-0">
<input type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'create' ? 'text-green-500' : ''">
{{ obj.sourceName ? (obj.objectKind === "function" && obj.arguments ? `${obj.sourceName}(${obj.arguments})` : obj.sourceName) : formatObjectName(obj) }}
</span>
</div>
<div v-else></div>
<!-- Operation -->
<div class="flex justify-center">
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5" :class="operationColors[obj.operationType]" />
</div>
<!-- Target (hide for create objects) -->
<div v-if="obj.operationType !== 'create'" class="flex items-center gap-2 min-w-0">
<input v-if="obj.operationType === 'delete'" type="checkbox" class="accent-primary shrink-0" :checked="obj.selected" @click.stop @change="onObjectCheckboxChange(obj, $event)" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="text-xs truncate" :class="obj.operationType === 'delete' ? 'text-red-500 line-through' : ''">
{{ obj.targetName ? (obj.objectKind === "function" && obj.arguments ? `${obj.targetName}(${obj.arguments})` : obj.targetName) : formatObjectName(obj) }}
</span>
</div>
<div v-else></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>