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

507 lines
19 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useState } from 'react'
2026-02-24 20:44:16 +00:00
import { DataGrid } from 'devextreme-react'
import {
Column,
Paging,
Scrolling,
SearchPanel,
Export,
Selection,
} from 'devextreme-react/data-grid'
import { Button, Checkbox, DatePicker, Dialog } from '@/components/ui'
2026-02-24 20:44:16 +00:00
import type { SqlQueryExecutionResultDto } from '@/proxy/sql-query-manager/models'
import { useLocalization } from '@/utils/hooks/useLocalization'
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'
2026-08-20 12:01:10 +00:00
import Input from '@/components/ui/Input'
type ResultColumn = { name: string; dataType?: string; isNullable?: boolean }
2026-02-24 20:44:16 +00:00
interface SqlResultsGridProps {
result: SqlQueryExecutionResultDto
queryText?: string
dataSourceCode?: string | null
isPostgreSql?: boolean
onExecuteMutation?: (queryText: string) => Promise<void>
}
const getEditableTableName = (queryText?: string) => {
if (!queryText || /\b(join|union|intersect|except)\b/i.test(queryText)) return null
const match = queryText.match(
/\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, "''")}'`
2026-02-24 20:44:16 +00:00
}
const SqlResultsGrid = ({
result,
queryText,
dataSourceCode,
isPostgreSql = false,
onExecuteMutation,
}: SqlResultsGridProps) => {
2026-02-24 20:44:16 +00:00
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[]>([])
2026-02-24 20:44:16 +00:00
const resultColumns = useMemo<ResultColumn[]>(() => {
2026-02-24 20:44:16 +00:00
if (result.metadata?.columns && Array.isArray(result.metadata.columns)) {
return result.metadata.columns
}
if (result.data && result.data.length > 0) {
const firstRow = result.data[0]
return Object.keys(firstRow).map((name) => ({
name,
dataType: typeof firstRow[name],
2026-02-24 20:44:16 +00:00
isNullable: true,
}))
}
return []
}, [result])
const dataSource = useMemo(() => result.data || [], [result])
const tableName = useMemo(() => getEditableTableName(queryText), [queryText])
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('')
}
2026-07-06 09:44:26 +00:00
const normalizeBoolean = (value: unknown): boolean => {
return value === true || value === 'true' || value === 1 || value === '1'
}
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});`,
)
}
2026-02-24 20:44:16 +00:00
if (!result.success) {
return (
<div className="flex h-full flex-col">
<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-xl text-red-500 dark:text-red-400" />
2026-02-24 20:44:16 +00:00
<div>
<div className="font-semibold text-red-700 dark:text-red-400">
{translate('::App.Platform.Error')}
</div>
<div className="text-sm text-red-600 dark:text-red-500">
{result.error || result.message}
</div>
</div>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col">
{dataSource.length > 0 || columns.length > 0 ? (
<>
<div className="relative flex-1 overflow-hidden">
<div className="absolute inset-0">
<DataGrid
dataSource={dataSource}
elementAttr={{ class: 'sql-results-grid' }}
showBorders={true}
showRowLines={true}
showColumnLines={true}
rowAlternationEnabled={true}
columnAutoWidth={true}
wordWrapEnabled={false}
allowColumnReordering={true}
allowColumnResizing={true}
columnResizingMode="widget"
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('::App.Platform.Add'),
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" />
<Paging enabled={true} pageSize={50} />
<SearchPanel
visible={true}
width={240}
placeholder={translate('::App.Platform.Search')}
/>
<Export enabled={true} allowExportSelectedData={true} />
<Selection mode="multiple" showCheckBoxesMode="always" />
{columns.map((column, index) => (
<Column
key={column.name || index}
dataField={column.name}
caption={column.name}
dataType={
/int|decimal|numeric|float|money/.test(column.dataType?.toLowerCase() || '')
? 'number'
: /date|time/.test(column.dataType?.toLowerCase() || '')
? 'date'
: /bit|boolean/.test(column.dataType?.toLowerCase() || '')
? 'boolean'
: 'string'
}
allowSorting={true}
allowFiltering={true}
allowHeaderFiltering={true}
/>
))}
</DataGrid>
</div>
2026-02-24 20:44:16 +00:00
</div>
</>
2026-02-24 20:44:16 +00:00
) : (
<div className="flex flex-1 items-center justify-center text-gray-500">
2026-02-24 20:44:16 +00:00
<div className="text-center">
<div className="mb-2 text-lg">{translate('::App.Platform.NoResults')}</div>
2026-02-24 20:44:16 +00:00
<div className="text-sm">
{result.rowsAffected > 0
? translate('::App.Platform.RowCount', { count: result.rowsAffected })
: translate('::App.Platform.NoResultsReturned')}
</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 }))
}
>
2026-07-06 09:44:26 +00:00
{normalizeBoolean(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') : '',
}))
}
/>
)
) : (
2026-08-20 12:01:10 +00:00
<Input
unstyle
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}>
{translate('::App.Platform.Cancel')}
</Button>
<Button size="sm" variant="solid" loading={isSaving} onClick={saveRecord}>
{translate('::App.Platform.Save')}
</Button>
</Dialog.Footer>
</Dialog>
2026-02-24 20:44:16 +00:00
</div>
)
}
export default SqlResultsGrid