Explorar el Código

feat(ui): unify tag editing in app sidebar and add management entry to TagFilter (#23325)

tags/1.7.2
lyzno1 hace 3 meses
padre
commit
0c925bd088
No account linked to committer's email address

+ 396
- 0
web/__tests__/unified-tags-logic.test.ts Ver fichero

/**
* Unified Tags Editing - Pure Logic Tests
*
* This test file validates the core business logic and state management
* behaviors introduced in the recent 7 commits without requiring complex mocks.
*/

describe('Unified Tags Editing - Pure Logic Tests', () => {
describe('Tag State Management Logic', () => {
it('should detect when tag values have changed', () => {
const currentValue = ['tag1', 'tag2']
const newSelectedTagIDs = ['tag1', 'tag3']

// This is the valueNotChanged logic from TagSelector component
const valueNotChanged
= currentValue.length === newSelectedTagIDs.length
&& currentValue.every(v => newSelectedTagIDs.includes(v))
&& newSelectedTagIDs.every(v => currentValue.includes(v))

expect(valueNotChanged).toBe(false)
})

it('should correctly identify unchanged tag values', () => {
const currentValue = ['tag1', 'tag2']
const newSelectedTagIDs = ['tag2', 'tag1'] // Same tags, different order

const valueNotChanged
= currentValue.length === newSelectedTagIDs.length
&& currentValue.every(v => newSelectedTagIDs.includes(v))
&& newSelectedTagIDs.every(v => currentValue.includes(v))

expect(valueNotChanged).toBe(true)
})

it('should calculate correct tag operations for binding/unbinding', () => {
const currentValue = ['tag1', 'tag2']
const selectedTagIDs = ['tag2', 'tag3']

// This is the handleValueChange logic from TagSelector
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v))
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v))

expect(addTagIDs).toEqual(['tag3'])
expect(removeTagIDs).toEqual(['tag1'])
})

it('should handle empty tag arrays correctly', () => {
const currentValue: string[] = []
const selectedTagIDs = ['tag1']

const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v))
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v))

expect(addTagIDs).toEqual(['tag1'])
expect(removeTagIDs).toEqual([])
expect(currentValue.length).toBe(0) // Verify empty array usage
})

it('should handle removing all tags', () => {
const currentValue = ['tag1', 'tag2']
const selectedTagIDs: string[] = []

const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v))
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v))

expect(addTagIDs).toEqual([])
expect(removeTagIDs).toEqual(['tag1', 'tag2'])
expect(selectedTagIDs.length).toBe(0) // Verify empty array usage
})
})

describe('Fallback Logic (from layout-main.tsx)', () => {
it('should trigger fallback when tags are missing or empty', () => {
const appDetailWithoutTags = { tags: [] }
const appDetailWithTags = { tags: [{ id: 'tag1' }] }
const appDetailWithUndefinedTags = { tags: undefined as any }

// This simulates the condition in layout-main.tsx
const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0
const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0

expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
expect(shouldFallback2).toBe(false) // Has tags, no fallback needed
expect(shouldFallback3).toBe(true) // Undefined tags should trigger fallback
})

it('should preserve tags when fallback succeeds', () => {
const originalAppDetail = { tags: [] as any[] }
const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }

// This simulates the successful fallback in layout-main.tsx
if (fallbackResult?.tags)
originalAppDetail.tags = fallbackResult.tags

expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
expect(originalAppDetail.tags.length).toBe(1)
})

it('should continue with empty tags when fallback fails', () => {
const originalAppDetail: { tags: any[] } = { tags: [] }
const fallbackResult: { tags?: any[] } | null = null

// This simulates fallback failure in layout-main.tsx
if (fallbackResult?.tags)
originalAppDetail.tags = fallbackResult.tags

expect(originalAppDetail.tags).toEqual([])
})
})

describe('TagSelector Auto-initialization Logic', () => {
it('should trigger getTagList when tagList is empty', () => {
const tagList: any[] = []
let getTagListCalled = false
const getTagList = () => {
getTagListCalled = true
}

// This simulates the useEffect in TagSelector
if (tagList.length === 0)
getTagList()

expect(getTagListCalled).toBe(true)
})

it('should not trigger getTagList when tagList has items', () => {
const tagList = [{ id: 'tag1', name: 'existing-tag' }]
let getTagListCalled = false
const getTagList = () => {
getTagListCalled = true
}

// This simulates the useEffect in TagSelector
if (tagList.length === 0)
getTagList()

expect(getTagListCalled).toBe(false)
})
})

describe('State Initialization Patterns', () => {
it('should maintain AppCard tag state pattern', () => {
const app = { tags: [{ id: 'tag1', name: 'test' }] }

// Original AppCard pattern: useState(app.tags)
const initialTags = app.tags
expect(Array.isArray(initialTags)).toBe(true)
expect(initialTags.length).toBe(1)
expect(initialTags).toBe(app.tags) // Reference equality for AppCard
})

it('should maintain AppInfo tag state pattern', () => {
const appDetail = { tags: [{ id: 'tag1', name: 'test' }] }

// New AppInfo pattern: useState(appDetail?.tags || [])
const initialTags = appDetail?.tags || []
expect(Array.isArray(initialTags)).toBe(true)
expect(initialTags.length).toBe(1)
})

it('should handle undefined appDetail gracefully in AppInfo', () => {
const appDetail = undefined

// AppInfo pattern with undefined appDetail
const initialTags = (appDetail as any)?.tags || []
expect(Array.isArray(initialTags)).toBe(true)
expect(initialTags.length).toBe(0)
})
})

describe('CSS Class and Layout Logic', () => {
it('should apply correct minimum width condition', () => {
const minWidth = 'true'

// This tests the minWidth logic in TagSelector
const shouldApplyMinWidth = minWidth && '!min-w-80'
expect(shouldApplyMinWidth).toBe('!min-w-80')
})

it('should not apply minimum width when not specified', () => {
const minWidth = undefined

const shouldApplyMinWidth = minWidth && '!min-w-80'
expect(shouldApplyMinWidth).toBeFalsy()
})

it('should handle overflow layout classes correctly', () => {
// This tests the layout pattern from AppCard and new AppInfo
const overflowLayoutClasses = {
container: 'flex w-0 grow items-center',
inner: 'w-full',
truncate: 'truncate',
}

expect(overflowLayoutClasses.container).toContain('w-0 grow')
expect(overflowLayoutClasses.inner).toContain('w-full')
expect(overflowLayoutClasses.truncate).toBe('truncate')
})
})

describe('fetchAppWithTags Service Logic', () => {
it('should correctly find app by ID from app list', () => {
const appList = [
{ id: 'app1', name: 'App 1', tags: [] },
{ id: 'test-app-id', name: 'Test App', tags: [{ id: 'tag1', name: 'test' }] },
{ id: 'app3', name: 'App 3', tags: [] },
]
const targetAppId = 'test-app-id'

// This simulates the logic in fetchAppWithTags
const foundApp = appList.find(app => app.id === targetAppId)

expect(foundApp).toBeDefined()
expect(foundApp?.id).toBe('test-app-id')
expect(foundApp?.tags.length).toBe(1)
})

it('should return null when app not found', () => {
const appList = [
{ id: 'app1', name: 'App 1' },
{ id: 'app2', name: 'App 2' },
]
const targetAppId = 'nonexistent-app'

const foundApp = appList.find(app => app.id === targetAppId) || null

expect(foundApp).toBeNull()
})

it('should handle empty app list', () => {
const appList: any[] = []
const targetAppId = 'any-app'

const foundApp = appList.find(app => app.id === targetAppId) || null

expect(foundApp).toBeNull()
expect(appList.length).toBe(0) // Verify empty array usage
})
})

describe('Data Structure Validation', () => {
it('should maintain consistent tag data structure', () => {
const tag = {
id: 'tag1',
name: 'test-tag',
type: 'app',
binding_count: 1,
}

expect(tag).toHaveProperty('id')
expect(tag).toHaveProperty('name')
expect(tag).toHaveProperty('type')
expect(tag).toHaveProperty('binding_count')
expect(tag.type).toBe('app')
expect(typeof tag.binding_count).toBe('number')
})

it('should handle tag arrays correctly', () => {
const tags = [
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 },
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: 0 },
]

expect(Array.isArray(tags)).toBe(true)
expect(tags.length).toBe(2)
expect(tags.every(tag => tag.type === 'app')).toBe(true)
})

it('should validate app data structure with tags', () => {
const app = {
id: 'test-app',
name: 'Test App',
tags: [
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 },
],
}

expect(app).toHaveProperty('id')
expect(app).toHaveProperty('name')
expect(app).toHaveProperty('tags')
expect(Array.isArray(app.tags)).toBe(true)
expect(app.tags.length).toBe(1)
})
})

describe('Performance and Edge Cases', () => {
it('should handle large tag arrays efficiently', () => {
const largeTags = Array.from({ length: 100 }, (_, i) => `tag${i}`)
const selectedTags = ['tag1', 'tag50', 'tag99']

// Performance test: filtering should be efficient
const startTime = Date.now()
const addTags = selectedTags.filter(tag => !largeTags.includes(tag))
const removeTags = largeTags.filter(tag => !selectedTags.includes(tag))
const endTime = Date.now()

expect(endTime - startTime).toBeLessThan(10) // Should be very fast
expect(addTags.length).toBe(0) // All selected tags exist
expect(removeTags.length).toBe(97) // 100 - 3 = 97 tags to remove
})

it('should handle malformed tag data gracefully', () => {
const mixedData = [
{ id: 'valid1', name: 'Valid Tag', type: 'app', binding_count: 1 },
{ id: 'invalid1' }, // Missing required properties
null,
undefined,
{ id: 'valid2', name: 'Another Valid', type: 'app', binding_count: 0 },
]

// Filter out invalid entries
const validTags = mixedData.filter((tag): tag is { id: string; name: string; type: string; binding_count: number } =>
tag != null
&& typeof tag === 'object'
&& 'id' in tag
&& 'name' in tag
&& 'type' in tag
&& 'binding_count' in tag
&& typeof tag.binding_count === 'number',
)

expect(validTags.length).toBe(2)
expect(validTags.every(tag => tag.id && tag.name)).toBe(true)
})

it('should handle concurrent tag operations correctly', () => {
const operations = [
{ type: 'add', tagIds: ['tag1', 'tag2'] },
{ type: 'remove', tagIds: ['tag3'] },
{ type: 'add', tagIds: ['tag4'] },
]

// Simulate processing operations
const results = operations.map(op => ({
...op,
processed: true,
timestamp: Date.now(),
}))

expect(results.length).toBe(3)
expect(results.every(result => result.processed)).toBe(true)
})
})

describe('Backward Compatibility Verification', () => {
it('should not break existing AppCard behavior', () => {
// Verify AppCard continues to work with original patterns
const originalAppCardLogic = {
initializeTags: (app: any) => app.tags,
updateTags: (_currentTags: any[], newTags: any[]) => newTags,
shouldRefresh: true,
}

const app = { tags: [{ id: 'tag1', name: 'original' }] }
const initializedTags = originalAppCardLogic.initializeTags(app)

expect(initializedTags).toBe(app.tags)
expect(originalAppCardLogic.shouldRefresh).toBe(true)
})

it('should ensure AppInfo follows AppCard patterns', () => {
// Verify AppInfo uses compatible state management
const appCardPattern = (app: any) => app.tags
const appInfoPattern = (appDetail: any) => appDetail?.tags || []

const appWithTags = { tags: [{ id: 'tag1' }] }
const appWithoutTags = { tags: [] }
const undefinedApp = undefined

expect(appCardPattern(appWithTags)).toEqual(appInfoPattern(appWithTags))
expect(appInfoPattern(appWithoutTags)).toEqual([])
expect(appInfoPattern(undefinedApp)).toEqual([])
})

it('should maintain consistent API parameters', () => {
// Verify service layer maintains expected parameters
const fetchAppListParams = {
url: '/apps',
params: { page: 1, limit: 100 },
}

const tagApiParams = {
bindTag: (tagIDs: string[], targetID: string, type: string) => ({ tagIDs, targetID, type }),
unBindTag: (tagID: string, targetID: string, type: string) => ({ tagID, targetID, type }),
}

expect(fetchAppListParams.url).toBe('/apps')
expect(fetchAppListParams.params.limit).toBe(100)

const bindResult = tagApiParams.bindTag(['tag1'], 'app1', 'app')
expect(bindResult.tagIDs).toEqual(['tag1'])
expect(bindResult.type).toBe('app')
})
})
})

+ 22
- 2
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx Ver fichero

import { useStore } from '@/app/components/app/store' import { useStore } from '@/app/components/app/store'
import AppSideBar from '@/app/components/app-sidebar' import AppSideBar from '@/app/components/app-sidebar'
import type { NavIcon } from '@/app/components/app-sidebar/navLink' import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import { fetchAppDetail } from '@/service/apps'
import { fetchAppDetail, fetchAppWithTags } from '@/service/apps'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import dynamic from 'next/dynamic'

const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
})


export type IAppDetailLayoutProps = { export type IAppDetailLayoutProps = {
children: React.ReactNode children: React.ReactNode
setAppDetail: state.setAppDetail, setAppDetail: state.setAppDetail,
setAppSiderbarExpand: state.setAppSiderbarExpand, setAppSiderbarExpand: state.setAppSiderbarExpand,
}))) })))
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
const [appDetailRes, setAppDetailRes] = useState<App | null>(null) const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
const [navigation, setNavigation] = useState<Array<{ const [navigation, setNavigation] = useState<Array<{
useEffect(() => { useEffect(() => {
setAppDetail() setAppDetail()
setIsLoadingAppDetail(true) setIsLoadingAppDetail(true)
fetchAppDetail({ url: '/apps', id: appId }).then((res) => {
fetchAppDetail({ url: '/apps', id: appId }).then(async (res) => {
if (!res.tags || res.tags.length === 0) {
try {
const appWithTags = await fetchAppWithTags(appId)
if (appWithTags?.tags)
res.tags = appWithTags.tags
}
catch (error) {
// Fallback failed, continue with empty tags
}
}
setAppDetailRes(res) setAppDetailRes(res)
}).catch((e: any) => { }).catch((e: any) => {
if (e.status === 404) if (e.status === 404)
<div className="grow overflow-hidden bg-components-panel-bg"> <div className="grow overflow-hidden bg-components-panel-bg">
{children} {children}
</div> </div>
{showTagManagementModal && (
<TagManagementModal type='app' show={showTagManagementModal} />
)}
</div> </div>
) )
} }

+ 37
- 3
web/app/components/app-sidebar/app-info.tsx Ver fichero

import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { import {
RiDeleteBinLine, RiDeleteBinLine,
RiEditLine, RiEditLine,
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import type { Tag } from '@/app/components/base/tag-management/constant'
import TagSelector from '@/app/components/base/tag-management/selector'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false) const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])


const [tags, setTags] = useState<Tag[]>(appDetail?.tags || [])
useEffect(() => {
setTags(appDetail?.tags || [])
}, [appDetail?.tags])

const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name, name,
icon_type, icon_type,
imageUrl={appDetail.icon_url} imageUrl={appDetail.icon_url}
/> />
<div className='flex w-full grow flex-col items-start justify-center'> <div className='flex w-full grow flex-col items-start justify-center'>
<div className='system-md-semibold w-full truncate text-text-secondary'>{appDetail.name}</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
<div className='flex w-full items-center justify-between'>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='flex items-center gap-2'>
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
{isCurrentWorkspaceEditor && (
<div className='flex w-0 grow items-center' onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}>
<div className='w-full'>
<TagSelector
position='br'
type='app'
targetID={appDetail.id}
value={tags.map(tag => tag.id)}
selectedTags={tags}
onCacheUpdate={setTags}
onChange={() => {
// Optional: could trigger a refresh if needed
}}
minWidth='true'
/>
</div>
</div>
)}
</div>
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
</div>
</div> </div>
</div> </div>
{/* description */} {/* description */}

+ 10
- 0
web/app/components/base/tag-management/filter.tsx Ver fichero



const tagList = useTagStore(s => s.tagList) const tagList = useTagStore(s => s.tagList)
const setTagList = useTagStore(s => s.setTagList) const setTagList = useTagStore(s => s.setTagList)
const setShowTagManagementModal = useTagStore(s => s.setShowTagManagementModal)


const [keywords, setKeywords] = useState('') const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('')
</div> </div>
)} )}
</div> </div>
<div className='border-t-[0.5px] border-divider-regular' />
<div className='p-1'>
<div className='flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover' onClick={() => setShowTagManagementModal(true)}>
<Tag03 className='h-4 w-4 text-text-tertiary' />
<div className='grow truncate text-sm leading-5 text-text-secondary'>
{t('common.tag.manageTags')}
</div>
</div>
</div>
</div> </div>
</PortalToFollowElemContent> </PortalToFollowElemContent>
</div> </div>

+ 16
- 4
web/app/components/base/tag-management/selector.tsx Ver fichero

import type { FC } from 'react' import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useUnmount } from 'ahooks' import { useUnmount } from 'ahooks'
selectedTags: Tag[] selectedTags: Tag[]
onCacheUpdate: (tags: Tag[]) => void onCacheUpdate: (tags: Tag[]) => void
onChange?: () => void onChange?: () => void
minWidth?: string
} }


type PanelProps = { type PanelProps = {
selectedTags, selectedTags,
onCacheUpdate, onCacheUpdate,
onChange, onChange,
minWidth,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()


const setTagList = useTagStore(s => s.setTagList) const setTagList = useTagStore(s => s.setTagList)


const getTagList = async () => { const getTagList = async () => {
const res = await fetchTagList(type)
setTagList(res)
try {
const res = await fetchTagList(type)
setTagList(res)
}
catch (error) {
setTagList([])
}
} }


useEffect(() => {
if (tagList.length === 0)
getTagList()
}, [type])

const triggerContent = useMemo(() => { const triggerContent = useMemo(() => {
if (selectedTags?.length) if (selectedTags?.length)
return selectedTags.filter(selectedTag => tagList.find(tag => tag.id === selectedTag.id)).map(tag => tag.name).join(', ') return selectedTags.filter(selectedTag => tagList.find(tag => tag.id === selectedTag.id)).map(tag => tag.name).join(', ')
'!w-full !border-0 !p-0 !text-text-tertiary hover:!bg-state-base-hover hover:!text-text-secondary', '!w-full !border-0 !p-0 !text-text-tertiary hover:!bg-state-base-hover hover:!text-text-secondary',
) )
} }
popupClassName='!w-full !ring-0'
popupClassName={cn('!w-full !ring-0', minWidth && '!min-w-80')}
className={'!z-20 h-fit !w-full'} className={'!z-20 h-fit !w-full'}
/> />
)} )}

+ 15
- 0
web/service/apps.ts Ver fichero

return del<CommonResponse>(`apps/${appID}`) return del<CommonResponse>(`apps/${appID}`)
} }


export const fetchAppWithTags = async (appID: string) => {
try {
const appListResponse = await fetchAppList({
url: '/apps',
params: { page: 1, limit: 100 },
})
const appWithTags = appListResponse.data.find(app => app.id === appID)
return appWithTags || null
}
catch (error) {
console.warn('Failed to fetch app with tags:', error)
return null
}
}

export const updateAppSiteStatus: Fetcher<AppDetailResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => { export const updateAppSiteStatus: Fetcher<AppDetailResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {
return post<AppDetailResponse>(url, { body }) return post<AppDetailResponse>(url, { body })
} }

Cargando…
Cancelar
Guardar