sozsoft-platform/ui/src/views/developerKit/SqlObjectExplorer.tsx

552 lines
20 KiB
TypeScript
Raw Normal View History

2026-02-24 20:44:16 +00:00
import { useState, useEffect } from 'react'
import {
FaRegFolder,
FaRegFolderOpen,
FaColumns,
FaSyncAlt,
FaTable,
2026-03-01 17:40:25 +00:00
FaPlus,
FaEye,
FaCog,
FaCode,
FaDatabase,
FaTrash,
2026-02-24 20:44:16 +00:00
} from 'react-icons/fa'
import type { DatabaseTableDto, SqlNativeObjectDto } from '@/proxy/sql-query-manager/models'
2026-02-24 20:44:16 +00:00
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
type FolderKey = 'tables' | 'views' | 'procedures' | 'functions'
2026-02-24 20:44:16 +00:00
interface TreeNode {
id: string
label: string
type: 'root' | 'folder' | 'object'
folder?: FolderKey
data?: DatabaseTableDto | SqlNativeObjectDto
2026-02-24 20:44:16 +00:00
children?: TreeNode[]
}
interface SqlObjectExplorerProps {
dataSource: string | null
onTemplateSelect?: (template: string, templateType: string) => void
onViewDefinition?: (schemaName: string, objectName: string) => void
2026-03-18 09:00:11 +00:00
onGenerateTableScript?: (schemaName: string, tableName: string) => void
2026-03-01 17:40:25 +00:00
onDesignTable?: (schemaName: string, tableName: string) => void
onNewTable?: () => void
2026-03-18 09:00:11 +00:00
onSelectedObjectsChange?: (objects: SqlExplorerSelectedObject[]) => void
2026-02-24 20:44:16 +00:00
refreshTrigger?: number
}
2026-03-18 09:00:11 +00:00
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' },
}
2026-02-24 20:44:16 +00:00
const SqlObjectExplorer = ({
dataSource,
onTemplateSelect,
onViewDefinition,
2026-03-18 09:00:11 +00:00
onGenerateTableScript,
2026-03-01 17:40:25 +00:00
onDesignTable,
onNewTable,
2026-03-18 09:00:11 +00:00
onSelectedObjectsChange,
2026-02-24 20:44:16 +00:00
refreshTrigger,
}: SqlObjectExplorerProps) => {
const { translate } = useLocalization()
const [treeData, setTreeData] = useState<TreeNode[]>([])
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set(['root']))
2026-02-24 20:44:16 +00:00
const [loading, setLoading] = useState(false)
const [filterText, setFilterText] = useState('')
2026-03-18 09:00:11 +00:00
const [selectedObjectIds, setSelectedObjectIds] = useState<Set<string>>(new Set())
const [dropConfirm, setDropConfirm] = useState<{ node: TreeNode } | null>(null)
const [dropping, setDropping] = useState(false)
2026-02-24 20:44:16 +00:00
const [contextMenu, setContextMenu] = useState<{
show: boolean; x: number; y: number; node: TreeNode | null
2026-02-24 20:44:16 +00:00
}>({ show: false, x: 0, y: 0, node: null })
useEffect(() => {
2026-03-18 09:00:11 +00:00
if (dataSource) {
loadObjects()
} else {
setTreeData([])
setSelectedObjectIds(new Set())
onSelectedObjectsChange?.([])
}
}, [dataSource, refreshTrigger])
2026-02-24 20:44:16 +00:00
2026-03-18 09:00:11 +00:00
useEffect(() => {
const selected = getSelectedObjects(treeData, selectedObjectIds)
onSelectedObjectsChange?.(selected)
}, [treeData, selectedObjectIds, onSelectedObjectsChange])
2026-02-24 20:44:16 +00:00
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,
})
2026-02-24 20:44:16 +00:00
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)),
},
],
}]
2026-02-24 20:44:16 +00:00
setTreeData(tree)
} catch (error: any) {
console.error('Failed to load objects', error)
2026-02-24 20:44:16 +00:00
} 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
2026-02-24 20:44:16 +00:00
})
}
const filterTree = (nodes: TreeNode[], search: string): TreeNode[] => {
if (!search.trim()) return nodes
const q = search.toLowerCase()
return nodes
2026-02-24 20:44:16 +00:00
.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
2026-02-24 20:44:16 +00:00
return null
})
.filter(Boolean) as TreeNode[]
2026-02-24 20:44:16 +00:00
}
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)
2026-02-24 20:44:16 +00:00
}
}
2026-03-18 09:00:11 +00:00
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}]`};`
2026-02-24 20:44:16 +00:00
}
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}`]
2026-02-24 20:44:16 +00:00
}
const handleDrop = async () => {
if (!dropConfirm || !dataSource) return
setDropping(true)
2026-02-24 20:44:16 +00:00
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)
2026-02-24 20:44:16 +00:00
loadObjects()
} catch (err: any) {
console.error('Drop failed', err)
} finally {
setDropping(false)
2026-02-24 20:44:16 +00:00
}
}
const closeCtx = () => setContextMenu({ show: false, x: 0, y: 0, node: null })
2026-02-24 20:44:16 +00:00
const getIcon = (node: TreeNode) => {
if (node.type === 'root') return <FaDatabase className="text-blue-500" />
2026-02-24 20:44:16 +00:00
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} />
2026-02-24 20:44:16 +00:00
}
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" />
2026-02-24 20:44:16 +00:00
}
const renderNode = (node: TreeNode, level = 0) => {
const isExpanded = expandedNodes.has(node.id)
2026-03-18 09:00:11 +00:00
const isChecked = selectedObjectIds.has(node.id)
2026-02-24 20:44:16 +00:00
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"
2026-02-24 20:44:16 +00:00
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => handleNodeClick(node)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ show: true, x: e.clientX, y: e.clientY, node }) }}
2026-02-24 20:44:16 +00:00
>
2026-03-18 09:00:11 +00:00
{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()}
/>
)}
2026-02-24 20:44:16 +00:00
{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>
)}
2026-02-24 20:44:16 +00:00
</div>
{isExpanded && node.children && (
<div>{node.children.map((c) => renderNode(c, level + 1))}</div>
2026-02-24 20:44:16 +00:00
)}
</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'
2026-02-24 20:44:16 +00:00
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>
2026-02-24 20:44:16 +00:00
</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>}
2026-03-16 09:52:15 +00:00
{!loading && treeData.length > 0 && filteredTree.length === 0 && <div className="text-center py-8 text-gray-500 text-sm">{translate('::App.Platform.NoResults')}</div>}
2026-02-24 20:44:16 +00:00
</div>
{/* Context menu */}
2026-02-24 20:44:16 +00:00
{contextMenu.show && (
<>
<div className="fixed inset-0 z-40" onClick={closeCtx} />
2026-02-24 20:44:16 +00:00
<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 && (
2026-03-18 09:00:11 +00:00
<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()
2026-03-18 09:00:11 +00:00
}}
>
<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">
2026-03-01 17:40:25 +00:00
<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)}
2026-03-01 17:40:25 +00:00
>
Cancel
2026-03-01 17:40:25 +00:00
</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>
2026-02-24 20:44:16 +00:00
</div>
</div>
2026-02-24 20:44:16 +00:00
</>
)}
</div>
)
}
export default SqlObjectExplorer