2025-12-05 13:45:45 +00:00
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
|
|
|
import { Dialog, Button, Notification, toast } from '@/components/ui'
|
|
|
|
|
import {
|
|
|
|
|
FaRegFolder,
|
|
|
|
|
FaRegFolderOpen,
|
|
|
|
|
FaRegFileAlt,
|
|
|
|
|
FaCog,
|
|
|
|
|
FaColumns,
|
|
|
|
|
FaCode,
|
|
|
|
|
FaSyncAlt,
|
|
|
|
|
FaEdit,
|
|
|
|
|
FaTrash,
|
2025-12-05 14:56:39 +00:00
|
|
|
FaTable,
|
2025-12-05 13:45:45 +00:00
|
|
|
} from 'react-icons/fa'
|
2025-12-05 14:56:39 +00:00
|
|
|
import { MdViewColumn } from 'react-icons/md'
|
2025-12-05 13:45:45 +00:00
|
|
|
import type { DataSourceDto } from '@/proxy/data-source'
|
|
|
|
|
import type {
|
|
|
|
|
SqlFunctionDto,
|
|
|
|
|
SqlQueryDto,
|
|
|
|
|
SqlStoredProcedureDto,
|
|
|
|
|
SqlViewDto,
|
|
|
|
|
SqlObjectType,
|
|
|
|
|
} from '@/proxy/sql-query-manager/models'
|
|
|
|
|
import {
|
2025-12-05 14:56:39 +00:00
|
|
|
sqlObjectManagerService,
|
2025-12-05 13:45:45 +00:00
|
|
|
} 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
|
2025-12-05 14:56:39 +00:00
|
|
|
type: 'root' | 'folder' | 'object' | 'column'
|
2025-12-05 13:45:45 +00:00
|
|
|
objectType?: SqlObjectType
|
2025-12-05 14:56:39 +00:00
|
|
|
data?: SqlObject | any
|
2025-12-05 13:45:45 +00:00
|
|
|
children?: TreeNode[]
|
|
|
|
|
expanded?: boolean
|
2025-12-05 14:56:39 +00:00
|
|
|
isColumn?: boolean
|
|
|
|
|
parentTable?: { schemaName: string; tableName: string }
|
2025-12-05 13:45:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>>(
|
2025-12-05 14:56:39 +00:00
|
|
|
new Set(['root']), // Only root expanded by default
|
2025-12-05 13:45:45 +00:00
|
|
|
)
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
2025-12-05 14:56:39 +00:00
|
|
|
const [filterText, setFilterText] = useState('')
|
2025-12-05 13:45:45 +00:00
|
|
|
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 {
|
2025-12-05 14:56:39 +00:00
|
|
|
// Single API call to get all objects
|
|
|
|
|
const response = await sqlObjectManagerService.getAllObjects(dataSource.code || '')
|
|
|
|
|
const allObjects = response.data
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
{
|
2025-12-05 13:48:32 +00:00
|
|
|
id: 'template-scalar-function',
|
|
|
|
|
label: 'Scalar Function',
|
2025-12-05 13:45:45 +00:00
|
|
|
type: 'object' as const,
|
2025-12-05 13:48:32 +00:00
|
|
|
data: { templateType: 'create-scalar-function' } as any,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'template-table-function',
|
|
|
|
|
label: 'Table-Valued Function',
|
|
|
|
|
type: 'object' as const,
|
|
|
|
|
data: { templateType: 'create-table-function' } as any,
|
2025-12-05 13:45:45 +00:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
2025-12-05 14:56:39 +00:00
|
|
|
{
|
|
|
|
|
id: 'tables',
|
|
|
|
|
label: `${translate('::App.Platform.Tables')} (${allObjects.tables.length})`,
|
|
|
|
|
type: 'folder',
|
|
|
|
|
expanded: expandedNodes.has('tables'),
|
|
|
|
|
children:
|
|
|
|
|
allObjects.tables.map((t) => ({
|
|
|
|
|
id: `table-${t.schemaName}-${t.tableName}`,
|
|
|
|
|
label: t.fullName,
|
|
|
|
|
type: 'object' as const,
|
|
|
|
|
data: t,
|
|
|
|
|
})) || [],
|
|
|
|
|
},
|
2025-12-05 13:45:45 +00:00
|
|
|
{
|
|
|
|
|
id: 'queries',
|
2025-12-05 14:56:39 +00:00
|
|
|
label: `${translate('::App.Platform.Queries')} (${allObjects.queries.length})`,
|
2025-12-05 13:45:45 +00:00
|
|
|
type: 'folder',
|
|
|
|
|
objectType: 1,
|
|
|
|
|
expanded: expandedNodes.has('queries'),
|
|
|
|
|
children:
|
2025-12-05 14:56:39 +00:00
|
|
|
allObjects.queries.map((q) => ({
|
2025-12-05 13:45:45 +00:00
|
|
|
id: q.id || '',
|
|
|
|
|
label: q.name,
|
|
|
|
|
type: 'object' as const,
|
|
|
|
|
objectType: 1 as SqlObjectType,
|
|
|
|
|
data: q,
|
|
|
|
|
})) || [],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'storedProcedures',
|
2025-12-05 14:56:39 +00:00
|
|
|
label: `${translate('::App.Platform.StoredProcedures')} (${allObjects.storedProcedures.length})`,
|
2025-12-05 13:45:45 +00:00
|
|
|
type: 'folder',
|
|
|
|
|
objectType: 2,
|
|
|
|
|
expanded: expandedNodes.has('storedProcedures'),
|
|
|
|
|
children:
|
2025-12-05 14:56:39 +00:00
|
|
|
allObjects.storedProcedures.map((sp) => ({
|
2025-12-05 13:45:45 +00:00
|
|
|
id: sp.id || '',
|
|
|
|
|
label: sp.displayName || sp.procedureName,
|
|
|
|
|
type: 'object' as const,
|
|
|
|
|
objectType: 2 as SqlObjectType,
|
|
|
|
|
data: sp,
|
|
|
|
|
})) || [],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'views',
|
2025-12-05 14:56:39 +00:00
|
|
|
label: `${translate('::App.Platform.Views')} (${allObjects.views.length})`,
|
2025-12-05 13:45:45 +00:00
|
|
|
type: 'folder',
|
|
|
|
|
objectType: 3,
|
|
|
|
|
expanded: expandedNodes.has('views'),
|
|
|
|
|
children:
|
2025-12-05 14:56:39 +00:00
|
|
|
allObjects.views.map((v) => ({
|
2025-12-05 13:45:45 +00:00
|
|
|
id: v.id || '',
|
|
|
|
|
label: v.displayName || v.viewName,
|
|
|
|
|
type: 'object' as const,
|
|
|
|
|
objectType: 3 as SqlObjectType,
|
|
|
|
|
data: v,
|
|
|
|
|
})) || [],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'functions',
|
2025-12-05 14:56:39 +00:00
|
|
|
label: `${translate('::App.Platform.Functions')} (${allObjects.functions.length})`,
|
2025-12-05 13:45:45 +00:00
|
|
|
type: 'folder',
|
|
|
|
|
objectType: 4,
|
|
|
|
|
expanded: expandedNodes.has('functions'),
|
|
|
|
|
children:
|
2025-12-05 14:56:39 +00:00
|
|
|
allObjects.functions.map((f) => ({
|
2025-12-05 13:45:45 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:56:39 +00:00
|
|
|
const loadTableColumns = async (schemaName: string, tableName: string): Promise<TreeNode[]> => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await sqlObjectManagerService.getTableColumns(
|
|
|
|
|
dataSource?.code || '',
|
|
|
|
|
schemaName,
|
|
|
|
|
tableName,
|
|
|
|
|
)
|
|
|
|
|
return response.data.map((col) => ({
|
|
|
|
|
id: `column-${schemaName}-${tableName}-${col.columnName}`,
|
|
|
|
|
label: `${col.columnName} (${col.dataType}${col.maxLength ? `(${col.maxLength})` : ''})${col.isNullable ? '' : ' NOT NULL'}`,
|
|
|
|
|
type: 'column' as const,
|
|
|
|
|
isColumn: true,
|
|
|
|
|
data: col,
|
|
|
|
|
parentTable: { schemaName, tableName },
|
|
|
|
|
}))
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toggleNode = async (nodeId: string) => {
|
|
|
|
|
const newSet = new Set(expandedNodes)
|
|
|
|
|
|
|
|
|
|
if (newSet.has(nodeId)) {
|
|
|
|
|
newSet.delete(nodeId)
|
|
|
|
|
setExpandedNodes(newSet)
|
|
|
|
|
} else {
|
|
|
|
|
newSet.add(nodeId)
|
|
|
|
|
setExpandedNodes(newSet)
|
|
|
|
|
|
|
|
|
|
// If it's a table node and hasn't loaded columns yet, load them
|
|
|
|
|
if (nodeId.startsWith('table-') && dataSource) {
|
|
|
|
|
const tableNode = findNodeById(treeData, nodeId)
|
|
|
|
|
if (tableNode && (!tableNode.children || tableNode.children.length === 0)) {
|
|
|
|
|
const tableData = tableNode.data as any
|
|
|
|
|
const columns = await loadTableColumns(tableData.schemaName, tableData.tableName)
|
|
|
|
|
|
|
|
|
|
// Update tree data with columns
|
|
|
|
|
setTreeData((prevTree) => {
|
|
|
|
|
return updateNodeChildren(prevTree, nodeId, columns)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const findNodeById = (nodes: TreeNode[], id: string): TreeNode | null => {
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
if (node.id === id) return node
|
|
|
|
|
if (node.children) {
|
|
|
|
|
const found = findNodeById(node.children, id)
|
|
|
|
|
if (found) return found
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateNodeChildren = (nodes: TreeNode[], nodeId: string, children: TreeNode[]): TreeNode[] => {
|
|
|
|
|
return nodes.map((node) => {
|
|
|
|
|
if (node.id === nodeId) {
|
|
|
|
|
return { ...node, children }
|
|
|
|
|
}
|
|
|
|
|
if (node.children) {
|
|
|
|
|
return { ...node, children: updateNodeChildren(node.children, nodeId, children) }
|
|
|
|
|
}
|
|
|
|
|
return node
|
2025-12-05 13:45:45 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:56:39 +00:00
|
|
|
const filterTree = (nodes: TreeNode[], searchText: string): TreeNode[] => {
|
|
|
|
|
if (!searchText.trim()) return nodes
|
|
|
|
|
|
|
|
|
|
const search = searchText.toLowerCase()
|
|
|
|
|
const filtered = nodes
|
|
|
|
|
.map((node) => {
|
|
|
|
|
const matchesSearch = node.label.toLowerCase().includes(search)
|
|
|
|
|
const filteredChildren = node.children ? filterTree(node.children, searchText) : []
|
|
|
|
|
|
|
|
|
|
if (matchesSearch || filteredChildren.length > 0) {
|
|
|
|
|
return {
|
|
|
|
|
...node,
|
|
|
|
|
children: filteredChildren.length > 0 ? filteredChildren : node.children,
|
|
|
|
|
} as TreeNode
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
})
|
|
|
|
|
.filter((node) => node !== null) as TreeNode[]
|
|
|
|
|
|
|
|
|
|
return filtered
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
const handleNodeClick = (node: TreeNode) => {
|
|
|
|
|
if (node.type === 'folder' || node.type === 'root') {
|
|
|
|
|
toggleNode(node.id)
|
2025-12-05 14:56:39 +00:00
|
|
|
} else if (node.type === 'column') {
|
|
|
|
|
// Column clicked - do nothing or show info
|
|
|
|
|
return
|
2025-12-05 13:45:45 +00:00
|
|
|
} 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
|
2025-12-05 14:56:39 +00:00
|
|
|
}
|
|
|
|
|
// Check if it's a table
|
|
|
|
|
else if (node.id.startsWith('table-') && onTemplateSelect) {
|
|
|
|
|
const table = node.data as any
|
|
|
|
|
const selectQuery = `-- SELECT from ${table.fullName || table.tableName}\nSELECT * \nFROM ${table.fullName || `[${table.schemaName}].[${table.tableName}]`}\nWHERE 1=1;`
|
|
|
|
|
onTemplateSelect(selectQuery, 'table-select')
|
|
|
|
|
}
|
|
|
|
|
else if (node.objectType) {
|
2025-12-05 13:45:45 +00:00
|
|
|
onObjectSelect(node.data, node.objectType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, node: TreeNode) => {
|
|
|
|
|
e.preventDefault()
|
2025-12-05 14:56:39 +00:00
|
|
|
|
|
|
|
|
// Don't show context menu for columns, templates, or tables
|
|
|
|
|
if (node.type === 'column' || node.id.startsWith('template-') || node.id.startsWith('table-')) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
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:
|
2025-12-05 14:56:39 +00:00
|
|
|
await sqlObjectManagerService.deleteQuery(object.id!)
|
2025-12-05 13:45:45 +00:00
|
|
|
break
|
|
|
|
|
case 2:
|
2025-12-05 14:56:39 +00:00
|
|
|
await sqlObjectManagerService.deleteStoredProcedure(object.id!)
|
2025-12-05 13:45:45 +00:00
|
|
|
break
|
|
|
|
|
case 3:
|
2025-12-05 14:56:39 +00:00
|
|
|
await sqlObjectManagerService.deleteView(object.id!)
|
2025-12-05 13:45:45 +00:00
|
|
|
break
|
|
|
|
|
case 4:
|
2025-12-05 14:56:39 +00:00
|
|
|
await sqlObjectManagerService.deleteFunction(object.id!)
|
2025-12-05 13:45:45 +00:00
|
|
|
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" />
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-05 14:56:39 +00:00
|
|
|
// Tables folder
|
|
|
|
|
if (node.id === 'tables')
|
|
|
|
|
return isExpanded ? (
|
|
|
|
|
<FaRegFolderOpen className="text-blue-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<FaRegFolder className="text-blue-500" />
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
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" />
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:56:39 +00:00
|
|
|
// Check if it's a table
|
|
|
|
|
if (node.id.startsWith('table-')) {
|
|
|
|
|
return <FaTable className="text-blue-500" />
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
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" />
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:56:39 +00:00
|
|
|
if (node.type === 'column') {
|
|
|
|
|
return <MdViewColumn className="text-gray-400 text-sm" />
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
return <FaRegFolder />
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const renderNode = (node: TreeNode, level = 0) => {
|
|
|
|
|
const isExpanded = expandedNodes.has(node.id)
|
|
|
|
|
const isSelected = node.type === 'object' && selectedObject?.id === node.id
|
2025-12-05 14:56:39 +00:00
|
|
|
const isColumn = node.type === 'column'
|
2025-12-05 13:45:45 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={node.id}>
|
|
|
|
|
<div
|
2025-12-05 14:56:39 +00:00
|
|
|
className={`flex items-center gap-2 py-1 px-2 ${isColumn ? 'cursor-default' : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700'} rounded ${
|
2025-12-05 13:45:45 +00:00
|
|
|
isSelected ? 'bg-blue-100 dark:bg-blue-900' : ''
|
|
|
|
|
}`}
|
|
|
|
|
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
2025-12-05 14:56:39 +00:00
|
|
|
onClick={() => !isColumn && handleNodeClick(node)}
|
|
|
|
|
onContextMenu={(e) => !isColumn && handleContextMenu(e, node)}
|
2025-12-05 13:45:45 +00:00
|
|
|
>
|
|
|
|
|
{getIcon(node)}
|
2025-12-05 14:56:39 +00:00
|
|
|
<span className={`text-sm flex-1 ${isColumn ? 'text-gray-600 dark:text-gray-400' : ''}`}>{node.label}</span>
|
2025-12-05 13:45:45 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isExpanded && node.children && (
|
|
|
|
|
<div>{node.children.map((child) => renderNode(child, level + 1))}</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:56:39 +00:00
|
|
|
const filteredTree = filterTree(treeData, filterText)
|
|
|
|
|
|
2025-12-05 13:45:45 +00:00
|
|
|
return (
|
2025-12-05 14:56:39 +00:00
|
|
|
<div className="h-full flex flex-col">
|
|
|
|
|
{/* Filter and Refresh Controls */}
|
|
|
|
|
<div className="p-2 border-b space-y-2 flex-shrink-0">
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<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>
|
2025-12-05 13:45:45 +00:00
|
|
|
</div>
|
2025-12-05 14:56:39 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Tree Content */}
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
{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 && filteredTree.length > 0 && (
|
|
|
|
|
<div className="space-y-1 p-2">{filteredTree.map((node) => renderNode(node))}</div>
|
|
|
|
|
)}
|
|
|
|
|
{!loading && treeData.length > 0 && filteredTree.length === 0 && (
|
|
|
|
|
<div className="text-center py-8 text-gray-500">
|
|
|
|
|
{translate('::App.Platform.NoResultsFound')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-12-05 13:45:45 +00:00
|
|
|
|
|
|
|
|
{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
|