diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index 5497786794..283c11acd8 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -1541,7 +1541,7 @@ describe('AppSelector', () => { it('should manage isLoadingMore state during load more', () => { mockHasNextPage = true - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + mockFetchNextPage.mockResolvedValue(undefined) renderWithQueryClient() @@ -1769,7 +1769,10 @@ describe('AppSelector', () => { it('should not call fetchNextPage when isLoadingMore is true', async () => { mockHasNextPage = true mockIsFetchingNextPage = false - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))) + let resolveFetchNextPage: (() => void) | undefined + mockFetchNextPage.mockImplementation(() => new Promise((resolve) => { + resolveFetchNextPage = resolve + })) renderWithQueryClient() @@ -1780,8 +1783,13 @@ describe('AppSelector', () => { // Trigger intersection - this starts loading triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) expect(mockFetchNextPage).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFetchNextPage?.() + }) }) it('should skip handleLoadMore when isFetchingNextPage is true', async () => { @@ -1825,8 +1833,10 @@ describe('AppSelector', () => { it('should return early from handleLoadMore when isLoadingMore is true', async () => { mockHasNextPage = true mockIsFetchingNextPage = false - // Make fetchNextPage slow to keep isLoadingMore true - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000))) + let resolveFetchNextPage: (() => void) | undefined + mockFetchNextPage.mockImplementation(() => new Promise((resolve) => { + resolveFetchNextPage = resolve + })) renderWithQueryClient() @@ -1843,6 +1853,10 @@ describe('AppSelector', () => { // Still only 1 call because isLoadingMore blocks it expect(mockFetchNextPage).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFetchNextPage?.() + }) }) it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index c32e959652..29cc4d1d40 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -54,6 +54,7 @@ const AppPicker: FC = ({ const observerTarget = useRef(null) const observerRef = useRef(null) const loadingRef = useRef(false) + const loadingResetTimeoutRef = useRef | null>(null) const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { const target = entries[0] @@ -63,8 +64,11 @@ const AppPicker: FC = ({ loadingRef.current = true onLoadMore() // Reset loading state - setTimeout(() => { + if (loadingResetTimeoutRef.current) + clearTimeout(loadingResetTimeoutRef.current) + loadingResetTimeoutRef.current = setTimeout(() => { loadingRef.current = false + loadingResetTimeoutRef.current = null }, 500) }, [hasMore, isLoading, onLoadMore]) @@ -116,6 +120,10 @@ const AppPicker: FC = ({ observerRef.current.disconnect() observerRef.current = null } + if (loadingResetTimeoutRef.current) { + clearTimeout(loadingResetTimeoutRef.current) + loadingResetTimeoutRef.current = null + } mutationObserver?.disconnect() } }, [isShow, handleIntersection]) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 5d0fa6d4b8..0d9a42668b 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -6,7 +6,7 @@ import type { import type { FC } from 'react' import type { App } from '@/types/app' import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { PortalToFollowElem, @@ -50,6 +50,7 @@ const AppSelector: FC = ({ const [isShow, onShowChange] = useState(false) const [searchText, setSearchText] = useState('') const [isLoadingMore, setIsLoadingMore] = useState(false) + const loadingMoreTimeoutRef = useRef | null>(null) const { data, @@ -106,12 +107,24 @@ const AppSelector: FC = ({ } finally { // Add a small delay to ensure state updates are complete - setTimeout(() => { + if (loadingMoreTimeoutRef.current) + clearTimeout(loadingMoreTimeoutRef.current) + loadingMoreTimeoutRef.current = setTimeout(() => { setIsLoadingMore(false) + loadingMoreTimeoutRef.current = null }, 300) } }, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage]) + useEffect(() => { + return () => { + if (loadingMoreTimeoutRef.current) { + clearTimeout(loadingMoreTimeoutRef.current) + loadingMoreTimeoutRef.current = null + } + } + }, []) + const handleTriggerClick = () => { if (disabled) return diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 36454d33e4..417de69c53 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import Conversion from '../conversion' @@ -347,6 +348,47 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/app-icon-picker', () => ({ + default: function MockAppIconPicker({ + onSelect, + onClose, + }: { + onSelect: (selection: { type: 'emoji' | 'image', icon?: string, background?: string, url?: string }) => void + onClose: () => void + }) { + const [pendingSelection, setPendingSelection] = React.useState<{ type: 'emoji' | 'image', icon?: string, background?: string, url?: string } | null>(null) + + return ( +
+ + + + +
+ ) + }, +})) + // Silence expected console.error from Dialog/Modal rendering beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -708,10 +750,7 @@ describe('PublishAsKnowledgePipelineModal', () => { const appIcon = getAppIcon() fireEvent.click(appIcon) - // Click the first emoji in the grid (search full document since Dialog uses portal) - const gridEmojis = document.querySelectorAll('.grid em-emoji') - expect(gridEmojis.length).toBeGreaterThan(0) - fireEvent.click(gridEmojis[0].parentElement!.parentElement!) + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.emojiOption/ })) // Click OK to confirm selection fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) @@ -728,9 +767,7 @@ describe('PublishAsKnowledgePipelineModal', () => { const appIcon = getAppIcon() fireEvent.click(appIcon) - // Switch to image tab - const imageTab = screen.getByRole('button', { name: /iconPicker\.image/ }) - fireEvent.click(imageTab) + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ })) // Picker should still be open expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument() @@ -1031,11 +1068,8 @@ describe('Integration Tests', () => { // Open picker and select an emoji const appIcon = getAppIcon() fireEvent.click(appIcon) - const gridEmojis = document.querySelectorAll('.grid em-emoji') - if (gridEmojis.length > 0) { - fireEvent.click(gridEmojis[0].parentElement!.parentElement!) - fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) - } + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.emojiOption/ })) + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))