mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 05:11:22 +08:00
refactor(web): modernize Studio app list data flow (#40109)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -8,8 +8,6 @@ const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockImportDSL = vi.hoisted(() => vi.fn())
|
||||
const mockImportDSLConfirm = vi.hoisted(() => vi.fn())
|
||||
const mockHandleCheckPluginDependencies = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateAppList = vi.hoisted(() => vi.fn())
|
||||
const mockSetNeedRefresh = vi.hoisted(() => vi.fn())
|
||||
const mockGetRedirection = vi.hoisted(() => vi.fn())
|
||||
const mockResolveImportedAppRedirectionTarget = vi.hoisted(() => vi.fn())
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
@@ -22,10 +20,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/apps/storage', () => ({
|
||||
useSetNeedRefreshAppList: () => mockSetNeedRefresh,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
@@ -52,14 +46,38 @@ vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
importDSL: (...args: unknown[]) => mockImportDSL(...args),
|
||||
importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args),
|
||||
}))
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useInvalidateAppList: () => mockInvalidateAppList,
|
||||
}))
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: {
|
||||
...actual.consoleQuery,
|
||||
systemFeatures: actual.consoleQuery.systemFeatures,
|
||||
apps: {
|
||||
...actual.consoleQuery.apps,
|
||||
imports: {
|
||||
...actual.consoleQuery.apps.imports,
|
||||
post: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: ({ body }: { body: Record<string, unknown> }) => mockImportDSL(body),
|
||||
}),
|
||||
},
|
||||
byImportId: {
|
||||
confirm: {
|
||||
post: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: ({ params }: { params: { import_id: string } }) =>
|
||||
mockImportDSLConfirm({ import_id: params.import_id }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
|
||||
@@ -77,6 +95,7 @@ describe('useImportDSL', () => {
|
||||
})
|
||||
|
||||
it('should complete a confirmed import that returns warnings', async () => {
|
||||
let resolvePluginCheck: (() => void) | undefined
|
||||
const pendingResponse = {
|
||||
id: 'import-1',
|
||||
status: DSLImportStatus.PENDING,
|
||||
@@ -105,6 +124,11 @@ describe('useImportDSL', () => {
|
||||
const onFailed = vi.fn()
|
||||
mockImportDSL.mockResolvedValue(pendingResponse)
|
||||
mockImportDSLConfirm.mockResolvedValue(completedResponse)
|
||||
mockHandleCheckPluginDependencies.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolvePluginCheck = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHookWithConsoleQuery(() => useImportDSL())
|
||||
|
||||
@@ -117,9 +141,24 @@ describe('useImportDSL', () => {
|
||||
{ onPending },
|
||||
)
|
||||
})
|
||||
let confirmPromise: Promise<void> | undefined
|
||||
act(() => {
|
||||
confirmPromise = result.current.handleImportDSLConfirm({ onSuccess, onFailed })
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
|
||||
})
|
||||
expect(result.current.isFetching).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleImportDSLConfirm({ onSuccess, onFailed })
|
||||
})
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolvePluginCheck?.()
|
||||
await act(async () => {
|
||||
await confirmPromise
|
||||
})
|
||||
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-1' })
|
||||
expect(onSuccess).toHaveBeenCalledWith(completedResponse)
|
||||
@@ -128,13 +167,12 @@ describe('useImportDSL', () => {
|
||||
description: 'app.newApp.appCreateDSLWarning',
|
||||
})
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
|
||||
expect(mockSetNeedRefresh).toHaveBeenCalledWith('1')
|
||||
expect(mockInvalidateAppList).toHaveBeenCalledTimes(1)
|
||||
expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
mode: AppModeEnum.AGENT,
|
||||
permission_keys: ['app.acl.view_layout'],
|
||||
})
|
||||
expect(mockGetRedirection).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.isFetching).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+27
-35
@@ -1,62 +1,57 @@
|
||||
import type { DSLImportMode, DSLImportResponse } from '@/models/app'
|
||||
import type { AppImportPayload, Import } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { importDSL, importDSLConfirm } from '@/service/apps'
|
||||
import { useInvalidateAppList } from '@/service/use-apps'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { resolveImportedAppRedirectionTarget } from '@/utils/imported-app-redirection'
|
||||
|
||||
type DSLPayload = {
|
||||
mode: DSLImportMode
|
||||
yaml_content?: string
|
||||
yaml_url?: string
|
||||
name?: string
|
||||
type DSLPayload = Omit<AppImportPayload, 'icon_type'> & {
|
||||
icon_type?: AppIconType
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
description?: string
|
||||
}
|
||||
type ResponseCallback = {
|
||||
onSuccess?: (payload: DSLImportResponse) => void
|
||||
onPending?: (payload: DSLImportResponse) => void
|
||||
onSuccess?: (payload: Import) => void
|
||||
onPending?: (payload: Import) => void
|
||||
onFailed?: () => void
|
||||
skipRedirectOnSuccess?: boolean
|
||||
}
|
||||
export const useImportDSL = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
const { push } = useRouter()
|
||||
const invalidateAppList = useInvalidateAppList()
|
||||
const { mutateAsync: importApp } = useMutation(consoleQuery.apps.imports.post.mutationOptions())
|
||||
const { mutateAsync: confirmImport } = useMutation(
|
||||
consoleQuery.apps.imports.byImportId.confirm.post.mutationOptions(),
|
||||
)
|
||||
const actionInFlightRef = useRef(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>()
|
||||
const importIdRef = useRef<string>('')
|
||||
const setNeedRefresh = useSetNeedRefreshAppList()
|
||||
|
||||
const handleImportDSL = useCallback(
|
||||
async (
|
||||
payload: DSLPayload,
|
||||
{ onSuccess, onPending, onFailed, skipRedirectOnSuccess }: ResponseCallback,
|
||||
) => {
|
||||
if (isFetching) return
|
||||
if (actionInFlightRef.current) return
|
||||
actionInFlightRef.current = true
|
||||
setIsFetching(true)
|
||||
|
||||
try {
|
||||
const response = await importDSL(payload)
|
||||
const response = await importApp({ body: payload })
|
||||
|
||||
if (!response) return
|
||||
|
||||
@@ -74,7 +69,7 @@ export const useImportDSL = () => {
|
||||
status === DSLImportStatus.COMPLETED ||
|
||||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
) {
|
||||
if (!app_id) return
|
||||
if (!app_id || !app_mode) throw new Error('Completed import is missing app metadata')
|
||||
|
||||
const message = t(
|
||||
($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'],
|
||||
@@ -88,8 +83,6 @@ export const useImportDSL = () => {
|
||||
if (status === DSLImportStatus.COMPLETED) toast.success(message)
|
||||
else toast.warning(message, { description })
|
||||
onSuccess?.(response)
|
||||
setNeedRefresh('1')
|
||||
invalidateAppList()
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
if (!skipRedirectOnSuccess) {
|
||||
const redirectionTarget = await resolveImportedAppRedirectionTarget({
|
||||
@@ -119,17 +112,16 @@ export const useImportDSL = () => {
|
||||
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
|
||||
onFailed?.()
|
||||
} finally {
|
||||
actionInFlightRef.current = false
|
||||
setIsFetching(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
isFetching,
|
||||
t,
|
||||
handleCheckPluginDependencies,
|
||||
isRbacEnabled,
|
||||
push,
|
||||
setNeedRefresh,
|
||||
invalidateAppList,
|
||||
importApp,
|
||||
currentUserId,
|
||||
workspacePermissionKeys,
|
||||
],
|
||||
@@ -141,13 +133,14 @@ export const useImportDSL = () => {
|
||||
onFailed,
|
||||
skipRedirectOnSuccess,
|
||||
}: Pick<ResponseCallback, 'onSuccess' | 'onFailed' | 'skipRedirectOnSuccess'>) => {
|
||||
if (isFetching) return
|
||||
setIsFetching(true)
|
||||
if (!importIdRef.current) return
|
||||
if (actionInFlightRef.current) return
|
||||
actionInFlightRef.current = true
|
||||
setIsFetching(true)
|
||||
|
||||
try {
|
||||
const response = await importDSLConfirm({
|
||||
import_id: importIdRef.current,
|
||||
const response = await confirmImport({
|
||||
params: { import_id: importIdRef.current },
|
||||
})
|
||||
|
||||
const { status, app_id, app_mode, permission_keys } = response
|
||||
@@ -157,6 +150,8 @@ export const useImportDSL = () => {
|
||||
status === DSLImportStatus.COMPLETED ||
|
||||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
) {
|
||||
if (!app_id || !app_mode) throw new Error('Completed import is missing app metadata')
|
||||
|
||||
onSuccess?.(response)
|
||||
const message = t(
|
||||
($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'],
|
||||
@@ -170,8 +165,6 @@ export const useImportDSL = () => {
|
||||
if (status === DSLImportStatus.COMPLETED) toast.success(message)
|
||||
else toast.warning(message, { description })
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
setNeedRefresh('1')
|
||||
invalidateAppList()
|
||||
if (!skipRedirectOnSuccess) {
|
||||
const redirectionTarget = await resolveImportedAppRedirectionTarget({
|
||||
id: app_id,
|
||||
@@ -193,17 +186,16 @@ export const useImportDSL = () => {
|
||||
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
|
||||
onFailed?.()
|
||||
} finally {
|
||||
actionInFlightRef.current = false
|
||||
setIsFetching(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
isFetching,
|
||||
t,
|
||||
handleCheckPluginDependencies,
|
||||
isRbacEnabled,
|
||||
setNeedRefresh,
|
||||
push,
|
||||
invalidateAppList,
|
||||
confirmImport,
|
||||
currentUserId,
|
||||
workspacePermissionKeys,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user