From e9e8081882332c7696c9c9c1a712aaf9e1dbc8fd Mon Sep 17 00:00:00 2001 From: eshmeshek Date: Wed, 28 Jan 2026 00:32:28 +0300 Subject: [PATCH] modified: frontend/src/pages/DatabaseSchema.tsx --- frontend/src/pages/DatabaseSchema.tsx | 259 ++++++++++++++++++++++---- 1 file changed, 219 insertions(+), 40 deletions(-) diff --git a/frontend/src/pages/DatabaseSchema.tsx b/frontend/src/pages/DatabaseSchema.tsx index 2d12c2b..d717bdf 100644 --- a/frontend/src/pages/DatabaseSchema.tsx +++ b/frontend/src/pages/DatabaseSchema.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ReactFlow, @@ -15,26 +15,27 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import Dagre from '@dagrejs/dagre'; -import { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw } from 'lucide-react'; +import { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw, Search, X, Table2, MessageSquare, Columns } from 'lucide-react'; import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api'; import { Database } from '@/types'; -// Calculate column position for handle placement -function getColumnYPosition(columnIndex: number): number { - const headerHeight = 44; - const columnHeight = 26; - return headerHeight + (columnIndex * columnHeight) + columnHeight / 2; +// Search result type +interface SearchResult { + type: 'table' | 'column' | 'comment'; + tableName: string; + tableSchema: string; + columnName?: string; + displayText: string; + secondaryText?: string; } // Custom node for table with per-column handles function TableNode({ data }: { data: TableInfo & Record }) { - // Find which columns are FK sources and which are PK targets const fkColumns = new Set(data.foreign_keys.map(fk => fk.column)); const pkColumns = new Set(data.columns.filter(c => c.is_primary).map(c => c.name)); return (
- {/* Table header */}
@@ -47,7 +48,6 @@ function TableNode({ data }: { data: TableInfo & Record }) { )}
- {/* Columns */}
{data.columns.map((col, index) => (
}) { className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative" title={col.comment || undefined} > - {/* Target handle for PK columns (incoming FK references) */} {pkColumns.has(col.name) && ( )} - {/* Source handle for FK columns (outgoing references) */} {fkColumns.has(col.name) && ( )} @@ -95,7 +91,6 @@ function TableNode({ data }: { data: TableInfo & Record }) { )}
- {/* FK summary */} {data.foreign_keys.length > 0 && (
{data.foreign_keys.length} FK @@ -109,7 +104,6 @@ const nodeTypes = { table: TableNode, }; -// Calculate node height based on columns count function getNodeHeight(table: TableInfo): number { const headerHeight = 44; const columnHeight = 26; @@ -117,16 +111,13 @@ function getNodeHeight(table: TableInfo): number { return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8; } -// Calculate connection weight for layout optimization function calculateTableWeights(schema: SchemaData): Map { const weights = new Map(); schema.tables.forEach(table => { const key = `${table.schema}.${table.name}`; - // Weight = number of FK connections (both incoming and outgoing) let weight = table.foreign_keys.length; - // Add incoming connections schema.tables.forEach(other => { other.foreign_keys.forEach(fk => { if (fk.references_table === table.name) { @@ -144,10 +135,8 @@ function calculateTableWeights(schema: SchemaData): Map { function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } { const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - // Calculate weights for better positioning const weights = calculateTableWeights(schema); - // Sort tables by weight (most connected first) for better layout const sortedTables = [...schema.tables].sort((a, b) => { const weightA = weights.get(`${a.schema}.${a.name}`) || 0; const weightB = weights.get(`${b.schema}.${b.name}`) || 0; @@ -155,15 +144,14 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] }); g.setGraph({ - rankdir: 'LR', - nodesep: 60, - ranksep: 150, - marginx: 50, - marginy: 50, - ranker: 'tight-tree', // Better for connected graphs + rankdir: 'TB', // Top-to-bottom for wider spread + nodesep: 40, // Horizontal spacing between nodes + ranksep: 80, // Vertical spacing between ranks + marginx: 30, + marginy: 30, + ranker: 'network-simplex', // Better distribution across plane }); - // Add nodes sortedTables.forEach((table) => { const nodeId = `${table.schema}.${table.name}`; const width = 280; @@ -171,22 +159,19 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] g.setNode(nodeId, { width, height }); }); - // Add edges with weights for layout algorithm schema.tables.forEach((table) => { const sourceId = `${table.schema}.${table.name}`; table.foreign_keys.forEach((fk) => { const targetTable = schema.tables.find(t => t.name === fk.references_table); if (targetTable) { const targetId = `${targetTable.schema}.${targetTable.name}`; - g.setEdge(sourceId, targetId, { weight: 2 }); // Higher weight keeps connected nodes closer + g.setEdge(sourceId, targetId, { weight: 2 }); } }); }); - // Run layout Dagre.layout(g); - // Create nodes with positions const nodes: Node[] = schema.tables.map((table) => { const nodeId = `${table.schema}.${table.name}`; const nodeWithPosition = g.node(nodeId); @@ -202,7 +187,6 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] }; }); - // Create edges with column-level handles const edges: Edge[] = []; schema.tables.forEach((table) => { @@ -238,14 +222,14 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] }); }); - console.log(`[Schema] Created ${nodes.length} nodes, ${edges.length} edges`); - return { nodes, edges }; } export default function DatabaseSchema() { const queryClient = useQueryClient(); const [selectedDbId, setSelectedDbId] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTables, setSelectedTables] = useState>(new Set()); const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({ queryKey: ['databases'], @@ -273,10 +257,121 @@ export default function DatabaseSchema() { const schema = schemaResponse?.data; + // Search across tables, columns, and comments + const searchResults = useMemo((): SearchResult[] => { + if (!schema || !searchQuery.trim()) return []; + const query = searchQuery.toLowerCase(); + const results: SearchResult[] = []; + + schema.tables.forEach(table => { + // Search by table name + if (table.name.toLowerCase().includes(query)) { + results.push({ + type: 'table', + tableName: table.name, + tableSchema: table.schema, + displayText: table.name, + secondaryText: table.schema, + }); + } + + // Search by table comment + if (table.comment && table.comment.toLowerCase().includes(query)) { + results.push({ + type: 'comment', + tableName: table.name, + tableSchema: table.schema, + displayText: table.name, + secondaryText: table.comment, + }); + } + + // Search by column name + table.columns.forEach(col => { + if (col.name.toLowerCase().includes(query)) { + results.push({ + type: 'column', + tableName: table.name, + tableSchema: table.schema, + columnName: col.name, + displayText: `${table.name}.${col.name}`, + secondaryText: col.type, + }); + } + + // Search by column comment + if (col.comment && col.comment.toLowerCase().includes(query)) { + results.push({ + type: 'comment', + tableName: table.name, + tableSchema: table.schema, + columnName: col.name, + displayText: `${table.name}.${col.name}`, + secondaryText: col.comment, + }); + } + }); + }); + + return results.slice(0, 50); + }, [schema, searchQuery]); + + // Get related tables (tables connected via FK) + const getRelatedTables = useCallback((tableNames: Set, allTables: TableInfo[]): Set => { + const related = new Set(); + tableNames.forEach(name => related.add(name)); + + allTables.forEach(table => { + if (tableNames.has(table.name)) { + table.foreign_keys.forEach(fk => { + related.add(fk.references_table); + }); + } + table.foreign_keys.forEach(fk => { + if (tableNames.has(fk.references_table)) { + related.add(table.name); + } + }); + }); + + return related; + }, []); + + // Filter schema for selected tables + related tables, or show all if nothing selected + const filteredSchema = useMemo((): SchemaData | null => { + if (!schema) return null; + if (selectedTables.size === 0) return schema; // Show all when nothing selected + + const relatedTables = getRelatedTables(selectedTables, schema.tables); + const filteredTables = schema.tables.filter(t => relatedTables.has(t.name)); + + return { + tables: filteredTables, + updated_at: schema.updated_at, + }; + }, [schema, selectedTables, getRelatedTables]); + const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { - if (!schema) return { nodes: [], edges: [] }; - return getLayoutedElements(schema); - }, [schema]); + if (!filteredSchema || filteredSchema.tables.length === 0) return { nodes: [], edges: [] }; + return getLayoutedElements(filteredSchema); + }, [filteredSchema]); + + const addTable = (tableName: string) => { + setSelectedTables(prev => new Set([...prev, tableName])); + setSearchQuery(''); + }; + + const removeTable = (tableName: string) => { + setSelectedTables(prev => { + const next = new Set(prev); + next.delete(tableName); + return next; + }); + }; + + const clearSelection = () => { + setSelectedTables(new Set()); + }; const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -286,6 +381,25 @@ export default function DatabaseSchema() { setEdges(layoutedEdges); }, [layoutedNodes, layoutedEdges, setNodes, setEdges]); + const getSearchIcon = (type: SearchResult['type']) => { + switch (type) { + case 'table': + return ; + case 'column': + return ; + case 'comment': + return ; + } + }; + + const getSearchTypeLabel = (type: SearchResult['type']) => { + switch (type) { + case 'table': return 'таблица'; + case 'column': return 'столбец'; + case 'comment': return 'коммент'; + } + }; + if (isLoadingDatabases) { return (
@@ -302,7 +416,11 @@ export default function DatabaseSchema() {
+ {selectedDbId && schema && ( +
+ + setSearchQuery(e.target.value)} + placeholder="Поиск по таблицам, столбцам, комментариям..." + className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + {searchResults.length > 0 && ( +
+ {searchResults.map((result, idx) => ( + + ))} +
+ )} +
+ )} + {selectedDbId && (
+ {/* Selected tables chips */} + {selectedTables.size > 0 && ( +
+ Фильтр: + {[...selectedTables].map(tableName => ( + + + {tableName} + + + ))} + +
+ )} + {/* Schema visualization */}
{!selectedDbId && (