erp-platform/ui/src/views/sqlQueryManager/components/SqlObjectExplorer.tsx
2025-12-05 16:45:45 +03:00

495 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Dialog, Button, Notification, toast } from '@/components/ui'
import {
FaRegFolder,
FaRegFolderOpen,
FaRegFileAlt,
FaCog,
FaColumns,
FaCode,
FaSyncAlt,
FaEdit,
FaTrash,
} from 'react-icons/fa'
import type { DataSourceDto } from '@/proxy/data-source'
import type {
SqlFunctionDto,
SqlQueryDto,
SqlStoredProcedureDto,
SqlViewDto,
SqlObjectType,
} from '@/proxy/sql-query-manager/models'
import {
sqlFunctionService,
sqlQueryService,
sqlStoredProcedureService,
sqlViewService,
} from '@/services/sql-query-manager.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
export type SqlObject = SqlFunctionDto | SqlQueryDto | SqlStoredProcedureDto | SqlViewDto
interface TreeNode {
id: string
label: string
type: 'root' | 'folder' | 'object'
objectType?: SqlObjectType
data?: SqlObject
children?: TreeNode[]
expanded?: boolean
}
interface SqlObjectExplorerProps {
dataSource: DataSourceDto | null
onObjectSelect: (object: SqlObject | null, objectType: SqlObjectType | null) => void
selectedObject: SqlObject | null
onTemplateSelect?: (template: string, templateType: string) => void
}
const SqlObjectExplorer = ({
dataSource,
onObjectSelect,
selectedObject,
onTemplateSelect,
}: SqlObjectExplorerProps) => {
const { translate } = useLocalization()
const [treeData, setTreeData] = useState<TreeNode[]>([])
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(
new Set(['root', 'templates', 'queries', 'storedProcedures', 'views', 'functions']),
)
const [loading, setLoading] = useState(false)
const [contextMenu, setContextMenu] = useState<{
show: boolean
x: number
y: number
node: TreeNode | null
}>({ show: false, x: 0, y: 0, node: null })
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [objectToDelete, setObjectToDelete] = useState<{
object: SqlObject
type: SqlObjectType
} | null>(null)
useEffect(() => {
if (dataSource) {
loadObjects()
} else {
setTreeData([])
}
}, [dataSource])
const loadObjects = async () => {
if (!dataSource) return
setLoading(true)
try {
const [queries, storedProcedures, views, functions] = await Promise.all([
sqlQueryService.getList({
skipCount: 0,
maxResultCount: 1000,
dataSourceCode: dataSource.code,
}),
sqlStoredProcedureService.getList({
skipCount: 0,
maxResultCount: 1000,
dataSourceCode: dataSource.code,
}),
sqlViewService.getList({
skipCount: 0,
maxResultCount: 1000,
dataSourceCode: dataSource.code,
}),
sqlFunctionService.getList({
skipCount: 0,
maxResultCount: 1000,
dataSourceCode: dataSource.code,
}),
])
const tree: TreeNode[] = [
{
id: 'root',
label: dataSource.code || 'Database',
type: 'root',
expanded: true,
children: [
{
id: 'templates',
label: translate('::App.Platform.Templates'),
type: 'folder',
expanded: expandedNodes.has('templates'),
children: [
{
id: 'template-select',
label: 'SELECT Query',
type: 'object' as const,
data: { templateType: 'select' } as any,
},
{
id: 'template-insert',
label: 'INSERT Query',
type: 'object' as const,
data: { templateType: 'insert' } as any,
},
{
id: 'template-update',
label: 'UPDATE Query',
type: 'object' as const,
data: { templateType: 'update' } as any,
},
{
id: 'template-delete',
label: 'DELETE Query',
type: 'object' as const,
data: { templateType: 'delete' } as any,
},
{
id: 'template-sp',
label: 'Stored Procedure',
type: 'object' as const,
data: { templateType: 'create-procedure' } as any,
},
{
id: 'template-view',
label: 'View',
type: 'object' as const,
data: { templateType: 'create-view' } as any,
},
{
id: 'template-function',
label: 'Function',
type: 'object' as const,
data: { templateType: 'create-function' } as any,
},
],
},
{
id: 'queries',
label: `${translate('::App.Platform.Queries')} (${queries.data.totalCount})`,
type: 'folder',
objectType: 1,
expanded: expandedNodes.has('queries'),
children:
queries.data.items?.map((q) => ({
id: q.id || '',
label: q.name,
type: 'object' as const,
objectType: 1 as SqlObjectType,
data: q,
})) || [],
},
{
id: 'storedProcedures',
label: `${translate('::App.Platform.StoredProcedures')} (${storedProcedures.data.totalCount})`,
type: 'folder',
objectType: 2,
expanded: expandedNodes.has('storedProcedures'),
children:
storedProcedures.data.items?.map((sp) => ({
id: sp.id || '',
label: sp.displayName || sp.procedureName,
type: 'object' as const,
objectType: 2 as SqlObjectType,
data: sp,
})) || [],
},
{
id: 'views',
label: `${translate('::App.Platform.Views')} (${views.data.totalCount})`,
type: 'folder',
objectType: 3,
expanded: expandedNodes.has('views'),
children:
views.data.items?.map((v) => ({
id: v.id || '',
label: v.displayName || v.viewName,
type: 'object' as const,
objectType: 3 as SqlObjectType,
data: v,
})) || [],
},
{
id: 'functions',
label: `${translate('::App.Platform.Functions')} (${functions.data.totalCount})`,
type: 'folder',
objectType: 4,
expanded: expandedNodes.has('functions'),
children:
functions.data.items?.map((f) => ({
id: f.id || '',
label: f.displayName || f.functionName,
type: 'object' as const,
objectType: 4 as SqlObjectType,
data: f,
})) || [],
},
],
},
]
setTreeData(tree)
} catch (error: any) {
toast.push(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{error.response?.data?.error?.message || translate('::App.Platform.FailedToLoadObjects')}
</Notification>,
{ placement: 'top-center' },
)
} finally {
setLoading(false)
}
}
const toggleNode = (nodeId: string) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev)
if (newSet.has(nodeId)) newSet.delete(nodeId)
else newSet.add(nodeId)
return newSet
})
}
const handleNodeClick = (node: TreeNode) => {
if (node.type === 'folder' || node.type === 'root') {
toggleNode(node.id)
} else if (node.type === 'object' && node.data) {
// Check if it's a template
if ((node.data as any).templateType && onTemplateSelect) {
const templateType = (node.data as any).templateType
onTemplateSelect('', templateType) // Template content will be generated in parent
} else if (node.objectType) {
onObjectSelect(node.data, node.objectType)
}
}
}
const handleContextMenu = (e: React.MouseEvent, node: TreeNode) => {
e.preventDefault()
setContextMenu({
show: true,
x: e.clientX,
y: e.clientY,
node,
})
}
const handleDelete = async () => {
if (!objectToDelete || !objectToDelete.object.id) return
try {
const { object, type } = objectToDelete
switch (type) {
case 1:
await sqlQueryService.delete(object.id!)
break
case 2:
await sqlStoredProcedureService.delete(object.id!)
break
case 3:
await sqlViewService.delete(object.id!)
break
case 4:
await sqlFunctionService.delete(object.id!)
break
}
toast.push(
<Notification type="success" title={translate('::App.Platform.Success')}>
{translate('::App.Platform.ObjectDeletedSuccessfully')}
</Notification>,
{ placement: 'top-center' },
)
setShowDeleteDialog(false)
setObjectToDelete(null)
loadObjects()
if (selectedObject?.id === object.id) {
onObjectSelect(null, null)
}
} catch (error: any) {
toast.push(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{error.response?.data?.error?.message || translate('::App.Platform.FailedToDeleteObject')}
</Notification>,
{ placement: 'top-center' },
)
}
}
const getIcon = (node: TreeNode) => {
if (node.type === 'root') return <FaRegFolder className="text-blue-500" />
if (node.type === 'folder') {
const isExpanded = expandedNodes.has(node.id)
// Templates folder
if (node.id === 'templates')
return isExpanded ? (
<FaRegFolderOpen className="text-orange-500" />
) : (
<FaRegFolder className="text-orange-500" />
)
if (node.objectType === 1)
return isExpanded ? (
<FaRegFolderOpen className="text-yellow-500" />
) : (
<FaRegFolder className="text-yellow-500" />
)
if (node.objectType === 2)
return isExpanded ? (
<FaRegFolderOpen className="text-green-500" />
) : (
<FaRegFolder className="text-green-500" />
)
if (node.objectType === 3)
return isExpanded ? (
<FaRegFolderOpen className="text-purple-500" />
) : (
<FaRegFolder className="text-purple-500" />
)
if (node.objectType === 4)
return isExpanded ? (
<FaRegFolderOpen className="text-red-500" />
) : (
<FaRegFolder className="text-red-500" />
)
}
if (node.type === 'object') {
// Check if it's a template
if ((node.data as any)?.templateType) {
return <FaCode className="text-orange-500" />
}
if (node.objectType === 1) return <FaRegFileAlt className="text-gray-500" />
if (node.objectType === 2) return <FaCog className="text-gray-500" />
if (node.objectType === 3) return <FaColumns className="text-gray-500" />
if (node.objectType === 4) return <FaCode className="text-gray-500" />
}
return <FaRegFolder />
}
const renderNode = (node: TreeNode, level = 0) => {
const isExpanded = expandedNodes.has(node.id)
const isSelected = node.type === 'object' && selectedObject?.id === node.id
return (
<div key={node.id}>
<div
className={`flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded ${
isSelected ? 'bg-blue-100 dark:bg-blue-900' : ''
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => handleNodeClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
>
{getIcon(node)}
<span className="text-sm flex-1">{node.label}</span>
</div>
{isExpanded && node.children && (
<div>{node.children.map((child) => renderNode(child, level + 1))}</div>
)}
</div>
)
}
return (
<div className="h-full">
{loading && <div className="text-center py-8 text-gray-500">{translate('::App.Platform.Loading')}</div>}
{!loading && treeData.length === 0 && (
<div className="text-center py-8 text-gray-500">
{translate('::App.Platform.NoDataSourceSelected')}
</div>
)}
{!loading && treeData.length > 0 && (
<div className="space-y-1">{treeData.map((node) => renderNode(node))}</div>
)}
{contextMenu.show && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setContextMenu({ show: false, x: 0, y: 0, node: null })}
/>
<div
className="fixed z-50 bg-white dark:bg-gray-800 shadow-lg rounded border border-gray-200 dark:border-gray-700 py-1"
style={{ top: contextMenu.y, left: contextMenu.x }}
>
{contextMenu.node?.type === 'object' && (
<>
<button
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
onClick={() => {
if (contextMenu.node?.data && contextMenu.node?.objectType) {
onObjectSelect(contextMenu.node.data, contextMenu.node.objectType)
}
setContextMenu({ show: false, x: 0, y: 0, node: null })
}}
>
<FaEdit className="inline mr-2" />
{translate('::App.Platform.Edit')}
</button>
<button
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm text-red-600"
onClick={() => {
if (contextMenu.node?.data && contextMenu.node?.objectType) {
setObjectToDelete({
object: contextMenu.node.data,
type: contextMenu.node.objectType,
})
setShowDeleteDialog(true)
}
setContextMenu({ show: false, x: 0, y: 0, node: null })
}}
>
<FaTrash className="inline mr-2" />
{translate('::App.Platform.Delete')}
</button>
</>
)}
{contextMenu.node?.type === 'folder' && (
<button
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
onClick={() => {
loadObjects()
setContextMenu({ show: false, x: 0, y: 0, node: null })
}}
>
<FaSyncAlt className="inline mr-2" />
{translate('::App.Platform.Refresh')}
</button>
)}
</div>
</>
)}
<Dialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onRequestClose={() => setShowDeleteDialog(false)}
>
<h5 className="mb-4">{translate('::App.Platform.ConfirmDelete')}</h5>
<p className="mb-4">{translate('::App.Platform.DeleteConfirmationMessage')}</p>
<div className="flex justify-end gap-2">
<Button variant="plain" onClick={() => setShowDeleteDialog(false)}>
{translate('::App.Platform.Cancel')}
</Button>
<Button variant="solid" onClick={handleDelete}>
{translate('::App.Platform.DeleteAction')}
</Button>
</div>
</Dialog>
</div>
)
}
export default SqlObjectExplorer