Sql Query Manager Add, Edit ve Delete butonları
This commit is contained in:
parent
947d21ecb7
commit
99e6f91b2a
3 changed files with 524 additions and 76 deletions
|
|
@ -282,6 +282,29 @@ div.dialog-after-open > div.dialog-content.maximized {
|
||||||
margin-left: 2px !important;
|
margin-left: 2px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sql-results-grid .dx-toolbar-items-container {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-results-grid .dx-toolbar-before {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-results-grid .dx-toolbar-before .dx-toolbar-item {
|
||||||
|
margin-right: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-results-grid .dx-toolbar-after {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dx-datagrid-rowsview .dx-datagrid-group-row-container {
|
.dx-datagrid-rowsview .dx-datagrid-group-row-container {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ interface SqlManagerState {
|
||||||
isDirty: boolean
|
isDirty: boolean
|
||||||
tableColumns: any | null
|
tableColumns: any | null
|
||||||
refreshTrigger: number
|
refreshTrigger: number
|
||||||
|
lastExecutedQuery: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SqlCopyResultItem {
|
interface SqlCopyResultItem {
|
||||||
|
|
@ -75,6 +76,7 @@ const SqlQueryManager = () => {
|
||||||
showProperties: false,
|
showProperties: false,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
tableColumns: null,
|
tableColumns: null,
|
||||||
|
lastExecutedQuery: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const [showTemplateConfirmDialog, setShowTemplateConfirmDialog] = useState(false)
|
const [showTemplateConfirmDialog, setShowTemplateConfirmDialog] = useState(false)
|
||||||
|
|
@ -138,6 +140,7 @@ const SqlQueryManager = () => {
|
||||||
selectedDataSource: dataSource.code ?? null,
|
selectedDataSource: dataSource.code ?? null,
|
||||||
editorContent: '',
|
editorContent: '',
|
||||||
executionResult: null,
|
executionResult: null,
|
||||||
|
lastExecutedQuery: '',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -639,6 +642,7 @@ GO`,
|
||||||
editorContent: '',
|
editorContent: '',
|
||||||
executionResult: null,
|
executionResult: null,
|
||||||
tableColumns: null,
|
tableColumns: null,
|
||||||
|
lastExecutedQuery: '',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -671,7 +675,13 @@ GO`,
|
||||||
// Seçili metni koru
|
// Seçili metni koru
|
||||||
const savedSelection = editorRef.current?.preserveSelection()
|
const savedSelection = editorRef.current?.preserveSelection()
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, isExecuting: true, executionResult: null, tableColumns: null }))
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isExecuting: true,
|
||||||
|
executionResult: null,
|
||||||
|
tableColumns: null,
|
||||||
|
lastExecutedQuery: queryToExecute,
|
||||||
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sqlObjectManagerService.executeQuery({
|
const result = await sqlObjectManagerService.executeQuery({
|
||||||
|
|
@ -1120,6 +1130,34 @@ GO`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResultsMutation = async (mutationQuery: string) => {
|
||||||
|
if (!state.selectedDataSource || !state.lastExecutedQuery) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mutationResult = await sqlObjectManagerService.executeQuery({
|
||||||
|
queryText: mutationQuery,
|
||||||
|
dataSourceCode: state.selectedDataSource,
|
||||||
|
})
|
||||||
|
if (!mutationResult.data.success) {
|
||||||
|
throw new Error(mutationResult.data.error || mutationResult.data.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedResult = await sqlObjectManagerService.executeQuery({
|
||||||
|
queryText: state.lastExecutedQuery,
|
||||||
|
dataSourceCode: state.selectedDataSource,
|
||||||
|
})
|
||||||
|
setState((prev) => ({ ...prev, executionResult: refreshedResult.data }))
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.push(
|
||||||
|
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
||||||
|
{error.response?.data?.error?.message || error.message || 'Record operation failed.'}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'top-center' },
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePreviewSqlDataFile = async (file: SqlDataExplorerEntry) => {
|
const handlePreviewSqlDataFile = async (file: SqlDataExplorerEntry) => {
|
||||||
if (!file.relativePath || isPreviewingSqlDataFile) {
|
if (!file.relativePath || isPreviewingSqlDataFile) {
|
||||||
return
|
return
|
||||||
|
|
@ -1240,7 +1278,10 @@ GO`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="flex flex-col overflow-hidden" style={{ height: 'calc(100vh - 130px)' }}>
|
<Container
|
||||||
|
className="flex flex-col overflow-x-hidden overflow-y-auto lg:overflow-hidden"
|
||||||
|
style={{ height: 'calc(100vh - 130px)' }}
|
||||||
|
>
|
||||||
<Helmet
|
<Helmet
|
||||||
titleTemplate={`%s | ${APP_NAME}`}
|
titleTemplate={`%s | ${APP_NAME}`}
|
||||||
title={translate('::' + 'App.SqlQueryManager')}
|
title={translate('::' + 'App.SqlQueryManager')}
|
||||||
|
|
@ -1320,7 +1361,7 @@ GO`,
|
||||||
icon={<FaPlay />}
|
icon={<FaPlay />}
|
||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
loading={state.isExecuting}
|
loading={state.isExecuting}
|
||||||
disabled={!state.selectedDataSource}
|
disabled={!state.selectedDataSource || !state.editorContent.trim()}
|
||||||
className="shadow-sm px-2 py-1"
|
className="shadow-sm px-2 py-1"
|
||||||
>
|
>
|
||||||
{translate('::App.Platform.Execute')}
|
{translate('::App.Platform.Execute')}
|
||||||
|
|
@ -1333,7 +1374,7 @@ GO`,
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex min-h-0 flex-col gap-3 lg:flex-row lg:gap-4">
|
<div className="flex-1 flex min-h-0 flex-col gap-3 lg:flex-row lg:gap-4">
|
||||||
{/* Left Panel - Object Explorer */}
|
{/* Left Panel - Object Explorer */}
|
||||||
<div className="w-full lg:w-1/3 flex-shrink-0 flex flex-col h-[35vh] min-h-[260px] max-h-[420px] lg:h-auto lg:min-h-0 lg:max-h-none bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 shadow">
|
<div className="w-full lg:w-1/3 flex-shrink-0 flex flex-col h-[220px] min-h-[220px] max-h-[260px] sm:h-[260px] sm:min-h-[260px] sm:max-h-[300px] lg:h-auto lg:min-h-0 lg:max-h-none bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 shadow">
|
||||||
<div className="border-b px-4 py-2 bg-gray-50 dark:bg-gray-800 flex-shrink-0 rounded-t-lg">
|
<div className="border-b px-4 py-2 bg-gray-50 dark:bg-gray-800 flex-shrink-0 rounded-t-lg">
|
||||||
<h6 className="font-semibold text-sm">
|
<h6 className="font-semibold text-sm">
|
||||||
{translate('::App.Platform.ObjectExplorer')}
|
{translate('::App.Platform.ObjectExplorer')}
|
||||||
|
|
@ -1355,7 +1396,9 @@ GO`,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center Panel - Editor and Results */}
|
{/* Center Panel - Editor and Results */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div
|
||||||
|
className={`flex flex-1 flex-col min-h-0 ${state.executionResult || state.tableColumns ? 'min-h-[620px] lg:min-h-0' : ''}`}
|
||||||
|
>
|
||||||
{state.executionResult || state.tableColumns ? (
|
{state.executionResult || state.tableColumns ? (
|
||||||
<Splitter direction="vertical" initialSize={250} minSize={150} maxSize={1200}>
|
<Splitter direction="vertical" initialSize={250} minSize={150} maxSize={1200}>
|
||||||
<div className="border rounded-lg shadow-sm bg-white dark:bg-gray-800 flex flex-col h-full">
|
<div className="border rounded-lg shadow-sm bg-white dark:bg-gray-800 flex flex-col h-full">
|
||||||
|
|
@ -1405,7 +1448,13 @@ GO`,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden p-2">
|
<div className="flex-1 overflow-hidden p-2">
|
||||||
<SqlResultsGrid result={(state.executionResult || state.tableColumns)!} />
|
<SqlResultsGrid
|
||||||
|
result={(state.executionResult || state.tableColumns)!}
|
||||||
|
queryText={state.lastExecutedQuery}
|
||||||
|
dataSourceCode={state.selectedDataSource}
|
||||||
|
isPostgreSql={isPostgreSql}
|
||||||
|
onExecuteMutation={handleResultsMutation}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Splitter>
|
</Splitter>
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,272 @@
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { DataGrid } from 'devextreme-react'
|
import { DataGrid } from 'devextreme-react'
|
||||||
import { Column, Paging, Scrolling, SearchPanel, Export, Selection } from 'devextreme-react/data-grid'
|
import {
|
||||||
|
Column,
|
||||||
|
Paging,
|
||||||
|
Scrolling,
|
||||||
|
SearchPanel,
|
||||||
|
Export,
|
||||||
|
Selection,
|
||||||
|
} from 'devextreme-react/data-grid'
|
||||||
|
import { Button, Checkbox, DatePicker, Dialog } from '@/components/ui'
|
||||||
import type { SqlQueryExecutionResultDto } from '@/proxy/sql-query-manager/models'
|
import type { SqlQueryExecutionResultDto } from '@/proxy/sql-query-manager/models'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import { FaTimesCircle } from 'react-icons/fa'
|
import { FaTimesCircle } from 'react-icons/fa'
|
||||||
|
import DateTimepicker from '@/components/ui/DatePicker/DateTimepicker'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
||||||
|
|
||||||
|
type ResultColumn = { name: string; dataType?: string; isNullable?: boolean }
|
||||||
|
|
||||||
interface SqlResultsGridProps {
|
interface SqlResultsGridProps {
|
||||||
result: SqlQueryExecutionResultDto
|
result: SqlQueryExecutionResultDto
|
||||||
|
queryText?: string
|
||||||
|
dataSourceCode?: string | null
|
||||||
|
isPostgreSql?: boolean
|
||||||
|
onExecuteMutation?: (queryText: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
|
const getEditableTableName = (queryText?: string) => {
|
||||||
const { translate } = useLocalization()
|
if (!queryText || /\b(join|union|intersect|except)\b/i.test(queryText)) return null
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const match = queryText.match(
|
||||||
// Get columns from metadata if available
|
/\bfrom\s+((?:\[[^\]]+\]|"[^"]+"|[\w$]+)(?:\s*\.\s*(?:\[[^\]]+\]|"[^"]+"|[\w$]+)){0,2})/i,
|
||||||
|
)
|
||||||
|
return match?.[1]?.replace(/\s+/g, '') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTableParts = (fullName: string, isPostgreSql: boolean) => {
|
||||||
|
const parts = fullName
|
||||||
|
.match(/\[[^\]]+\]|"[^"]+"|[\w$]+/g)
|
||||||
|
?.map((part) => part.replace(/^\[|\]$|^"|"$/g, ''))
|
||||||
|
|
||||||
|
if (!parts?.length) return null
|
||||||
|
return {
|
||||||
|
schemaName: parts.length > 1 ? parts[parts.length - 2] : isPostgreSql ? 'public' : 'dbo',
|
||||||
|
tableName: parts[parts.length - 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteIdentifier = (name: string, isPostgreSql: boolean) =>
|
||||||
|
isPostgreSql ? `"${name.replace(/"/g, '""')}"` : `[${name.replace(/]/g, ']]')}]`
|
||||||
|
|
||||||
|
const isBooleanColumn = (column: ResultColumn) =>
|
||||||
|
/bit|bool/.test(column.dataType?.toLowerCase() || '')
|
||||||
|
const isNumberColumn = (column: ResultColumn) =>
|
||||||
|
/int|decimal|numeric|float|double|money|real/.test(column.dataType?.toLowerCase() || '')
|
||||||
|
const isDateColumn = (column: ResultColumn) =>
|
||||||
|
/date|time|timestamp/.test(column.dataType?.toLowerCase() || '') || /date|time/i.test(column.name)
|
||||||
|
const isDateTimeColumn = (column: ResultColumn) =>
|
||||||
|
/time|datetime|datetimeoffset|timestamp/.test(column.dataType?.toLowerCase() || '') ||
|
||||||
|
/time$/i.test(column.name)
|
||||||
|
|
||||||
|
const createNewId = () => {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
|
||||||
|
const random = Math.floor(Math.random() * 16)
|
||||||
|
const value = character === 'x' ? random : (random & 0x3) | 0x8
|
||||||
|
return value.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSqlLiteral = (value: unknown, column: ResultColumn) => {
|
||||||
|
if (value === null || value === undefined || value === '') return 'NULL'
|
||||||
|
|
||||||
|
const dataType = column.dataType?.toLowerCase() || ''
|
||||||
|
if (dataType.includes('bit') || dataType.includes('bool')) {
|
||||||
|
return value === true || value === 'true' || value === 1 || value === '1' ? '1' : '0'
|
||||||
|
}
|
||||||
|
if (/int|decimal|numeric|float|double|money|real/.test(dataType)) {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
if (Number.isFinite(numberValue)) return String(numberValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `'${String(value).replace(/'/g, "''")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
const SqlResultsGrid = ({
|
||||||
|
result,
|
||||||
|
queryText,
|
||||||
|
dataSourceCode,
|
||||||
|
isPostgreSql = false,
|
||||||
|
onExecuteMutation,
|
||||||
|
}: SqlResultsGridProps) => {
|
||||||
|
const { translate } = useLocalization()
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([])
|
||||||
|
const [focusedRow, setFocusedRow] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [focusedRowKey, setFocusedRowKey] = useState<string | number | null>(null)
|
||||||
|
const [editingRow, setEditingRow] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string | boolean>>({})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [validationError, setValidationError] = useState('')
|
||||||
|
const [tableColumns, setTableColumns] = useState<ResultColumn[]>([])
|
||||||
|
|
||||||
|
const resultColumns = useMemo<ResultColumn[]>(() => {
|
||||||
if (result.metadata?.columns && Array.isArray(result.metadata.columns)) {
|
if (result.metadata?.columns && Array.isArray(result.metadata.columns)) {
|
||||||
return result.metadata.columns
|
return result.metadata.columns
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise infer from data
|
|
||||||
if (result.data && result.data.length > 0) {
|
if (result.data && result.data.length > 0) {
|
||||||
const firstRow = result.data[0]
|
const firstRow = result.data[0]
|
||||||
return Object.keys(firstRow).map((key) => ({
|
return Object.keys(firstRow).map((name) => ({
|
||||||
name: key,
|
name,
|
||||||
dataType: typeof firstRow[key],
|
dataType: typeof firstRow[name],
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return []
|
return []
|
||||||
}, [result])
|
}, [result])
|
||||||
|
|
||||||
const dataSource = useMemo(() => {
|
const dataSource = useMemo(() => result.data || [], [result])
|
||||||
return result.data || []
|
const tableName = useMemo(() => getEditableTableName(queryText), [queryText])
|
||||||
}, [result])
|
const tableParts = useMemo(
|
||||||
|
() => (tableName ? getTableParts(tableName, isPostgreSql) : null),
|
||||||
|
[isPostgreSql, tableName],
|
||||||
|
)
|
||||||
|
const columns = resultColumns.length > 0 ? resultColumns : tableColumns
|
||||||
|
const primaryKey = useMemo(
|
||||||
|
() => columns.find((column) => column.name.toLowerCase() === 'id'),
|
||||||
|
[columns],
|
||||||
|
)
|
||||||
|
const canModify = Boolean(tableName && primaryKey && onExecuteMutation)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableColumns([])
|
||||||
|
if (resultColumns.length > 0 || !dataSourceCode || !tableParts) return
|
||||||
|
|
||||||
|
let isActive = true
|
||||||
|
sqlObjectManagerService
|
||||||
|
.getTableColumns(dataSourceCode, tableParts.schemaName, tableParts.tableName)
|
||||||
|
.then((response) => {
|
||||||
|
if (!isActive) return
|
||||||
|
setTableColumns(
|
||||||
|
response.data.map((column) => ({
|
||||||
|
name: column.columnName,
|
||||||
|
dataType: column.dataType,
|
||||||
|
isNullable: column.isNullable,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (isActive) setTableColumns([])
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
}, [dataSourceCode, resultColumns.length, tableParts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstRow = dataSource[0]
|
||||||
|
const key = primaryKey && firstRow ? firstRow[primaryKey.name] : null
|
||||||
|
setFocusedRow(firstRow || null)
|
||||||
|
setFocusedRowKey(typeof key === 'string' || typeof key === 'number' ? key : null)
|
||||||
|
}, [dataSource, primaryKey])
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
setEditingRow(null)
|
||||||
|
setIsAdding(true)
|
||||||
|
const initialValues: Record<string, string | boolean> = {}
|
||||||
|
columns.filter(isBooleanColumn).forEach((column) => {
|
||||||
|
initialValues[column.name] = false
|
||||||
|
})
|
||||||
|
if (primaryKey) initialValues[primaryKey.name] = createNewId()
|
||||||
|
setFormValues(initialValues)
|
||||||
|
setValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = () => {
|
||||||
|
const row = focusedRow
|
||||||
|
if (!row) return
|
||||||
|
setEditingRow(row)
|
||||||
|
setIsAdding(false)
|
||||||
|
setFormValues(
|
||||||
|
Object.fromEntries(
|
||||||
|
columns.map((column) => [
|
||||||
|
column.name,
|
||||||
|
isBooleanColumn(column) ? Boolean(row[column.name]) : String(row[column.name] ?? ''),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
if (isSaving) return
|
||||||
|
setEditingRow(null)
|
||||||
|
setIsAdding(false)
|
||||||
|
setFormValues({})
|
||||||
|
setValidationError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeMutation = async (sql: string) => {
|
||||||
|
if (!onExecuteMutation) return
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await onExecuteMutation(sql)
|
||||||
|
setSelectedRows([])
|
||||||
|
setFocusedRow(null)
|
||||||
|
setEditingRow(null)
|
||||||
|
setIsAdding(false)
|
||||||
|
setFormValues({})
|
||||||
|
} catch {
|
||||||
|
// The parent reports the execution error; keep the dialog open for correction.
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveRecord = async () => {
|
||||||
|
if (!tableName) return
|
||||||
|
|
||||||
|
const requiredColumns = columns.filter(
|
||||||
|
(column) => column.isNullable === false && (isAdding || column.name !== primaryKey?.name),
|
||||||
|
)
|
||||||
|
const missingRequiredColumn = requiredColumns.find((column) => {
|
||||||
|
const value = formValues[column.name]
|
||||||
|
return value === undefined || value === null || value === ''
|
||||||
|
})
|
||||||
|
if (missingRequiredColumn) {
|
||||||
|
setValidationError(`${missingRequiredColumn.name} is required.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdding) {
|
||||||
|
const populatedColumns = columns.filter(
|
||||||
|
(column) => formValues[column.name] !== '' && formValues[column.name] !== undefined,
|
||||||
|
)
|
||||||
|
if (populatedColumns.length === 0) return
|
||||||
|
const sql = `INSERT INTO ${tableName} (${populatedColumns.map((column) => quoteIdentifier(column.name, isPostgreSql)).join(', ')}) VALUES (${populatedColumns.map((column) => toSqlLiteral(formValues[column.name], column)).join(', ')});`
|
||||||
|
await executeMutation(sql)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingRow || !primaryKey) return
|
||||||
|
const editableColumns = columns.filter((column) => column.name !== primaryKey.name)
|
||||||
|
const sql = `UPDATE ${tableName} SET ${editableColumns.map((column) => `${quoteIdentifier(column.name, isPostgreSql)} = ${toSqlLiteral(formValues[column.name], column)}`).join(', ')} WHERE ${quoteIdentifier(primaryKey.name, isPostgreSql)} = ${toSqlLiteral(editingRow[primaryKey.name], primaryKey)};`
|
||||||
|
await executeMutation(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelectedRows = async () => {
|
||||||
|
if (!tableName || !primaryKey || selectedRows.length === 0) return
|
||||||
|
if (!window.confirm(`Delete ${selectedRows.length} selected record(s)?`)) return
|
||||||
|
|
||||||
|
const values = selectedRows
|
||||||
|
.map((row) => toSqlLiteral(row[primaryKey.name], primaryKey))
|
||||||
|
.join(', ')
|
||||||
|
await executeMutation(
|
||||||
|
`DELETE FROM ${tableName} WHERE ${quoteIdentifier(primaryKey.name, isPostgreSql)} IN (${values});`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
|
<div className="mb-4 flex items-center gap-2 rounded border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||||
<FaTimesCircle className="text-red-500 dark:text-red-400 text-xl" />
|
<FaTimesCircle className="text-xl text-red-500 dark:text-red-400" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-red-700 dark:text-red-400">
|
<div className="font-semibold text-red-700 dark:text-red-400">
|
||||||
{translate('::App.Platform.Error')}
|
{translate('::App.Platform.Error')}
|
||||||
|
|
@ -54,12 +281,14 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{dataSource.length > 0 ? (
|
{dataSource.length > 0 || columns.length > 0 ? (
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<>
|
||||||
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
|
elementAttr={{ class: 'sql-results-grid' }}
|
||||||
showBorders={true}
|
showBorders={true}
|
||||||
showRowLines={true}
|
showRowLines={true}
|
||||||
showColumnLines={true}
|
showColumnLines={true}
|
||||||
|
|
@ -70,30 +299,82 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
|
||||||
allowColumnResizing={true}
|
allowColumnResizing={true}
|
||||||
columnResizingMode="widget"
|
columnResizingMode="widget"
|
||||||
height="100%"
|
height="100%"
|
||||||
|
keyExpr={primaryKey?.name}
|
||||||
|
focusedRowEnabled={Boolean(primaryKey)}
|
||||||
|
focusedRowKey={focusedRowKey}
|
||||||
|
onSelectionChanged={(event) => setSelectedRows(event.selectedRowsData)}
|
||||||
|
onFocusedRowChanged={(event) => {
|
||||||
|
setFocusedRow(event.row?.data || null)
|
||||||
|
setFocusedRowKey(
|
||||||
|
typeof event.row?.key === 'string' || typeof event.row?.key === 'number'
|
||||||
|
? event.row.key
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onToolbarPreparing={(event) => {
|
||||||
|
const items = event.toolbarOptions.items || []
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.name === 'searchPanel' || item.name === 'exportButton') {
|
||||||
|
item.location = 'after'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (canModify) {
|
||||||
|
event.toolbarOptions.items = [
|
||||||
|
{
|
||||||
|
location: 'before',
|
||||||
|
widget: 'dxButton',
|
||||||
|
options: {
|
||||||
|
icon: 'add',
|
||||||
|
text: translate('::ListForms.ListForm.AddNewRecord'),
|
||||||
|
onClick: openAddDialog,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
location: 'before',
|
||||||
|
widget: 'dxButton',
|
||||||
|
options: {
|
||||||
|
icon: 'edit',
|
||||||
|
text: translate('::App.Platform.Edit'),
|
||||||
|
disabled: !focusedRow,
|
||||||
|
onClick: openEditDialog,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
location: 'before',
|
||||||
|
widget: 'dxButton',
|
||||||
|
options: {
|
||||||
|
icon: 'trash',
|
||||||
|
text: `Delete Selected (${selectedRows.length})`,
|
||||||
|
disabled: selectedRows.length === 0,
|
||||||
|
onClick: deleteSelectedRows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Scrolling mode="virtual" rowRenderingMode="virtual" />
|
<Scrolling mode="virtual" rowRenderingMode="virtual" />
|
||||||
<Paging enabled={true} pageSize={50} />
|
<Paging enabled={true} pageSize={50} />
|
||||||
<SearchPanel visible={true} width={240} placeholder={translate('::App.Platform.Search')} />
|
<SearchPanel
|
||||||
|
visible={true}
|
||||||
|
width={240}
|
||||||
|
placeholder={translate('::App.Platform.Search')}
|
||||||
|
/>
|
||||||
<Export enabled={true} allowExportSelectedData={true} />
|
<Export enabled={true} allowExportSelectedData={true} />
|
||||||
<Selection mode="multiple" showCheckBoxesMode="always" />
|
<Selection mode="multiple" showCheckBoxesMode="always" />
|
||||||
|
|
||||||
{columns.map((col, index) => (
|
{columns.map((column, index) => (
|
||||||
<Column
|
<Column
|
||||||
key={col.name || index}
|
key={column.name || index}
|
||||||
dataField={col.name}
|
dataField={column.name}
|
||||||
caption={col.name}
|
caption={column.name}
|
||||||
dataType={
|
dataType={
|
||||||
col.dataType?.toLowerCase().includes('int') ||
|
/int|decimal|numeric|float|money/.test(column.dataType?.toLowerCase() || '')
|
||||||
col.dataType?.toLowerCase().includes('decimal') ||
|
|
||||||
col.dataType?.toLowerCase().includes('numeric') ||
|
|
||||||
col.dataType?.toLowerCase().includes('float') ||
|
|
||||||
col.dataType?.toLowerCase().includes('money')
|
|
||||||
? 'number'
|
? 'number'
|
||||||
: col.dataType?.toLowerCase().includes('date') ||
|
: /date|time/.test(column.dataType?.toLowerCase() || '')
|
||||||
col.dataType?.toLowerCase().includes('time')
|
|
||||||
? 'date'
|
? 'date'
|
||||||
: col.dataType?.toLowerCase().includes('bit') ||
|
: /bit|boolean/.test(column.dataType?.toLowerCase() || '')
|
||||||
col.dataType?.toLowerCase().includes('boolean')
|
|
||||||
? 'boolean'
|
? 'boolean'
|
||||||
: 'string'
|
: 'string'
|
||||||
}
|
}
|
||||||
|
|
@ -105,10 +386,11 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
<div className="flex flex-1 items-center justify-center text-gray-500">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg mb-2">{translate('::App.Platform.NoResults')}</div>
|
<div className="mb-2 text-lg">{translate('::App.Platform.NoResults')}</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{result.rowsAffected > 0
|
{result.rowsAffected > 0
|
||||||
? translate('::App.Platform.RowCount', { count: result.rowsAffected })
|
? translate('::App.Platform.RowCount', { count: result.rowsAffected })
|
||||||
|
|
@ -117,6 +399,100 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
isOpen={isAdding || editingRow !== null}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onRequestClose={closeDialog}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<Dialog.Header>{isAdding ? 'Add New Record' : 'Edit Record'}</Dialog.Header>
|
||||||
|
<Dialog.Body className="mb-3 grid max-h-[65vh] grid-cols-1 gap-3 overflow-y-auto sm:grid-cols-2">
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isPrimaryKey = column.name === primaryKey?.name
|
||||||
|
const required = column.isNullable === false
|
||||||
|
const value = formValues[column.name]
|
||||||
|
return (
|
||||||
|
<label key={column.name} className="flex min-w-0 flex-col gap-1 text-sm font-medium">
|
||||||
|
<span>
|
||||||
|
{column.name}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</span>
|
||||||
|
{isBooleanColumn(column) ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={Boolean(value)}
|
||||||
|
disabled={!isAdding && isPrimaryKey}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setFormValues((current) => ({ ...current, [column.name]: checked }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Boolean(value) ? 'True' : 'False'}
|
||||||
|
</Checkbox>
|
||||||
|
) : isDateColumn(column) ? (
|
||||||
|
isDateTimeColumn(column) ? (
|
||||||
|
<DateTimepicker
|
||||||
|
value={
|
||||||
|
typeof value === 'string' && dayjs(value).isValid()
|
||||||
|
? dayjs(value).toDate()
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
disabled={!isAdding && isPrimaryKey}
|
||||||
|
inputFormat="YYYY-MM-DD HH:mm:ss"
|
||||||
|
onChange={(date) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[column.name]: date ? dayjs(date).format('YYYY-MM-DDTHH:mm:ss') : '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DatePicker
|
||||||
|
value={
|
||||||
|
typeof value === 'string' && dayjs(value).isValid()
|
||||||
|
? dayjs(value).toDate()
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
disabled={!isAdding && isPrimaryKey}
|
||||||
|
inputFormat="YYYY-MM-DD"
|
||||||
|
onChange={(date) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[column.name]: date ? dayjs(date).format('YYYY-MM-DD') : '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={isNumberColumn(column) ? 'number' : 'text'}
|
||||||
|
value={typeof value === 'boolean' ? String(value) : value || ''}
|
||||||
|
required={required}
|
||||||
|
disabled={!isAdding && isPrimaryKey}
|
||||||
|
className="h-9 rounded-md border border-gray-300 bg-white px-3 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:disabled:bg-gray-700"
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[column.name]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Dialog.Body>
|
||||||
|
<Dialog.Footer className="flex justify-end gap-2 border-t pt-3">
|
||||||
|
{validationError && (
|
||||||
|
<span className="mr-auto text-sm text-red-500">{validationError}</span>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={closeDialog} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="solid" loading={isSaving} onClick={saveRecord}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue