878 lines
32 KiB
TypeScript
878 lines
32 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import {
|
||
FaRegFolder,
|
||
FaRegFolderOpen,
|
||
FaColumns,
|
||
FaSyncAlt,
|
||
FaTable,
|
||
FaPlus,
|
||
FaEye,
|
||
FaCog,
|
||
FaCode,
|
||
FaDatabase,
|
||
FaTrash,
|
||
FaBolt,
|
||
FaCopy,
|
||
} from 'react-icons/fa'
|
||
import type { DatabaseTableDto, SqlNativeObjectDto } from '@/proxy/sql-query-manager/models'
|
||
import type { CrudEndpoint } from '@/proxy/developerKit/models'
|
||
import { DataSourceTypeEnum } from '@/proxy/form/models'
|
||
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||
import Button from '@/components/ui/Button'
|
||
import { Notification, toast } from '@/components/ui'
|
||
import { CrudEndpointBadges, getErrorMessage, toEntityName } from './CrudEndpointDialog'
|
||
import { developerKitService } from '@/services/developerKit.service'
|
||
|
||
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
|
||
dataSourceType?: DataSourceTypeEnum
|
||
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
|
||
/** Entity adina gore acilmis CRUD endpoint'leri; tablo satirlarinda ikon olarak gosterilir. */
|
||
crudEndpointsByEntity?: Map<string, CrudEndpoint[]>
|
||
onManageCrudEndpoints?: (schemaName: string, tableName: string) => void
|
||
/** Secili tablolar icin CRUD endpoint uretir; verilmezse buton gosterilmez. */
|
||
onGenerateCrudEndpoints?: () => void
|
||
isGeneratingCrudEndpoints?: boolean
|
||
/** Kopyalama / dogrudan SQL diyalogunu acar; verilmezse buton gosterilmez. */
|
||
onCopySelectedObjects?: () => void
|
||
/** Explorer endpoint'leri degistirdiginde (orn. tablo drop) listeyi tazelemek icin cagrilir. */
|
||
onCrudEndpointsChanged?: () => 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,
|
||
dataSourceType,
|
||
onTemplateSelect,
|
||
onViewDefinition,
|
||
onGenerateTableScript,
|
||
onDesignTable,
|
||
onNewTable,
|
||
onSelectedObjectsChange,
|
||
crudEndpointsByEntity,
|
||
onManageCrudEndpoints,
|
||
onGenerateCrudEndpoints,
|
||
isGeneratingCrudEndpoints,
|
||
onCopySelectedObjects,
|
||
onCrudEndpointsChanged,
|
||
refreshTrigger,
|
||
}: SqlObjectExplorerProps) => {
|
||
const { translate } = useLocalization()
|
||
const isPostgreSql = dataSourceType === DataSourceTypeEnum.Postgresql
|
||
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())
|
||
/**
|
||
* Silinmek uzere onay bekleyen nesneler. Satirdaki cop kutusu tek nesneyi,
|
||
* baslikaki "secilenleri sil" tum secimi ayni listeye koyar; boylece tekil ve
|
||
* toplu silme tek kod yolundan gecer.
|
||
*/
|
||
const [dropTargets, setDropTargets] = useState<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)
|
||
if (next.has(nodeId)) {
|
||
next.delete(nodeId)
|
||
} else {
|
||
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[]
|
||
}
|
||
|
||
/**
|
||
* Tek tik nesneyi secer/secimi kaldirir (checkbox ile ayni state), klasorlerde acar/kapatir.
|
||
* Sorgu sablonu ya da tanim yuklemek icin cift tik kullanilir.
|
||
*/
|
||
const handleNodeClick = (node: TreeNode) => {
|
||
if (node.type !== 'object') {
|
||
toggleNode(node.id)
|
||
return
|
||
}
|
||
|
||
toggleObjectSelection(node.id, !selectedObjectIds.has(node.id))
|
||
}
|
||
|
||
const handleNodeDoubleClick = (node: TreeNode) => {
|
||
if (node.type !== 'object') return
|
||
|
||
if (node.folder === 'tables') {
|
||
// Generate SELECT template for tables
|
||
const t = node.data as DatabaseTableDto
|
||
const fullName =
|
||
t.fullName ??
|
||
(isPostgreSql ? `"${t.schemaName}"."${t.tableName}"` : `[${t.schemaName}].[${t.tableName}]`)
|
||
onTemplateSelect?.(
|
||
isPostgreSql
|
||
? `SELECT *\nFROM ${fullName}\nLIMIT 10;`
|
||
: `SELECT TOP 10 *\nFROM ${fullName};`,
|
||
'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 getSelectedNodes = (nodes: TreeNode[], ids: Set<string>): TreeNode[] => {
|
||
const selected: TreeNode[] = []
|
||
|
||
const walk = (list: TreeNode[]) => {
|
||
for (const node of list) {
|
||
if (node.type === 'object' && ids.has(node.id)) selected.push(node)
|
||
if (node.children?.length) walk(node.children)
|
||
}
|
||
}
|
||
|
||
walk(nodes)
|
||
return selected
|
||
}
|
||
|
||
const getSelectedObjects = (nodes: TreeNode[], ids: Set<string>): SqlExplorerSelectedObject[] =>
|
||
getSelectedNodes(nodes, ids)
|
||
.map(mapNodeToSelectedObject)
|
||
.filter((obj): obj is SqlExplorerSelectedObject => obj !== null)
|
||
|
||
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
|
||
const fullName =
|
||
t.fullName ??
|
||
(isPostgreSql ? `"${t.schemaName}"."${t.tableName}"` : `[${t.schemaName}].[${t.tableName}]`)
|
||
return isPostgreSql ? `DROP TABLE IF EXISTS ${fullName};` : `DROP TABLE ${fullName};`
|
||
}
|
||
const obj = node.data as SqlNativeObjectDto
|
||
const fullName =
|
||
obj.fullName ??
|
||
(isPostgreSql
|
||
? `"${obj.schemaName}"."${obj.objectName}"`
|
||
: `[${obj.schemaName}].[${obj.objectName}]`)
|
||
const keyword =
|
||
node.folder === 'views' ? 'VIEW' : node.folder === 'procedures' ? 'PROCEDURE' : 'FUNCTION'
|
||
return isPostgreSql ? `DROP ${keyword} IF EXISTS ${fullName};` : `DROP ${keyword} ${fullName};`
|
||
}
|
||
|
||
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}`]
|
||
}
|
||
|
||
/**
|
||
* Onaylanan nesneleri tek tek dusurur; bir nesnenin hatasi digerlerini engellemez.
|
||
* Basarili olanlar icin SQL seed dosyalari ve (tablolarda) CRUD endpoint'leri de temizlenir.
|
||
*/
|
||
const handleDrop = async () => {
|
||
if (!dropTargets?.length || !dataSource) return
|
||
|
||
setDropping(true)
|
||
const dropped: TreeNode[] = []
|
||
const failures: string[] = []
|
||
|
||
try {
|
||
for (const node of dropTargets) {
|
||
try {
|
||
const result = await sqlObjectManagerService.executeQuery({
|
||
queryText: buildDropSql(node),
|
||
dataSourceCode: dataSource,
|
||
})
|
||
if (result.data?.success === false) {
|
||
throw new Error(result.data.error || result.data.message)
|
||
}
|
||
dropped.push(node)
|
||
} catch (err) {
|
||
failures.push(
|
||
`${node.label}: ${getErrorMessage(err, translate('::App.Platform.IslemBasarisiz'))}`,
|
||
)
|
||
}
|
||
}
|
||
|
||
if (dropped.length > 0) {
|
||
// configs/seeds/SqlData altindaki eslesen seed dosyalari da silinir.
|
||
try {
|
||
const fileNames = [
|
||
...new Set(dropped.flatMap(getSqlDataFileCandidates).filter(Boolean)),
|
||
]
|
||
if (fileNames.length > 0) {
|
||
await sqlObjectManagerService.deleteSqlDataFiles({ fileNames })
|
||
}
|
||
} catch {
|
||
// Non-blocking: object drop succeeded even if seed file cleanup fails.
|
||
}
|
||
|
||
// Tablo dusurulduyse ona ait CRUD endpoint'leri de anlamsiz kalir; kayitlar
|
||
// silinince backend crud/{EntityName}.json seed dosyasini da kaldirir.
|
||
const droppedTables = dropped.filter((node) => node.folder === 'tables')
|
||
if (droppedTables.length > 0) {
|
||
for (const node of droppedTables) {
|
||
try {
|
||
const table = node.data as DatabaseTableDto
|
||
await developerKitService.deleteGeneratedEndpointsByEntity(
|
||
toEntityName(table.tableName),
|
||
)
|
||
} catch {
|
||
// Non-blocking: izin yoksa ya da endpoint yoksa drop yine basarilidir.
|
||
}
|
||
}
|
||
onCrudEndpointsChanged?.()
|
||
}
|
||
|
||
// Silinen nesneler secimde kalmasin; aksi halde toolbar sayaci yaniltir.
|
||
const droppedIds = new Set(dropped.map((node) => node.id))
|
||
setSelectedObjectIds((prev) => new Set([...prev].filter((id) => !droppedIds.has(id))))
|
||
}
|
||
|
||
toast.push(
|
||
<Notification
|
||
type={failures.length > 0 ? 'warning' : 'success'}
|
||
title={
|
||
failures.length > 0
|
||
? translate('::App.Platform.Warning')
|
||
: translate('::App.Platform.Success')
|
||
}
|
||
>
|
||
{failures.length > 0
|
||
? failures.join(' | ')
|
||
: `${translate('::App.Platform.OperationCompleted')} (${dropped.length})`}
|
||
</Notification>,
|
||
{ placement: 'bottom-end' },
|
||
)
|
||
|
||
setDropTargets(null)
|
||
loadObjects()
|
||
} 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 getNodeCrudEndpoints = (node: TreeNode): CrudEndpoint[] => {
|
||
if (node.type !== 'object' || node.folder !== 'tables' || !crudEndpointsByEntity) return []
|
||
const table = node.data as DatabaseTableDto
|
||
return crudEndpointsByEntity.get(toEntityName(table.tableName)) ?? []
|
||
}
|
||
|
||
const buildCrudEndpointTitle = (endpoint: CrudEndpoint) =>
|
||
`${endpoint.operationType} — ${endpoint.method} ${endpoint.path} (${
|
||
endpoint.isActive
|
||
? translate('::App.Listform.ListformField.IsActive')
|
||
: translate('::App.DeveloperKitCrudEndpoints.InactiveCount')
|
||
})`
|
||
|
||
const renderNode = (node: TreeNode, level = 0) => {
|
||
const isExpanded = expandedNodes.has(node.id)
|
||
const isChecked = selectedObjectIds.has(node.id)
|
||
const crudEndpoints = getNodeCrudEndpoints(node)
|
||
return (
|
||
<div key={node.id}>
|
||
<div
|
||
className={`group flex items-center gap-2 py-1 px-2 cursor-pointer rounded ${
|
||
isChecked
|
||
? 'bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/40'
|
||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||
}`}
|
||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||
title={node.type === 'object' ? translate('::App.Platform.SelectOrOpenHint') : undefined}
|
||
onClick={() => handleNodeClick(node)}
|
||
onDoubleClick={() => handleNodeDoubleClick(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>
|
||
{crudEndpoints.length > 0 && (
|
||
<CrudEndpointBadges
|
||
endpoints={crudEndpoints}
|
||
buildTitle={buildCrudEndpointTitle}
|
||
onClick={
|
||
onManageCrudEndpoints
|
||
? () => {
|
||
const table = node.data as DatabaseTableDto
|
||
onManageCrudEndpoints(table.schemaName, table.tableName)
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
)}
|
||
{node.type === 'object' && (
|
||
<Button
|
||
title="Drop"
|
||
size="xs"
|
||
variant="plain"
|
||
shape="circle"
|
||
icon={<FaTrash className="text-xs" />}
|
||
className="opacity-0 group-hover:opacity-100 !h-6 !w-6 !px-0 hover:!bg-red-100 dark:hover:!bg-red-900 text-red-500 transition-opacity flex-shrink-0"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setDropTargets([node])
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
{isExpanded && node.children && (
|
||
<div>{node.children.map((c) => renderNode(c, level + 1))}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const filteredTree = filterTree(treeData, filterText)
|
||
|
||
/** CRUD endpoint uretimi yalnizca tablolar icin anlamli. */
|
||
const selectedTableCount = getSelectedNodes(treeData, selectedObjectIds).filter(
|
||
(node) => node.folder === 'tables',
|
||
).length
|
||
|
||
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">
|
||
{/*
|
||
Nesne islemleri seridi. Yuksekligi sabit (min-h) — secim yapildikca
|
||
butonlar gorunse de agac asagi kaymaz.
|
||
*/}
|
||
<div className="flex min-h-[34px] items-center gap-2 border-b px-3 py-1 bg-gray-50 dark:bg-gray-800 flex-shrink-0 rounded-t-lg text-xs">
|
||
{selectedObjectIds.size > 0 && (
|
||
<>
|
||
<span className="font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap">
|
||
{selectedObjectIds.size} {translate('::App.SqlQueryManager.Selected')}
|
||
</span>
|
||
<Button
|
||
size="xs"
|
||
variant="plain"
|
||
className="!h-auto !px-1 !py-0 text-xs text-blue-600 hover:underline dark:text-blue-300"
|
||
onClick={() => setSelectedObjectIds(new Set())}
|
||
>
|
||
{translate('::App.SqlQueryManager.ClearSelection')}
|
||
</Button>
|
||
</>
|
||
)}
|
||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||
{onCopySelectedObjects && selectedObjectIds.size > 0 && (
|
||
<Button
|
||
size="xs"
|
||
variant="plain"
|
||
icon={<FaCopy className="text-xs" />}
|
||
className="!h-auto !px-1 !py-0 text-xs text-gray-600 hover:underline dark:text-gray-300"
|
||
title={translate('::App.Platform.CopyOrExecuteSql')}
|
||
onClick={onCopySelectedObjects}
|
||
>
|
||
{translate('::App.Platform.CopySelectedObjects')}
|
||
</Button>
|
||
)}
|
||
{selectedObjectIds.size > 0 && (
|
||
<Button
|
||
size="xs"
|
||
variant="plain"
|
||
icon={<FaTrash className="text-xs" />}
|
||
className="!h-auto !px-1 !py-0 text-xs text-red-600 hover:underline dark:text-red-400"
|
||
onClick={() => setDropTargets(getSelectedNodes(treeData, selectedObjectIds))}
|
||
>
|
||
{translate('::App.SqlQueryManager.DropSelected')}
|
||
</Button>
|
||
)}
|
||
{onGenerateCrudEndpoints && selectedTableCount > 0 && (
|
||
<Button
|
||
size="xs"
|
||
variant="plain"
|
||
icon={<FaBolt className="text-xs" />}
|
||
loading={isGeneratingCrudEndpoints}
|
||
className="!h-auto !px-1 !py-0 text-xs text-amber-600 hover:underline dark:text-amber-400"
|
||
title={translate('::App.DeveloperKitCrudEndpoints.CreateCrudEndpointHint')}
|
||
onClick={onGenerateCrudEndpoints}
|
||
>
|
||
{translate('::App.DeveloperKitCrudEndpoints.CreateCrudEndpoint')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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-800 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<Button
|
||
onClick={loadObjects}
|
||
disabled={loading || !dataSource}
|
||
size="sm"
|
||
variant="default"
|
||
shape="none"
|
||
icon={<FaSyncAlt className={loading ? 'animate-spin' : ''} />}
|
||
className="!rounded-md border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||
title={translate('::App.Platform.Refresh')}
|
||
/>
|
||
</div>
|
||
|
||
{/* Tree */}
|
||
<div className="flex-1 overflow-auto">
|
||
{loading && (
|
||
<div className="text-center py-8 text-gray-500 text-sm">
|
||
{translate('::App.Platform.LoadingWithThreeDot')}
|
||
</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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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>
|
||
)}
|
||
|
||
{isTableObj && onManageCrudEndpoints && (
|
||
<Button
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
onManageCrudEndpoints(t.schemaName, t.tableName)
|
||
closeCtx()
|
||
}}
|
||
>
|
||
<FaBolt className="text-amber-500" />{' '}
|
||
{translate('::App.SqlQueryManager.CrudEndpoints')}
|
||
</Button>
|
||
)}
|
||
|
||
{/* NATIVE object <20> View Definition */}
|
||
{isNativeObj && (
|
||
<Button
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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
|
||
variant="plain"
|
||
shape="none"
|
||
className="w-full !h-auto !justify-start !rounded-none !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 — tekil ve toplu silmede ayni diyalog */}
|
||
{dropTargets && dropTargets.length > 0 && (
|
||
<div
|
||
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||
onClick={() => !dropping && setDropTargets(null)}
|
||
>
|
||
<div
|
||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-lg 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">
|
||
{translate('::App.SqlQueryManager.DropObjects')} ({dropTargets.length})
|
||
</h6>
|
||
</div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||
{translate('::App.SqlQueryManager.DropObjectsWarning')}
|
||
</p>
|
||
<div className="mb-4 max-h-60 overflow-auto rounded bg-gray-100 dark:bg-gray-700">
|
||
{dropTargets.map((node) => (
|
||
<code key={node.id} className="block px-3 py-1 text-sm break-all">
|
||
{buildDropSql(node)}
|
||
</code>
|
||
))}
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button
|
||
disabled={dropping}
|
||
size="sm"
|
||
variant="default"
|
||
className="disabled:opacity-50"
|
||
onClick={() => setDropTargets(null)}
|
||
>
|
||
{translate('::App.Platform.Cancel')}
|
||
</Button>
|
||
<Button
|
||
disabled={dropping}
|
||
size="sm"
|
||
variant="solid"
|
||
color="red-600"
|
||
className="hover:!bg-red-700 disabled:opacity-50 flex items-center gap-2"
|
||
onClick={handleDrop}
|
||
>
|
||
{dropping && <FaSyncAlt className="animate-spin text-xs" />}
|
||
{translate('::App.SqlQueryManager.Drop')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default SqlObjectExplorer
|