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

1020 lines
38 KiB
TypeScript
Raw Normal View History

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'
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>
<input
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>
<input
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]">
<input
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)}
>
&#931; {translate('::App.SqlViewDesigner.GroupByColumn')}
</button>
<label className="flex items-center gap-1 text-[11px]">
<input
type="checkbox"
checked={settings.topEnabled}
onChange={(e) => setSettings((s) => ({ ...s, topEnabled: e.target.checked }))}
/>
{isPostgreSql ? 'LIMIT' : 'TOP'}
</label>
{settings.topEnabled && (
<>
<input
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]">
<input
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" />
<input
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">&#9696;</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