+
{sourceAppsLoading
?
: sourceApps.length === 0
@@ -208,20 +214,12 @@ function SourceAppList() {
onSelect={() => selectSourceApp(app)}
/>
))}
- {sourceAppsQuery.hasNextPage && (
-
-
+ {sourceAppsQuery.isFetchingNextPage && (
+
+ {t('createModal.loadingApps')}
)}
+ {sourceAppsQuery.hasNextPage &&
}
)}
diff --git a/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts b/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts
index bacafb08696..609b120b4f8 100644
--- a/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts
+++ b/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts
@@ -139,7 +139,7 @@ async function mountedStore() {
},
})
store.set(queryClientAtom, queryClient)
- const unsubscribe = store.sub(state.createReleaseFormValuesAtom, () => undefined)
+ const unsubscribe = store.sub(state.createReleaseFormIsSubmittingAtom, () => undefined)
return {
state,
diff --git a/web/features/deployments/create-release/state/__tests__/index.spec.ts b/web/features/deployments/create-release/state/__tests__/index.spec.ts
index dfb1bb12264..3ed7abae1dd 100644
--- a/web/features/deployments/create-release/state/__tests__/index.spec.ts
+++ b/web/features/deployments/create-release/state/__tests__/index.spec.ts
@@ -126,7 +126,7 @@ async function mountedStore() {
},
})
store.set(queryClientAtom, queryClient)
- const unsubscribe = store.sub(state.createReleaseFormValuesAtom, () => undefined)
+ const unsubscribe = store.sub(state.createReleaseFormIsSubmittingAtom, () => undefined)
return {
queryClient,
@@ -232,20 +232,6 @@ describe('create release state', () => {
}
})
- it('should keep default form values before editing', async () => {
- const { state, store, unsubscribe } = await mountedStore()
-
- expect(store.get(state.createReleaseFormValuesAtom)).toEqual({
- dslFile: undefined,
- releaseDescription: '',
- releaseName: '',
- releaseSourceMode: 'sourceApp',
- sourceApp: undefined,
- })
-
- unsubscribe()
- })
-
it('should validate release name only when submitting', async () => {
const { state, store, unsubscribe } = await mountedStore()
diff --git a/web/features/deployments/create-release/state/index.ts b/web/features/deployments/create-release/state/index.ts
index ce9daa7b7dc..c88c54cccf9 100644
--- a/web/features/deployments/create-release/state/index.ts
+++ b/web/features/deployments/create-release/state/index.ts
@@ -106,7 +106,6 @@ export const createReleaseFormAtom = atomWithForm({
const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom)
-export const createReleaseFormValuesAtom = createReleaseFormAtoms.valuesAtom
export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom
export const createReleaseSourceModeFieldAtom = createReleaseFormAtoms.fieldAtom('releaseSourceMode')
export const createReleaseSourceAppFieldAtom = createReleaseFormAtoms.fieldAtom('sourceApp')
diff --git a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx
index dbf553b8d1f..d77bfde6f90 100644
--- a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx
+++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx
@@ -1,8 +1,51 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { render, screen } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SourceAppPicker } from '../source-app-picker'
+const mocks = vi.hoisted(() => {
+ const sourceAppsQuery = {
+ data: {
+ pages: [{
+ data: [{
+ id: 'app-1',
+ name: 'Workflow App',
+ }],
+ }],
+ },
+ error: null,
+ fetchNextPage: vi.fn(),
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isLoading: false,
+ }
+
+ return {
+ sourceAppsQuery,
+ useInfiniteScroll: vi.fn(() => ({
+ rootEl: null,
+ rootRef: vi.fn(),
+ sentinelEl: null,
+ sentinelRef: vi.fn(),
+ })),
+ }
+})
+
+vi.mock('@/features/deployments/create-release/state', async () => {
+ const { atom } = await import('jotai')
+
+ return {
+ createReleaseSourceAppSearchTextAtom: atom(''),
+ createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
+ }
+})
+
+vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
+ useInfiniteScroll: mocks.useInfiniteScroll,
+}))
+
function renderSourceAppPicker(disabled: boolean) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -24,10 +67,59 @@ function renderSourceAppPicker(disabled: boolean) {
}
describe('SourceAppPicker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ Object.assign(mocks.sourceAppsQuery, {
+ data: {
+ pages: [{
+ data: [{
+ id: 'app-1',
+ name: 'Workflow App',
+ }],
+ }],
+ },
+ error: null,
+ fetchNextPage: vi.fn(),
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isLoading: false,
+ })
+ })
+
it('should disable the switch control when disabled', () => {
renderSourceAppPicker(true)
expect(screen.getByText('Workflow 1')).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).toBeDisabled()
})
+
+ it('should use infinite scroll to load more apps when the picker is open', async () => {
+ const user = userEvent.setup()
+
+ renderSourceAppPicker(false)
+
+ expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
+ mocks.sourceAppsQuery,
+ expect.objectContaining({
+ enabled: false,
+ rootMargin: '0px 0px 160px 0px',
+ threshold: 0.1,
+ }),
+ )
+
+ await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }))
+
+ await waitFor(() => {
+ expect(mocks.useInfiniteScroll).toHaveBeenLastCalledWith(
+ mocks.sourceAppsQuery,
+ expect.objectContaining({
+ enabled: true,
+ rootMargin: '0px 0px 160px 0px',
+ threshold: 0.1,
+ }),
+ )
+ })
+ expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument()
+ })
})
diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx
index a0b6dde2fc6..9dd2731af6f 100644
--- a/web/features/deployments/create-release/ui/dialog.tsx
+++ b/web/features/deployments/create-release/ui/dialog.tsx
@@ -43,9 +43,11 @@ function CreateReleaseCloseButton() {
export function CreateReleaseDialogContent() {
return (
-
-
-
+
+
+
+
+
)
}
@@ -75,7 +77,7 @@ function CreateReleaseDialogSurface() {
}
return (
-
+ <>
-
+ >
)
}
diff --git a/web/features/deployments/create-release/ui/source-app-picker.tsx b/web/features/deployments/create-release/ui/source-app-picker.tsx
index 633cea433fc..2f3c06fb0bd 100644
--- a/web/features/deployments/create-release/ui/source-app-picker.tsx
+++ b/web/features/deployments/create-release/ui/source-app-picker.tsx
@@ -1,7 +1,6 @@
'use client'
import type { SourceAppPickerValue } from '../state'
import type { App } from '@/types/app'
-import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
@@ -19,6 +18,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
+import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { TitleTooltip } from '../../components/title-tooltip'
import {
createReleaseSourceAppSearchTextAtom,
@@ -134,13 +134,18 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
const [isShow, setIsShow] = useState(false)
const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom)
const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom)
+ const sourceAppsQuery = useAtomValue(createReleaseSourceAppsQueryAtom)
const {
data,
isLoading,
isFetchingNextPage,
- fetchNextPage,
hasNextPage,
- } = useAtomValue(createReleaseSourceAppsQueryAtom)
+ } = sourceAppsQuery
+ const { rootRef, sentinelRef } = useInfiniteScroll
(sourceAppsQuery, {
+ enabled: isShow && !disabled,
+ rootMargin: '0px 0px 160px 0px',
+ threshold: 0.1,
+ })
const apps = data?.pages.flatMap(page => page.data) ?? []
@@ -202,7 +207,7 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
/>
-