sozsoft-platform/ui/src/views/developerKit/CrudEndpointManager.tsx
2026-08-15 12:41:49 +03:00

1036 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
FaSearch,
FaCopy,
FaCheckCircle,
FaExclamationCircle,
FaDatabase,
FaSyncAlt,
FaPaperPlane,
FaTrash,
FaBolt,
FaTable,
FaToggleOn,
FaToggleOff,
FaChevronRight,
FaChevronDown,
} from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
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
status: number
data?: unknown
error?: unknown
timestamp: string
}
interface ParameterInput {
name: string
value: 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<string, string> = {
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<CrudFilter, string> = {
all: '!bg-blue-500 text-white',
with: '!bg-green-500 text-white',
without: '!bg-slate-500 text-white',
}
// Helper: tableName -> PascalCase entity name
function toPascalCase(tableName: string): string {
const name = tableName.replace(/^.*\./g, '')
return name
.replace(/[_-]([a-z])/gi, (_: string, c: string) => c.toUpperCase())
.replace(/^([a-z])/, (c: string) => c.toUpperCase())
}
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()
// Endpoints
const [generatedEndpoints, setGeneratedEndpoints] = useState<CrudEndpoint[]>([])
// Data source + tables
const [dataSources, setDataSources] = useState<DataSourceDto[]>([])
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(null)
const [dbTables, setDbTables] = useState<DatabaseTableDto[]>([])
const [loadingTables, setLoadingTables] = useState(false)
// Selection
const [selectedTable, setSelectedTable] = useState<DatabaseTableDto | null>(null)
const [tableSearch, setTableSearch] = useState('')
const [crudFilter, setCrudFilter] = useState<CrudFilter>('all')
// Endpoint management state
const [generatingFor, setGeneratingFor] = useState<string | null>(null)
const [deletingAll, setDeletingAll] = useState<string | null>(null)
const [confirmDeleteTable, setConfirmDeleteTable] = useState<DatabaseTableDto | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null)
// Test state
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
const [loadingEndpoints, setLoadingEndpoints] = useState<Set<string>>(new Set())
const [parameterValues, setParameterValues] = useState<Record<string, Record<string, string>>>({})
const [requestBodies, setRequestBodies] = useState<Record<string, string>>({})
const pushError = useCallback((message: string) => {
toast.push(
<Notification type="danger" duration={3000}>
{message}
</Notification>,
)
}, [])
const pushSuccess = useCallback((message: string) => {
toast.push(
<Notification type="success" duration={2000}>
{message}
</Notification>,
)
}, [])
// 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)
setSelectedDataSource((current) => current ?? items[0]?.code ?? null)
})
.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) => {
if (!cancelled) setDbTables(res.data.tables || [])
})
.catch((err) => {
if (!cancelled)
pushError(getErrorMessage(err, translate('::App.Platform.FailedToLoadObjects')))
})
.finally(() => {
if (!cancelled) setLoadingTables(false)
})
return () => {
cancelled = true
}
}, [selectedDataSource, pushError, translate])
// entityName -> endpoints (tek geçiş, tablo başına filtrelemeyi önler)
const endpointsByEntity = useMemo(() => {
const map = new Map<string, CrudEndpoint[]>()
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[] =>
endpointsByEntity.get(toPascalCase(tableName)) ?? EMPTY_ENDPOINTS,
[endpointsByEntity],
)
// fullName -> { total, active }
const tableStats = useMemo(() => {
const map = new Map<string, TableStat>()
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<Record<string, DatabaseTableDto[]>>((acc, t) => {
const schema = t.schemaName || 'dbo'
;(acc[schema] ??= []).push(t)
return acc
}, {}),
[filteredTables],
)
const filterLabels = useMemo<Record<CrudFilter, string>>(
() => ({
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 selectedTableEndpoints = useMemo(
() => (selectedTable ? getEndpointsForTable(selectedTable.tableName) : EMPTY_ENDPOINTS),
[selectedTable, getEndpointsForTable],
)
const selectedActiveCount = useMemo(
() => selectedTableEndpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0),
[selectedTableEndpoints],
)
return (
<div className="flex flex-col h-full gap-4 bg-gray-50 dark:bg-gray-950">
<PageTitle title={translate('::App.DeveloperKit.CrudEndpoints')} />
{/* Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-2">
<Widget
title={translate('::App.DeveloperKitCrudEndpoints.TotalTables')}
value={dbTables.length}
color="blue"
icon="FaDatabase"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.TotalTablesDescription')}
/>
<Widget
title={translate('::App.DeveloperKitCrudEndpoints.EndpointInstalled')}
value={withCrudCount}
color="green"
icon="FaCheckCircle"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.EndpointInstalledDescription')}
/>
<Widget
title={translate('::App.DeveloperKitCrudEndpoints.ActiveEndpoint')}
value={totalActiveEndpoints}
color="teal"
icon="FaBolt"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.ActiveEndpointDescription')}
/>
<Widget
title={translate('::App.Platform.DataSource')}
value={dataSources.length}
color="purple"
icon="FaServer"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitCrudEndpoints.DataSourceDescription')}
/>
</div>
{/* Main two-panel layout */}
<div
className="flex flex-col gap-4 lg:flex-row bg-gray-50 dark:bg-gray-950"
style={{ minHeight: 400, height: 'calc(100vh - 250px)' }}
>
{/* Left Panel: Table List */}
<div className="w-full lg:w-72 lg:flex-shrink-0 flex flex-col bg-white dark:bg-gray-900 rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden h-[38vh] min-h-[260px] lg:h-auto lg:min-h-0">
{/* DataSource selector */}
<div className="p-3 border-b border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800">
<select
value={selectedDataSource || ''}
onChange={(e) => setSelectedDataSource(e.target.value)}
aria-label={translate('::App.Platform.DataSource')}
className="w-full px-2 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"
>
{dataSources.length === 0 && (
<option value="">{translate('::App.Platform.LoadingWithThreeDot')}</option>
)}
{dataSources.map((ds) => (
<option key={ds.id} value={ds.code ?? ''}>
{ds.code}
</option>
))}
</select>
</div>
{/* Search + CRUD filter */}
<div className="p-3 border-b border-slate-200 dark:border-gray-700 space-y-2">
<div className="relative">
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-xs" />
<input
type="search"
placeholder={translate('::App.DeveloperKitCrudEndpoints.SearchTable')}
value={tableSearch}
onChange={(e) => 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"
/>
</div>
<div className="flex rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden text-xs font-medium">
{CRUD_FILTERS.map((f) => (
<Button
type="button"
key={f}
onClick={() => setCrudFilter(f)}
variant="plain"
shape="none"
aria-pressed={crudFilter === f}
className={`flex-1 !h-auto !rounded-none !px-0 !py-1.5 transition-colors ${
crudFilter === f
? FILTER_ACTIVE_CLASS[f]
: '!bg-white text-slate-500 hover:!bg-slate-50 dark:!bg-gray-900 dark:text-gray-400 dark:hover:!bg-gray-800'
}`}
>
{filterLabels[f]}
</Button>
))}
</div>
</div>
{/* Table list */}
<div className="flex-1 overflow-y-auto">
{loadingTables ? (
<div className="flex items-center justify-center p-8 text-slate-400">
<FaSyncAlt className="animate-spin mr-2" />
<span className="text-sm">{translate('::App.Platform.LoadingWithThreeDot')}</span>
</div>
) : filteredTables.length === 0 ? (
<div className="p-6 text-center text-slate-400 text-sm">
{selectedDataSource
? translate('::App.Platform.NoTablesAvailable')
: translate('::App.DeveloperKitCrudEndpoints.SelectDataSource')}
</div>
) : (
Object.entries(tablesBySchema).map(([schema, tables]) => (
<div key={schema}>
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-gray-400 uppercase tracking-wide bg-slate-50 dark:bg-gray-800 border-b border-slate-100 dark:border-gray-700 sticky top-0">
{schema}
</div>
{tables.map((table) => {
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 (
<Button
type="button"
key={table.fullName}
onClick={() => setSelectedTable(table)}
variant="plain"
shape="none"
className={`w-full !h-auto !items-center !justify-between !rounded-none border-b border-slate-50 !px-3 !py-1 text-left transition-colors hover:!bg-slate-50 dark:border-gray-800 dark:hover:!bg-gray-800 ${
isSelected
? '!bg-blue-50 border-l-2 border-l-blue-500 dark:!bg-blue-900/20 dark:border-l-blue-400'
: ''
}`}
>
<span className="flex w-full min-w-0 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<FaTable
className={`flex-shrink-0 text-xs ${
hasEndpoints ? 'text-green-500' : 'text-slate-300'
}`}
/>
<span className="truncate text-sm text-slate-700 dark:text-gray-100">
{table.tableName}
</span>
</span>
<span className="flex flex-shrink-0 items-center gap-1">
{hasEndpoints && (
<span
className={`rounded-full px-1.5 py-0.5 text-xs font-medium ${
active > 0
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: 'bg-slate-100 text-slate-500 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{active}/{total}
</span>
)}
{isSelected && (
<FaChevronRight className="flex-shrink-0 text-xs text-blue-400" />
)}
</span>
</span>
</Button>
)
})}
</div>
))
)}
</div>
</div>
{/* Right Panel: Endpoint Management */}
<div className="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-900 rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden min-h-0">
{!selectedTable ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 dark:text-gray-500 p-8">
<FaDatabase className="text-4xl mb-3 text-slate-200 dark:text-gray-700" />
<p className="text-base font-medium dark:text-gray-300">
{translate('::App.DeveloperKitCrudEndpoints.SelectTablePrompt')}
</p>
<p className="text-sm mt-1 dark:text-gray-400">
{translate('::App.DeveloperKitCrudEndpoints.SelectTableDescription')}
</p>
</div>
) : (
<div className="flex flex-col h-full overflow-hidden">
{/* Table header */}
<div className="flex flex-col gap-3 p-4 border-b border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 p-2 rounded-lg">
<FaTable />
</div>
<h4 className="text-slate-900 dark:text-gray-100">
{selectedTable.schemaName}.{selectedTable.tableName}
</h4>
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedTableEndpoints.length > 0 && (
<Button
type="button"
onClick={() => setConfirmDeleteTable(selectedTable)}
disabled={deletingAll === selectedTable.fullName}
variant="plain"
shape="none"
icon={
deletingAll === selectedTable.fullName ? (
<FaSyncAlt className="animate-spin" />
) : (
<FaTrash />
)
}
className="!inline-flex !h-auto !items-center !justify-center gap-2 whitespace-nowrap !rounded-lg border border-red-200 !px-3 !py-1.5 text-sm text-red-600 transition-colors hover:!bg-red-50 disabled:opacity-50 dark:border-red-700 dark:hover:!bg-red-900/20"
>
{translate('::App.DeveloperKitCrudEndpoints.DeleteAll')}
</Button>
)}
<Button
type="button"
onClick={() => handleGenerate(selectedTable)}
disabled={
generatingFor === selectedTable.fullName ||
deletingAll === selectedTable.fullName
}
variant="solid"
icon={
generatingFor === selectedTable.fullName ? (
<FaSyncAlt className="animate-spin" />
) : (
<FaBolt />
)
}
className="!inline-flex !h-auto !items-center !justify-center gap-2 whitespace-nowrap !rounded-lg !px-4 !py-1.5 text-sm font-medium text-white transition-colors hover:!bg-blue-700 disabled:opacity-50 dark:!bg-blue-700 dark:hover:!bg-blue-800"
>
{selectedTableEndpoints.length > 0
? translate('::App.DeveloperKitCrudEndpoints.Regenerate')
: translate('::App.DeveloperKitCrudEndpoints.CreateCrudEndpoint')}
</Button>
</div>
</div>
{/* Endpoints list */}
<div className="flex-1 overflow-y-auto p-4">
{selectedTableEndpoints.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400 dark:text-gray-500">
<FaBolt className="text-3xl mb-3 text-slate-200 dark:text-gray-700" />
<p className="font-medium dark:text-gray-300">
{translate('::App.DeveloperKitCrudEndpoints.NoEndpointsYet')}
</p>
<p className="text-sm mt-1 dark:text-gray-400">
{translate('::App.DeveloperKitCrudEndpoints.ClickToCreate')}
</p>
</div>
) : (
<div className="space-y-3">
{selectedTableEndpoints.map((ep) => {
const isExpanded = expandedEndpoint === ep.id
const testResult = testResults[ep.id]
const isTesting = loadingEndpoints.has(ep.id)
const parameters = isExpanded ? getEndpointParameters(ep) : EMPTY_PARAMS
const resultPayload = testResult
? JSON.stringify(
testResult.success ? testResult.data : testResult.error,
null,
2,
)
: ''
return (
<div
key={ep.id}
className="border border-slate-200 dark:border-gray-700 rounded-lg overflow-hidden"
>
{/* Endpoint row */}
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-900 hover:bg-slate-50 dark:hover:bg-gray-800 transition-colors">
{/* Toggle */}
<Button
type="button"
onClick={() => handleToggle(ep.id)}
disabled={togglingId === ep.id}
title={
ep.isActive
? translate('::App.DeveloperKitCrudEndpoints.Disable')
: translate('::App.DeveloperKitCrudEndpoints.Enable')
}
variant="plain"
shape="none"
className={`!h-8 !w-8 flex-shrink-0 !rounded !p-0 text-xl transition-colors hover:!bg-transparent ${
ep.isActive
? 'text-green-500 hover:text-green-700'
: 'text-slate-300 hover:text-slate-500'
} disabled:opacity-40`}
>
{togglingId === ep.id ? (
<FaSyncAlt className="animate-spin text-base" />
) : ep.isActive ? (
<FaToggleOn />
) : (
<FaToggleOff />
)}
</Button>
{/* Method badge */}
<span
className={`flex-shrink-0 px-2 py-0.5 text-xs font-bold rounded border ${
METHOD_COLOR[ep.method] ||
'bg-slate-100 dark:bg-gray-800 text-slate-700 dark:text-gray-300 border-slate-200 dark:border-gray-700'
}`}
>
{ep.method}
</span>
{/* Operation */}
<div className="flex-1 min-w-0">
<span className="font-medium text-slate-800 dark:text-gray-100 text-sm">
{ep.operationType}
</span>
<code className="ml-3 text-xs text-slate-500 dark:text-gray-400 bg-slate-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{ep.path}
</code>
</div>
{/* Expand */}
<Button
type="button"
onClick={() => setExpandedEndpoint(isExpanded ? null : ep.id)}
variant="plain"
shape="none"
aria-expanded={isExpanded}
className="!h-7 !w-7 !rounded !p-0 text-slate-400 transition-colors hover:!bg-transparent hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200"
title={translate('::App.DeveloperKitCrudEndpoints.TestDetails')}
>
{isExpanded ? (
<FaChevronDown className="text-xs" />
) : (
<FaChevronRight className="text-xs" />
)}
</Button>
</div>
{/* Expanded detail + test */}
{isExpanded && (
<div className="border-t border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800 p-4 space-y-4">
{/* Parameters */}
{parameters.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 dark:text-gray-300 mb-2">
{translate('::App.DeveloperKitCrudEndpoints.Parameters')}
</p>
<div className="space-y-2">
{parameters.map((param) => (
<div key={param.name} className="flex items-center gap-2">
<span
className={`text-xs px-1.5 py-0.5 rounded font-mono ${
param.type === 'path'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{param.name}
</span>
<input
type="text"
value={param.value}
aria-label={param.name}
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[ep.id]: {
...prev[ep.id],
[param.name]: e.target.value,
},
}))
}
className="flex-1 px-2 py-1 text-xs border border-slate-300 dark:border-gray-700 rounded focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
/>
</div>
))}
</div>
</div>
)}
{/* Request body */}
{needsBody(ep) && (
<div>
<p className="text-xs font-semibold text-slate-600 dark:text-gray-300 mb-2">
{translate('::App.DeveloperKitCrudEndpoints.RequestBody')}
</p>
<textarea
value={getRequestBody(ep)}
aria-label={translate(
'::App.DeveloperKitCrudEndpoints.RequestBody',
)}
onChange={(e) =>
setRequestBodies((prev) => ({
...prev,
[ep.id]: e.target.value,
}))
}
rows={5}
spellCheck={false}
className="w-full px-2 py-1.5 text-xs border border-slate-300 dark:border-gray-700 rounded font-mono focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
/>
</div>
)}
{/* Test button */}
<div className="flex items-center gap-2">
<Button
type="button"
onClick={() => testEndpoint(ep)}
disabled={isTesting}
variant="solid"
icon={
isTesting ? (
<FaSyncAlt className="animate-spin" />
) : (
<FaPaperPlane />
)
}
className="!inline-flex !h-auto !items-center !justify-center gap-2 whitespace-nowrap !rounded-lg !px-4 !py-1.5 text-sm text-white transition-colors hover:!bg-blue-700 disabled:opacity-50"
>
{isTesting
? translate('::App.DeveloperKitEndpoint.SendLoading')
: translate('::App.DeveloperKitCrudEndpoints.Test')}
</Button>
{testResult && (
<Button
type="button"
onClick={() =>
setTestResults((prev) => {
const next = { ...prev }
delete next[ep.id]
return next
})
}
variant="plain"
shape="none"
className="!h-auto !rounded !px-2 !py-1.5 text-xs text-slate-500 hover:!bg-transparent hover:text-slate-700"
>
{translate('::App.Platform.Clear')}
</Button>
)}
</div>
{/* Test result */}
{testResult && (
<div
className={`rounded-lg border p-3 ${
testResult.success
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
: 'border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
}`}
>
<div className="flex items-center gap-2 mb-2">
{testResult.success ? (
<FaCheckCircle className="text-green-500 dark:text-green-400" />
) : (
<FaExclamationCircle className="text-red-500 dark:text-red-400" />
)}
<span className="text-sm font-medium">
HTTP {testResult.status}
</span>
<span className="text-xs text-slate-500 dark:text-gray-400 ml-auto">
{new Date(testResult.timestamp).toLocaleTimeString()}
</span>
</div>
<div className="relative">
<pre className="text-xs bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded p-2 overflow-x-auto max-h-48 text-gray-900 dark:text-gray-100">
{resultPayload}
</pre>
<Button
type="button"
onClick={() => copyToClipboard(resultPayload)}
variant="plain"
shape="none"
title={translate('::App.Platform.CopyToClipboard')}
icon={<FaCopy className="text-xs" />}
className="absolute right-1.5 top-1.5 !h-6 !w-6 !rounded !p-0 text-slate-400 hover:!bg-transparent hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
</div>
</div>
)}
{/* C# Code Preview */}
{ep.csharpCode && (
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-semibold text-slate-600 dark:text-gray-300">
{translate('::App.DeveloperKitCrudEndpoints.CsharpCode')}
</p>
<Button
type="button"
onClick={() => copyToClipboard(ep.csharpCode)}
variant="plain"
shape="none"
icon={<FaCopy />}
className="!inline-flex !h-auto !items-center !justify-center gap-1 whitespace-nowrap !rounded-none !px-0 !py-0 text-xs text-slate-400 hover:!bg-transparent hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200"
>
{translate('::App.Platform.Copy')}
</Button>
</div>
<pre className="text-xs bg-slate-800 dark:bg-gray-900 text-green-300 rounded-lg p-3 overflow-x-auto max-h-48 font-mono">
{ep.csharpCode}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Footer info */}
{selectedTableEndpoints.length > 0 && (
<div className="border-t border-slate-200 dark:border-gray-700 px-2 py-1 bg-slate-50 dark:bg-gray-800 flex items-center gap-4 text-xs text-slate-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<FaCheckCircle className="text-green-400" />
{selectedActiveCount} {translate('::App.Listform.ListformField.IsActive')}
</span>
<span>
{selectedTableEndpoints.length - selectedActiveCount}{' '}
{translate('::App.DeveloperKitCrudEndpoints.InactiveCount')}
</span>
<span className="ml-auto">
{translate('::App.DeveloperKitCrudEndpoints.EndpointSummary')}
</span>
</div>
)}
</div>
)}
</div>
</div>
<ConfirmDialog
isOpen={!!confirmDeleteTable}
type="danger"
title={translate('::App.Platform.DeleteConfirmation')}
confirmText={translate('::App.DeveloperKitCrudEndpoints.DeleteAll')}
cancelText={translate('::App.Platform.Cancel')}
confirmButtonColor="red-600"
onCancel={() => setConfirmDeleteTable(null)}
onConfirm={() => {
const table = confirmDeleteTable
setConfirmDeleteTable(null)
if (table) handleDeleteAll(table)
}}
>
<p>{translate('::App.Platform.DeleteConfirmationMessage')}</p>
</ConfirmDialog>
</div>
)
}
export default CrudEndpointManager