diff --git a/ui/src/views/developerKit/CrudEndpointManager.tsx b/ui/src/views/developerKit/CrudEndpointManager.tsx index bc132ffe..cefecb2e 100644 --- a/ui/src/views/developerKit/CrudEndpointManager.tsx +++ b/ui/src/views/developerKit/CrudEndpointManager.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' -import axios from 'axios' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { FaSearch, FaCopy, @@ -21,11 +20,14 @@ import Widget from '@/components/common/Widget' import { getDataSources } from '@/services/data-source.service' import { sqlObjectManagerService } from '@/services/sql-query-manager.service' import { developerKitService } from '@/services/developerKit.service' +import apiService from '@/services/api.service' import type { DataSourceDto } from '@/proxy/data-source' import type { DatabaseTableDto } from '@/proxy/sql-query-manager/models' import type { CrudEndpoint } from '@/proxy/developerKit/models' import PageTitle from '@/components/shared/PageTitle' +import { ConfirmDialog } from '@/components/shared' import Button from '@/components/ui/Button' +import { Notification, toast } from '@/components/ui' interface TestResult { success: boolean @@ -38,9 +40,41 @@ interface TestResult { interface ParameterInput { name: string value: string - type: 'path' | 'query' | 'body' - required: boolean - description?: string + type: 'path' | 'query' +} + +type CrudFilter = 'all' | 'with' | 'without' + +interface TableStat { + total: number + active: number +} + +const CRUD_FILTERS: readonly CrudFilter[] = ['all', 'with', 'without'] as const + +const EMPTY_ENDPOINTS: CrudEndpoint[] = [] +const EMPTY_PARAMS: ParameterInput[] = [] + +const DEFAULT_SAMPLE_BODY = JSON.stringify( + { name: 'Sample Item', description: 'Description', isActive: true }, + null, + 2, +) + +const DEFAULT_ID_PARAM = '3fa85f64-5717-4562-b3fc-2c963f66afa6' + +const METHOD_COLOR: Record = { + GET: 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800', + POST: 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800', + PUT: 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800', + DELETE: + 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800', +} + +const FILTER_ACTIVE_CLASS: Record = { + all: '!bg-blue-500 text-white', + with: '!bg-green-500 text-white', + without: '!bg-slate-500 text-white', } // Helper: tableName -> PascalCase entity name @@ -51,26 +85,27 @@ function toPascalCase(tableName: string): string { .replace(/^([a-z])/, (c: string) => c.toUpperCase()) } -const METHOD_COLOR: Record = { - GET: 'bg-blue-100 text-blue-800 border-blue-200', - POST: 'bg-green-100 text-green-800 border-green-200', - PUT: 'bg-yellow-100 text-yellow-800 border-yellow-200', - DELETE: 'bg-red-100 text-red-800 border-red-200', +const getErrorMessage = (error: unknown, fallback: string): string => { + const response = ( + error as { response?: { data?: { error?: { message?: string }; message?: string } } } + )?.response + return ( + response?.data?.error?.message || + response?.data?.message || + (error instanceof Error ? error.message : undefined) || + fallback + ) } +const needsBody = (ep: CrudEndpoint) => + ep.operationType === 'Create' || ep.operationType === 'Update' + const CrudEndpointManager: React.FC = () => { const { translate } = useLocalization() - // Endpoint state + // Endpoints const [generatedEndpoints, setGeneratedEndpoints] = useState([]) - useEffect(() => { - developerKitService - .getGeneratedListEndpoints() - .then((res) => setGeneratedEndpoints(res.items || [])) - .catch((err) => console.error('Failed to load endpoints', err)) - }, []) - // Data source + tables const [dataSources, setDataSources] = useState([]) const [selectedDataSource, setSelectedDataSource] = useState(null) @@ -80,11 +115,12 @@ const CrudEndpointManager: React.FC = () => { // Selection const [selectedTable, setSelectedTable] = useState(null) const [tableSearch, setTableSearch] = useState('') - const [crudFilter, setCrudFilter] = useState<'all' | 'with' | 'without'>('all') + const [crudFilter, setCrudFilter] = useState('all') // Endpoint management state const [generatingFor, setGeneratingFor] = useState(null) const [deletingAll, setDeletingAll] = useState(null) + const [confirmDeleteTable, setConfirmDeleteTable] = useState(null) const [togglingId, setTogglingId] = useState(null) const [expandedEndpoint, setExpandedEndpoint] = useState(null) @@ -94,229 +130,354 @@ const CrudEndpointManager: React.FC = () => { const [parameterValues, setParameterValues] = useState>>({}) const [requestBodies, setRequestBodies] = useState>({}) - // Load data sources on mount + const pushError = useCallback((message: string) => { + toast.push( + + {message} + , + ) + }, []) + + const pushSuccess = useCallback((message: string) => { + toast.push( + + {message} + , + ) + }, []) + + // Load endpoints + data sources on mount useEffect(() => { + let cancelled = false + + developerKitService + .getGeneratedListEndpoints() + .then((res) => { + if (!cancelled) setGeneratedEndpoints(res.items || []) + }) + .catch((err) => { + if (!cancelled) pushError(getErrorMessage(err, translate('::App.Platform.ErrorLoading'))) + }) + getDataSources() .then((res) => { + if (cancelled) return const items = res.data.items || [] setDataSources(items) - if (items.length > 0) { - setSelectedDataSource(items[0].code ?? null) - } + setSelectedDataSource((current) => current ?? items[0]?.code ?? null) }) - .catch(console.error) - }, []) + .catch((err) => { + if (!cancelled) + pushError(getErrorMessage(err, translate('::App.Platform.FailedtoloadDatasources'))) + }) + + return () => { + cancelled = true + } + }, [pushError, translate]) // Load tables when datasource changes useEffect(() => { if (!selectedDataSource) return + let cancelled = false + setLoadingTables(true) setDbTables([]) setSelectedTable(null) + sqlObjectManagerService .getAllObjects(selectedDataSource) .then((res) => { - setDbTables(res.data.tables || []) + if (!cancelled) setDbTables(res.data.tables || []) + }) + .catch((err) => { + if (!cancelled) + pushError(getErrorMessage(err, translate('::App.Platform.FailedToLoadObjects'))) + }) + .finally(() => { + if (!cancelled) setLoadingTables(false) }) - .catch(console.error) - .finally(() => setLoadingTables(false)) - }, [selectedDataSource]) - // Helpers + return () => { + cancelled = true + } + }, [selectedDataSource, pushError, translate]) + + // entityName -> endpoints (tek geçiş, tablo başına filtrelemeyi önler) + const endpointsByEntity = useMemo(() => { + const map = new Map() + for (const ep of generatedEndpoints) { + const list = map.get(ep.entityName) + if (list) list.push(ep) + else map.set(ep.entityName, [ep]) + } + return map + }, [generatedEndpoints]) + const getEndpointsForTable = useCallback( - (tableName: string): CrudEndpoint[] => { - const entityName = toPascalCase(tableName) - return generatedEndpoints.filter((ep) => ep.entityName === entityName) + (tableName: string): CrudEndpoint[] => + endpointsByEntity.get(toPascalCase(tableName)) ?? EMPTY_ENDPOINTS, + [endpointsByEntity], + ) + + // fullName -> { total, active } + const tableStats = useMemo(() => { + const map = new Map() + for (const table of dbTables) { + const endpoints = endpointsByEntity.get(toPascalCase(table.tableName)) ?? EMPTY_ENDPOINTS + map.set(table.fullName, { + total: endpoints.length, + active: endpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0), + }) + } + return map + }, [dbTables, endpointsByEntity]) + + const withCrudCount = useMemo( + () => dbTables.reduce((sum, t) => sum + ((tableStats.get(t.fullName)?.total ?? 0) > 0 ? 1 : 0), 0), + [dbTables, tableStats], + ) + + const filteredTables = useMemo(() => { + const search = tableSearch.trim().toLowerCase() + return dbTables.filter((t) => { + const matchesSearch = + !search || + t.fullName.toLowerCase().includes(search) || + t.tableName.toLowerCase().includes(search) + if (!matchesSearch) return false + if (crudFilter === 'all') return true + const hasCrud = (tableStats.get(t.fullName)?.total ?? 0) > 0 + return crudFilter === 'with' ? hasCrud : !hasCrud + }) + }, [dbTables, tableSearch, crudFilter, tableStats]) + + const tablesBySchema = useMemo( + () => + filteredTables.reduce>((acc, t) => { + const schema = t.schemaName || 'dbo' + ;(acc[schema] ??= []).push(t) + return acc + }, {}), + [filteredTables], + ) + + const filterLabels = useMemo>( + () => ({ + all: `${translate('::App.StaticLookup.All')} (${dbTables.length})`, + with: `${translate('::App.DeveloperKitCrudEndpoints.FilterWith')} (${withCrudCount})`, + without: `${translate('::App.DeveloperKitCrudEndpoints.FilterWithout')} (${dbTables.length - withCrudCount})`, + }), + [dbTables.length, withCrudCount, translate], + ) + + // Generate CRUD endpoints for a table + const handleGenerate = useCallback( + async (table: DatabaseTableDto) => { + setGeneratingFor(table.fullName) + try { + const entityName = toPascalCase(table.tableName) + const result = await developerKitService.generateCrudEndpoints(entityName) + setGeneratedEndpoints((prev) => [ + ...prev.filter((ep) => ep.entityName !== entityName), + ...(result.items || []), + ]) + pushSuccess(translate('::App.Platform.OperationCompleted')) + } catch (err) { + pushError(getErrorMessage(err, translate('::App.Platform.IslemBasarisiz'))) + } finally { + setGeneratingFor(null) + } }, + [pushError, pushSuccess, translate], + ) + + // Delete all endpoints for a table + const handleDeleteAll = useCallback( + async (table: DatabaseTableDto) => { + setDeletingAll(table.fullName) + try { + const endpoints = getEndpointsForTable(table.tableName) + const results = await Promise.allSettled( + endpoints.map((ep) => developerKitService.deleteGeneratedEndpoint(ep.id)), + ) + + // Kısmi başarıda yalnızca gerçekten silinenleri state'ten düş. + const deletedIds = new Set( + endpoints.filter((_, i) => results[i].status === 'fulfilled').map((ep) => ep.id), + ) + if (deletedIds.size) { + setGeneratedEndpoints((prev) => prev.filter((ep) => !deletedIds.has(ep.id))) + } + + const failed = results.find((r) => r.status === 'rejected') + if (failed) { + pushError( + getErrorMessage( + (failed as PromiseRejectedResult).reason, + translate('::App.Platform.ErrorDeleting'), + ), + ) + } else { + pushSuccess(translate('::App.Platform.ObjectDeletedSuccessfully')) + } + } catch (err) { + pushError(getErrorMessage(err, translate('::App.Platform.ErrorDeleting'))) + } finally { + setDeletingAll(null) + } + }, + [getEndpointsForTable, pushError, pushSuccess, translate], + ) + + // Toggle single endpoint + const handleToggle = useCallback( + async (endpointId: string) => { + setTogglingId(endpointId) + try { + const updated = await developerKitService.toggleGeneratedEndpoint(endpointId) + setGeneratedEndpoints((prev) => prev.map((ep) => (ep.id === endpointId ? updated : ep))) + } catch (err) { + pushError(getErrorMessage(err, translate('::App.Platform.IslemBasarisiz'))) + } finally { + setTogglingId(null) + } + }, + [pushError, translate], + ) + + const copyToClipboard = useCallback( + async (text: string) => { + try { + await navigator.clipboard.writeText(text) + pushSuccess(translate('::App.Platform.Copied')) + } catch (err) { + pushError(getErrorMessage(err, translate('::App.Platform.CopyFailed'))) + } + }, + [pushError, pushSuccess, translate], + ) + + // Test endpoint helpers + const getEndpointParameters = useCallback( + (endpoint: CrudEndpoint): ParameterInput[] => { + const vals = parameterValues[endpoint.id] + const valueOf = (name: string, fallback: string) => vals?.[name] ?? fallback + + switch (endpoint.operationType) { + case 'GetById': + case 'Update': + case 'Delete': + return [{ name: 'id', value: valueOf('id', DEFAULT_ID_PARAM), type: 'path' }] + case 'GetList': + return [ + { name: 'SkipCount', value: valueOf('SkipCount', '0'), type: 'query' }, + { name: 'MaxResultCount', value: valueOf('MaxResultCount', '10'), type: 'query' }, + ] + default: + return [] + } + }, + [parameterValues], + ) + + const getRequestBody = useCallback( + (ep: CrudEndpoint) => requestBodies[ep.id] ?? DEFAULT_SAMPLE_BODY, + [requestBodies], + ) + + const testEndpoint = useCallback( + async (endpoint: CrudEndpoint) => { + setLoadingEndpoints((prev) => new Set(prev).add(endpoint.id)) + try { + const params = getEndpointParameters(endpoint) + const pathValues = new Map( + params.filter((p) => p.type === 'path').map((p) => [p.name, p.value]), + ) + + // Path backend tarafından üretilir ({id} gibi yer tutucular içerir). + const basePath = endpoint.path || `/api/app/crudendpoint/${endpoint.entityName}` + const url = basePath.replace(/\{(\w+)\}/g, (match, name: string) => { + const value = pathValues.get(name) + return value === undefined ? match : encodeURIComponent(value) + }) + + const queryParams = Object.fromEntries( + params.filter((p) => p.type === 'query').map((p) => [p.name, p.value]), + ) + + let data: unknown + if (needsBody(endpoint)) { + try { + data = JSON.parse(getRequestBody(endpoint)) + } catch { + data = {} + } + } + + // apiService üzerinden gider: Authorization, __tenant ve Accept-Language + // interceptor'ları uygulanır. + const res = await apiService.fetchData({ + method: endpoint.method, + url, + params: Object.keys(queryParams).length ? queryParams : undefined, + timeout: 10000, + data, + }) + + setTestResults((prev) => ({ + ...prev, + [endpoint.id]: { + success: true, + status: res.status, + data: res.data, + timestamp: new Date().toISOString(), + }, + })) + } catch (error: unknown) { + const axiosErr = error as { + response?: { status?: number; data?: unknown } + message?: string + } + setTestResults((prev) => ({ + ...prev, + [endpoint.id]: { + success: false, + status: axiosErr.response?.status || 0, + error: axiosErr.response?.data ?? axiosErr.message, + timestamp: new Date().toISOString(), + }, + })) + } finally { + setLoadingEndpoints((prev) => { + const next = new Set(prev) + next.delete(endpoint.id) + return next + }) + } + }, + [getEndpointParameters, getRequestBody], + ) + + // Derived stats + const totalActiveEndpoints = useMemo( + () => generatedEndpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0), [generatedEndpoints], ) - const activeEndpointCount = (tableName: string) => - getEndpointsForTable(tableName).filter((ep) => ep.isActive).length + const selectedTableEndpoints = useMemo( + () => (selectedTable ? getEndpointsForTable(selectedTable.tableName) : EMPTY_ENDPOINTS), + [selectedTable, getEndpointsForTable], + ) - const allEndpointCount = (tableName: string) => getEndpointsForTable(tableName).length - - // Filtered table list - const filteredTables = dbTables.filter((t) => { - const matchesSearch = - t.fullName.toLowerCase().includes(tableSearch.toLowerCase()) || - t.tableName.toLowerCase().includes(tableSearch.toLowerCase()) - const hasCrud = allEndpointCount(t.tableName) > 0 - const matchesCrudFilter = - crudFilter === 'all' || - (crudFilter === 'with' && hasCrud) || - (crudFilter === 'without' && !hasCrud) - return matchesSearch && matchesCrudFilter - }) - - // Group by schema - const tablesBySchema = filteredTables.reduce>((acc, t) => { - const schema = t.schemaName || 'dbo' - if (!acc[schema]) acc[schema] = [] - acc[schema].push(t) - return acc - }, {}) - - // Generate CRUD endpoints for selected table - const handleGenerate = async (table: DatabaseTableDto) => { - const key = table.fullName - setGeneratingFor(key) - try { - const entityName = toPascalCase(table.tableName) - const result = await developerKitService.generateCrudEndpoints(entityName) - setGeneratedEndpoints((prev) => [ - ...prev.filter((ep) => ep.entityName !== entityName), - ...(result.items || []), - ]) - } catch (err) { - console.error('Generate failed', err) - } finally { - setGeneratingFor(null) - } - } - - // Delete all endpoints for a table - const handleDeleteAll = async (table: DatabaseTableDto) => { - const key = table.fullName - setDeletingAll(key) - try { - const endpoints = getEndpointsForTable(table.tableName) - await Promise.all(endpoints.map((ep) => developerKitService.deleteGeneratedEndpoint(ep.id))) - const deletedIds = new Set(endpoints.map((ep) => ep.id)) - setGeneratedEndpoints((prev) => prev.filter((ep) => !deletedIds.has(ep.id))) - } catch (err) { - console.error('Delete failed', err) - } finally { - setDeletingAll(null) - } - } - - // Toggle single endpoint - const handleToggle = async (endpointId: string) => { - setTogglingId(endpointId) - try { - const updated = await developerKitService.toggleGeneratedEndpoint(endpointId) - setGeneratedEndpoints((prev) => prev.map((ep) => (ep.id === endpointId ? updated : ep))) - } catch (err) { - console.error('Toggle failed', err) - } finally { - setTogglingId(null) - } - } - - // Test endpoint helpers - const getEndpointParameters = (endpoint: CrudEndpoint): ParameterInput[] => { - const params: ParameterInput[] = [] - const vals = parameterValues[endpoint.id] || {} - switch (endpoint.operationType) { - case 'GetById': - case 'Update': - case 'Delete': - params.push({ - name: 'id', - value: vals.id || '3fa85f64-5717-4562-b3fc-2c963f66afa6', - type: 'path', - required: true, - description: 'Entity ID', - }) - break - case 'GetList': - params.push( - { - name: 'SkipCount', - value: vals.SkipCount || '0', - type: 'query', - required: false, - description: 'Skip count', - }, - { - name: 'MaxResultCount', - value: vals.MaxResultCount || '10', - type: 'query', - required: false, - description: 'Max records', - }, - ) - break - } - return params - } - - const needsBody = (ep: CrudEndpoint) => - ep.operationType === 'Create' || ep.operationType === 'Update' - - const getRequestBody = (ep: CrudEndpoint) => { - if (requestBodies[ep.id]) return requestBodies[ep.id] - return JSON.stringify( - { name: 'Sample Item', description: 'Description', isActive: true }, - null, - 2, - ) - } - - const testEndpoint = async (endpoint: CrudEndpoint) => { - setLoadingEndpoints((prev) => new Set(prev).add(endpoint.id)) - try { - let url = `${import.meta.env.VITE_API_URL}/api/app/crudendpoint/${endpoint.entityName?.toLowerCase()}` - const params = getEndpointParameters(endpoint) - const pathParam = params.find((p) => p.type === 'path') - if (pathParam) url += `/${pathParam.value}` - const queryParams = params.filter((p) => p.type === 'query') - if (queryParams.length) { - url += '?' + queryParams.map((p) => `${p.name}=${encodeURIComponent(p.value)}`).join('&') - } - let data = undefined - if (needsBody(endpoint)) { - try { - data = JSON.parse(getRequestBody(endpoint)) - } catch { - data = {} - } - } - const res = await axios({ - method: endpoint.method, - url, - timeout: 10000, - headers: { 'Content-Type': 'application/json' }, - data, - }) - setTestResults((prev) => ({ - ...prev, - [endpoint.id]: { - success: true, - status: res.status, - data: res.data, - timestamp: new Date().toISOString(), - }, - })) - } catch (error: unknown) { - const axiosErr = error as { response?: { status?: number; data?: unknown }; message?: string } - setTestResults((prev) => ({ - ...prev, - [endpoint.id]: { - success: false, - status: axiosErr.response?.status || 0, - error: axiosErr.response?.data || axiosErr.message, - timestamp: new Date().toISOString(), - }, - })) - } finally { - setLoadingEndpoints((prev) => { - const s = new Set(prev) - s.delete(endpoint.id) - return s - }) - } - } - - // Derived stats - const tablesWithEndpoints = dbTables.filter((t) => allEndpointCount(t.tableName) > 0).length - const totalActiveEndpoints = generatedEndpoints.filter((ep) => ep.isActive).length - const selectedTableEndpoints = selectedTable ? getEndpointsForTable(selectedTable.tableName) : [] + const selectedActiveCount = useMemo( + () => selectedTableEndpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0), + [selectedTableEndpoints], + ) return (
- + {/* Stats Row */}
@@ -330,7 +491,7 @@ const CrudEndpointManager: React.FC = () => { /> {
@@ -382,42 +546,32 @@ const CrudEndpointManager: React.FC = () => {
setTableSearch(e.target.value)} + aria-label={translate('::App.DeveloperKitCrudEndpoints.SearchTable')} className="w-full pl-7 pr-3 py-1.5 text-sm border border-slate-300 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" />
- {(['all', 'with', 'without'] as const).map((f) => { - const labels = { - all: `${translate('::App.StaticLookup.All')} (${dbTables.length})`, - with: `${translate('::App.DeveloperKitCrudEndpoints.FilterWith')} (${dbTables.filter((t) => allEndpointCount(t.tableName) > 0).length})`, - without: `${translate('::App.DeveloperKitCrudEndpoints.FilterWithout')} (${dbTables.filter((t) => allEndpointCount(t.tableName) === 0).length})`, - } - const active = crudFilter === f - return ( - - ) - })} + {CRUD_FILTERS.map((f) => ( + + ))}
@@ -441,8 +595,9 @@ const CrudEndpointManager: React.FC = () => { {schema} {tables.map((table) => { - const active = activeEndpointCount(table.tableName) - const total = allEndpointCount(table.tableName) + const stat = tableStats.get(table.fullName) + const active = stat?.active ?? 0 + const total = stat?.total ?? 0 const isSelected = selectedTable?.fullName === table.fullName const hasEndpoints = total > 0 return ( @@ -515,17 +670,15 @@ const CrudEndpointManager: React.FC = () => {
-
-

- {selectedTable.schemaName}.{selectedTable.tableName} -

-
+

+ {selectedTable.schemaName}.{selectedTable.tableName} +

{selectedTableEndpoints.length > 0 && (