SqlViewDesignerDialog
This commit is contained in:
parent
eb77285e59
commit
ece4ee9783
16 changed files with 4007 additions and 69 deletions
|
|
@ -20538,6 +20538,360 @@
|
|||
"en": "No suitable PK/UNIQUE column was found in the selected table for the FK.",
|
||||
"tr": "Seçilen tabloda FK için uygun PK/UNIQUE kolon bulunamadı."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Title",
|
||||
"en": "View Designer",
|
||||
"tr": "View Tasarımcısı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Description",
|
||||
"en": "Pick sources, build joins, filters and aggregations, then deploy with T-SQL",
|
||||
"tr": "Kaynakları seçin, join / filtre / özet tanımlayın ve T-SQL ile deploy edin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.EditView",
|
||||
"en": "Edit View",
|
||||
"tr": "View Düzenle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.SearchObjects",
|
||||
"en": "Search tables and views",
|
||||
"tr": "Tablo ve view ara"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.NoObjectsFound",
|
||||
"en": "No matching object",
|
||||
"tr": "Eşleşen nesne yok"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.PaneDiagram",
|
||||
"en": "Diagram pane",
|
||||
"tr": "Diyagram paneli"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.PaneCriteria",
|
||||
"en": "Criteria pane",
|
||||
"tr": "Kriter paneli"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.PaneSql",
|
||||
"en": "SQL pane",
|
||||
"tr": "SQL paneli"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.AddTable",
|
||||
"en": "Add Table",
|
||||
"tr": "Tablo Ekle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.EmptyCanvasHint",
|
||||
"en": "Add a table or view from the left panel, then drag from a column handle to another column to create a join.",
|
||||
"tr": "Soldaki panelden tablo veya view ekleyin; join kurmak için bir kolonun tutamacından diğer kolona tıklayın."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.PendingLinkHint",
|
||||
"en": "Pick the column to join with (Esc / click empty area to cancel)",
|
||||
"tr": "Bağlanacak kolonu seçin (Esc / boş alana tıklayarak iptal)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ToggleAllColumns",
|
||||
"en": "Select / clear all columns",
|
||||
"tr": "Tüm kolonları seç / temizle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ShowSelectedOnly",
|
||||
"en": "Show only selected columns",
|
||||
"tr": "Yalnızca seçili kolonları göster"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ShowAllColumns",
|
||||
"en": "Show all columns",
|
||||
"tr": "Tüm kolonları göster"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.JoinType",
|
||||
"en": "Join type",
|
||||
"tr": "Join tipi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.RemoveJoin",
|
||||
"en": "Remove join",
|
||||
"tr": "Join'i kaldır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.RemoveTable",
|
||||
"en": "Remove table",
|
||||
"tr": "Tabloyu kaldır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.TableColumn",
|
||||
"en": "Table",
|
||||
"tr": "Tablo"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Output",
|
||||
"en": "Output",
|
||||
"tr": "Çıktı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.GroupByColumn",
|
||||
"en": "Group By",
|
||||
"tr": "Group By"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.SortType",
|
||||
"en": "Sort Type",
|
||||
"tr": "Sıralama Tipi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.SortOrder",
|
||||
"en": "Sort Order",
|
||||
"tr": "Sıra No"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Filter",
|
||||
"en": "Filter",
|
||||
"tr": "Filtre"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.OrColumn",
|
||||
"en": "Or...",
|
||||
"tr": "Veya..."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.AddRow",
|
||||
"en": "Add row",
|
||||
"tr": "Satır ekle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.FilterBuilder",
|
||||
"en": "Filter builder",
|
||||
"tr": "Filtre yardımcısı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Apply",
|
||||
"en": "Apply",
|
||||
"tr": "Uygula"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.GroupByToggle",
|
||||
"en": "Toggle Group By (Σ)",
|
||||
"tr": "Group By'ı aç / kapat (Σ)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ResizeBox",
|
||||
"en": "Drag to resize",
|
||||
"tr": "Sürükleyerek boyutlandır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.AddExpression",
|
||||
"en": "Add expression",
|
||||
"tr": "İfade ekle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ExpressionPlaceholder",
|
||||
"en": "CASE WHEN … END",
|
||||
"tr": "CASE WHEN … END"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.AliasLocked",
|
||||
"en": "Alias is locked: free expressions or subqueries reference it by name",
|
||||
"tr": "Takma ad kilitli: serbest ifadeler veya alt sorgular bu adı metin olarak kullanıyor"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationExpressionAlias",
|
||||
"en": "Expression columns need an output name",
|
||||
"tr": "İfade kolonlarına çıktı adı verilmelidir"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Alias",
|
||||
"en": "Alias",
|
||||
"tr": "Takma ad"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Column",
|
||||
"en": "Column",
|
||||
"tr": "Kolon"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.OutputAlias",
|
||||
"en": "Output name",
|
||||
"tr": "Çıktı adı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.NoColumnSelected",
|
||||
"en": "No column selected yet",
|
||||
"tr": "Henüz kolon seçilmedi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Value",
|
||||
"en": "Value",
|
||||
"tr": "Değer"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValueModeLiteral",
|
||||
"en": "Literal",
|
||||
"tr": "Sabit değer"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValueModeExpression",
|
||||
"en": "SQL expression",
|
||||
"tr": "SQL ifadesi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.Schema",
|
||||
"en": "Schema",
|
||||
"tr": "Şema"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ViewName",
|
||||
"en": "View name",
|
||||
"tr": "View adı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.NothingToGenerate",
|
||||
"en": "Add at least one source and one column",
|
||||
"tr": "En az bir kaynak ve bir kolon ekleyin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.OpenInEditor",
|
||||
"en": "Open in Query Editor",
|
||||
"tr": "Query Editor'da aç"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.DeploySucceeded",
|
||||
"en": "View deployed successfully",
|
||||
"tr": "View başarıyla oluşturuldu"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.DeployFailed",
|
||||
"en": "View could not be deployed",
|
||||
"tr": "View oluşturulamadı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ParseFailedTitle",
|
||||
"en": "This view cannot be opened in the visual designer",
|
||||
"tr": "Bu view görsel tasarımcıda açılamıyor"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ParseFailedDescription",
|
||||
"en": "Its definition uses SQL the designer does not model. Continue in the Query Editor, or redesign it from scratch.",
|
||||
"tr": "Tanımı tasarımcının modellemediği bir SQL yapısı içeriyor. Query Editor'dan devam edebilir ya da sıfırdan tasarlayabilirsiniz."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.RedesignFromScratch",
|
||||
"en": "Redesign from scratch",
|
||||
"tr": "Sıfırdan tasarla"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationNoSource",
|
||||
"en": "Add at least one source",
|
||||
"tr": "En az bir kaynak ekleyin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationAliasRequired",
|
||||
"en": "alias is required",
|
||||
"tr": "takma ad zorunlu"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationAliasDuplicate",
|
||||
"en": "alias is used more than once",
|
||||
"tr": "takma ad birden fazla kez kullanılmış"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationJoinRequired",
|
||||
"en": "at least one join condition is required",
|
||||
"tr": "en az bir join koşulu gerekli"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationNoColumn",
|
||||
"en": "Select at least one column",
|
||||
"tr": "En az bir kolon seçin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationColumnAliasDuplicate",
|
||||
"en": "output name is used more than once",
|
||||
"tr": "çıktı adı birden fazla kez kullanılmış"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationViewNameRequired",
|
||||
"en": "View name is required",
|
||||
"tr": "View adı zorunludur"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.SqlViewDesigner.ValidationViewNameInvalid",
|
||||
"en": "View name contains invalid characters",
|
||||
"tr": "View adı geçersiz karakter içeriyor"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitTableDesigner.ColumnTemplates",
|
||||
"en": "Column Templates",
|
||||
"tr": "Kolon Şablonları"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitTableDesigner.ColumnTemplatesHint",
|
||||
"en": "Pick a ready-made column set to append",
|
||||
"tr": "Eklemek istediğiniz hazır kolon setini seçin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitTableDesigner.OneToMany",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ interface SqlObjectExplorerProps {
|
|||
onGenerateTableScript?: (schemaName: string, tableName: string) => void
|
||||
onDesignTable?: (schemaName: string, tableName: string) => void
|
||||
onNewTable?: () => void
|
||||
/** Gorsel View Designer'i yeni view icin acar; verilmezse SQL sablonu kullanilir. */
|
||||
onNewView?: () => void
|
||||
/** Mevcut bir view'i gorsel tasarimcida acar. */
|
||||
onDesignView?: (schemaName: string, objectName: string) => void
|
||||
onSelectedObjectsChange?: (objects: SqlExplorerSelectedObject[]) => void
|
||||
/** Entity adina gore acilmis CRUD endpoint'leri; tablo/view satirlarinda ikon olarak gosterilir. */
|
||||
crudEndpointsByEntity?: Map<string, CrudEndpoint[]>
|
||||
|
|
@ -89,6 +93,8 @@ const SqlObjectExplorer = ({
|
|||
onGenerateTableScript,
|
||||
onDesignTable,
|
||||
onNewTable,
|
||||
onNewView,
|
||||
onDesignView,
|
||||
onSelectedObjectsChange,
|
||||
crudEndpointsByEntity,
|
||||
onManageCrudEndpoints,
|
||||
|
|
@ -377,9 +383,7 @@ const SqlObjectExplorer = ({
|
|||
if (dropped.length > 0) {
|
||||
// configs/seeds/SqlData altindaki eslesen seed dosyalari da silinir.
|
||||
try {
|
||||
const fileNames = [
|
||||
...new Set(dropped.flatMap(getSqlDataFileCandidates).filter(Boolean)),
|
||||
]
|
||||
const fileNames = [...new Set(dropped.flatMap(getSqlDataFileCandidates).filter(Boolean))]
|
||||
if (fileNames.length > 0) {
|
||||
await sqlObjectManagerService.deleteSqlDataFiles({ fileNames })
|
||||
}
|
||||
|
|
@ -730,6 +734,21 @@ const SqlObjectExplorer = ({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{isNativeObj && ctxNode!.folder === 'views' && onDesignView && (
|
||||
<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
|
||||
onDesignView(obj.schemaName, obj.objectName)
|
||||
closeCtx()
|
||||
}}
|
||||
>
|
||||
<FaEye className="text-purple-500" /> Design View
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{ctxCrudObjectType && onManageCrudEndpoints && (
|
||||
<Button
|
||||
variant="plain"
|
||||
|
|
@ -785,7 +804,8 @@ const SqlObjectExplorer = ({
|
|||
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')
|
||||
if (onNewView) onNewView()
|
||||
else onTemplateSelect?.('', 'create-view')
|
||||
closeCtx()
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { usePermission } from '@/utils/hooks/usePermission'
|
|||
|
||||
const SqlResultsGrid = lazy(() => import('./SqlResultsGrid'))
|
||||
const SqlTableDesignerDialog = lazy(() => import('./SqlTableDesignerDialog'))
|
||||
const SqlViewDesignerDialog = lazy(() => import('./SqlViewDesignerDialog'))
|
||||
|
||||
interface SqlManagerState {
|
||||
dataSources: DataSourceDto[]
|
||||
|
|
@ -85,6 +86,11 @@ const SqlQueryManager = () => {
|
|||
schemaName: string
|
||||
tableName: string
|
||||
} | null>(null)
|
||||
const [showViewDesignerDialog, setShowViewDesignerDialog] = useState(false)
|
||||
const [designViewData, setDesignViewData] = useState<{
|
||||
schemaName: string
|
||||
objectName: string
|
||||
} | null>(null)
|
||||
const [selectedExplorerObjects, setSelectedExplorerObjects] = useState<
|
||||
SqlExplorerSelectedObject[]
|
||||
>([])
|
||||
|
|
@ -238,6 +244,16 @@ const SqlQueryManager = () => {
|
|||
setShowTableDesignerDialog(true)
|
||||
}, [])
|
||||
|
||||
const handleNewView = useCallback(() => {
|
||||
setDesignViewData(null)
|
||||
setShowViewDesignerDialog(true)
|
||||
}, [])
|
||||
|
||||
const handleDesignView = useCallback((schemaName: string, objectName: string) => {
|
||||
setDesignViewData({ schemaName, objectName })
|
||||
setShowViewDesignerDialog(true)
|
||||
}, [])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -671,7 +687,7 @@ GO`,
|
|||
...prev,
|
||||
executionResult: result.data,
|
||||
isExecuting: false,
|
||||
}))
|
||||
}))
|
||||
|
||||
// Seçili metni geri yükle
|
||||
setTimeout(() => {
|
||||
|
|
@ -712,7 +728,7 @@ GO`,
|
|||
...prev,
|
||||
editorContent: definition,
|
||||
executionResult: null,
|
||||
isDirty: false,
|
||||
isDirty: false,
|
||||
}))
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
|
@ -746,7 +762,7 @@ GO`,
|
|||
...prev,
|
||||
editorContent: script,
|
||||
executionResult: null,
|
||||
isDirty: false,
|
||||
isDirty: false,
|
||||
}))
|
||||
|
||||
toast.push(
|
||||
|
|
@ -862,8 +878,7 @@ GO`,
|
|||
objectType: obj.objectType,
|
||||
status: 'error',
|
||||
message:
|
||||
error.response?.data?.error?.message ||
|
||||
translate('::App.Platform.IslemBasarisiz'),
|
||||
error.response?.data?.error?.message || translate('::App.Platform.IslemBasarisiz'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -941,8 +956,7 @@ GO`,
|
|||
objectType: 'script',
|
||||
status: 'error',
|
||||
message:
|
||||
error.response?.data?.error?.message ||
|
||||
translate('::App.Platform.IslemBasarisiz'),
|
||||
error.response?.data?.error?.message || translate('::App.Platform.IslemBasarisiz'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1141,6 +1155,8 @@ GO`,
|
|||
refreshTrigger={state.refreshTrigger}
|
||||
onNewTable={handleNewTable}
|
||||
onDesignTable={handleDesignTable}
|
||||
onNewView={handleNewView}
|
||||
onDesignView={handleDesignView}
|
||||
onSelectedObjectsChange={setSelectedExplorerObjects}
|
||||
crudEndpointsByEntity={crudEndpointsByEntity}
|
||||
onManageCrudEndpoints={
|
||||
|
|
@ -1207,7 +1223,11 @@ GO`,
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-2">
|
||||
<Suspense fallback={<div className="p-4">{translate('::App.Platform.LoadingWithThreeDot')}</div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="p-4">{translate('::App.Platform.LoadingWithThreeDot')}</div>
|
||||
}
|
||||
>
|
||||
<SqlResultsGrid
|
||||
result={state.executionResult}
|
||||
queryText={state.lastExecutedQuery}
|
||||
|
|
@ -1288,6 +1308,36 @@ GO`,
|
|||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* View Designer Dialog */}
|
||||
{showViewDesignerDialog && (
|
||||
<Suspense fallback={null}>
|
||||
<SqlViewDesignerDialog
|
||||
isOpen
|
||||
onClose={() => {
|
||||
setShowViewDesignerDialog(false)
|
||||
setDesignViewData(null)
|
||||
}}
|
||||
dataSource={state.selectedDataSource ?? ''}
|
||||
isPostgreSql={isPostgreSql}
|
||||
initialViewData={designViewData}
|
||||
onOpenInEditor={(sql) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
editorContent: sql,
|
||||
executionResult: null,
|
||||
isDirty: false,
|
||||
}))
|
||||
}}
|
||||
onDeployed={async () => {
|
||||
setState((prev) => ({ ...prev, refreshTrigger: prev.refreshTrigger + 1 }))
|
||||
if (canManageCrudEndpoints) {
|
||||
await loadCrudEndpoints()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* CRUD Endpoint Dialog */}
|
||||
{crudDialogObject && canManageCrudEndpoints && (
|
||||
<CrudEndpointDialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button, Dialog, Notification, toast, Checkbox } from '@/components/ui'
|
||||
import {
|
||||
|
|
@ -16,6 +16,14 @@ import {
|
|||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaKey,
|
||||
FaLayerGroup,
|
||||
FaRegClipboard,
|
||||
FaUsers,
|
||||
FaHistory,
|
||||
FaProjectDiagram,
|
||||
FaTasks,
|
||||
FaChartBar,
|
||||
FaRegCalendarAlt,
|
||||
} from 'react-icons/fa'
|
||||
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
||||
import { getMenus } from '@/services/menu.service'
|
||||
|
|
@ -252,8 +260,8 @@ const WORKFLOW_COLUMNS: ColumnDefinition[] = [
|
|||
description: 'Workflow primary key',
|
||||
},
|
||||
{
|
||||
id: '__ApprovalUserId',
|
||||
columnName: 'ApprovalUserName',
|
||||
id: '__UserId',
|
||||
columnName: 'UserName',
|
||||
dataType: 'nvarchar',
|
||||
maxLength: '256',
|
||||
isNullable: true,
|
||||
|
|
@ -261,8 +269,8 @@ const WORKFLOW_COLUMNS: ColumnDefinition[] = [
|
|||
description: 'Workflow approval user name',
|
||||
},
|
||||
{
|
||||
id: '__ApprovalStatus',
|
||||
columnName: 'ApprovalStatus',
|
||||
id: '__Status',
|
||||
columnName: 'Status',
|
||||
dataType: 'nvarchar',
|
||||
maxLength: '50',
|
||||
isNullable: true,
|
||||
|
|
@ -270,8 +278,8 @@ const WORKFLOW_COLUMNS: ColumnDefinition[] = [
|
|||
description: 'Workflow approval status',
|
||||
},
|
||||
{
|
||||
id: '__ApprovalDate',
|
||||
columnName: 'ApprovalDate',
|
||||
id: '__Date',
|
||||
columnName: 'Date',
|
||||
dataType: 'datetime',
|
||||
maxLength: '',
|
||||
isNullable: true,
|
||||
|
|
@ -279,8 +287,8 @@ const WORKFLOW_COLUMNS: ColumnDefinition[] = [
|
|||
description: 'Workflow approval date',
|
||||
},
|
||||
{
|
||||
id: '__ApprovalDescription',
|
||||
columnName: 'ApprovalDescription',
|
||||
id: '__Description',
|
||||
columnName: 'Description',
|
||||
dataType: 'nvarchar',
|
||||
maxLength: '200',
|
||||
isNullable: true,
|
||||
|
|
@ -1007,9 +1015,7 @@ function generateAlterTableSql(
|
|||
if (changed) {
|
||||
hasChanges = true
|
||||
const cname = orig.constraintName ?? `FK_${tableName}_${orig.fkColumnName}`
|
||||
lines.push(
|
||||
`-- ✏️ ${translate('::App.TableDesignerDiff.UpdateFk', { name: cname })}`,
|
||||
)
|
||||
lines.push(`-- ✏️ ${translate('::App.TableDesignerDiff.UpdateFk', { name: cname })}`)
|
||||
lines.push(`ALTER TABLE ${fullTableName}`)
|
||||
lines.push(` DROP CONSTRAINT [${cname}];`)
|
||||
lines.push('')
|
||||
|
|
@ -1030,15 +1036,11 @@ function generateAlterTableSql(
|
|||
|
||||
const dropIndexSql = (ix: TableIndex) => {
|
||||
if (ix.indexType === 'PrimaryKey' || ix.indexType === 'UniqueKey') {
|
||||
lines.push(
|
||||
`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`,
|
||||
)
|
||||
lines.push(`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`)
|
||||
lines.push(`ALTER TABLE ${fullTableName}`)
|
||||
lines.push(` DROP CONSTRAINT [${ix.indexName}];`)
|
||||
} else {
|
||||
lines.push(
|
||||
`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`,
|
||||
)
|
||||
lines.push(`-- ❌ ${translate('::App.TableDesignerDiff.DropIndex', { name: ix.indexName })}`)
|
||||
lines.push(`DROP INDEX [${ix.indexName}] ON ${fullTableName};`)
|
||||
}
|
||||
lines.push('')
|
||||
|
|
@ -1315,6 +1317,9 @@ const SqlTableDesignerDialog = ({
|
|||
const [columns, setColumns] = useState<ColumnDefinition[]>([createEmptyColumn()])
|
||||
const [originalColumns, setOriginalColumns] = useState<ColumnDefinition[]>([])
|
||||
const [colsLoading, setColsLoading] = useState(false)
|
||||
/** Hazir kolon sablonlari panelinin acik/kapali durumu. */
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||
const templatesRef = useRef<HTMLDivElement | null>(null)
|
||||
const [settings, setSettings] = useState<TableSettings>(DEFAULT_SETTINGS)
|
||||
const [rawMenuItems, setRawMenuItems] = useState<MenuItem[]>([])
|
||||
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
|
||||
|
|
@ -2077,6 +2082,7 @@ const SqlTableDesignerDialog = ({
|
|||
setTargetTableKeyColumns([])
|
||||
setSelectedMenuCode('')
|
||||
setMenuAddDialogOpen(false)
|
||||
setTemplatesOpen(false)
|
||||
setGenerateCrud(false)
|
||||
setCrudOperations([...CRUD_OPERATION_TYPES])
|
||||
onClose()
|
||||
|
|
@ -2125,6 +2131,83 @@ const SqlTableDesignerDialog = ({
|
|||
|
||||
// ── Step 0: Column Designer ────────────────────────────────────────────────
|
||||
|
||||
/** Hazir kolon setleri; buton kalabaligi yerine tek bir sablon panelinde toplanir. */
|
||||
const columnTemplates: {
|
||||
key: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
apply: () => void
|
||||
}[] = [
|
||||
{
|
||||
key: 'multiTenant',
|
||||
label: translate('::App.SqlQueryManager.AddMultiTenantColumns'),
|
||||
hint: 'TenantId',
|
||||
icon: <FaUsers />,
|
||||
color: 'bg-emerald-500',
|
||||
apply: addMultiTenantColumns,
|
||||
},
|
||||
{
|
||||
key: 'fullAudited',
|
||||
label: translate('::App.SqlQueryManager.AddFullAuditedColumns'),
|
||||
hint: 'Creation / Modification / Deletion',
|
||||
icon: <FaHistory />,
|
||||
color: 'bg-blue-500',
|
||||
apply: addFullAuditedColumns,
|
||||
},
|
||||
{
|
||||
key: 'workflow',
|
||||
label: translate('::App.SqlQueryManager.AddWorkflowColumns'),
|
||||
hint: 'Workflow state columns',
|
||||
icon: <FaProjectDiagram />,
|
||||
color: 'bg-indigo-500',
|
||||
apply: addWorkflowColumns,
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
label: 'Todo Columns',
|
||||
hint: 'Status / Priority / DueDate',
|
||||
icon: <FaTasks />,
|
||||
color: 'bg-cyan-500',
|
||||
apply: addTodoColumns,
|
||||
},
|
||||
{
|
||||
key: 'gantt',
|
||||
label: 'Gantt Columns',
|
||||
hint: 'Start / End / Progress',
|
||||
icon: <FaChartBar />,
|
||||
color: 'bg-violet-500',
|
||||
apply: addGanttColumns,
|
||||
},
|
||||
{
|
||||
key: 'scheduler',
|
||||
label: 'Scheduler Columns',
|
||||
hint: 'Event start / end / recurrence',
|
||||
icon: <FaRegCalendarAlt />,
|
||||
color: 'bg-teal-500',
|
||||
apply: addSchedulerColumns,
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!templatesOpen) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (templatesRef.current && !templatesRef.current.contains(e.target as Node)) {
|
||||
setTemplatesOpen(false)
|
||||
}
|
||||
}
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setTemplatesOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
document.addEventListener('keydown', onEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick)
|
||||
document.removeEventListener('keydown', onEsc)
|
||||
}
|
||||
}, [templatesOpen])
|
||||
|
||||
const renderColumnDesigner = () => (
|
||||
<div className="flex flex-col">
|
||||
{colsLoading && (
|
||||
|
|
@ -2135,47 +2218,81 @@ const SqlTableDesignerDialog = ({
|
|||
)}
|
||||
{!colsLoading && (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="xs" variant="solid" color="green-600" onClick={addMultiTenantColumns}>
|
||||
{translate('::App.SqlQueryManager.AddMultiTenantColumns')}
|
||||
</Button>
|
||||
<Button size="xs" variant="solid" color="blue-600" onClick={addFullAuditedColumns}>
|
||||
{translate('::App.SqlQueryManager.AddFullAuditedColumns')}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative" ref={templatesRef}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="solid"
|
||||
color="amber-600"
|
||||
onClick={importColumnsFromRememberedCreateTable}
|
||||
disabled={isEditMode}
|
||||
color="blue-600"
|
||||
icon={<FaLayerGroup />}
|
||||
onClick={() => setTemplatesOpen((o) => !o)}
|
||||
>
|
||||
{translate('::App.DeveloperKitTableDesigner.PasteCreateTableColumns')}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{translate('::App.DeveloperKitTableDesigner.ColumnTemplates')}
|
||||
<FaChevronDown
|
||||
className={`text-[9px] transition-transform ${templatesOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{templatesOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-[440px] max-w-[85vw] rounded-lg border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="px-1.5 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
{translate('::App.DeveloperKitTableDesigner.ColumnTemplatesHint')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
{columnTemplates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.key}
|
||||
type="button"
|
||||
className="flex items-start gap-2 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/60"
|
||||
onClick={() => {
|
||||
tpl.apply()
|
||||
setTemplatesOpen(false)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[11px] text-white ${tpl.color}`}
|
||||
>
|
||||
{tpl.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold text-gray-700 dark:text-gray-100">
|
||||
{tpl.label}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] leading-snug text-gray-400">
|
||||
{tpl.hint}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="xs" variant="solid" color="indigo-600" onClick={addWorkflowColumns}>
|
||||
{translate('::App.SqlQueryManager.AddWorkflowColumns')}
|
||||
</Button>
|
||||
<Button size="xs" variant="solid" color="cyan-600" onClick={addTodoColumns}>
|
||||
Add Todo Columns
|
||||
</Button>
|
||||
<Button size="xs" variant="solid" color="violet-600" onClick={addGanttColumns}>
|
||||
Add Gantt Columns
|
||||
</Button>
|
||||
<Button size="xs" variant="solid" color="teal-600" onClick={addSchedulerColumns}>
|
||||
Add Scheduler Columns
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2">
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="solid"
|
||||
color="red-600"
|
||||
variant="plain"
|
||||
icon={<FaRegClipboard />}
|
||||
onClick={importColumnsFromRememberedCreateTable}
|
||||
disabled={isEditMode}
|
||||
>
|
||||
{translate('::App.DeveloperKitTableDesigner.PasteCreateTableColumns')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
||||
{columns.length} {translate('::App.SqlQueryManager.ColumnName')}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="plain"
|
||||
icon={<FaTrash />}
|
||||
onClick={clearAllColumns}
|
||||
title={translate('::App.SqlQueryManager.ClearAllColumns')}
|
||||
>
|
||||
{translate('::App.SqlQueryManager.ClearAllColumns')}
|
||||
</Button>
|
||||
|
|
@ -2678,7 +2795,9 @@ const SqlTableDesignerDialog = ({
|
|||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
{translate('::App.SqlQueryManager.TargetColumn')}
|
||||
{targetColsLoading ? ` — ${translate('::App.Platform.LoadingWithThreeDot')}` : ''}
|
||||
{targetColsLoading
|
||||
? ` — ${translate('::App.Platform.LoadingWithThreeDot')}`
|
||||
: ''}
|
||||
</label>
|
||||
<select
|
||||
value={fkForm.referencedColumn}
|
||||
|
|
@ -2843,7 +2962,8 @@ const SqlTableDesignerDialog = ({
|
|||
<div className="space-y-3">
|
||||
{indexesLoading && (
|
||||
<div className="flex items-center justify-center py-10 text-gray-500 text-sm gap-2">
|
||||
<span className="animate-spin">◠</span> {translate('::App.Platform.LoadingWithThreeDot')}
|
||||
<span className="animate-spin">◠</span>{' '}
|
||||
{translate('::App.Platform.LoadingWithThreeDot')}
|
||||
</div>
|
||||
)}
|
||||
{!indexesLoading && (
|
||||
|
|
@ -3203,9 +3323,7 @@ const SqlTableDesignerDialog = ({
|
|||
size="xs"
|
||||
variant="plain"
|
||||
className="!h-auto !px-1 !py-0 text-xs text-blue-500 hover:underline"
|
||||
onClick={() =>
|
||||
setCrudOperations(allCrudSelected ? [] : [...CRUD_OPERATION_TYPES])
|
||||
}
|
||||
onClick={() => setCrudOperations(allCrudSelected ? [] : [...CRUD_OPERATION_TYPES])}
|
||||
>
|
||||
{allCrudSelected
|
||||
? translate('::App.SqlQueryManager.ClearSelection')
|
||||
|
|
@ -3332,9 +3450,7 @@ const SqlTableDesignerDialog = ({
|
|||
!dataSource ||
|
||||
isDeploying ||
|
||||
(isEditMode &&
|
||||
generatedSql.includes(
|
||||
translate('::App.TableDesignerDiff.NoChanges'),
|
||||
))
|
||||
generatedSql.includes(translate('::App.TableDesignerDiff.NoChanges')))
|
||||
}
|
||||
>
|
||||
{translate('::App.Platform.Deploy')}
|
||||
|
|
|
|||
1019
ui/src/views/developerKit/SqlViewDesignerDialog.tsx
Normal file
1019
ui/src/views/developerKit/SqlViewDesignerDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
472
ui/src/views/developerKit/sqlViewDesigner/CriteriaGrid.tsx
Normal file
472
ui/src/views/developerKit/sqlViewDesigner/CriteriaGrid.tsx
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
/**
|
||||
* View Designer — Criteria grid (SSMS Query Designer'in orta bolumu).
|
||||
*
|
||||
* Sutunlar: Column · Alias · Table · Output · Group By · Sort Type · Sort Order
|
||||
* · Filter · Or... Filtre hucreleri serbest yuklemdir ("> 100", "LIKE '%a%'");
|
||||
* yaninda kucuk bir yardimci ile de doldurulabilir.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { FaCode, FaGripVertical, FaMagic, FaPlus, FaTrash } from 'react-icons/fa'
|
||||
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
||||
import {
|
||||
GROUP_MODES,
|
||||
GROUP_MODE_LABEL,
|
||||
isAggregateMode,
|
||||
orColumnCount,
|
||||
quoteLiteral,
|
||||
type CriteriaRow,
|
||||
type GroupMode,
|
||||
type ViewSource,
|
||||
} from './viewModel'
|
||||
|
||||
interface CriteriaGridProps {
|
||||
rows: CriteriaRow[]
|
||||
sources: ViewSource[]
|
||||
columnsOf: (sourceId: string) => DatabaseColumnDto[]
|
||||
grouped: boolean
|
||||
onUpdateRow: (id: string, patch: Partial<CriteriaRow>) => void
|
||||
onUpdateFilter: (id: string, index: number, value: string) => void
|
||||
onRemoveRow: (id: string) => void
|
||||
onMoveRow: (id: string, delta: number) => void
|
||||
onAddRow: () => void
|
||||
onAddExpression: () => void
|
||||
labels: {
|
||||
column: string
|
||||
alias: string
|
||||
table: string
|
||||
output: string
|
||||
groupBy: string
|
||||
sortType: string
|
||||
sortOrder: string
|
||||
filter: string
|
||||
or: string
|
||||
addRow: string
|
||||
addExpression: string
|
||||
expressionPlaceholder: string
|
||||
empty: string
|
||||
builder: string
|
||||
builderValue: string
|
||||
builderLiteral: string
|
||||
builderExpression: string
|
||||
apply: string
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
'=',
|
||||
'<>',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'LIKE',
|
||||
'NOT LIKE',
|
||||
'IN',
|
||||
'NOT IN',
|
||||
'IS NULL',
|
||||
'IS NOT NULL',
|
||||
'BETWEEN',
|
||||
]
|
||||
|
||||
export function CriteriaGrid({
|
||||
rows,
|
||||
sources,
|
||||
columnsOf,
|
||||
grouped,
|
||||
onUpdateRow,
|
||||
onUpdateFilter,
|
||||
onRemoveRow,
|
||||
onMoveRow,
|
||||
onAddRow,
|
||||
onAddExpression,
|
||||
labels,
|
||||
}: CriteriaGridProps) {
|
||||
const [builder, setBuilder] = useState<{ rowId: string; index: number } | null>(null)
|
||||
|
||||
const orCount = orColumnCount(rows)
|
||||
|
||||
const cellCls =
|
||||
'w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-gray-300 focus:border-blue-500 focus:bg-white dark:text-gray-100 dark:hover:border-gray-600 dark:focus:bg-gray-700'
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-2 py-1 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30"
|
||||
onClick={onAddRow}
|
||||
disabled={sources.length === 0}
|
||||
>
|
||||
<FaPlus className="text-[9px]" /> {labels.addRow}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||
onClick={onAddExpression}
|
||||
>
|
||||
<FaCode className="text-[9px]" /> {labels.addExpression}
|
||||
</button>
|
||||
<span className="text-[10px] text-gray-400">{rows.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-gray-100 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-300">
|
||||
<tr>
|
||||
<th className="w-6 border border-gray-200 px-1 py-1 dark:border-gray-700" />
|
||||
<th className="min-w-[150px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||||
{labels.column}
|
||||
</th>
|
||||
<th className="min-w-[110px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||||
{labels.alias}
|
||||
</th>
|
||||
<th className="min-w-[90px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||||
{labels.table}
|
||||
</th>
|
||||
<th className="w-14 border border-gray-200 px-1 py-1 dark:border-gray-700">
|
||||
{labels.output}
|
||||
</th>
|
||||
{grouped && (
|
||||
<th className="min-w-[110px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||||
{labels.groupBy}
|
||||
</th>
|
||||
)}
|
||||
<th className="w-[86px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||||
{labels.sortType}
|
||||
</th>
|
||||
<th className="w-[70px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||||
{labels.sortOrder}
|
||||
</th>
|
||||
{Array.from({ length: orCount }).map((_, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="min-w-[130px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700"
|
||||
>
|
||||
{i === 0 ? labels.filter : labels.or}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-8 border border-gray-200 px-1 py-1 dark:border-gray-700" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
className="border border-gray-200 px-2 py-6 text-center text-xs text-gray-400 dark:border-gray-700"
|
||||
colSpan={(grouped ? 9 : 8) + orCount}
|
||||
>
|
||||
{labels.empty}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
const isAgg = grouped && isAggregateMode(row.groupMode)
|
||||
const isExpression = row.expression !== undefined
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={
|
||||
row.output ? 'bg-white dark:bg-gray-900' : 'bg-gray-50/70 dark:bg-gray-800/40'
|
||||
}
|
||||
>
|
||||
<td className="border border-gray-200 px-0.5 text-center dark:border-gray-700">
|
||||
<div className="flex flex-col items-center leading-none">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[8px] text-gray-300 hover:text-blue-500"
|
||||
onClick={() => onMoveRow(row.id, -1)}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<FaGripVertical className="text-[8px] text-gray-300" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-[8px] text-gray-300 hover:text-blue-500"
|
||||
onClick={() => onMoveRow(row.id, 1)}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||||
{isExpression ? (
|
||||
<input
|
||||
className={`${cellCls} font-mono text-[11px] text-indigo-700 dark:text-indigo-300`}
|
||||
placeholder={labels.expressionPlaceholder}
|
||||
value={row.expression ?? ''}
|
||||
title={row.expression}
|
||||
onChange={(e) => onUpdateRow(row.id, { expression: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className={cellCls}
|
||||
value={row.columnName}
|
||||
onChange={(e) => onUpdateRow(row.id, { columnName: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{columnsOf(row.sourceId).map((c) => (
|
||||
<option key={c.columnName} value={c.columnName}>
|
||||
{c.columnName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||||
<input
|
||||
className={cellCls}
|
||||
placeholder={row.columnName}
|
||||
value={row.alias}
|
||||
onChange={(e) => onUpdateRow(row.id, { alias: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||||
{isExpression ? (
|
||||
<span className="px-1 text-[10px] italic text-gray-400">SQL</span>
|
||||
) : (
|
||||
<select
|
||||
className={cellCls}
|
||||
value={row.sourceId}
|
||||
onChange={(e) =>
|
||||
onUpdateRow(row.id, { sourceId: e.target.value, columnName: '' })
|
||||
}
|
||||
>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.alias || s.objectName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="border border-gray-200 text-center dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={row.output}
|
||||
disabled={row.groupMode === 'Where'}
|
||||
onChange={(e) => onUpdateRow(row.id, { output: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{grouped && (
|
||||
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||||
<select
|
||||
className={cellCls}
|
||||
value={row.groupMode}
|
||||
disabled={isExpression}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as GroupMode
|
||||
onUpdateRow(row.id, {
|
||||
groupMode: mode,
|
||||
...(mode === 'Where' ? { output: false } : {}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{GROUP_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{GROUP_MODE_LABEL[m]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||||
<select
|
||||
className={cellCls}
|
||||
value={row.sortType}
|
||||
onChange={(e) => {
|
||||
const sortType = e.target.value as '' | 'ASC' | 'DESC'
|
||||
onUpdateRow(row.id, {
|
||||
sortType,
|
||||
sortOrder: sortType
|
||||
? row.sortOrder || rows.filter((r) => r.sortType).length + 1
|
||||
: 0,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="ASC">Ascending</option>
|
||||
<option value="DESC">Descending</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className={cellCls}
|
||||
value={row.sortOrder || ''}
|
||||
disabled={!row.sortType}
|
||||
onChange={(e) =>
|
||||
onUpdateRow(row.id, { sortOrder: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{Array.from({ length: orCount }).map((_, i) => (
|
||||
<td
|
||||
key={i}
|
||||
className="relative border border-gray-200 px-0.5 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
className={cellCls}
|
||||
placeholder={i === 0 ? "> 100 · = 'A' · IS NULL" : ''}
|
||||
value={row.filters[i] ?? ''}
|
||||
onChange={(e) => onUpdateFilter(row.id, i, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[9px] text-gray-300 hover:text-blue-500"
|
||||
title={labels.builder}
|
||||
onClick={() =>
|
||||
setBuilder(
|
||||
builder?.rowId === row.id && builder.index === i
|
||||
? null
|
||||
: { rowId: row.id, index: i },
|
||||
)
|
||||
}
|
||||
>
|
||||
<FaMagic />
|
||||
</button>
|
||||
</div>
|
||||
{builder?.rowId === row.id && builder.index === i && (
|
||||
<FilterBuilder
|
||||
labels={labels}
|
||||
onApply={(text) => {
|
||||
onUpdateFilter(row.id, i, text)
|
||||
setBuilder(null)
|
||||
}}
|
||||
onClose={() => setBuilder(null)}
|
||||
/>
|
||||
)}
|
||||
{isAgg && (row.filters[i] ?? '').trim() && (
|
||||
<span className="pointer-events-none absolute -top-1 right-6 rounded bg-indigo-100 px-1 text-[8px] font-semibold text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
||||
HAVING
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
<td className="border border-gray-200 text-center dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-gray-400 hover:text-red-500"
|
||||
onClick={() => onRemoveRow(row.id)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Filtre yardimcisi ────────────────────────────────────────────────────────
|
||||
|
||||
function FilterBuilder({
|
||||
labels,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
labels: CriteriaGridProps['labels']
|
||||
onApply: (text: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [operator, setOperator] = useState('=')
|
||||
const [value, setValue] = useState('')
|
||||
const [value2, setValue2] = useState('')
|
||||
const [mode, setMode] = useState<'literal' | 'expression'>('literal')
|
||||
|
||||
const needsValue = operator !== 'IS NULL' && operator !== 'IS NOT NULL'
|
||||
|
||||
const build = () => {
|
||||
if (!needsValue) return operator
|
||||
const render = (v: string) => (mode === 'literal' ? quoteLiteral(v.trim()) : v.trim())
|
||||
if (operator === 'BETWEEN') return `BETWEEN ${render(value)} AND ${render(value2)}`
|
||||
if (operator === 'IN' || operator === 'NOT IN') {
|
||||
if (mode === 'expression') return `${operator} (${value.trim()})`
|
||||
const items = value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
.map(quoteLiteral)
|
||||
return `${operator} (${items.join(', ')})`
|
||||
}
|
||||
return `${operator} ${render(value)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-full z-50 mt-1 w-[230px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<select
|
||||
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value={operator}
|
||||
onChange={(e) => setOperator(e.target.value)}
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{op}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{needsValue && (
|
||||
<>
|
||||
<input
|
||||
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder={labels.builderValue}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
{operator === 'BETWEEN' && (
|
||||
<input
|
||||
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder={labels.builderValue}
|
||||
value={value2}
|
||||
onChange={(e) => setValue2(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
className="mb-1 w-full rounded border px-1 py-1 text-[11px] dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as 'literal' | 'expression')}
|
||||
>
|
||||
<option value="literal">{labels.builderLiteral}</option>
|
||||
<option value="expression">{labels.builderExpression}</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-blue-600 px-1 py-1 text-[11px] font-semibold text-white hover:bg-blue-700"
|
||||
onClick={() => onApply(build())}
|
||||
>
|
||||
{labels.apply}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CriteriaGrid
|
||||
726
ui/src/views/developerKit/sqlViewDesigner/DiagramPane.tsx
Normal file
726
ui/src/views/developerKit/sqlViewDesigner/DiagramPane.tsx
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
/**
|
||||
* View Designer — Diagram paneli (SSMS Query Designer'in ust bolumu).
|
||||
*
|
||||
* Tablolar surukle-birak ile konumlandirilir (WorkflowCanvas ile ayni
|
||||
* `@dnd-kit/core` deseni), kolonlarin yanindaki tutamaca tiklanip hedef kolona
|
||||
* tiklanarak JOIN kurulur, join cizgisine tiklanarak tipi degistirilir.
|
||||
*
|
||||
* Kutular sabit yukseklikte tutulur; kolon sayisi fazlaysa kutu icinde
|
||||
* kaydirilir ve alt kenardan yukseklik ayarlanabilir. Kaydirilan kolonun capa
|
||||
* noktasi kutu kenarina kenetlenir, boylece cizgi her zaman kutuya baglanir.
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaCompressAlt,
|
||||
FaExpandAlt,
|
||||
FaEye,
|
||||
FaLayerGroup,
|
||||
FaTable,
|
||||
FaTimes,
|
||||
} from 'react-icons/fa'
|
||||
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
||||
import {
|
||||
BOX_FOOTER_HEIGHT,
|
||||
BOX_HEADER_HEIGHT,
|
||||
BOX_MIN_ROWS,
|
||||
BOX_PADDING,
|
||||
BOX_ROW_HEIGHT,
|
||||
BOX_WIDTH,
|
||||
JOIN_KINDS,
|
||||
JOIN_OPERATORS,
|
||||
boxHeight,
|
||||
isDerivedSource,
|
||||
type JoinKind,
|
||||
type JoinOperator,
|
||||
type ViewSource,
|
||||
} from './viewModel'
|
||||
|
||||
export interface PendingLink {
|
||||
sourceId: string
|
||||
columnName: string
|
||||
}
|
||||
|
||||
/** Diagramda cizilecek tek bir join kosulu. */
|
||||
interface JoinLine {
|
||||
key: string
|
||||
/** Join'i tasiyan (sag taraftaki) kaynak. */
|
||||
sourceId: string
|
||||
conditionId: string
|
||||
leftSourceId: string
|
||||
leftColumn: string
|
||||
rightColumn: string
|
||||
operator: JoinOperator
|
||||
joinKind: JoinKind
|
||||
}
|
||||
|
||||
interface DiagramPaneProps {
|
||||
sources: ViewSource[]
|
||||
columnsOf: (sourceId: string) => DatabaseColumnDto[]
|
||||
loadingSourceIds: Set<string>
|
||||
/** Bir kolon ciktiya secili mi. */
|
||||
isColumnSelected: (sourceId: string, columnName: string) => boolean
|
||||
zoom: number
|
||||
dragPreview: { id: string; delta: { x: number; y: number } } | null
|
||||
selectedJoinKey: string | null
|
||||
pendingLink: PendingLink | null
|
||||
/** Ifade/alt sorgu metinleri alias'lara metin olarak baglidir; kilitli tutulur. */
|
||||
lockAliases: boolean
|
||||
onToggleColumn: (sourceId: string, columnName: string) => void
|
||||
onToggleAllColumns: (sourceId: string) => void
|
||||
onRemoveSource: (sourceId: string) => void
|
||||
onUpdateSource: (sourceId: string, patch: Partial<ViewSource>) => void
|
||||
onBeginLink: (link: PendingLink | null) => void
|
||||
onCompleteLink: (target: PendingLink) => void
|
||||
onSelectJoin: (key: string | null) => void
|
||||
onUpdateJoin: (sourceId: string, conditionId: string, patch: { operator?: JoinOperator }) => void
|
||||
onRemoveJoin: (sourceId: string, conditionId: string) => void
|
||||
emptyHint: string
|
||||
pendingHint: string
|
||||
labels: {
|
||||
alias: string
|
||||
selectAll: string
|
||||
onlySelected: string
|
||||
allColumns: string
|
||||
joinType: string
|
||||
removeJoin: string
|
||||
removeTable: string
|
||||
resize: string
|
||||
aliasLocked: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Kutunun gorunur kolon listesi. */
|
||||
const visibleColumns = (
|
||||
src: ViewSource,
|
||||
all: DatabaseColumnDto[],
|
||||
isSelected: (sourceId: string, columnName: string) => boolean,
|
||||
) => (src.showSelectedOnly ? all.filter((c) => isSelected(src.id, c.columnName)) : all)
|
||||
|
||||
/** Kolon listesinin kaydirilabilir alaninin yuksekligi. */
|
||||
const listHeight = (src: ViewSource, columnCount: number) =>
|
||||
Math.max(1, Math.min(src.visibleRows, columnCount || 1)) * BOX_ROW_HEIGHT
|
||||
|
||||
const JOIN_COLOR: Record<JoinKind, string> = {
|
||||
INNER: '#2563eb',
|
||||
LEFT: '#16a34a',
|
||||
RIGHT: '#b45309',
|
||||
FULL: '#7c3aed',
|
||||
CROSS: '#64748b',
|
||||
}
|
||||
|
||||
export function DiagramPane({
|
||||
sources,
|
||||
columnsOf,
|
||||
loadingSourceIds,
|
||||
isColumnSelected,
|
||||
zoom,
|
||||
dragPreview,
|
||||
selectedJoinKey,
|
||||
pendingLink,
|
||||
lockAliases,
|
||||
onToggleColumn,
|
||||
onToggleAllColumns,
|
||||
onRemoveSource,
|
||||
onUpdateSource,
|
||||
onBeginLink,
|
||||
onCompleteLink,
|
||||
onSelectJoin,
|
||||
onUpdateJoin,
|
||||
onRemoveJoin,
|
||||
emptyHint,
|
||||
pendingHint,
|
||||
labels,
|
||||
}: DiagramPaneProps) {
|
||||
const [joinMenu, setJoinMenu] = useState<{ key: string; x: number; y: number } | null>(null)
|
||||
const [hoveredJoin, setHoveredJoin] = useState<string | null>(null)
|
||||
/** Kutu ici kaydirma ofsetleri; capa noktalari bunlara gore kayar. */
|
||||
const [scrollTops, setScrollTops] = useState<Record<string, number>>({})
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(sourceId: string, top: number) =>
|
||||
setScrollTops((prev) => (prev[sourceId] === top ? prev : { ...prev, [sourceId]: top })),
|
||||
[],
|
||||
)
|
||||
|
||||
/** Surukleme onizlemesi uygulanmis konumlar. */
|
||||
const positioned = useMemo(
|
||||
() =>
|
||||
sources.map((s) =>
|
||||
dragPreview?.id === s.id
|
||||
? { ...s, x: s.x + dragPreview.delta.x, y: s.y + dragPreview.delta.y }
|
||||
: s,
|
||||
),
|
||||
[sources, dragPreview],
|
||||
)
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{
|
||||
src: ViewSource
|
||||
columns: DatabaseColumnDto[]
|
||||
height: number
|
||||
listH: number
|
||||
indexOf: Map<string, number>
|
||||
}
|
||||
>()
|
||||
positioned.forEach((s) => {
|
||||
const cols = visibleColumns(s, columnsOf(s.id), isColumnSelected)
|
||||
map.set(s.id, {
|
||||
src: s,
|
||||
columns: cols,
|
||||
height: boxHeight(s, cols.length),
|
||||
listH: listHeight(s, cols.length),
|
||||
indexOf: new Map(cols.map((c, i) => [c.columnName.toLowerCase(), i])),
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [positioned, columnsOf, isColumnSelected])
|
||||
|
||||
const lines = useMemo<JoinLine[]>(() => {
|
||||
const out: JoinLine[] = []
|
||||
sources.slice(1).forEach((s) => {
|
||||
if (s.joinKind === 'CROSS') return
|
||||
s.conditions.forEach((c) => {
|
||||
if (!c.leftSourceId || !c.leftColumn || !c.rightColumn) return
|
||||
out.push({
|
||||
key: `${s.id}:${c.id}`,
|
||||
sourceId: s.id,
|
||||
conditionId: c.id,
|
||||
leftSourceId: c.leftSourceId,
|
||||
leftColumn: c.leftColumn,
|
||||
rightColumn: c.rightColumn,
|
||||
operator: c.operator,
|
||||
joinKind: s.joinKind,
|
||||
})
|
||||
})
|
||||
})
|
||||
return out
|
||||
}, [sources])
|
||||
|
||||
const canvasWidth = Math.max(900, ...positioned.map((s) => s.x + BOX_WIDTH + 80))
|
||||
const canvasHeight = Math.max(
|
||||
340,
|
||||
...positioned.map((s) => s.y + (layout.get(s.id)?.height ?? 120) + 60),
|
||||
)
|
||||
|
||||
/**
|
||||
* Kolonun capa noktasi. Kolon kaydirma yuzunden gorunmuyorsa liste alaninin
|
||||
* ust/alt kenarina kenetlenir; kutu daraltilmissa baslik hizasi kullanilir.
|
||||
*/
|
||||
const anchorOf = (sourceId: string, columnName: string) => {
|
||||
const entry = layout.get(sourceId)
|
||||
if (!entry) return null
|
||||
const { src, listH, indexOf } = entry
|
||||
if (src.collapsed) {
|
||||
return { boxX: src.x, y: src.y + BOX_HEADER_HEIGHT / 2 }
|
||||
}
|
||||
const idx = indexOf.get(columnName.toLowerCase())
|
||||
const listTop = src.y + BOX_HEADER_HEIGHT + BOX_PADDING
|
||||
if (idx === undefined) {
|
||||
return { boxX: src.x, y: listTop + listH / 2 }
|
||||
}
|
||||
const scroll = scrollTops[src.id] ?? 0
|
||||
const raw = listTop + idx * BOX_ROW_HEIGHT + BOX_ROW_HEIGHT / 2 - scroll
|
||||
const min = listTop + 3
|
||||
const max = listTop + listH - 3
|
||||
return { boxX: src.x, y: Math.min(max, Math.max(min, raw)) }
|
||||
}
|
||||
|
||||
const renderLine = (line: JoinLine) => {
|
||||
const left = anchorOf(line.leftSourceId, line.leftColumn)
|
||||
const right = anchorOf(line.sourceId, line.rightColumn)
|
||||
if (!left || !right) return null
|
||||
|
||||
// Kutular arasindaki yone gore capa kenarini sec.
|
||||
const leftIsWest = left.boxX <= right.boxX
|
||||
const x1 = leftIsWest ? left.boxX + BOX_WIDTH : left.boxX
|
||||
const x2 = leftIsWest ? right.boxX : right.boxX + BOX_WIDTH
|
||||
const y1 = left.y
|
||||
const y2 = right.y
|
||||
const dir = leftIsWest ? 1 : -1
|
||||
const stub = 18
|
||||
const midX = (x1 + x2) / 2
|
||||
const midY = (y1 + y2) / 2
|
||||
const r = Math.min(8, Math.abs(y2 - y1) / 2, Math.abs(midX - (x1 + dir * stub)) || 8)
|
||||
|
||||
// Ortogonal yol: kisa cikis -> dikey -> kisa giris (SSMS'teki gibi).
|
||||
const path =
|
||||
Math.abs(y2 - y1) < 2
|
||||
? `M ${x1} ${y1} L ${x2} ${y2}`
|
||||
: [
|
||||
`M ${x1} ${y1}`,
|
||||
`L ${midX - dir * r} ${y1}`,
|
||||
`Q ${midX} ${y1} ${midX} ${y1 + Math.sign(y2 - y1) * r}`,
|
||||
`L ${midX} ${y2 - Math.sign(y2 - y1) * r}`,
|
||||
`Q ${midX} ${y2} ${midX + dir * r} ${y2}`,
|
||||
`L ${x2} ${y2}`,
|
||||
].join(' ')
|
||||
|
||||
const color = JOIN_COLOR[line.joinKind]
|
||||
const active = selectedJoinKey === line.key || hoveredJoin === line.key
|
||||
const open = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onSelectJoin(line.key)
|
||||
setJoinMenu({ key: line.key, x: midX, y: midY })
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={line.key} data-join-line>
|
||||
<path d={path} fill="none" stroke={color} strokeWidth={active ? 2.25 : 1.5} />
|
||||
{/* Uc noktalari kutuya kenetler */}
|
||||
<circle cx={x1} cy={y1} r={3} fill={color} />
|
||||
<circle cx={x2} cy={y2} r={3} fill={color} />
|
||||
{/* Tiklama alanini genisleten seffaf yol */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={14}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={open}
|
||||
onMouseEnter={() => setHoveredJoin(line.key)}
|
||||
onMouseLeave={() => setHoveredJoin(null)}
|
||||
/>
|
||||
{/* SSMS'teki elmas isaret; detay yalnizca hover/secimde acilir */}
|
||||
<g
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={open}
|
||||
onMouseEnter={() => setHoveredJoin(line.key)}
|
||||
onMouseLeave={() => setHoveredJoin(null)}
|
||||
>
|
||||
<title>{`${line.joinKind} JOIN · ${line.leftColumn} ${line.operator} ${line.rightColumn}`}</title>
|
||||
<rect
|
||||
x={midX - 7}
|
||||
y={midY - 7}
|
||||
width={14}
|
||||
height={14}
|
||||
rx={2}
|
||||
transform={`rotate(45 ${midX} ${midY})`}
|
||||
fill="#fff"
|
||||
stroke={color}
|
||||
strokeWidth={active ? 2 : 1.25}
|
||||
/>
|
||||
<text
|
||||
x={midX}
|
||||
y={midY + 3}
|
||||
textAnchor="middle"
|
||||
fontSize={8}
|
||||
fontWeight={700}
|
||||
fill={color}
|
||||
>
|
||||
{line.operator}
|
||||
</text>
|
||||
</g>
|
||||
{active && (
|
||||
<g className="pointer-events-none">
|
||||
<rect
|
||||
x={midX - 30}
|
||||
y={midY - 26}
|
||||
width={60}
|
||||
height={15}
|
||||
rx={7}
|
||||
fill={color}
|
||||
opacity={0.95}
|
||||
/>
|
||||
<text x={midX} y={midY - 15} textAnchor="middle" fontSize={9} fill="#fff">
|
||||
{line.joinKind}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
const activeJoin = joinMenu ? lines.find((l) => l.key === joinMenu.key) : undefined
|
||||
const activeJoinSource = activeJoin
|
||||
? sources.find((s) => s.id === activeJoin.sourceId)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full overflow-auto rounded-lg border border-gray-200 bg-white [--grid-color:#edf1f6] dark:border-gray-700 dark:bg-gray-950 dark:[--grid-color:#243244]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(var(--grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--grid-color) 1px, transparent 1px)',
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
onClick={() => {
|
||||
setJoinMenu(null)
|
||||
onSelectJoin(null)
|
||||
onBeginLink(null)
|
||||
}}
|
||||
>
|
||||
{pendingLink && (
|
||||
<div className="sticky left-2 top-2 z-50 m-2 inline-flex items-center rounded-md border border-blue-300 bg-blue-50 px-3 py-1 text-[11px] text-blue-800 shadow dark:border-blue-500/60 dark:bg-blue-900/40 dark:text-blue-100">
|
||||
{pendingHint}
|
||||
</div>
|
||||
)}
|
||||
{sources.length === 0 && (
|
||||
<div className="sticky left-4 top-4 z-30 inline-block max-w-[380px] rounded-lg border border-gray-300 bg-white/95 p-3 text-xs text-gray-500 shadow dark:border-gray-700 dark:bg-gray-900/95 dark:text-gray-300">
|
||||
{emptyHint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ width: canvasWidth * zoom, height: canvasHeight * zoom }}>
|
||||
<div
|
||||
className="relative origin-top-left"
|
||||
style={{ width: canvasWidth, height: canvasHeight, transform: `scale(${zoom})` }}
|
||||
>
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 z-20 overflow-visible"
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{lines.map(renderLine)}
|
||||
</svg>
|
||||
|
||||
{positioned.map((src) => {
|
||||
const entry = layout.get(src.id)
|
||||
return (
|
||||
<TableBox
|
||||
key={src.id}
|
||||
src={src}
|
||||
columns={entry?.columns ?? []}
|
||||
listH={entry?.listH ?? BOX_ROW_HEIGHT}
|
||||
loading={loadingSourceIds.has(src.id)}
|
||||
isColumnSelected={isColumnSelected}
|
||||
pendingLink={pendingLink}
|
||||
lockAliases={lockAliases}
|
||||
labels={labels}
|
||||
zoom={zoom}
|
||||
onScroll={handleScroll}
|
||||
onToggleColumn={onToggleColumn}
|
||||
onToggleAllColumns={onToggleAllColumns}
|
||||
onRemoveSource={onRemoveSource}
|
||||
onUpdateSource={onUpdateSource}
|
||||
onBeginLink={onBeginLink}
|
||||
onCompleteLink={onCompleteLink}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{joinMenu && activeJoin && activeJoinSource && (
|
||||
<div
|
||||
className="absolute z-[80] w-[190px] rounded-lg border border-gray-200 bg-white p-2 text-xs shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
style={{ left: joinMenu.x - 95, top: joinMenu.y + 16 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
|
||||
{labels.joinType}
|
||||
</div>
|
||||
<select
|
||||
className="mb-1.5 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value={activeJoinSource.joinKind}
|
||||
onChange={(e) =>
|
||||
onUpdateSource(activeJoinSource.id, { joinKind: e.target.value as JoinKind })
|
||||
}
|
||||
>
|
||||
{JOIN_KINDS.filter((k) => k !== 'CROSS').map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k === 'INNER' ? 'INNER JOIN' : `${k} OUTER JOIN`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="mb-1.5 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value={activeJoin.operator}
|
||||
onChange={(e) =>
|
||||
onUpdateJoin(activeJoin.sourceId, activeJoin.conditionId, {
|
||||
operator: e.target.value as JoinOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
{JOIN_OPERATORS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{`${activeJoin.leftColumn} ${op} ${activeJoin.rightColumn}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-1 py-1 text-left text-[11px] text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => {
|
||||
onRemoveJoin(activeJoin.sourceId, activeJoin.conditionId)
|
||||
setJoinMenu(null)
|
||||
}}
|
||||
>
|
||||
{labels.removeJoin}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tablo kutusu ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableBoxProps {
|
||||
src: ViewSource
|
||||
columns: DatabaseColumnDto[]
|
||||
listH: number
|
||||
loading: boolean
|
||||
isColumnSelected: (sourceId: string, columnName: string) => boolean
|
||||
pendingLink: PendingLink | null
|
||||
lockAliases: boolean
|
||||
labels: DiagramPaneProps['labels']
|
||||
zoom: number
|
||||
onScroll: (sourceId: string, top: number) => void
|
||||
onToggleColumn: (sourceId: string, columnName: string) => void
|
||||
onToggleAllColumns: (sourceId: string) => void
|
||||
onRemoveSource: (sourceId: string) => void
|
||||
onUpdateSource: (sourceId: string, patch: Partial<ViewSource>) => void
|
||||
onBeginLink: (link: PendingLink | null) => void
|
||||
onCompleteLink: (target: PendingLink) => void
|
||||
}
|
||||
|
||||
function TableBox({
|
||||
src,
|
||||
columns,
|
||||
listH,
|
||||
loading,
|
||||
isColumnSelected,
|
||||
pendingLink,
|
||||
lockAliases,
|
||||
labels,
|
||||
zoom,
|
||||
onScroll,
|
||||
onToggleColumn,
|
||||
onToggleAllColumns,
|
||||
onRemoveSource,
|
||||
onUpdateSource,
|
||||
onBeginLink,
|
||||
onCompleteLink,
|
||||
}: TableBoxProps) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: src.id,
|
||||
disabled: Boolean(pendingLink),
|
||||
})
|
||||
const resizeState = useRef<{ startY: number; startRows: number } | null>(null)
|
||||
|
||||
const isLinkSource = pendingLink?.sourceId === src.id
|
||||
const derived = isDerivedSource(src)
|
||||
const maxRows = Math.max(1, columns.length)
|
||||
|
||||
const beginResize = (e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
resizeState.current = { startY: e.clientY, startRows: Math.min(src.visibleRows, maxRows) }
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
const onResize = (e: React.PointerEvent) => {
|
||||
const state = resizeState.current
|
||||
if (!state) return
|
||||
const deltaRows = Math.round((e.clientY - state.startY) / zoom / BOX_ROW_HEIGHT)
|
||||
const next = Math.min(maxRows, Math.max(BOX_MIN_ROWS, state.startRows + deltaRows))
|
||||
if (next !== src.visibleRows) onUpdateSource(src.id, { visibleRows: next })
|
||||
}
|
||||
|
||||
const endResize = (e: React.PointerEvent) => {
|
||||
resizeState.current = null
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-diagram-box
|
||||
className={classNames(
|
||||
'absolute z-40 touch-none rounded-lg border-2 bg-white shadow-lg dark:bg-gray-900',
|
||||
isLinkSource
|
||||
? 'border-blue-500 outline outline-[3px] outline-blue-500/20'
|
||||
: derived
|
||||
? 'border-indigo-400 dark:border-indigo-500'
|
||||
: 'border-gray-400 dark:border-gray-600',
|
||||
)}
|
||||
style={{
|
||||
left: src.x,
|
||||
top: src.y,
|
||||
width: BOX_WIDTH,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Baslik — surukleme tutamaci */}
|
||||
<div
|
||||
className="flex cursor-move items-center gap-1 rounded-t-md bg-gray-100 px-1.5 dark:bg-gray-800"
|
||||
style={{ height: BOX_HEADER_HEIGHT }}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{derived ? (
|
||||
<FaLayerGroup className="shrink-0 text-[11px] text-indigo-500" />
|
||||
) : (
|
||||
<FaTable className="shrink-0 text-[11px] text-teal-500" />
|
||||
)}
|
||||
<span
|
||||
className="truncate text-[11px] font-semibold text-gray-700 dark:text-gray-100"
|
||||
title={derived ? src.derivedSql : `${src.schemaName}.${src.objectName}`}
|
||||
>
|
||||
{src.objectName}
|
||||
</span>
|
||||
{derived && (
|
||||
<span className="shrink-0 rounded bg-indigo-100 px-1 text-[8px] font-semibold text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
||||
{src.applyKind ? `${src.applyKind} APPLY` : 'SUB'}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
className="ml-auto w-[46px] shrink-0 rounded border border-gray-300 bg-white px-1 py-0 text-[10px] text-gray-600 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
value={src.alias}
|
||||
disabled={lockAliases}
|
||||
title={lockAliases ? labels.aliasLocked : labels.alias}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onChange={(e) => onUpdateSource(src.id, { alias: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[10px] text-gray-400 hover:text-blue-500"
|
||||
title={src.showSelectedOnly ? labels.allColumns : labels.onlySelected}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onUpdateSource(src.id, { showSelectedOnly: !src.showSelectedOnly })}
|
||||
>
|
||||
{src.showSelectedOnly ? <FaExpandAlt /> : <FaCompressAlt />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[10px] text-gray-400 hover:text-blue-500"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onUpdateSource(src.id, { collapsed: !src.collapsed })}
|
||||
>
|
||||
{src.collapsed ? <FaChevronRight /> : <FaChevronDown />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[10px] text-gray-400 hover:text-red-500"
|
||||
title={labels.removeTable}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onRemoveSource(src.id)}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!src.collapsed && (
|
||||
<>
|
||||
<div
|
||||
className="overflow-y-auto overflow-x-hidden"
|
||||
style={{ height: listH, marginTop: BOX_PADDING, marginBottom: BOX_PADDING }}
|
||||
onScroll={(e) => onScroll(src.id, e.currentTarget.scrollTop)}
|
||||
>
|
||||
{loading && (
|
||||
<div
|
||||
className="px-2 text-[10px] text-gray-400"
|
||||
style={{ height: BOX_ROW_HEIGHT, lineHeight: `${BOX_ROW_HEIGHT}px` }}
|
||||
>
|
||||
…
|
||||
</div>
|
||||
)}
|
||||
{!loading && columns.length === 0 && (
|
||||
<div
|
||||
className="px-2 text-[10px] text-gray-400"
|
||||
style={{ height: BOX_ROW_HEIGHT, lineHeight: `${BOX_ROW_HEIGHT}px` }}
|
||||
>
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
{columns.map((col) => {
|
||||
const selected = isColumnSelected(src.id, col.columnName)
|
||||
const isPendingCell =
|
||||
pendingLink?.sourceId === src.id && pendingLink.columnName === col.columnName
|
||||
const linkable = pendingLink && pendingLink.sourceId !== src.id
|
||||
return (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className={classNames(
|
||||
'group flex items-center gap-1 px-1 text-[11px]',
|
||||
isPendingCell && 'bg-blue-100 dark:bg-blue-900/40',
|
||||
linkable && 'cursor-crosshair hover:bg-green-100 dark:hover:bg-green-900/30',
|
||||
)}
|
||||
style={{ height: BOX_ROW_HEIGHT }}
|
||||
onClick={() => {
|
||||
if (linkable) onCompleteLink({ sourceId: src.id, columnName: col.columnName })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 shrink-0"
|
||||
checked={selected}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onChange={() => onToggleColumn(src.id, col.columnName)}
|
||||
/>
|
||||
<span
|
||||
className="truncate text-gray-700 dark:text-gray-200"
|
||||
title={`${col.columnName} · ${col.dataType}`}
|
||||
>
|
||||
{col.columnName}
|
||||
</span>
|
||||
{/* Join tutamaci — tiklayip hedef kolona tiklanir */}
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'ml-auto h-2.5 w-2.5 shrink-0 rounded-full border transition-opacity',
|
||||
isPendingCell
|
||||
? 'border-blue-600 bg-blue-500 opacity-100'
|
||||
: 'border-gray-400 bg-white opacity-0 group-hover:opacity-100 dark:bg-gray-700',
|
||||
)}
|
||||
title={labels.joinType}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isPendingCell) onBeginLink(null)
|
||||
else if (pendingLink && pendingLink.sourceId !== src.id)
|
||||
onCompleteLink({ sourceId: src.id, columnName: col.columnName })
|
||||
else onBeginLink({ sourceId: src.id, columnName: col.columnName })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-b-md border-t border-gray-100 px-1.5 dark:border-gray-700"
|
||||
style={{ height: BOX_FOOTER_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-[10px] text-blue-600 hover:underline"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => onToggleAllColumns(src.id)}
|
||||
>
|
||||
<FaEye className="mr-1 inline text-[9px]" />
|
||||
{labels.selectAll}
|
||||
</button>
|
||||
<span className="ml-auto shrink-0 text-[9px] text-gray-400">
|
||||
{Math.min(src.visibleRows, maxRows)}/{columns.length}
|
||||
</span>
|
||||
{/* Yukseklik tutamaci */}
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 cursor-ns-resize text-gray-300 hover:text-blue-500"
|
||||
title={labels.resize}
|
||||
onPointerDown={beginResize}
|
||||
onPointerMove={onResize}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
>
|
||||
<svg viewBox="0 0 10 10" className="h-3 w-3">
|
||||
<path d="M1 6 L9 6 M1 8.5 L9 8.5" stroke="currentColor" strokeWidth="1.2" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiagramPane
|
||||
1181
ui/src/views/developerKit/sqlViewDesigner/viewModel.ts
Normal file
1181
ui/src/views/developerKit/sqlViewDesigner/viewModel.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue