refactor(web): modernize Studio app list data flow (#40109)

This commit is contained in:
yyh
2026-08-07 02:23:38 +00:00
committed by GitHub
parent 5dfd83e9ab
commit 73197e4354
99 changed files with 4146 additions and 4908 deletions
+54 -16
View File
@@ -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
View File
@@ -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,
],