From 99e6f91b2a3bd91fb8ab35ef8fa235ab1ba974e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Fri, 19 Jun 2026 23:09:42 +0300 Subject: [PATCH] =?UTF-8?q?Sql=20Query=20Manager=20Add,=20Edit=20ve=20Dele?= =?UTF-8?q?te=20butonlar=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/index.css | 23 + ui/src/views/developerKit/SqlQueryManager.tsx | 63 ++- ui/src/views/developerKit/SqlResultsGrid.tsx | 514 +++++++++++++++--- 3 files changed, 524 insertions(+), 76 deletions(-) diff --git a/ui/src/index.css b/ui/src/index.css index 40ef9d3..263e689 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -282,6 +282,29 @@ div.dialog-after-open > div.dialog-content.maximized { 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 { background-color: transparent !important; } diff --git a/ui/src/views/developerKit/SqlQueryManager.tsx b/ui/src/views/developerKit/SqlQueryManager.tsx index 7555508..70f8ba0 100644 --- a/ui/src/views/developerKit/SqlQueryManager.tsx +++ b/ui/src/views/developerKit/SqlQueryManager.tsx @@ -42,6 +42,7 @@ interface SqlManagerState { isDirty: boolean tableColumns: any | null refreshTrigger: number + lastExecutedQuery: string } interface SqlCopyResultItem { @@ -75,6 +76,7 @@ const SqlQueryManager = () => { showProperties: false, isDirty: false, tableColumns: null, + lastExecutedQuery: '', }) const [showTemplateConfirmDialog, setShowTemplateConfirmDialog] = useState(false) @@ -138,6 +140,7 @@ const SqlQueryManager = () => { selectedDataSource: dataSource.code ?? null, editorContent: '', executionResult: null, + lastExecutedQuery: '', isDirty: false, })) }, []) @@ -639,6 +642,7 @@ GO`, editorContent: '', executionResult: null, tableColumns: null, + lastExecutedQuery: '', isDirty: false, })) }, []) @@ -671,7 +675,13 @@ GO`, // Seçili metni koru 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 { 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( + + {error.response?.data?.error?.message || error.message || 'Record operation failed.'} + , + { placement: 'top-center' }, + ) + throw error + } + } + const handlePreviewSqlDataFile = async (file: SqlDataExplorerEntry) => { if (!file.relativePath || isPreviewingSqlDataFile) { return @@ -1240,7 +1278,10 @@ GO`, ) return ( - + } onClick={handleExecute} loading={state.isExecuting} - disabled={!state.selectedDataSource} + disabled={!state.selectedDataSource || !state.editorContent.trim()} className="shadow-sm px-2 py-1" > {translate('::App.Platform.Execute')} @@ -1333,7 +1374,7 @@ GO`, {/* Main Content Area */}
{/* Left Panel - Object Explorer */} -
+
{translate('::App.Platform.ObjectExplorer')} @@ -1355,7 +1396,9 @@ GO`,
{/* Center Panel - Editor and Results */} -
+
{state.executionResult || state.tableColumns ? (
@@ -1405,7 +1448,13 @@ GO`,
- +
@@ -1461,7 +1510,7 @@ GO`,
-
{translate('::App.SqlQueryManager.MoveFiles')}
+
{translate('::App.SqlQueryManager.MoveFiles')}
diff --git a/ui/src/views/developerKit/SqlResultsGrid.tsx b/ui/src/views/developerKit/SqlResultsGrid.tsx index cfa4702..4abc625 100644 --- a/ui/src/views/developerKit/SqlResultsGrid.tsx +++ b/ui/src/views/developerKit/SqlResultsGrid.tsx @@ -1,45 +1,272 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from '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 { 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' + +type ResultColumn = { name: string; dataType?: string; isNullable?: boolean } interface SqlResultsGridProps { result: SqlQueryExecutionResultDto + queryText?: string + dataSourceCode?: string | null + isPostgreSql?: boolean + onExecuteMutation?: (queryText: string) => Promise } -const SqlResultsGrid = ({ result }: SqlResultsGridProps) => { - const { translate } = useLocalization() +const getEditableTableName = (queryText?: string) => { + if (!queryText || /\b(join|union|intersect|except)\b/i.test(queryText)) return null - const columns = useMemo(() => { - // Get columns from metadata if available + 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, "''")}'` +} + +const SqlResultsGrid = ({ + result, + queryText, + dataSourceCode, + isPostgreSql = false, + onExecuteMutation, +}: SqlResultsGridProps) => { + const { translate } = useLocalization() + const [selectedRows, setSelectedRows] = useState[]>([]) + const [focusedRow, setFocusedRow] = useState | null>(null) + const [focusedRowKey, setFocusedRowKey] = useState(null) + const [editingRow, setEditingRow] = useState | null>(null) + const [isAdding, setIsAdding] = useState(false) + const [formValues, setFormValues] = useState>({}) + const [isSaving, setIsSaving] = useState(false) + const [validationError, setValidationError] = useState('') + const [tableColumns, setTableColumns] = useState([]) + + const resultColumns = useMemo(() => { if (result.metadata?.columns && Array.isArray(result.metadata.columns)) { return result.metadata.columns } - - // Otherwise infer from data if (result.data && result.data.length > 0) { const firstRow = result.data[0] - return Object.keys(firstRow).map((key) => ({ - name: key, - dataType: typeof firstRow[key], + return Object.keys(firstRow).map((name) => ({ + name, + dataType: typeof firstRow[name], isNullable: true, })) } - return [] }, [result]) - const dataSource = useMemo(() => { - return result.data || [] - }, [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 = {} + 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) { return ( -
-
- +
+
+
{translate('::App.Platform.Error')} @@ -54,61 +281,116 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => { } return ( -
- {dataSource.length > 0 ? ( -
-
- - - - - - +
+ {dataSource.length > 0 || columns.length > 0 ? ( + <> +
+
+ 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, + ] + } + }} + > + + + + + - {columns.map((col, index) => ( - - ))} - + {columns.map((column, index) => ( + + ))} + +
-
+ ) : ( -
+
-
{translate('::App.Platform.NoResults')}
+
{translate('::App.Platform.NoResults')}
{result.rowsAffected > 0 ? translate('::App.Platform.RowCount', { count: result.rowsAffected }) @@ -117,6 +399,100 @@ const SqlResultsGrid = ({ result }: SqlResultsGridProps) => {
)} + + + {isAdding ? 'Add New Record' : 'Edit Record'} + + {columns.map((column) => { + const isPrimaryKey = column.name === primaryKey?.name + const required = column.isNullable === false + const value = formValues[column.name] + return ( + + ) + })} + + + {validationError && ( + {validationError} + )} + + + +
) }