880 lines
34 KiB
TypeScript
880 lines
34 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
import {
|
|
FaBolt,
|
|
FaSyncAlt,
|
|
FaToggleOn,
|
|
FaToggleOff,
|
|
FaTrash,
|
|
FaCopy,
|
|
FaChevronDown,
|
|
FaChevronRight,
|
|
FaPaperPlane,
|
|
FaCheckCircle,
|
|
FaExclamationCircle,
|
|
FaList,
|
|
FaSearch,
|
|
FaPlus,
|
|
FaPen,
|
|
} from 'react-icons/fa'
|
|
import { Button, Dialog, Notification, toast } from '@/components/ui'
|
|
import { ConfirmDialog } from '@/components/shared'
|
|
import { developerKitService } from '@/services/developerKit.service'
|
|
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
|
import apiService from '@/services/api.service'
|
|
import type { CrudEndpoint } from '@/proxy/developerKit/models'
|
|
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
import { usePermission } from '@/utils/hooks/usePermission'
|
|
import Input from '@/components/ui/Input'
|
|
|
|
// ─── Paylasilan yardimcilar ───────────────────────────────────────────────────
|
|
// Endpoint'leri ureten/gosteren tum ekranlar (SqlQueryManager, SqlObjectExplorer,
|
|
// SqlTableDesignerDialog, ListForm Wizard) bu dosyadaki export'lari kullanir.
|
|
|
|
/**
|
|
* CRUD endpoint uretimi/yonetimi icin gereken izin.
|
|
* Backend tarafinda PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints ile ayni.
|
|
*/
|
|
export const CRUD_ENDPOINT_PERMISSION = 'App.SqlQueryManager.CrudEndpoints'
|
|
|
|
/** Uretilen endpoint operasyonlari; backend ile ayni sirada ve adlarla. */
|
|
export const CRUD_OPERATION_TYPES = [
|
|
'GetList',
|
|
'GetById',
|
|
'Create',
|
|
'Update',
|
|
'Delete',
|
|
] as const
|
|
|
|
export type CrudOperationType = (typeof CRUD_OPERATION_TYPES)[number]
|
|
|
|
/** Endpoint uretilebilen SQL nesne turleri. */
|
|
export type CrudObjectType = 'table' | 'view'
|
|
|
|
/**
|
|
* View yazma islemlerini desteklemez; bu nesneler icin yalnizca okuma
|
|
* endpoint'leri uretilir. Backend de ayni kisiti objectType parametresiyle uygular.
|
|
*/
|
|
export const READONLY_OPERATION_TYPES: CrudOperationType[] = ['GetList', 'GetById']
|
|
|
|
/**
|
|
* Endpoint'lerde kullanilan entity adini turetir.
|
|
*
|
|
* Entity adi calisma zamaninda `DynamicEntityManager` tarafindan dogrudan tablo adi
|
|
* olarak SQL'e girer (`SELECT * FROM {entityName}`), bu yuzden tablo adi aynen korunur;
|
|
* yalnizca `dbo.` gibi sema oneki ayiklanir. Ad normalize edilirse (`Adm_D_Approval` →
|
|
* `AdmDApproval`) uretilen endpoint calisma aninda olmayan bir tabloyu sorgular.
|
|
*/
|
|
export const toEntityName = (tableName: string): string =>
|
|
(tableName || '').replace(/^.*\./g, '').trim()
|
|
|
|
export 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',
|
|
}
|
|
|
|
interface OperationMeta {
|
|
icon: ReactNode
|
|
/** Aktif endpoint rengi; pasif olanlar gri gosterilir. */
|
|
color: string
|
|
}
|
|
|
|
const OPERATION_META: Record<string, OperationMeta> = {
|
|
GetList: { icon: <FaList />, color: 'text-blue-500' },
|
|
GetById: { icon: <FaSearch />, color: 'text-sky-500' },
|
|
Create: { icon: <FaPlus />, color: 'text-green-500' },
|
|
Update: { icon: <FaPen />, color: 'text-amber-500' },
|
|
Delete: { icon: <FaTrash />, color: 'text-red-500' },
|
|
}
|
|
|
|
export const getOperationMeta = (operationType: string): OperationMeta =>
|
|
OPERATION_META[operationType] ?? { icon: <FaList />, color: 'text-gray-400' }
|
|
|
|
/** Endpoint'leri CRUD_OPERATION_TYPES sirasina gore siralar; bilinmeyenler sona gider. */
|
|
export const sortByCrudOperation = <T extends { operationType: string }>(endpoints: T[]): T[] => {
|
|
const order = (operationType: string) => {
|
|
const index = CRUD_OPERATION_TYPES.indexOf(operationType as CrudOperationType)
|
|
return index === -1 ? CRUD_OPERATION_TYPES.length : index
|
|
}
|
|
return [...endpoints].sort((a, b) => order(a.operationType) - order(b.operationType))
|
|
}
|
|
|
|
/** Endpoint listesini entity adina gore gruplar. */
|
|
export const groupEndpointsByEntity = (endpoints: CrudEndpoint[]): Map<string, CrudEndpoint[]> => {
|
|
const map = new Map<string, CrudEndpoint[]>()
|
|
for (const endpoint of endpoints) {
|
|
const list = map.get(endpoint.entityName)
|
|
if (list) list.push(endpoint)
|
|
else map.set(endpoint.entityName, [endpoint])
|
|
}
|
|
// Explorer rozetleri de diyalogla ayni sirada cizilir.
|
|
for (const [entityName, list] of map) {
|
|
map.set(entityName, sortByCrudOperation(list))
|
|
}
|
|
return map
|
|
}
|
|
|
|
export 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
|
|
)
|
|
}
|
|
|
|
interface CrudEndpointBadgesProps {
|
|
endpoints: CrudEndpoint[]
|
|
/** Ikon uzerindeki tooltip metnini uretir: (operationType, method, path, isActive) */
|
|
buildTitle: (endpoint: CrudEndpoint) => string
|
|
onClick?: () => void
|
|
}
|
|
|
|
/**
|
|
* Bir tablonun acilmis CRUD endpoint'lerini kucuk ikonlarla gosterir.
|
|
* Aktif olanlar renkli, pasif olanlar soluk gri cizilir; tooltip operasyonu aciklar.
|
|
*/
|
|
export const CrudEndpointBadges = ({ endpoints, buildTitle, onClick }: CrudEndpointBadgesProps) => {
|
|
if (endpoints.length === 0) return null
|
|
|
|
return (
|
|
<span
|
|
className="flex flex-shrink-0 items-center gap-1"
|
|
onClick={
|
|
onClick
|
|
? (e) => {
|
|
e.stopPropagation()
|
|
onClick()
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{endpoints.map((endpoint) => {
|
|
const meta = getOperationMeta(endpoint.operationType)
|
|
return (
|
|
<span
|
|
key={endpoint.id}
|
|
title={buildTitle(endpoint)}
|
|
className={`text-[10px] ${endpoint.isActive ? meta.color : 'text-gray-300 dark:text-gray-600'}`}
|
|
>
|
|
{meta.icon}
|
|
</span>
|
|
)
|
|
})}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── Dialog ───────────────────────────────────────────────────────────────────
|
|
|
|
interface CrudEndpointDialogProps {
|
|
isOpen: boolean
|
|
/** Endpoint'leri yonetilecek tablo/view; entity adi nesne adindan turetilir. */
|
|
tableName: string
|
|
schemaName?: string
|
|
/** Nesne turu; 'view' verildiginde yalnizca GetList/GetById uretilir. */
|
|
objectType?: CrudObjectType
|
|
/** Verilirse istek govdesi tablonun gercek kolonlarindan uretilir. */
|
|
dataSourceCode?: string | null
|
|
onClose: () => void
|
|
/** Endpoint listesi degistiginde cagrilir; cagiran taraf kendi state'ini tazeler. */
|
|
onChanged?: (entityName: string, endpoints: CrudEndpoint[]) => void
|
|
}
|
|
|
|
interface TestResult {
|
|
success: boolean
|
|
status: number
|
|
durationMs: number
|
|
payload: string
|
|
}
|
|
|
|
interface EndpointParameter {
|
|
name: string
|
|
type: 'path' | 'query'
|
|
value: string
|
|
}
|
|
|
|
/** Create istegine gonderilmemesi gereken ABP audit/sistem kolonlari. */
|
|
const SYSTEM_COLUMNS = new Set(
|
|
[
|
|
'Id',
|
|
'TenantId',
|
|
'CreationTime',
|
|
'CreatorId',
|
|
'LastModificationTime',
|
|
'LastModifierId',
|
|
'IsDeleted',
|
|
'DeleterId',
|
|
'DeletionTime',
|
|
'ConcurrencyStamp',
|
|
'ExtraProperties',
|
|
].map((name) => name.toLowerCase()),
|
|
)
|
|
|
|
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000'
|
|
|
|
/** SQL veri tipine gore ornek govde degeri uretir. */
|
|
const getSampleValue = (column: DatabaseColumnDto): unknown => {
|
|
const type = (column.dataType || '').toLowerCase()
|
|
|
|
if (column.isNullable) return null
|
|
if (type.includes('guid') || type === 'uuid') return EMPTY_GUID
|
|
if (type.includes('bit') || type.includes('bool')) return false
|
|
if (
|
|
type.includes('int') ||
|
|
type.includes('decimal') ||
|
|
type.includes('numeric') ||
|
|
type.includes('money') ||
|
|
type.includes('float') ||
|
|
type.includes('real') ||
|
|
type.includes('double')
|
|
) {
|
|
return 0
|
|
}
|
|
if (type.includes('date') || type.includes('time')) return new Date().toISOString()
|
|
return ''
|
|
}
|
|
|
|
const GENERIC_BODY = JSON.stringify({ name: 'Sample', isActive: true }, null, 2)
|
|
|
|
/** Endpoint'in path'indeki {placeholder} adlarini cikarir. */
|
|
const getPathParameterNames = (path: string): string[] =>
|
|
[...(path || '').matchAll(/\{(\w+)\}/g)].map((match) => match[1])
|
|
|
|
const needsBody = (endpoint: CrudEndpoint) =>
|
|
endpoint.operationType === 'Create' || endpoint.operationType === 'Update'
|
|
|
|
/**
|
|
* Tek bir tablonun CRUD endpoint'lerini yonetir: uretme/yeniden uretme, tekil
|
|
* aktif/pasif, tekil silme, tumunu silme ve endpoint'i parametreleriyle calistirip
|
|
* sonucunu gorme. SqlQueryManager ve ListForm Wizard ayni diyalogu kullanir.
|
|
*/
|
|
const CrudEndpointDialog = ({
|
|
isOpen,
|
|
tableName,
|
|
schemaName,
|
|
objectType = 'table',
|
|
dataSourceCode,
|
|
onClose,
|
|
onChanged,
|
|
}: CrudEndpointDialogProps) => {
|
|
const { translate } = useLocalization()
|
|
const { checkPermission } = usePermission()
|
|
const canManageCrudEndpoints = checkPermission(CRUD_ENDPOINT_PERMISSION)
|
|
const entityName = toEntityName(tableName)
|
|
const isReadOnlyObject = objectType === 'view'
|
|
|
|
const [endpoints, setEndpoints] = useState<CrudEndpoint[]>([])
|
|
const [columns, setColumns] = useState<DatabaseColumnDto[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
const [deletingAll, setDeletingAll] = useState(false)
|
|
const [busyId, setBusyId] = useState<string | null>(null)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false)
|
|
const [testingId, setTestingId] = useState<string | null>(null)
|
|
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
|
|
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>,
|
|
{ placement: 'bottom-end' },
|
|
)
|
|
}, [])
|
|
|
|
const pushSuccess = useCallback((message: string) => {
|
|
toast.push(
|
|
<Notification type="success" duration={2000}>
|
|
{message}
|
|
</Notification>,
|
|
{ placement: 'bottom-end' },
|
|
)
|
|
}, [])
|
|
|
|
const publish = useCallback(
|
|
(next: CrudEndpoint[]) => {
|
|
// Liste veritabani sirasiyla gelir; kullaniciya her zaman CRUD sirasinda gosterilir.
|
|
const sorted = sortByCrudOperation(next)
|
|
setEndpoints(sorted)
|
|
onChanged?.(entityName, sorted)
|
|
},
|
|
[entityName, onChanged],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !entityName) return
|
|
let cancelled = false
|
|
|
|
setLoading(true)
|
|
setExpandedId(null)
|
|
setTestResults({})
|
|
developerKitService
|
|
.getEndpointsByEntity(entityName)
|
|
.then((items) => {
|
|
if (!cancelled) setEndpoints(sortByCrudOperation(items))
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) pushError(getErrorMessage(err, translate('::App.Platform.ErrorLoading')))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isOpen, entityName, pushError, translate])
|
|
|
|
// Govde sablonu icin tablo kolonlari; datasource bilinmiyorsa genel sablon kullanilir.
|
|
useEffect(() => {
|
|
if (!isOpen || !dataSourceCode || isReadOnlyObject) {
|
|
setColumns([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
|
|
sqlObjectManagerService
|
|
.getTableColumns(dataSourceCode, schemaName || 'dbo', tableName)
|
|
.then((res) => {
|
|
if (!cancelled) setColumns(res.data || [])
|
|
})
|
|
.catch(() => {
|
|
// Kolonlar okunamazsa tester genel govde sablonuyla calismaya devam eder.
|
|
if (!cancelled) setColumns([])
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isOpen, dataSourceCode, schemaName, tableName, isReadOnlyObject])
|
|
|
|
const sampleBodies = useMemo(() => {
|
|
if (columns.length === 0) return { create: GENERIC_BODY, update: GENERIC_BODY }
|
|
|
|
const build = (includeId: boolean) => {
|
|
const body: Record<string, unknown> = {}
|
|
for (const column of columns) {
|
|
const isSystem = SYSTEM_COLUMNS.has(column.columnName.toLowerCase())
|
|
const isId = column.columnName.toLowerCase() === 'id'
|
|
if (isSystem && !(includeId && isId)) continue
|
|
body[column.columnName] = isId ? EMPTY_GUID : getSampleValue(column)
|
|
}
|
|
return JSON.stringify(body, null, 2)
|
|
}
|
|
|
|
return { create: build(false), update: build(true) }
|
|
}, [columns])
|
|
|
|
const getDefaultBody = useCallback(
|
|
(endpoint: CrudEndpoint) =>
|
|
endpoint.operationType === 'Update' ? sampleBodies.update : sampleBodies.create,
|
|
[sampleBodies],
|
|
)
|
|
|
|
const getParameters = useCallback(
|
|
(endpoint: CrudEndpoint): EndpointParameter[] => {
|
|
const values = parameterValues[endpoint.id]
|
|
const valueOf = (name: string, fallback: string) => values?.[name] ?? fallback
|
|
|
|
const pathParams: EndpointParameter[] = getPathParameterNames(endpoint.path).map((name) => ({
|
|
name,
|
|
type: 'path',
|
|
value: valueOf(name, name.toLowerCase() === 'id' ? EMPTY_GUID : ''),
|
|
}))
|
|
|
|
if (endpoint.operationType !== 'GetList') return pathParams
|
|
|
|
return [
|
|
...pathParams,
|
|
{ name: 'SkipCount', type: 'query', value: valueOf('SkipCount', '0') },
|
|
{ name: 'MaxResultCount', type: 'query', value: valueOf('MaxResultCount', '10') },
|
|
{ name: 'Sorting', type: 'query', value: valueOf('Sorting', '') },
|
|
]
|
|
},
|
|
[parameterValues],
|
|
)
|
|
|
|
const handleGenerate = async () => {
|
|
setGenerating(true)
|
|
try {
|
|
const result = await developerKitService.generateCrudEndpoints(entityName, undefined, objectType)
|
|
publish(result.items || [])
|
|
pushSuccess(translate('::App.Platform.OperationCompleted'))
|
|
} catch (err) {
|
|
pushError(getErrorMessage(err, translate('::App.Platform.IslemBasarisiz')))
|
|
} finally {
|
|
setGenerating(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteAll = async () => {
|
|
setDeletingAll(true)
|
|
try {
|
|
await developerKitService.deleteGeneratedEndpointsByEntity(entityName)
|
|
publish([])
|
|
pushSuccess(translate('::App.Platform.ObjectDeletedSuccessfully'))
|
|
} catch (err) {
|
|
pushError(getErrorMessage(err, translate('::App.Platform.ErrorDeleting')))
|
|
} finally {
|
|
setDeletingAll(false)
|
|
}
|
|
}
|
|
|
|
const handleToggle = async (endpoint: CrudEndpoint) => {
|
|
setBusyId(endpoint.id)
|
|
try {
|
|
const updated = await developerKitService.toggleGeneratedEndpoint(endpoint.id)
|
|
publish(endpoints.map((ep) => (ep.id === endpoint.id ? updated : ep)))
|
|
} catch (err) {
|
|
pushError(getErrorMessage(err, translate('::App.Platform.IslemBasarisiz')))
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (endpoint: CrudEndpoint) => {
|
|
setBusyId(endpoint.id)
|
|
try {
|
|
await developerKitService.deleteGeneratedEndpoint(endpoint.id)
|
|
publish(endpoints.filter((ep) => ep.id !== endpoint.id))
|
|
} catch (err) {
|
|
pushError(getErrorMessage(err, translate('::App.Platform.ErrorDeleting')))
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Endpoint'i doldurulan parametre ve govde ile cagirir. Istek apiService uzerinden
|
|
* gider; Authorization, __tenant ve Accept-Language interceptor'lari uygulanir.
|
|
*/
|
|
const handleTest = async (endpoint: CrudEndpoint) => {
|
|
setTestingId(endpoint.id)
|
|
const startedAt = performance.now()
|
|
|
|
try {
|
|
const parameters = getParameters(endpoint)
|
|
const pathValues = new Map(
|
|
parameters.filter((p) => p.type === 'path').map((p) => [p.name, p.value]),
|
|
)
|
|
|
|
const url = endpoint.path.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
const value = pathValues.get(name)
|
|
return value ? encodeURIComponent(value) : match
|
|
})
|
|
|
|
const params = Object.fromEntries(
|
|
parameters
|
|
.filter((p) => p.type === 'query' && p.value !== '')
|
|
.map((p) => [p.name, p.value]),
|
|
)
|
|
|
|
let data: unknown
|
|
if (needsBody(endpoint)) {
|
|
const raw = requestBodies[endpoint.id] ?? getDefaultBody(endpoint)
|
|
try {
|
|
data = JSON.parse(raw)
|
|
} catch {
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[endpoint.id]: {
|
|
success: false,
|
|
status: 0,
|
|
durationMs: 0,
|
|
payload: translate('::App.Platform.InvalidJson'),
|
|
},
|
|
}))
|
|
return
|
|
}
|
|
}
|
|
|
|
const response = await apiService.fetchData({
|
|
method: endpoint.method,
|
|
url,
|
|
params: Object.keys(params).length > 0 ? params : undefined,
|
|
data,
|
|
timeout: 15000,
|
|
})
|
|
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[endpoint.id]: {
|
|
success: true,
|
|
status: response.status,
|
|
durationMs: Math.round(performance.now() - startedAt),
|
|
payload: JSON.stringify(response.data, null, 2),
|
|
},
|
|
}))
|
|
} catch (error: unknown) {
|
|
const axiosError = error as { response?: { status?: number; data?: unknown }; message?: string }
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[endpoint.id]: {
|
|
success: false,
|
|
status: axiosError.response?.status ?? 0,
|
|
durationMs: Math.round(performance.now() - startedAt),
|
|
payload: JSON.stringify(axiosError.response?.data ?? axiosError.message, null, 2),
|
|
},
|
|
}))
|
|
} finally {
|
|
setTestingId(null)
|
|
}
|
|
}
|
|
|
|
const copyToClipboard = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
pushSuccess(translate('::App.Platform.Copied'))
|
|
} catch (err) {
|
|
pushError(getErrorMessage(err, translate('::App.Platform.CopyFailed')))
|
|
}
|
|
}
|
|
|
|
const activeCount = endpoints.reduce((sum, ep) => sum + (ep.isActive ? 1 : 0), 0)
|
|
const busy = loading || generating || deletingAll
|
|
|
|
// Izin yoksa dialog hic acilmaz; cagiran ekranlar da butonu gizler.
|
|
if (!canManageCrudEndpoints) return null
|
|
|
|
return (
|
|
<Dialog isOpen={isOpen} onClose={onClose} onRequestClose={onClose} width={900}>
|
|
<Dialog.Body className="flex flex-col gap-3 mt-2">
|
|
<div>
|
|
<h5 className="mb-0">{translate('::App.SqlQueryManager.CrudEndpoints')}</h5>
|
|
<p className="mb-0 text-sm text-gray-500 dark:text-gray-400">
|
|
{schemaName ? `${schemaName}.${tableName}` : tableName}
|
|
{' — '}
|
|
{entityName}
|
|
{isReadOnlyObject && (
|
|
<span className="ml-2 rounded border border-purple-200 bg-purple-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-purple-700 dark:border-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
|
view
|
|
</span>
|
|
)}
|
|
</p>
|
|
{isReadOnlyObject && (
|
|
<p className="mt-1 mb-0 text-xs text-gray-500 dark:text-gray-400">
|
|
{READONLY_OPERATION_TYPES.join(' / ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-h-[180px] max-h-[55vh] overflow-y-auto">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center gap-2 py-12 text-gray-400">
|
|
<FaSyncAlt className="animate-spin" />
|
|
<span className="text-sm">{translate('::App.Platform.LoadingWithThreeDot')}</span>
|
|
</div>
|
|
) : endpoints.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400 dark:text-gray-500">
|
|
<FaBolt className="mb-3 text-3xl text-gray-200 dark:text-gray-700" />
|
|
<p className="mb-0 font-medium">
|
|
{translate('::App.DeveloperKitCrudEndpoints.NoEndpointsYet')}
|
|
</p>
|
|
<p className="mt-1 mb-0 text-sm">
|
|
{translate('::App.DeveloperKitCrudEndpoints.ClickToCreate')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{endpoints.map((ep) => {
|
|
const meta = getOperationMeta(ep.operationType)
|
|
const isExpanded = expandedId === ep.id
|
|
const parameters = isExpanded ? getParameters(ep) : []
|
|
const testResult = testResults[ep.id]
|
|
const isTesting = testingId === ep.id
|
|
return (
|
|
<div
|
|
key={ep.id}
|
|
className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div className="flex items-center gap-3 bg-white p-2 dark:bg-gray-800">
|
|
<Button
|
|
size="xs"
|
|
variant="plain"
|
|
shape="circle"
|
|
disabled={busyId === ep.id}
|
|
title={
|
|
ep.isActive
|
|
? translate('::App.DeveloperKitCrudEndpoints.Disable')
|
|
: translate('::App.DeveloperKitCrudEndpoints.Enable')
|
|
}
|
|
onClick={() => handleToggle(ep)}
|
|
icon={
|
|
busyId === ep.id ? (
|
|
<FaSyncAlt className="animate-spin" />
|
|
) : ep.isActive ? (
|
|
<FaToggleOn className="text-lg text-green-500" />
|
|
) : (
|
|
<FaToggleOff className="text-lg text-gray-300" />
|
|
)
|
|
}
|
|
/>
|
|
<span
|
|
className={`flex-shrink-0 rounded border px-2 py-0.5 text-xs font-bold ${
|
|
METHOD_COLOR[ep.method] ||
|
|
'border-gray-200 bg-gray-100 text-gray-700 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
|
}`}
|
|
>
|
|
{ep.method}
|
|
</span>
|
|
<span className={`flex-shrink-0 ${meta.color}`}>{meta.icon}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-100">
|
|
{ep.operationType}
|
|
</span>
|
|
<code className="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
|
{ep.path}
|
|
</code>
|
|
</div>
|
|
{testResult && !isExpanded && (
|
|
<span
|
|
className={`flex-shrink-0 text-xs font-semibold ${
|
|
testResult.success ? 'text-green-600' : 'text-red-600'
|
|
}`}
|
|
>
|
|
{testResult.status}
|
|
</span>
|
|
)}
|
|
<Button
|
|
size="xs"
|
|
variant="plain"
|
|
shape="circle"
|
|
title={translate('::App.Platform.Delete')}
|
|
disabled={busyId === ep.id}
|
|
onClick={() => handleDelete(ep)}
|
|
icon={<FaTrash className="text-red-500" />}
|
|
/>
|
|
<Button
|
|
size="xs"
|
|
variant="plain"
|
|
shape="circle"
|
|
aria-expanded={isExpanded}
|
|
title={translate('::App.DeveloperKitCrudEndpoints.TestDetails')}
|
|
onClick={() => setExpandedId(isExpanded ? null : ep.id)}
|
|
icon={isExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
|
/>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="space-y-3 border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
|
|
{parameters.length > 0 && (
|
|
<div>
|
|
<p className="mb-2 text-xs font-semibold text-gray-600 dark:text-gray-300">
|
|
{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={`w-32 flex-shrink-0 rounded px-1.5 py-0.5 font-mono text-xs ${
|
|
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
|
|
unstyle
|
|
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 rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-900 focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{needsBody(ep) && (
|
|
<div>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">
|
|
{translate('::App.DeveloperKitCrudEndpoints.RequestBody')}
|
|
</span>
|
|
<Button
|
|
size="xs"
|
|
variant="plain"
|
|
onClick={() =>
|
|
setRequestBodies((prev) => ({
|
|
...prev,
|
|
[ep.id]: getDefaultBody(ep),
|
|
}))
|
|
}
|
|
>
|
|
{translate('::App.Platform.Reset')}
|
|
</Button>
|
|
</div>
|
|
<textarea
|
|
rows={8}
|
|
spellCheck={false}
|
|
value={requestBodies[ep.id] ?? getDefaultBody(ep)}
|
|
aria-label={translate('::App.DeveloperKitCrudEndpoints.RequestBody')}
|
|
onChange={(e) =>
|
|
setRequestBodies((prev) => ({ ...prev, [ep.id]: e.target.value }))
|
|
}
|
|
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 font-mono text-xs text-gray-900 focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="solid"
|
|
disabled={isTesting}
|
|
onClick={() => handleTest(ep)}
|
|
icon={
|
|
isTesting ? (
|
|
<FaSyncAlt className="animate-spin" />
|
|
) : (
|
|
<FaPaperPlane />
|
|
)
|
|
}
|
|
>
|
|
{isTesting
|
|
? translate('::App.DeveloperKitEndpoint.SendLoading')
|
|
: translate('::App.DeveloperKitCrudEndpoints.Test')}
|
|
</Button>
|
|
{testResult && (
|
|
<Button
|
|
size="sm"
|
|
variant="plain"
|
|
onClick={() =>
|
|
setTestResults((prev) => {
|
|
const next = { ...prev }
|
|
delete next[ep.id]
|
|
return next
|
|
})
|
|
}
|
|
>
|
|
{translate('::App.Platform.Clear')}
|
|
</Button>
|
|
)}
|
|
{!ep.isActive && (
|
|
<span className="text-xs text-amber-600 dark:text-amber-400">
|
|
{translate('::App.DeveloperKitCrudEndpoints.InactiveCount')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{testResult && (
|
|
<div
|
|
className={`rounded-lg border p-2 ${
|
|
testResult.success
|
|
? 'border-green-200 bg-green-50 dark:border-green-700 dark:bg-green-900/20'
|
|
: 'border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/20'
|
|
}`}
|
|
>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
{testResult.success ? (
|
|
<FaCheckCircle className="text-green-500" />
|
|
) : (
|
|
<FaExclamationCircle className="text-red-500" />
|
|
)}
|
|
<span className="text-sm font-medium">HTTP {testResult.status}</span>
|
|
<span className="ml-auto text-xs text-gray-500 dark:text-gray-400">
|
|
{testResult.durationMs} ms
|
|
</span>
|
|
<Button
|
|
size="xs"
|
|
variant="plain"
|
|
shape="circle"
|
|
title={translate('::App.Platform.CopyToClipboard')}
|
|
onClick={() => copyToClipboard(testResult.payload)}
|
|
icon={<FaCopy className="text-xs" />}
|
|
/>
|
|
</div>
|
|
<pre className="max-h-56 overflow-auto rounded border border-gray-200 bg-white p-2 text-xs text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100">
|
|
{testResult.payload}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{endpoints.length > 0 && (
|
|
<div className="flex items-center gap-4 border-t pt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<span>
|
|
{activeCount} {translate('::App.Listform.ListformField.IsActive')}
|
|
</span>
|
|
<span>
|
|
{endpoints.length - activeCount}{' '}
|
|
{translate('::App.DeveloperKitCrudEndpoints.InactiveCount')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</Dialog.Body>
|
|
|
|
<Dialog.Footer className="mt-1 flex flex-wrap items-center gap-2 border-t pt-3">
|
|
{endpoints.length > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
icon={deletingAll ? <FaSyncAlt className="animate-spin" /> : <FaTrash />}
|
|
disabled={busy}
|
|
onClick={() => setConfirmDeleteAll(true)}
|
|
className="text-red-600"
|
|
>
|
|
{translate('::App.DeveloperKitCrudEndpoints.DeleteAll')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="solid"
|
|
icon={generating ? <FaSyncAlt className="animate-spin" /> : <FaBolt />}
|
|
disabled={busy}
|
|
onClick={handleGenerate}
|
|
>
|
|
{endpoints.length > 0
|
|
? translate('::App.DeveloperKitCrudEndpoints.Regenerate')
|
|
: translate('::App.DeveloperKitCrudEndpoints.CreateCrudEndpoint')}
|
|
</Button>
|
|
<Button className="ml-auto" variant="solid" onClick={onClose}>
|
|
{translate('::App.Platform.Close')}
|
|
</Button>
|
|
</Dialog.Footer>
|
|
|
|
<ConfirmDialog
|
|
isOpen={confirmDeleteAll}
|
|
type="danger"
|
|
title={translate('::App.Platform.DeleteConfirmation')}
|
|
confirmText={translate('::App.DeveloperKitCrudEndpoints.DeleteAll')}
|
|
cancelText={translate('::App.Platform.Cancel')}
|
|
confirmButtonColor="red-600"
|
|
onCancel={() => setConfirmDeleteAll(false)}
|
|
onConfirm={() => {
|
|
setConfirmDeleteAll(false)
|
|
handleDeleteAll()
|
|
}}
|
|
>
|
|
<p>{translate('::App.Platform.DeleteConfirmationMessage')}</p>
|
|
</ConfirmDialog>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
export default CrudEndpointDialog
|