2026-08-19 07:50:33 +00:00
|
|
|
/**
|
|
|
|
|
* SQL View Designer — SSMS Query Designer duzeninde gorsel view olusturma araci.
|
|
|
|
|
*
|
|
|
|
|
* Uc panel: Diagram (tablolar + join oklari) / Criteria grid / T-SQL onizleme.
|
|
|
|
|
* Mevcut bir view acilirken tanim `parseViewSql` ile geri okunmaya calisilir;
|
|
|
|
|
* tasarimcinin kanonik sekline uymayan bir tanimda dialog "ham SQL" moduna
|
|
|
|
|
* duser ve kullanici Query Editor'dan devam eder.
|
|
|
|
|
*/
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { DndContext, type DragEndEvent, type DragMoveEvent } from '@dnd-kit/core'
|
|
|
|
|
import { Button, Dialog, Notification, toast } from '@/components/ui'
|
|
|
|
|
import {
|
|
|
|
|
FaCloudUploadAlt,
|
|
|
|
|
FaCode,
|
|
|
|
|
FaColumns,
|
|
|
|
|
FaExclamationTriangle,
|
|
|
|
|
FaEye,
|
|
|
|
|
FaMinus,
|
|
|
|
|
FaPlus,
|
|
|
|
|
FaProjectDiagram,
|
|
|
|
|
FaSearch,
|
|
|
|
|
FaTable,
|
|
|
|
|
} from 'react-icons/fa'
|
|
|
|
|
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
|
|
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
|
|
|
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
|
|
|
|
import DiagramPane, { type PendingLink } from './sqlViewDesigner/DiagramPane'
|
|
|
|
|
import CriteriaGrid from './sqlViewDesigner/CriteriaGrid'
|
2026-08-20 12:01:10 +00:00
|
|
|
import Input from '@/components/ui/Input'
|
2026-08-19 07:50:33 +00:00
|
|
|
import {
|
|
|
|
|
BOX_DEFAULT_ROWS,
|
|
|
|
|
createEmptySettings,
|
|
|
|
|
createExpressionRow,
|
|
|
|
|
createRow,
|
|
|
|
|
generateViewSql,
|
|
|
|
|
isDerivedSource,
|
|
|
|
|
newId,
|
|
|
|
|
nextSourcePosition,
|
|
|
|
|
parseViewSql,
|
|
|
|
|
suggestAlias,
|
|
|
|
|
validateViewModel,
|
|
|
|
|
type CriteriaRow,
|
|
|
|
|
type JoinOperator,
|
|
|
|
|
type ViewModel,
|
|
|
|
|
type ViewSource,
|
|
|
|
|
} from './sqlViewDesigner/viewModel'
|
|
|
|
|
|
|
|
|
|
interface ViewDesignerDialogProps {
|
|
|
|
|
isOpen: boolean
|
|
|
|
|
onClose: () => void
|
|
|
|
|
dataSource: string | null
|
|
|
|
|
isPostgreSql?: boolean
|
|
|
|
|
/** Dolu ise duzenleme modu; view tanimi cekilip parse edilmeye calisilir. */
|
|
|
|
|
initialViewData?: { schemaName: string; objectName: string } | null
|
|
|
|
|
onDeployed?: (view: { schemaName: string; objectName: string }) => void | Promise<void>
|
|
|
|
|
/** Parse edilemeyen tanimi Query Editor'a devretmek icin. */
|
|
|
|
|
onOpenInEditor?: (sql: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CatalogObject {
|
|
|
|
|
schemaName: string
|
|
|
|
|
objectName: string
|
|
|
|
|
objectType: 'table' | 'view'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ForeignKeyRow {
|
|
|
|
|
parentTable: string
|
|
|
|
|
parentColumn: string
|
|
|
|
|
referencedTable: string
|
|
|
|
|
referencedColumn: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const columnKey = (schemaName: string, objectName: string) => `${schemaName}.${objectName}`
|
|
|
|
|
|
|
|
|
|
const SqlViewDesignerDialog = ({
|
|
|
|
|
isOpen,
|
|
|
|
|
onClose,
|
|
|
|
|
dataSource,
|
|
|
|
|
isPostgreSql = false,
|
|
|
|
|
initialViewData,
|
|
|
|
|
onDeployed,
|
|
|
|
|
onOpenInEditor,
|
|
|
|
|
}: ViewDesignerDialogProps) => {
|
|
|
|
|
const { translate } = useLocalization()
|
|
|
|
|
const isEditMode = !!initialViewData
|
|
|
|
|
|
|
|
|
|
const [sources, setSources] = useState<ViewSource[]>([])
|
|
|
|
|
const [rows, setRows] = useState<CriteriaRow[]>([])
|
|
|
|
|
const [settings, setSettings] = useState(() => createEmptySettings(isPostgreSql))
|
|
|
|
|
|
|
|
|
|
const [catalog, setCatalog] = useState<CatalogObject[]>([])
|
|
|
|
|
const [catalogLoading, setCatalogLoading] = useState(false)
|
|
|
|
|
const [catalogSearch, setCatalogSearch] = useState('')
|
|
|
|
|
const [paletteOpen, setPaletteOpen] = useState(true)
|
|
|
|
|
const [columnCache, setColumnCache] = useState<Record<string, DatabaseColumnDto[]>>({})
|
|
|
|
|
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(new Set())
|
|
|
|
|
const [foreignKeys, setForeignKeys] = useState<ForeignKeyRow[]>([])
|
|
|
|
|
|
|
|
|
|
const [zoom, setZoom] = useState(1)
|
|
|
|
|
const [dragPreview, setDragPreview] = useState<{
|
|
|
|
|
id: string
|
|
|
|
|
delta: { x: number; y: number }
|
|
|
|
|
} | null>(null)
|
|
|
|
|
const [pendingLink, setPendingLink] = useState<PendingLink | null>(null)
|
|
|
|
|
const [selectedJoinKey, setSelectedJoinKey] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
const [showDiagram, setShowDiagram] = useState(true)
|
|
|
|
|
const [showCriteria, setShowCriteria] = useState(true)
|
|
|
|
|
const [showSql, setShowSql] = useState(true)
|
|
|
|
|
|
|
|
|
|
const [isDeploying, setIsDeploying] = useState(false)
|
|
|
|
|
const [definitionLoading, setDefinitionLoading] = useState(false)
|
|
|
|
|
/** Parse edilemeyen mevcut view'in ham tanimi; dolu ise tasarimci devre disi. */
|
|
|
|
|
const [rawDefinition, setRawDefinition] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
// ── Katalog ───────────────────────────────────────────────────────────────
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isOpen || !dataSource) return
|
|
|
|
|
setCatalogLoading(true)
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
.getAllObjects(dataSource)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
const tables: CatalogObject[] = (res.data?.tables ?? []).map((t) => ({
|
|
|
|
|
schemaName: t.schemaName,
|
|
|
|
|
objectName: t.tableName,
|
|
|
|
|
objectType: 'table',
|
|
|
|
|
}))
|
|
|
|
|
const views: CatalogObject[] = (res.data?.views ?? [])
|
|
|
|
|
.filter(
|
|
|
|
|
(v) =>
|
|
|
|
|
!initialViewData ||
|
|
|
|
|
v.schemaName !== initialViewData.schemaName ||
|
|
|
|
|
v.objectName !== initialViewData.objectName,
|
|
|
|
|
)
|
|
|
|
|
.map((v) => ({
|
|
|
|
|
schemaName: v.schemaName,
|
|
|
|
|
objectName: v.objectName,
|
|
|
|
|
objectType: 'view' as const,
|
|
|
|
|
}))
|
|
|
|
|
setCatalog([...tables, ...views])
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setCatalog([]))
|
|
|
|
|
.finally(() => setCatalogLoading(false))
|
|
|
|
|
}, [isOpen, dataSource, initialViewData])
|
|
|
|
|
|
|
|
|
|
// ── FK listesi (otomatik join onerisi icin) ───────────────────────────────
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isOpen || !dataSource || isPostgreSql) return
|
|
|
|
|
const fkQuery = [
|
|
|
|
|
'SELECT',
|
|
|
|
|
' pt.name AS parentTable,',
|
|
|
|
|
' pc.name AS parentColumn,',
|
|
|
|
|
' rt.name AS referencedTable,',
|
|
|
|
|
' rc.name AS referencedColumn',
|
|
|
|
|
'FROM sys.foreign_keys fk',
|
|
|
|
|
'INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id',
|
|
|
|
|
'INNER JOIN sys.tables pt ON fkc.parent_object_id = pt.object_id',
|
|
|
|
|
'INNER JOIN sys.columns pc ON fkc.parent_object_id = pc.object_id AND fkc.parent_column_id = pc.column_id',
|
|
|
|
|
'INNER JOIN sys.tables rt ON fkc.referenced_object_id = rt.object_id',
|
|
|
|
|
'INNER JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id',
|
|
|
|
|
].join('\n')
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
.executeQuery({ queryText: fkQuery, dataSourceCode: dataSource })
|
|
|
|
|
.then((res) => setForeignKeys((res.data?.data as ForeignKeyRow[]) ?? []))
|
|
|
|
|
.catch(() => setForeignKeys([]))
|
|
|
|
|
}, [isOpen, dataSource, isPostgreSql])
|
|
|
|
|
|
|
|
|
|
// ── Duzenleme modu ────────────────────────────────────────────────────────
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isOpen) return
|
|
|
|
|
if (!initialViewData || !dataSource) {
|
|
|
|
|
setRawDefinition(null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setDefinitionLoading(true)
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
.getNativeObjectDefinition(dataSource, initialViewData.schemaName, initialViewData.objectName)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
const definition = res.data || ''
|
|
|
|
|
const parsed = parseViewSql(definition)
|
|
|
|
|
if (parsed) {
|
|
|
|
|
setSources(parsed.sources)
|
|
|
|
|
setRows(parsed.rows)
|
|
|
|
|
setSettings(parsed.settings)
|
|
|
|
|
setRawDefinition(null)
|
|
|
|
|
} else {
|
|
|
|
|
setRawDefinition(definition)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setRawDefinition(''))
|
|
|
|
|
.finally(() => setDefinitionLoading(false))
|
|
|
|
|
}, [isOpen, dataSource, initialViewData])
|
|
|
|
|
|
|
|
|
|
// ── Kolon metadata ────────────────────────────────────────────────────────
|
|
|
|
|
const ensureColumns = useCallback(
|
|
|
|
|
(schemaName: string, objectName: string) => {
|
|
|
|
|
const key = columnKey(schemaName, objectName)
|
|
|
|
|
if (!dataSource || columnCache[key]) return
|
|
|
|
|
setLoadingKeys((prev) => new Set(prev).add(key))
|
|
|
|
|
sqlObjectManagerService
|
|
|
|
|
.getTableColumns(dataSource, schemaName, objectName)
|
|
|
|
|
.then((res) => setColumnCache((prev) => ({ ...prev, [key]: res.data ?? [] })))
|
|
|
|
|
.catch(() => setColumnCache((prev) => ({ ...prev, [key]: [] })))
|
|
|
|
|
.finally(() =>
|
|
|
|
|
setLoadingKeys((prev) => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
next.delete(key)
|
|
|
|
|
return next
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
[dataSource, columnCache],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
sources.forEach((s) => {
|
|
|
|
|
if (!isDerivedSource(s)) ensureColumns(s.schemaName, s.objectName)
|
|
|
|
|
})
|
|
|
|
|
}, [sources, ensureColumns])
|
|
|
|
|
|
|
|
|
|
const columnsOf = useCallback(
|
|
|
|
|
(sourceId: string): DatabaseColumnDto[] => {
|
|
|
|
|
const src = sources.find((s) => s.id === sourceId)
|
|
|
|
|
if (!src) return []
|
|
|
|
|
if (isDerivedSource(src)) {
|
|
|
|
|
return (src.derivedColumns ?? []).map((columnName) => ({
|
|
|
|
|
columnName,
|
|
|
|
|
dataType: 'subquery',
|
|
|
|
|
isNullable: true,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
return columnCache[columnKey(src.schemaName, src.objectName)] ?? []
|
|
|
|
|
},
|
|
|
|
|
[sources, columnCache],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const loadingSourceIds = useMemo(() => {
|
|
|
|
|
const set = new Set<string>()
|
|
|
|
|
sources.forEach((s) => {
|
|
|
|
|
if (!isDerivedSource(s) && loadingKeys.has(columnKey(s.schemaName, s.objectName))) {
|
|
|
|
|
set.add(s.id)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return set
|
|
|
|
|
}, [sources, loadingKeys])
|
|
|
|
|
|
|
|
|
|
// ── Kaynak islemleri ──────────────────────────────────────────────────────
|
|
|
|
|
/** Iki tablo arasinda FK varsa hazir join kosulu uretir. */
|
|
|
|
|
const suggestJoin = (target: CatalogObject, existing: ViewSource[]) => {
|
|
|
|
|
for (const src of [...existing].reverse()) {
|
|
|
|
|
const fk = foreignKeys.find(
|
|
|
|
|
(f) =>
|
|
|
|
|
(f.parentTable === target.objectName && f.referencedTable === src.objectName) ||
|
|
|
|
|
(f.parentTable === src.objectName && f.referencedTable === target.objectName),
|
|
|
|
|
)
|
|
|
|
|
if (!fk) continue
|
|
|
|
|
const targetIsParent = fk.parentTable === target.objectName
|
|
|
|
|
return {
|
|
|
|
|
id: newId(),
|
|
|
|
|
leftSourceId: src.id,
|
|
|
|
|
leftColumn: targetIsParent ? fk.referencedColumn : fk.parentColumn,
|
|
|
|
|
operator: '=' as JoinOperator,
|
|
|
|
|
rightColumn: targetIsParent ? fk.parentColumn : fk.referencedColumn,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addSource = (obj: CatalogObject) => {
|
|
|
|
|
setSources((prev) => {
|
|
|
|
|
const pos = nextSourcePosition(prev.length)
|
|
|
|
|
const suggested = prev.length > 0 ? suggestJoin(obj, prev) : null
|
|
|
|
|
const next: ViewSource = {
|
|
|
|
|
id: newId(),
|
|
|
|
|
schemaName: obj.schemaName,
|
|
|
|
|
objectName: obj.objectName,
|
|
|
|
|
alias: suggestAlias(
|
|
|
|
|
obj.objectName,
|
|
|
|
|
prev.map((s) => s.alias),
|
|
|
|
|
),
|
|
|
|
|
joinKind: 'INNER',
|
|
|
|
|
conditions: suggested ? [suggested] : [],
|
|
|
|
|
x: pos.x,
|
|
|
|
|
y: pos.y,
|
|
|
|
|
collapsed: false,
|
|
|
|
|
showSelectedOnly: false,
|
|
|
|
|
visibleRows: BOX_DEFAULT_ROWS,
|
|
|
|
|
}
|
|
|
|
|
return [...prev, next]
|
|
|
|
|
})
|
|
|
|
|
ensureColumns(obj.schemaName, obj.objectName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removeSource = (sourceId: string) => {
|
|
|
|
|
setSources((prev) =>
|
|
|
|
|
prev
|
|
|
|
|
.filter((s) => s.id !== sourceId)
|
|
|
|
|
.map((s) => ({
|
|
|
|
|
...s,
|
|
|
|
|
conditions: s.conditions.filter((c) => c.leftSourceId !== sourceId),
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
setRows((prev) => prev.filter((r) => r.sourceId !== sourceId))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateSource = (sourceId: string, patch: Partial<ViewSource>) =>
|
|
|
|
|
setSources((prev) => prev.map((s) => (s.id === sourceId ? { ...s, ...patch } : s)))
|
|
|
|
|
|
|
|
|
|
const updateJoin = (sourceId: string, conditionId: string, patch: { operator?: JoinOperator }) =>
|
|
|
|
|
setSources((prev) =>
|
|
|
|
|
prev.map((s) =>
|
|
|
|
|
s.id === sourceId
|
|
|
|
|
? {
|
|
|
|
|
...s,
|
|
|
|
|
conditions: s.conditions.map((c) => (c.id === conditionId ? { ...c, ...patch } : c)),
|
|
|
|
|
}
|
|
|
|
|
: s,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const removeJoin = (sourceId: string, conditionId: string) =>
|
|
|
|
|
setSources((prev) =>
|
|
|
|
|
prev.map((s) =>
|
|
|
|
|
s.id === sourceId
|
|
|
|
|
? { ...s, conditions: s.conditions.filter((c) => c.id !== conditionId) }
|
|
|
|
|
: s,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/** Diagramda kolondan kolona baglanti tamamlandiginda join kurar. */
|
|
|
|
|
const completeLink = (target: PendingLink) => {
|
|
|
|
|
if (!pendingLink) return
|
|
|
|
|
setSources((prev) => {
|
|
|
|
|
const fromIdx = prev.findIndex((s) => s.id === pendingLink.sourceId)
|
|
|
|
|
const toIdx = prev.findIndex((s) => s.id === target.sourceId)
|
|
|
|
|
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return prev
|
|
|
|
|
// Join kosulu her zaman FROM'da sonra gelen kaynagin uzerinde durur.
|
|
|
|
|
const ownerIdx = Math.max(fromIdx, toIdx)
|
|
|
|
|
const otherIdx = Math.min(fromIdx, toIdx)
|
|
|
|
|
const ownerIsTarget = ownerIdx === toIdx
|
|
|
|
|
return prev.map((s, i) => {
|
|
|
|
|
if (i !== ownerIdx) return s
|
|
|
|
|
const exists = s.conditions.some(
|
|
|
|
|
(c) =>
|
|
|
|
|
c.leftSourceId === prev[otherIdx].id &&
|
|
|
|
|
c.leftColumn === (ownerIsTarget ? pendingLink.columnName : target.columnName) &&
|
|
|
|
|
c.rightColumn === (ownerIsTarget ? target.columnName : pendingLink.columnName),
|
|
|
|
|
)
|
|
|
|
|
if (exists) return s
|
|
|
|
|
return {
|
|
|
|
|
...s,
|
|
|
|
|
// Kosul eklenen CROSS JOIN otomatik olarak INNER'a doner.
|
|
|
|
|
joinKind: s.joinKind === 'CROSS' ? ('INNER' as const) : s.joinKind,
|
|
|
|
|
conditions: [
|
|
|
|
|
...s.conditions,
|
|
|
|
|
{
|
|
|
|
|
id: newId(),
|
|
|
|
|
leftSourceId: prev[otherIdx].id,
|
|
|
|
|
leftColumn: ownerIsTarget ? pendingLink.columnName : target.columnName,
|
|
|
|
|
operator: '=' as JoinOperator,
|
|
|
|
|
rightColumn: ownerIsTarget ? target.columnName : pendingLink.columnName,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
setPendingLink(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Grid satirlari ────────────────────────────────────────────────────────
|
|
|
|
|
const isColumnSelected = useCallback(
|
|
|
|
|
(sourceId: string, columnName: string) =>
|
|
|
|
|
rows.some((r) => r.sourceId === sourceId && r.columnName === columnName && r.output),
|
|
|
|
|
[rows],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const toggleColumn = (sourceId: string, columnName: string) => {
|
|
|
|
|
setRows((prev) => {
|
|
|
|
|
const existing = prev.find(
|
|
|
|
|
(r) => r.sourceId === sourceId && r.columnName === columnName && r.output,
|
|
|
|
|
)
|
|
|
|
|
if (existing) {
|
|
|
|
|
// Filtresi varsa satiri koru, yalnizca ciktidan cikar.
|
|
|
|
|
const hasFilter = existing.filters.some((f) => f.trim())
|
|
|
|
|
if (hasFilter) {
|
|
|
|
|
return prev.map((r) =>
|
|
|
|
|
r.id === existing.id ? { ...r, output: false, groupMode: 'Where' as const } : r,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return prev.filter((r) => r.id !== existing.id)
|
|
|
|
|
}
|
|
|
|
|
return [...prev, createRow(sourceId, columnName)]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toggleAllColumns = (sourceId: string) => {
|
|
|
|
|
const list = columnsOf(sourceId)
|
|
|
|
|
const allSelected =
|
|
|
|
|
list.length > 0 && list.every((c) => isColumnSelected(sourceId, c.columnName))
|
|
|
|
|
if (allSelected) {
|
|
|
|
|
setRows((prev) => prev.filter((r) => !(r.sourceId === sourceId && r.output)))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setRows((prev) => {
|
|
|
|
|
const next = [...prev]
|
|
|
|
|
list.forEach((c) => {
|
|
|
|
|
if (
|
|
|
|
|
!next.some((r) => r.sourceId === sourceId && r.columnName === c.columnName && r.output)
|
|
|
|
|
) {
|
|
|
|
|
next.push(createRow(sourceId, c.columnName))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateRow = (id: string, patch: Partial<CriteriaRow>) =>
|
|
|
|
|
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)))
|
|
|
|
|
|
|
|
|
|
const updateFilter = (id: string, index: number, value: string) =>
|
|
|
|
|
setRows((prev) =>
|
|
|
|
|
prev.map((r) => {
|
|
|
|
|
if (r.id !== id) return r
|
|
|
|
|
const filters = [...r.filters]
|
|
|
|
|
while (filters.length <= index) filters.push('')
|
|
|
|
|
filters[index] = value
|
|
|
|
|
return { ...r, filters }
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const removeRow = (id: string) => setRows((prev) => prev.filter((r) => r.id !== id))
|
|
|
|
|
|
|
|
|
|
const moveRow = (id: string, delta: number) =>
|
|
|
|
|
setRows((prev) => {
|
|
|
|
|
const idx = prev.findIndex((r) => r.id === id)
|
|
|
|
|
const target = idx + delta
|
|
|
|
|
if (idx < 0 || target < 0 || target >= prev.length) return prev
|
|
|
|
|
const next = [...prev]
|
|
|
|
|
const [item] = next.splice(idx, 1)
|
|
|
|
|
next.splice(target, 0, item)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const addExpressionRow = () => setRows((prev) => [...prev, createExpressionRow()])
|
|
|
|
|
|
|
|
|
|
const addEmptyRow = () => {
|
|
|
|
|
const src = sources[0]
|
|
|
|
|
if (!src) return
|
|
|
|
|
const first = columnsOf(src.id)[0]
|
|
|
|
|
setRows((prev) => [...prev, createRow(src.id, first?.columnName ?? '')])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sigma dugmesi. Kapatilirken aggregate secimleri geri alinir, yoksa grid'de
|
|
|
|
|
* gorunmeyen bir mod SQL'i sessizce degistirirdi.
|
|
|
|
|
*/
|
|
|
|
|
const toggleGroupBy = (enabled: boolean) => {
|
|
|
|
|
setSettings((s) => ({ ...s, groupBy: enabled }))
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
setRows((prev) =>
|
|
|
|
|
prev.map((r) => (r.groupMode === 'Where' ? r : { ...r, groupMode: 'GroupBy' as const })),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Turetilenler ──────────────────────────────────────────────────────────
|
|
|
|
|
const model: ViewModel = useMemo(() => ({ sources, rows, settings }), [sources, rows, settings])
|
|
|
|
|
const grouped = settings.groupBy
|
|
|
|
|
/**
|
|
|
|
|
* Serbest ifadeler ve alt sorgu govdeleri alias'lara metin olarak refere
|
|
|
|
|
* eder; alias degisimi bunlari sessizce bozacagi icin kilitlenir.
|
|
|
|
|
*/
|
|
|
|
|
const lockAliases = useMemo(
|
|
|
|
|
() => sources.some(isDerivedSource) || rows.some((r) => r.expression !== undefined),
|
|
|
|
|
[sources, rows],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const validationTexts = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
noSource: translate('::App.SqlViewDesigner.ValidationNoSource'),
|
|
|
|
|
aliasRequired: translate('::App.SqlViewDesigner.ValidationAliasRequired'),
|
|
|
|
|
aliasDuplicate: translate('::App.SqlViewDesigner.ValidationAliasDuplicate'),
|
|
|
|
|
joinConditionRequired: translate('::App.SqlViewDesigner.ValidationJoinRequired'),
|
|
|
|
|
noOutputColumn: translate('::App.SqlViewDesigner.ValidationNoColumn'),
|
|
|
|
|
columnAliasDuplicate: translate('::App.SqlViewDesigner.ValidationColumnAliasDuplicate'),
|
|
|
|
|
expressionAliasRequired: translate('::App.SqlViewDesigner.ValidationExpressionAlias'),
|
|
|
|
|
viewNameRequired: translate('::App.SqlViewDesigner.ValidationViewNameRequired'),
|
|
|
|
|
viewNameInvalid: translate('::App.SqlViewDesigner.ValidationViewNameInvalid'),
|
|
|
|
|
}),
|
|
|
|
|
[translate],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const issues = useMemo(() => validateViewModel(model, validationTexts), [model, validationTexts])
|
|
|
|
|
const generatedSql = useMemo(
|
|
|
|
|
() => generateViewSql(model, { isPostgreSql }),
|
|
|
|
|
[model, isPostgreSql],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const filteredCatalog = useMemo(() => {
|
|
|
|
|
const term = catalogSearch.trim().toLowerCase()
|
|
|
|
|
if (!term) return catalog
|
|
|
|
|
return catalog.filter((o) => `${o.schemaName}.${o.objectName}`.toLowerCase().includes(term))
|
|
|
|
|
}, [catalog, catalogSearch])
|
|
|
|
|
|
|
|
|
|
// ── Kapatma / deploy ──────────────────────────────────────────────────────
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
setSources([])
|
|
|
|
|
setRows([])
|
|
|
|
|
setSettings(createEmptySettings(isPostgreSql))
|
|
|
|
|
setCatalogSearch('')
|
|
|
|
|
setRawDefinition(null)
|
|
|
|
|
setPendingLink(null)
|
|
|
|
|
setSelectedJoinKey(null)
|
|
|
|
|
setZoom(1)
|
|
|
|
|
onClose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeploy = async () => {
|
|
|
|
|
if (!dataSource || !generatedSql) return
|
|
|
|
|
setIsDeploying(true)
|
|
|
|
|
try {
|
|
|
|
|
const executable = generatedSql
|
|
|
|
|
.split(/^\s*GO\s*$/gim)
|
|
|
|
|
.map((b) => b.trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
|
|
|
|
const result = await sqlObjectManagerService.executeQuery({
|
|
|
|
|
queryText: executable,
|
|
|
|
|
dataSourceCode: dataSource,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (result.data?.success) {
|
|
|
|
|
toast.push(
|
|
|
|
|
<Notification type="success" title={translate('::App.Platform.Success')}>
|
|
|
|
|
{translate('::App.SqlViewDesigner.DeploySucceeded')}
|
|
|
|
|
</Notification>,
|
|
|
|
|
{ placement: 'bottom-end' },
|
|
|
|
|
)
|
|
|
|
|
await onDeployed?.({
|
|
|
|
|
schemaName: settings.schemaName,
|
|
|
|
|
objectName: settings.viewName.trim(),
|
|
|
|
|
})
|
|
|
|
|
handleClose()
|
|
|
|
|
} else {
|
|
|
|
|
toast.push(
|
|
|
|
|
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
|
|
|
|
{result.data?.error ||
|
|
|
|
|
result.data?.message ||
|
|
|
|
|
translate('::App.SqlViewDesigner.DeployFailed')}
|
|
|
|
|
</Notification>,
|
|
|
|
|
{ placement: 'bottom-end' },
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
toast.push(
|
|
|
|
|
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
|
|
|
|
{error.response?.data?.error?.message || translate('::App.SqlViewDesigner.DeployFailed')}
|
|
|
|
|
</Notification>,
|
|
|
|
|
{ placement: 'bottom-end' },
|
|
|
|
|
)
|
|
|
|
|
} finally {
|
|
|
|
|
setIsDeploying(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Surukleme ─────────────────────────────────────────────────────────────
|
|
|
|
|
const handleDragMove = (event: DragMoveEvent | null) => {
|
|
|
|
|
if (!event) {
|
|
|
|
|
setDragPreview(null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setDragPreview({
|
|
|
|
|
id: String(event.active.id),
|
|
|
|
|
delta: { x: event.delta.x / zoom, y: event.delta.y / zoom },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
|
|
|
const id = String(event.active.id)
|
|
|
|
|
setSources((prev) =>
|
|
|
|
|
prev.map((s) =>
|
|
|
|
|
s.id === id
|
|
|
|
|
? {
|
|
|
|
|
...s,
|
|
|
|
|
x: Math.max(0, s.x + event.delta.x / zoom),
|
|
|
|
|
y: Math.max(0, s.y + event.delta.y / zoom),
|
|
|
|
|
}
|
|
|
|
|
: s,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
setDragPreview(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
|
const inputCls =
|
|
|
|
|
'rounded border px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
|
|
|
|
|
|
|
|
|
const renderToolbar = () => (
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-2 dark:border-gray-700">
|
|
|
|
|
<span className="text-[11px] font-semibold text-gray-500">
|
|
|
|
|
{translate('::App.SqlViewDesigner.Schema')}
|
|
|
|
|
</span>
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
className={`${inputCls} w-[90px]`}
|
|
|
|
|
value={settings.schemaName}
|
|
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, schemaName: e.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-[11px] font-semibold text-gray-500">
|
|
|
|
|
{translate('::App.SqlViewDesigner.ViewName')} *
|
|
|
|
|
</span>
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
className={`${inputCls} w-[190px]`}
|
|
|
|
|
placeholder="Vw_OrderSummary"
|
|
|
|
|
value={settings.viewName}
|
|
|
|
|
disabled={isEditMode}
|
|
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, viewName: e.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
<label className="flex items-center gap-1 text-[11px]">
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
type="checkbox"
|
|
|
|
|
checked={settings.distinct}
|
|
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, distinct: e.target.checked }))}
|
|
|
|
|
/>
|
|
|
|
|
DISTINCT
|
|
|
|
|
</label>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${
|
|
|
|
|
settings.groupBy
|
|
|
|
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300'
|
|
|
|
|
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
title={translate('::App.SqlViewDesigner.GroupByToggle')}
|
|
|
|
|
onClick={() => toggleGroupBy(!settings.groupBy)}
|
|
|
|
|
>
|
|
|
|
|
Σ {translate('::App.SqlViewDesigner.GroupByColumn')}
|
|
|
|
|
</button>
|
|
|
|
|
<label className="flex items-center gap-1 text-[11px]">
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
type="checkbox"
|
|
|
|
|
checked={settings.topEnabled}
|
|
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, topEnabled: e.target.checked }))}
|
|
|
|
|
/>
|
|
|
|
|
{isPostgreSql ? 'LIMIT' : 'TOP'}
|
|
|
|
|
</label>
|
|
|
|
|
{settings.topEnabled && (
|
|
|
|
|
<>
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
className={`${inputCls} w-[70px]`}
|
|
|
|
|
value={settings.topCount}
|
|
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, topCount: e.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
{!isPostgreSql && (
|
|
|
|
|
<label className="flex items-center gap-1 text-[11px]">
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
type="checkbox"
|
|
|
|
|
checked={settings.topPercent}
|
|
|
|
|
onChange={(e) => setSettings((s) => ({ ...s, topPercent: e.target.checked }))}
|
|
|
|
|
/>
|
|
|
|
|
PERCENT
|
|
|
|
|
</label>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
|
|
|
{/* SSMS'teki panel gosterme/gizleme dugmeleri */}
|
|
|
|
|
<PaneToggle
|
|
|
|
|
active={showDiagram}
|
|
|
|
|
icon={<FaProjectDiagram />}
|
|
|
|
|
title={translate('::App.SqlViewDesigner.PaneDiagram')}
|
|
|
|
|
onClick={() => setShowDiagram((v) => !v)}
|
|
|
|
|
/>
|
|
|
|
|
<PaneToggle
|
|
|
|
|
active={showCriteria}
|
|
|
|
|
icon={<FaColumns />}
|
|
|
|
|
title={translate('::App.SqlViewDesigner.PaneCriteria')}
|
|
|
|
|
onClick={() => setShowCriteria((v) => !v)}
|
|
|
|
|
/>
|
|
|
|
|
<PaneToggle
|
|
|
|
|
active={showSql}
|
|
|
|
|
icon={<FaCode />}
|
|
|
|
|
title={translate('::App.SqlViewDesigner.PaneSql')}
|
|
|
|
|
onClick={() => setShowSql((v) => !v)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded p-1 text-[10px] text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
onClick={() => setZoom((z) => Math.max(0.5, Number((z - 0.1).toFixed(2))))}
|
|
|
|
|
>
|
|
|
|
|
<FaMinus />
|
|
|
|
|
</button>
|
|
|
|
|
<span className="w-9 text-center text-[11px] text-gray-500">{Math.round(zoom * 100)}%</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded p-1 text-[10px] text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
onClick={() => setZoom((z) => Math.min(1.5, Number((z + 0.1).toFixed(2))))}
|
|
|
|
|
>
|
|
|
|
|
<FaPlus />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const renderPalette = () => (
|
|
|
|
|
<div
|
|
|
|
|
className={`flex flex-col rounded-lg border border-gray-200 transition-all dark:border-gray-700 ${
|
|
|
|
|
paletteOpen ? 'w-[220px]' : 'w-[34px]'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex items-center gap-1 border-b border-gray-200 px-2 py-1 text-[11px] font-semibold text-gray-500 dark:border-gray-700"
|
|
|
|
|
onClick={() => setPaletteOpen((v) => !v)}
|
|
|
|
|
>
|
|
|
|
|
<FaTable className="text-teal-500" />
|
|
|
|
|
{paletteOpen && translate('::App.SqlViewDesigner.AddTable')}
|
|
|
|
|
</button>
|
|
|
|
|
{paletteOpen && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="relative p-1">
|
|
|
|
|
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-400" />
|
2026-08-20 12:01:10 +00:00
|
|
|
<Input
|
|
|
|
|
unstyle
|
2026-08-19 07:50:33 +00:00
|
|
|
className="w-full rounded border py-1 pl-6 pr-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
|
|
|
placeholder={translate('::App.SqlViewDesigner.SearchObjects')}
|
|
|
|
|
value={catalogSearch}
|
|
|
|
|
onChange={(e) => setCatalogSearch(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 overflow-auto p-1">
|
|
|
|
|
{catalogLoading && (
|
|
|
|
|
<div className="p-2 text-[11px] text-gray-400">
|
|
|
|
|
{translate('::App.Platform.Loading')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{!catalogLoading && filteredCatalog.length === 0 && (
|
|
|
|
|
<div className="p-2 text-[11px] text-gray-400">
|
|
|
|
|
{translate('::App.SqlViewDesigner.NoObjectsFound')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{filteredCatalog.map((obj) => (
|
|
|
|
|
<button
|
|
|
|
|
key={`${obj.schemaName}.${obj.objectName}`}
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
|
|
|
onClick={() => addSource(obj)}
|
|
|
|
|
title={`${obj.schemaName}.${obj.objectName}`}
|
|
|
|
|
>
|
|
|
|
|
{obj.objectType === 'table' ? (
|
|
|
|
|
<FaTable className="shrink-0 text-teal-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<FaEye className="shrink-0 text-purple-500" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="truncate text-gray-700 dark:text-gray-200">{obj.objectName}</span>
|
|
|
|
|
<FaPlus className="ml-auto shrink-0 text-[8px] text-gray-400" />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const renderRawMode = () => (
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<div className="flex items-start gap-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
|
|
|
|
<FaExclamationTriangle className="mt-0.5 shrink-0" />
|
|
|
|
|
<div>
|
|
|
|
|
<div className="font-semibold">{translate('::App.SqlViewDesigner.ParseFailedTitle')}</div>
|
|
|
|
|
<div>{translate('::App.SqlViewDesigner.ParseFailedDescription')}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<pre className="max-h-[380px] overflow-auto whitespace-pre rounded-lg bg-gray-900 p-4 text-xs leading-relaxed text-green-300">
|
|
|
|
|
{rawDefinition}
|
|
|
|
|
</pre>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{onOpenInEditor && (
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="solid"
|
|
|
|
|
color="blue-600"
|
|
|
|
|
icon={<FaCode />}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onOpenInEditor(rawDefinition ?? '')
|
|
|
|
|
handleClose()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{translate('::App.SqlViewDesigner.OpenInEditor')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="plain"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRawDefinition(null)
|
|
|
|
|
setSettings((s) => ({
|
|
|
|
|
...s,
|
|
|
|
|
schemaName: initialViewData?.schemaName ?? s.schemaName,
|
|
|
|
|
viewName: initialViewData?.objectName ?? s.viewName,
|
|
|
|
|
}))
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{translate('::App.SqlViewDesigner.RedesignFromScratch')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog isOpen={isOpen} onClose={handleClose} onRequestClose={handleClose} width={1320}>
|
|
|
|
|
<Dialog.Header className="-mt-5 flex flex-col gap-2 p-2">
|
|
|
|
|
<div className="flex flex-shrink-0 items-center gap-3 border-b pb-2">
|
|
|
|
|
<FaEye className="text-2xl text-purple-500" />
|
|
|
|
|
<div>
|
|
|
|
|
<h5 className="font-bold">
|
|
|
|
|
{isEditMode
|
|
|
|
|
? `${translate('::App.SqlViewDesigner.EditView')} — ${initialViewData?.objectName}`
|
|
|
|
|
: translate('::App.SqlViewDesigner.Title')}
|
|
|
|
|
</h5>
|
|
|
|
|
<p className="text-xs text-gray-500">
|
|
|
|
|
{translate('::App.SqlViewDesigner.Description')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{rawDefinition === null && renderToolbar()}
|
|
|
|
|
</Dialog.Header>
|
|
|
|
|
|
|
|
|
|
<Dialog.Body className="flex flex-col gap-2">
|
|
|
|
|
{definitionLoading ? (
|
|
|
|
|
<div className="flex min-h-[420px] items-center justify-center text-gray-500">
|
|
|
|
|
<span className="mr-2 animate-spin">◠</span>
|
|
|
|
|
{translate('::App.Platform.Loading')}
|
|
|
|
|
</div>
|
|
|
|
|
) : rawDefinition !== null ? (
|
|
|
|
|
renderRawMode()
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{issues.length > 0 && (
|
|
|
|
|
<div className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
|
|
|
|
{issues.slice(0, 4).map((issue, i) => (
|
|
|
|
|
<div key={i} className="flex items-center gap-1.5">
|
|
|
|
|
<FaExclamationTriangle className="shrink-0 text-[9px]" />
|
|
|
|
|
{issue.message}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showDiagram && (
|
|
|
|
|
<div className="flex gap-2" style={{ height: 320 }}>
|
|
|
|
|
{renderPalette()}
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<DndContext
|
|
|
|
|
onDragMove={handleDragMove}
|
|
|
|
|
onDragCancel={() => handleDragMove(null)}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<DiagramPane
|
|
|
|
|
sources={sources}
|
|
|
|
|
columnsOf={columnsOf}
|
|
|
|
|
loadingSourceIds={loadingSourceIds}
|
|
|
|
|
isColumnSelected={isColumnSelected}
|
|
|
|
|
zoom={zoom}
|
|
|
|
|
dragPreview={dragPreview}
|
|
|
|
|
selectedJoinKey={selectedJoinKey}
|
|
|
|
|
pendingLink={pendingLink}
|
|
|
|
|
lockAliases={lockAliases}
|
|
|
|
|
emptyHint={translate('::App.SqlViewDesigner.EmptyCanvasHint')}
|
|
|
|
|
pendingHint={translate('::App.SqlViewDesigner.PendingLinkHint')}
|
|
|
|
|
labels={{
|
|
|
|
|
alias: translate('::App.SqlViewDesigner.Alias'),
|
|
|
|
|
selectAll: translate('::App.SqlViewDesigner.ToggleAllColumns'),
|
|
|
|
|
onlySelected: translate('::App.SqlViewDesigner.ShowSelectedOnly'),
|
|
|
|
|
allColumns: translate('::App.SqlViewDesigner.ShowAllColumns'),
|
|
|
|
|
joinType: translate('::App.SqlViewDesigner.JoinType'),
|
|
|
|
|
removeJoin: translate('::App.SqlViewDesigner.RemoveJoin'),
|
|
|
|
|
removeTable: translate('::App.SqlViewDesigner.RemoveTable'),
|
|
|
|
|
aliasLocked: translate('::App.SqlViewDesigner.AliasLocked'),
|
|
|
|
|
resize: translate('::App.SqlViewDesigner.ResizeBox'),
|
|
|
|
|
}}
|
|
|
|
|
onToggleColumn={toggleColumn}
|
|
|
|
|
onToggleAllColumns={toggleAllColumns}
|
|
|
|
|
onRemoveSource={removeSource}
|
|
|
|
|
onUpdateSource={updateSource}
|
|
|
|
|
onBeginLink={setPendingLink}
|
|
|
|
|
onCompleteLink={completeLink}
|
|
|
|
|
onSelectJoin={setSelectedJoinKey}
|
|
|
|
|
onUpdateJoin={updateJoin}
|
|
|
|
|
onRemoveJoin={removeJoin}
|
|
|
|
|
/>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showCriteria && (
|
|
|
|
|
<div style={{ height: showDiagram && showSql ? 210 : 320 }}>
|
|
|
|
|
<CriteriaGrid
|
|
|
|
|
rows={rows}
|
|
|
|
|
sources={sources}
|
|
|
|
|
columnsOf={columnsOf}
|
|
|
|
|
grouped={grouped}
|
|
|
|
|
labels={{
|
|
|
|
|
column: translate('::App.SqlViewDesigner.Column'),
|
|
|
|
|
alias: translate('::App.SqlViewDesigner.OutputAlias'),
|
|
|
|
|
table: translate('::App.SqlViewDesigner.TableColumn'),
|
|
|
|
|
output: translate('::App.SqlViewDesigner.Output'),
|
|
|
|
|
groupBy: translate('::App.SqlViewDesigner.GroupByColumn'),
|
|
|
|
|
sortType: translate('::App.SqlViewDesigner.SortType'),
|
|
|
|
|
sortOrder: translate('::App.SqlViewDesigner.SortOrder'),
|
|
|
|
|
filter: translate('::App.SqlViewDesigner.Filter'),
|
|
|
|
|
or: translate('::App.SqlViewDesigner.OrColumn'),
|
|
|
|
|
addRow: translate('::App.SqlViewDesigner.AddRow'),
|
|
|
|
|
addExpression: translate('::App.SqlViewDesigner.AddExpression'),
|
|
|
|
|
expressionPlaceholder: translate('::App.SqlViewDesigner.ExpressionPlaceholder'),
|
|
|
|
|
empty: translate('::App.SqlViewDesigner.NoColumnSelected'),
|
|
|
|
|
builder: translate('::App.SqlViewDesigner.FilterBuilder'),
|
|
|
|
|
builderValue: translate('::App.SqlViewDesigner.Value'),
|
|
|
|
|
builderLiteral: translate('::App.SqlViewDesigner.ValueModeLiteral'),
|
|
|
|
|
builderExpression: translate('::App.SqlViewDesigner.ValueModeExpression'),
|
|
|
|
|
apply: translate('::App.SqlViewDesigner.Apply'),
|
|
|
|
|
}}
|
|
|
|
|
onUpdateRow={updateRow}
|
|
|
|
|
onUpdateFilter={updateFilter}
|
|
|
|
|
onRemoveRow={removeRow}
|
|
|
|
|
onMoveRow={moveRow}
|
|
|
|
|
onAddRow={addEmptyRow}
|
|
|
|
|
onAddExpression={addExpressionRow}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showSql && (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<div className="absolute right-2 top-1 z-10 flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="text-[11px] text-blue-300 hover:underline"
|
|
|
|
|
onClick={() => navigator.clipboard.writeText(generatedSql)}
|
|
|
|
|
>
|
|
|
|
|
{translate('::App.Platform.Copy')}
|
|
|
|
|
</button>
|
|
|
|
|
{onOpenInEditor && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="text-[11px] text-blue-300 hover:underline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onOpenInEditor(generatedSql)
|
|
|
|
|
handleClose()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{translate('::App.SqlViewDesigner.OpenInEditor')}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<pre
|
|
|
|
|
className="overflow-auto whitespace-pre rounded-lg bg-gray-900 p-3 text-[11px] leading-relaxed text-green-300"
|
|
|
|
|
style={{ height: showDiagram && showCriteria ? 150 : 320 }}
|
|
|
|
|
>
|
|
|
|
|
{generatedSql || translate('::App.SqlViewDesigner.NothingToGenerate')}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Dialog.Body>
|
|
|
|
|
|
|
|
|
|
<Dialog.Footer className="mt-1 flex items-center justify-between border-t pt-3">
|
|
|
|
|
<Button variant="plain" onClick={handleClose}>
|
|
|
|
|
{translate('::App.Platform.Cancel')}
|
|
|
|
|
</Button>
|
|
|
|
|
{rawDefinition === null && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="solid"
|
|
|
|
|
color="green-600"
|
|
|
|
|
icon={<FaCloudUploadAlt />}
|
|
|
|
|
onClick={handleDeploy}
|
|
|
|
|
loading={isDeploying}
|
|
|
|
|
disabled={!dataSource || isDeploying || issues.length > 0 || !generatedSql}
|
|
|
|
|
>
|
|
|
|
|
{translate('::App.Platform.Deploy')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</Dialog.Footer>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PaneToggle({
|
|
|
|
|
active,
|
|
|
|
|
icon,
|
|
|
|
|
title,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
active: boolean
|
|
|
|
|
icon: React.ReactNode
|
|
|
|
|
title: string
|
|
|
|
|
onClick: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={title}
|
|
|
|
|
className={`rounded p-1.5 text-[11px] transition-colors ${
|
|
|
|
|
active
|
|
|
|
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
|
|
|
|
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
>
|
|
|
|
|
{icon}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default SqlViewDesignerDialog
|