551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import {
|
||
FaRegFolder,
|
||
FaRegFolderOpen,
|
||
FaColumns,
|
||
FaSyncAlt,
|
||
FaTable,
|
||
FaPlus,
|
||
FaEye,
|
||
FaCog,
|
||
FaCode,
|
||
FaDatabase,
|
||
FaTrash,
|
||
} from 'react-icons/fa'
|
||
import type { DatabaseTableDto, SqlNativeObjectDto } from '@/proxy/sql-query-manager/models'
|
||
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||
|
||
type FolderKey = 'tables' | 'views' | 'procedures' | 'functions'
|
||
|
||
interface TreeNode {
|
||
id: string
|
||
label: string
|
||
type: 'root' | 'folder' | 'object'
|
||
folder?: FolderKey
|
||
data?: DatabaseTableDto | SqlNativeObjectDto
|
||
children?: TreeNode[]
|
||
}
|
||
|
||
interface SqlObjectExplorerProps {
|
||
dataSource: string | null
|
||
onTemplateSelect?: (template: string, templateType: string) => void
|
||
onViewDefinition?: (schemaName: string, objectName: string) => void
|
||
onGenerateTableScript?: (schemaName: string, tableName: string) => void
|
||
onDesignTable?: (schemaName: string, tableName: string) => void
|
||
onNewTable?: () => void
|
||
onSelectedObjectsChange?: (objects: SqlExplorerSelectedObject[]) => void
|
||
refreshTrigger?: number
|
||
}
|
||
|
||
export interface SqlExplorerSelectedObject {
|
||
id: string
|
||
objectType: 'table' | 'view' | 'procedure' | 'function'
|
||
schemaName: string
|
||
objectName: string
|
||
fullName: string
|
||
}
|
||
|
||
const FOLDER_META: Record<FolderKey, { label: string; color: string }> = {
|
||
tables: { label: 'Tables', color: 'text-teal-500' },
|
||
views: { label: 'Views', color: 'text-purple-500' },
|
||
procedures: { label: 'Stored Procedures', color: 'text-green-600' },
|
||
functions: { label: 'Functions', color: 'text-orange-500' },
|
||
}
|
||
|
||
const SqlObjectExplorer = ({
|
||
dataSource,
|
||
onTemplateSelect,
|
||
onViewDefinition,
|
||
onGenerateTableScript,
|
||
onDesignTable,
|
||
onNewTable,
|
||
onSelectedObjectsChange,
|
||
refreshTrigger,
|
||
}: SqlObjectExplorerProps) => {
|
||
const { translate } = useLocalization()
|
||
const [treeData, setTreeData] = useState<TreeNode[]>([])
|
||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set(['root']))
|
||
const [loading, setLoading] = useState(false)
|
||
const [filterText, setFilterText] = useState('')
|
||
const [selectedObjectIds, setSelectedObjectIds] = useState<Set<string>>(new Set())
|
||
const [dropConfirm, setDropConfirm] = useState<{ node: TreeNode } | null>(null)
|
||
const [dropping, setDropping] = useState(false)
|
||
const [contextMenu, setContextMenu] = useState<{
|
||
show: boolean; x: number; y: number; node: TreeNode | null
|
||
}>({ show: false, x: 0, y: 0, node: null })
|
||
|
||
useEffect(() => {
|
||
if (dataSource) {
|
||
loadObjects()
|
||
} else {
|
||
setTreeData([])
|
||
setSelectedObjectIds(new Set())
|
||
onSelectedObjectsChange?.([])
|
||
}
|
||
}, [dataSource, refreshTrigger])
|
||
|
||
useEffect(() => {
|
||
const selected = getSelectedObjects(treeData, selectedObjectIds)
|
||
onSelectedObjectsChange?.(selected)
|
||
}, [treeData, selectedObjectIds, onSelectedObjectsChange])
|
||
|
||
const loadObjects = async () => {
|
||
if (!dataSource) return
|
||
setLoading(true)
|
||
try {
|
||
const { data } = await sqlObjectManagerService.getAllObjects(dataSource)
|
||
|
||
const makeObjectNode = (folder: FolderKey, obj: SqlNativeObjectDto): TreeNode => ({
|
||
id: `${folder}-${obj.schemaName}-${obj.objectName}`,
|
||
label: obj.fullName ?? `[${obj.schemaName}].[${obj.objectName}]`,
|
||
type: 'object',
|
||
folder,
|
||
data: obj,
|
||
})
|
||
|
||
const tree: TreeNode[] = [{
|
||
id: 'root',
|
||
label: dataSource,
|
||
type: 'root',
|
||
children: [
|
||
{
|
||
id: 'tables',
|
||
label: `Tables (${data.tables.length})`,
|
||
type: 'folder',
|
||
folder: 'tables',
|
||
children: data.tables.map((t) => ({
|
||
id: `tables-${t.schemaName}-${t.tableName}`,
|
||
label: t.fullName ?? `[${t.schemaName}].[${t.tableName}]`,
|
||
type: 'object' as const,
|
||
folder: 'tables' as FolderKey,
|
||
data: t,
|
||
})),
|
||
},
|
||
{
|
||
id: 'views',
|
||
label: `Views (${data.views.length})`,
|
||
type: 'folder',
|
||
folder: 'views',
|
||
children: data.views.map((v) => makeObjectNode('views', v)),
|
||
},
|
||
{
|
||
id: 'procedures',
|
||
label: `Stored Procedures (${data.storedProcedures.length})`,
|
||
type: 'folder',
|
||
folder: 'procedures',
|
||
children: data.storedProcedures.map((p) => makeObjectNode('procedures', p)),
|
||
},
|
||
{
|
||
id: 'functions',
|
||
label: `Functions (${data.functions.length})`,
|
||
type: 'folder',
|
||
folder: 'functions',
|
||
children: data.functions.map((f) => makeObjectNode('functions', f)),
|
||
},
|
||
],
|
||
}]
|
||
|
||
setTreeData(tree)
|
||
} catch (error: any) {
|
||
console.error('Failed to load objects', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const toggleNode = (nodeId: string) => {
|
||
setExpandedNodes((prev) => {
|
||
const next = new Set(prev)
|
||
next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const filterTree = (nodes: TreeNode[], search: string): TreeNode[] => {
|
||
if (!search.trim()) return nodes
|
||
const q = search.toLowerCase()
|
||
return nodes
|
||
.map((node) => {
|
||
const match = node.label.toLowerCase().includes(q)
|
||
const kids = node.children ? filterTree(node.children, search) : []
|
||
if (match || kids.length > 0)
|
||
return { ...node, children: kids.length > 0 ? kids : node.children } as TreeNode
|
||
return null
|
||
})
|
||
.filter(Boolean) as TreeNode[]
|
||
}
|
||
|
||
const handleNodeClick = (node: TreeNode) => {
|
||
if (node.type !== 'object') { toggleNode(node.id); return }
|
||
|
||
if (node.folder === 'tables') {
|
||
// Generate SELECT template for tables
|
||
const t = node.data as DatabaseTableDto
|
||
onTemplateSelect?.(`SELECT TOP 10 *\nFROM ${t.fullName ?? `[${t.schemaName}].[${t.tableName}]`};`, 'table-select')
|
||
} else {
|
||
// Load native object definition into editor
|
||
const obj = node.data as SqlNativeObjectDto
|
||
onViewDefinition?.(obj.schemaName, obj.objectName)
|
||
}
|
||
}
|
||
|
||
const mapNodeToSelectedObject = (node: TreeNode): SqlExplorerSelectedObject | null => {
|
||
if (node.type !== 'object' || !node.folder || !node.data) return null
|
||
|
||
if (node.folder === 'tables') {
|
||
const t = node.data as DatabaseTableDto
|
||
return {
|
||
id: node.id,
|
||
objectType: 'table',
|
||
schemaName: t.schemaName,
|
||
objectName: t.tableName,
|
||
fullName: t.fullName ?? `[${t.schemaName}].[${t.tableName}]`,
|
||
}
|
||
}
|
||
|
||
const obj = node.data as SqlNativeObjectDto
|
||
const objectType =
|
||
node.folder === 'views'
|
||
? 'view'
|
||
: node.folder === 'procedures'
|
||
? 'procedure'
|
||
: 'function'
|
||
|
||
return {
|
||
id: node.id,
|
||
objectType,
|
||
schemaName: obj.schemaName,
|
||
objectName: obj.objectName,
|
||
fullName: obj.fullName ?? `[${obj.schemaName}].[${obj.objectName}]`,
|
||
}
|
||
}
|
||
|
||
const getSelectedObjects = (nodes: TreeNode[], ids: Set<string>): SqlExplorerSelectedObject[] => {
|
||
const selected: SqlExplorerSelectedObject[] = []
|
||
|
||
const walk = (list: TreeNode[]) => {
|
||
for (const node of list) {
|
||
if (node.type === 'object' && ids.has(node.id)) {
|
||
const mapped = mapNodeToSelectedObject(node)
|
||
if (mapped) selected.push(mapped)
|
||
}
|
||
if (node.children?.length) walk(node.children)
|
||
}
|
||
}
|
||
|
||
walk(nodes)
|
||
return selected
|
||
}
|
||
|
||
const toggleObjectSelection = (nodeId: string, checked: boolean) => {
|
||
setSelectedObjectIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (checked) next.add(nodeId)
|
||
else next.delete(nodeId)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const buildDropSql = (node: TreeNode): string => {
|
||
if (node.folder === 'tables') {
|
||
const t = node.data as DatabaseTableDto
|
||
return `DROP TABLE ${t.fullName ?? `[${t.schemaName}].[${t.tableName}]`};`
|
||
}
|
||
const obj = node.data as SqlNativeObjectDto
|
||
const keyword =
|
||
node.folder === 'views' ? 'VIEW' :
|
||
node.folder === 'procedures' ? 'PROCEDURE' :
|
||
'FUNCTION'
|
||
return `DROP ${keyword} ${obj.fullName ?? `[${obj.schemaName}].[${obj. objectName}]`};`
|
||
}
|
||
|
||
const getSqlDataFileCandidates = (node: TreeNode): string[] => {
|
||
if (node.folder === 'tables') {
|
||
const t = node.data as DatabaseTableDto
|
||
return [t.tableName, `${t.schemaName}_${t.tableName}`]
|
||
}
|
||
|
||
const obj = node.data as SqlNativeObjectDto
|
||
return [obj.objectName, `${obj.schemaName}_${obj.objectName}`]
|
||
}
|
||
|
||
const handleDrop = async () => {
|
||
if (!dropConfirm || !dataSource) return
|
||
setDropping(true)
|
||
try {
|
||
await sqlObjectManagerService.executeQuery({
|
||
queryText: buildDropSql(dropConfirm.node),
|
||
dataSourceCode: dataSource,
|
||
})
|
||
|
||
// If a matching seed file exists under DbMigrator/Seeds/SqlData, delete it too.
|
||
try {
|
||
const fileNames = [...new Set(getSqlDataFileCandidates(dropConfirm.node).filter(Boolean))]
|
||
if (fileNames.length > 0) {
|
||
await sqlObjectManagerService.deleteSqlDataFiles({ fileNames })
|
||
}
|
||
} catch {
|
||
// Non-blocking: object drop succeeded even if seed file cleanup fails.
|
||
}
|
||
|
||
setDropConfirm(null)
|
||
loadObjects()
|
||
} catch (err: any) {
|
||
console.error('Drop failed', err)
|
||
} finally {
|
||
setDropping(false)
|
||
}
|
||
}
|
||
|
||
const closeCtx = () => setContextMenu({ show: false, x: 0, y: 0, node: null })
|
||
|
||
const getIcon = (node: TreeNode) => {
|
||
if (node.type === 'root') return <FaDatabase className="text-blue-500" />
|
||
if (node.type === 'folder') {
|
||
const open = expandedNodes.has(node.id)
|
||
const cls = FOLDER_META[node.folder!]?.color ?? 'text-blue-500'
|
||
return open
|
||
? <FaRegFolderOpen className={cls} />
|
||
: <FaRegFolder className={cls} />
|
||
}
|
||
if (node.folder === 'tables') return <FaTable className="text-teal-500" />
|
||
if (node.folder === 'views') return <FaEye className="text-purple-500" />
|
||
if (node.folder === 'procedures') return <FaCog className="text-green-600" />
|
||
if (node.folder === 'functions') return <FaCode className="text-orange-500" />
|
||
return <FaColumns className="text-gray-400" />
|
||
}
|
||
|
||
const renderNode = (node: TreeNode, level = 0) => {
|
||
const isExpanded = expandedNodes.has(node.id)
|
||
const isChecked = selectedObjectIds.has(node.id)
|
||
return (
|
||
<div key={node.id}>
|
||
<div
|
||
className="group flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||
onClick={() => handleNodeClick(node)}
|
||
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ show: true, x: e.clientX, y: e.clientY, node }) }}
|
||
>
|
||
{node.type === 'object' && (
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4"
|
||
checked={isChecked}
|
||
onChange={(e) => toggleObjectSelection(node.id, e.target.checked)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
)}
|
||
{getIcon(node)}
|
||
<span className="text-sm flex-1 truncate">{node.label}</span>
|
||
{node.type === 'object' && (
|
||
<button
|
||
title="Drop"
|
||
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-red-100 dark:hover:bg-red-900 text-red-500 transition-opacity flex-shrink-0"
|
||
onClick={(e) => { e.stopPropagation(); setDropConfirm({ node }) }}
|
||
>
|
||
<FaTrash className="text-xs" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{isExpanded && node.children && (
|
||
<div>{node.children.map((c) => renderNode(c, level + 1))}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const filteredTree = filterTree(treeData, filterText)
|
||
|
||
useEffect(() => {
|
||
if (filterText.trim()) {
|
||
const allIds = new Set<string>()
|
||
const collect = (nodes: TreeNode[]) => {
|
||
for (const node of nodes) {
|
||
allIds.add(node.id)
|
||
if (node.children?.length) collect(node.children)
|
||
}
|
||
}
|
||
collect(filteredTree)
|
||
setExpandedNodes(allIds)
|
||
} else {
|
||
setExpandedNodes(new Set(['root']))
|
||
}
|
||
}, [filterText])
|
||
|
||
// Context menu items per folder
|
||
const ctxNode = contextMenu.node
|
||
const isTableObj = ctxNode?.type === 'object' && ctxNode.folder === 'tables'
|
||
const isNativeObj = ctxNode?.type === 'object' && ctxNode.folder !== 'tables'
|
||
const isTablesDir = ctxNode?.id === 'tables'
|
||
const isViewsDir = ctxNode?.id === 'views'
|
||
const isProcsDir = ctxNode?.id === 'procedures'
|
||
const isFuncsDir = ctxNode?.id === 'functions'
|
||
const isFolderNode = ctxNode?.type === 'folder'
|
||
|
||
return (
|
||
<div className="flex-1 flex flex-col min-h-0">
|
||
{/* Search + refresh */}
|
||
<div className="p-2 border-b flex gap-2 flex-shrink-0">
|
||
<input
|
||
type="text"
|
||
placeholder={translate('::App.Platform.Search')}
|
||
value={filterText}
|
||
onChange={(e) => setFilterText(e.target.value)}
|
||
className="flex-1 px-3 py-1.5 text-sm border rounded-md bg-white dark:bg-gray-700 dark:border-gray-600"
|
||
/>
|
||
<button
|
||
onClick={loadObjects}
|
||
disabled={loading || !dataSource}
|
||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
title={translate('::App.Platform.Refresh')}
|
||
>
|
||
<FaSyncAlt className={loading ? 'animate-spin' : ''} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tree */}
|
||
<div className="flex-1 overflow-auto">
|
||
{loading && <div className="text-center py-8 text-gray-500 text-sm">{translate('::App.Platform.Loading')}</div>}
|
||
{!loading && treeData.length === 0 && <div className="text-center py-8 text-gray-500 text-sm">{translate('::App.Platform.NoDataSourceSelected')}</div>}
|
||
{!loading && filteredTree.length > 0 && <div className="p-1">{filteredTree.map((n) => renderNode(n))}</div>}
|
||
{!loading && treeData.length > 0 && filteredTree.length === 0 && <div className="text-center py-8 text-gray-500 text-sm">{translate('::App.Platform.NoResults')}</div>}
|
||
</div>
|
||
|
||
{/* Context menu */}
|
||
{contextMenu.show && (
|
||
<>
|
||
<div className="fixed inset-0 z-40" onClick={closeCtx} />
|
||
<div
|
||
className="fixed z-50 bg-white dark:bg-gray-800 shadow-lg rounded border border-gray-200 dark:border-gray-700 py-1 min-w-[180px]"
|
||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||
>
|
||
{/* TABLE object <20> Design */}
|
||
{isTableObj && (
|
||
<button
|
||
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => {
|
||
const t = ctxNode!.data as DatabaseTableDto
|
||
onGenerateTableScript?.(t.schemaName, t.tableName)
|
||
closeCtx()
|
||
}}
|
||
>
|
||
<FaCode className="text-blue-600" /> Script olustur
|
||
</button>
|
||
)}
|
||
|
||
{isTableObj && (
|
||
<button
|
||
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => {
|
||
const t = ctxNode!.data as DatabaseTableDto
|
||
onDesignTable?.(t.schemaName, t.tableName)
|
||
closeCtx()
|
||
}}
|
||
>
|
||
<FaTable className="text-teal-600" /> Design Table
|
||
</button>
|
||
)}
|
||
|
||
{/* NATIVE object <20> View Definition */}
|
||
{isNativeObj && (
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => {
|
||
const obj = ctxNode!.data as SqlNativeObjectDto
|
||
onViewDefinition?.(obj.schemaName, obj.objectName)
|
||
closeCtx()
|
||
}}>
|
||
{ctxNode!.folder === 'views' && <FaEye className="text-purple-500" />}
|
||
{ctxNode!.folder === 'procedures' && <FaCog className="text-green-600" />}
|
||
{ctxNode!.folder === 'functions' && <FaCode className="text-orange-500" />}
|
||
View Definition
|
||
</button>
|
||
)}
|
||
|
||
{/* FOLDER <20> New ... */}
|
||
{isTablesDir && (
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => { onNewTable?.(); closeCtx() }}>
|
||
<FaPlus className="text-teal-600" /> New Table
|
||
</button>
|
||
)}
|
||
{isViewsDir && (
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => { onTemplateSelect?.('', 'create-view'); closeCtx() }}>
|
||
<FaPlus className="text-purple-500" /> New View
|
||
</button>
|
||
)}
|
||
{isProcsDir && (
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => { onTemplateSelect?.('', 'create-procedure'); closeCtx() }}>
|
||
<FaPlus className="text-green-600" /> New Stored Procedure
|
||
</button>
|
||
)}
|
||
{isFuncsDir && (
|
||
<>
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => { onTemplateSelect?.('', 'create-scalar-function'); closeCtx() }}>
|
||
<FaPlus className="text-orange-500" /> New Scalar Function
|
||
</button>
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => { onTemplateSelect?.('', 'create-table-function'); closeCtx() }}>
|
||
<FaPlus className="text-orange-500" /> New Table-Valued Function
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Separator + Refresh for folders */}
|
||
{isFolderNode && (
|
||
<>
|
||
<div className="my-1 border-t border-gray-100 dark:border-gray-700" />
|
||
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||
onClick={() => { loadObjects(); closeCtx() }}>
|
||
<FaSyncAlt /> {translate('::App.Platform.Refresh')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Drop Confirm Dialog */}
|
||
{dropConfirm && (
|
||
<>
|
||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={() => !dropping && setDropConfirm(null)}>
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full mx-4" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<FaTrash className="text-red-500 text-lg flex-shrink-0" />
|
||
<h6 className="font-semibold text-gray-900 dark:text-gray-100">Drop Object</h6>
|
||
</div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||
The following object will be permanently dropped:
|
||
</p>
|
||
<code className="block text-sm bg-gray-100 dark:bg-gray-700 rounded px-3 py-2 mb-4 break-all">
|
||
{buildDropSql(dropConfirm.node)}
|
||
</code>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
disabled={dropping}
|
||
className="px-4 py-1.5 text-sm rounded border hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
onClick={() => setDropConfirm(null)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
disabled={dropping}
|
||
className="px-4 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 flex items-center gap-2"
|
||
onClick={handleDrop}
|
||
>
|
||
{dropping && <FaSyncAlt className="animate-spin text-xs" />}
|
||
Drop
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default SqlObjectExplorer
|