mirror of
https://github.com/langgenius/dify.git
synced 2026-10-30 18:34:29 +08:00
refactor(web): align education autocomplete async behavior (#40500)
This commit is contained in:
@@ -72,11 +72,10 @@ const autocompleteListClassName = [
|
||||
]
|
||||
|
||||
const autocompleteItemClassName = [
|
||||
'mx-1 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
|
||||
'mx-1 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden',
|
||||
'hover:bg-state-base-hover-alt hover:text-text-primary',
|
||||
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary',
|
||||
'motion-reduce:transition-none',
|
||||
]
|
||||
|
||||
const autocompleteInputGroupVariants = cva(
|
||||
|
||||
@@ -79,12 +79,11 @@ const comboboxListClassName = [
|
||||
]
|
||||
|
||||
const comboboxItemClassName = [
|
||||
'grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden transition-colors',
|
||||
'grid min-h-8 cursor-pointer select-none grid-cols-[1fr_auto] items-center gap-2 rounded-lg px-2 py-1.5 text-text-secondary outline-hidden',
|
||||
'hover:bg-state-base-hover-alt hover:text-text-primary',
|
||||
'data-highlighted:bg-state-base-hover data-highlighted:text-text-primary',
|
||||
'data-selected:text-text-primary',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-30 data-disabled:hover:bg-transparent data-disabled:hover:text-text-secondary',
|
||||
'motion-reduce:transition-none',
|
||||
]
|
||||
|
||||
const comboboxTriggerVariants = cva(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import InstitutionField from '../institution-field'
|
||||
@@ -7,9 +7,8 @@ const educationAutocompleteQueryMock = vi.hoisted(() => ({
|
||||
options: vi.fn(),
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
@@ -21,7 +20,15 @@ const educationAutocompleteQueryMock = vi.hoisted(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const debouncedValueMock = vi.hoisted(() => ({
|
||||
hold: false,
|
||||
value: '',
|
||||
}))
|
||||
|
||||
const keepPreviousDataMock = vi.hoisted(() => vi.fn((previousData: unknown) => previousData))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: keepPreviousDataMock,
|
||||
useInfiniteQuery: (options: unknown) => {
|
||||
educationAutocompleteQueryMock.options(options)
|
||||
return educationAutocompleteQueryMock
|
||||
@@ -43,7 +50,8 @@ vi.mock('@/service/client', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('foxact/use-debounced-value', () => ({
|
||||
useDebouncedValue: <T,>(value: T) => value,
|
||||
useDebouncedValue: <T,>(value: T) =>
|
||||
debouncedValueMock.hold ? (debouncedValueMock.value as T) : value,
|
||||
}))
|
||||
|
||||
const ControlledInstitutionField = () => {
|
||||
@@ -51,14 +59,48 @@ const ControlledInstitutionField = () => {
|
||||
return <InstitutionField value={value} onValueChange={setValue} />
|
||||
}
|
||||
|
||||
function stubIntersectionObserver() {
|
||||
let callback: IntersectionObserverCallback | undefined
|
||||
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class MockIntersectionObserver {
|
||||
constructor(nextCallback: IntersectionObserverCallback) {
|
||||
callback = nextCallback
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
|
||||
return async () => {
|
||||
await waitFor(() => {
|
||||
expect(callback).toBeDefined()
|
||||
})
|
||||
act(() => {
|
||||
callback?.(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('InstitutionField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
educationAutocompleteQueryMock.hasNextPage = false
|
||||
educationAutocompleteQueryMock.isFetchingNextPage = false
|
||||
educationAutocompleteQueryMock.isPending = false
|
||||
educationAutocompleteQueryMock.isSuccess = true
|
||||
educationAutocompleteQueryMock.isError = false
|
||||
educationAutocompleteQueryMock.isFetching = false
|
||||
educationAutocompleteQueryMock.data.pages[0]!.data = ['Alpha University', 'Beta College']
|
||||
debouncedValueMock.hold = false
|
||||
debouncedValueMock.value = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses a free-form institution name as the suggestions query', async () => {
|
||||
@@ -115,8 +157,42 @@ describe('InstitutionField', () => {
|
||||
expect(input).toHaveValue('Dify Academy')
|
||||
})
|
||||
|
||||
it('requests the next page when the suggestions reach the scroll boundary', async () => {
|
||||
it('keeps previous suggestions open while a new search settles', async () => {
|
||||
const user = userEvent.setup()
|
||||
const view = render(<ControlledInstitutionField />)
|
||||
const input = screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/)
|
||||
|
||||
await user.type(input, 'A')
|
||||
expect(await screen.findByText('Alpha University')).toBeInTheDocument()
|
||||
|
||||
debouncedValueMock.hold = true
|
||||
debouncedValueMock.value = 'A'
|
||||
await user.type(input, 'l')
|
||||
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Alpha University')).toBeInTheDocument()
|
||||
|
||||
debouncedValueMock.hold = false
|
||||
educationAutocompleteQueryMock.isFetching = true
|
||||
view.rerender(<ControlledInstitutionField />)
|
||||
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Alpha University')).toBeInTheDocument()
|
||||
expect(educationAutocompleteQueryMock.options.mock.lastCall?.[0]).toMatchObject({
|
||||
placeholderData: keepPreviousDataMock,
|
||||
})
|
||||
|
||||
educationAutocompleteQueryMock.data.pages[0]!.data = ['Alpine University']
|
||||
educationAutocompleteQueryMock.isFetching = false
|
||||
view.rerender(<ControlledInstitutionField />)
|
||||
|
||||
expect(screen.getByText('Alpine University')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha University')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requests the next page when the suggestions sentinel enters the preload area', async () => {
|
||||
const user = userEvent.setup()
|
||||
const triggerIntersection = stubIntersectionObserver()
|
||||
educationAutocompleteQueryMock.hasNextPage = true
|
||||
|
||||
render(<ControlledInstitutionField />)
|
||||
@@ -126,15 +202,50 @@ describe('InstitutionField', () => {
|
||||
'A',
|
||||
)
|
||||
|
||||
const scrollContainer = await screen.findByRole('listbox')
|
||||
Object.defineProperties(scrollContainer, {
|
||||
scrollTop: { value: 60, configurable: true },
|
||||
scrollHeight: { value: 100, configurable: true },
|
||||
clientHeight: { value: 40, configurable: true },
|
||||
await screen.findByRole('listbox')
|
||||
await triggerIntersection()
|
||||
|
||||
expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledOnce()
|
||||
expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledWith({
|
||||
cancelRefetch: false,
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
it('keeps keyboard navigation at the loaded list boundary', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationAutocompleteQueryMock.hasNextPage = true
|
||||
|
||||
expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
render(<ControlledInstitutionField />)
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/),
|
||||
'A',
|
||||
)
|
||||
await screen.findByText('Beta College')
|
||||
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}')
|
||||
|
||||
expect(screen.getByText('Beta College').closest('[role="option"]')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps loaded suggestions visible when the next page fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationAutocompleteQueryMock.hasNextPage = true
|
||||
educationAutocompleteQueryMock.isError = true
|
||||
|
||||
render(<ControlledInstitutionField />)
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/),
|
||||
'A',
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Alpha University')).toBeInTheDocument()
|
||||
expect(
|
||||
screen
|
||||
.getAllByText('common.dynamicSelect.error')
|
||||
.some((element) => element.closest('[role="status"]') !== null),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,6 +239,7 @@ describe('EducationApplyPage billing boundary', () => {
|
||||
screen.getByRole('combobox', { name: 'education.form.schoolName.title' }),
|
||||
'DifyUniversity',
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: 'education.form.schoolRole.title' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AutocompleteChangeEventDetails } from '@langgenius/dify-ui/autocomplete'
|
||||
import type { UIEventHandler } from 'react'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteCollection,
|
||||
AutocompleteContent,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteInput,
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
AutocompleteStatus,
|
||||
} from '@langgenius/dify-ui/autocomplete'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useDebouncedValue } from 'foxact/use-debounced-value'
|
||||
import { useId, useState } from 'react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
@@ -29,31 +30,39 @@ type InstitutionFieldProps = {
|
||||
const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => {
|
||||
const { t } = useTranslation()
|
||||
const inputId = useId()
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300)
|
||||
const isSearchReady = !!searchQuery && searchQuery === debouncedSearchQuery
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isSuccess } =
|
||||
useInfiniteQuery({
|
||||
...consoleQuery.account.education.autocomplete.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
keywords: debouncedSearchQuery,
|
||||
limit: EDUCATION_AUTOCOMPLETE_PAGE_SIZE,
|
||||
page: Number(pageParam),
|
||||
},
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.has_next ? (lastPage.curr_page ?? pages.length - 1) + 1 : undefined,
|
||||
const hasSearchQuery = !!searchQuery
|
||||
const isDebouncing = hasSearchQuery && searchQuery !== debouncedSearchQuery
|
||||
const { data, fetchNextPage, hasNextPage, isError, isFetching } = useInfiniteQuery({
|
||||
...consoleQuery.account.education.autocomplete.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
keywords: debouncedSearchQuery,
|
||||
limit: EDUCATION_AUTOCOMPLETE_PAGE_SIZE,
|
||||
page: Number(pageParam),
|
||||
},
|
||||
}),
|
||||
enabled: isSearchReady,
|
||||
})
|
||||
const suggestions = isSearchReady ? (data?.pages.flatMap((page) => page.data ?? []) ?? []) : []
|
||||
const isLoading = isPending || isFetchingNextPage
|
||||
const shouldOpenPopup = isPopupOpen && isSearchReady && (isLoading || isSuccess)
|
||||
const shouldShowEmpty = shouldOpenPopup && isSuccess && !isLoading && suggestions.length === 0
|
||||
const shouldShowLoading = shouldOpenPopup && isLoading
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.has_next ? (lastPage.curr_page ?? pages.length - 1) + 1 : undefined,
|
||||
}),
|
||||
enabled: hasSearchQuery && !!debouncedSearchQuery,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const suggestions = hasSearchQuery ? (data?.pages.flatMap((page) => page.data ?? []) ?? []) : []
|
||||
const isLoading = isDebouncing || isFetching
|
||||
const shouldOpenPopup = isPopupOpen && hasSearchQuery
|
||||
let footerState: 'error' | 'loading' | null = null
|
||||
if (shouldOpenPopup) {
|
||||
if (isLoading) footerState = 'loading'
|
||||
else if (isError) footerState = 'error'
|
||||
else if (hasNextPage) footerState = 'loading'
|
||||
}
|
||||
const shouldShowEmpty = shouldOpenPopup && footerState === null && suggestions.length === 0
|
||||
const canLoadMore = shouldOpenPopup && hasNextPage && !isLoading && !isError
|
||||
|
||||
const handleValueChange = (inputValue: string, eventDetails: AutocompleteChangeEventDetails) => {
|
||||
onValueChange(inputValue)
|
||||
@@ -67,14 +76,6 @@ const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => {
|
||||
setIsPopupOpen(!!inputValue)
|
||||
}
|
||||
|
||||
const handleScroll: UIEventHandler<HTMLDivElement> = (event) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0
|
||||
if (!isAtBottom || !hasNextPage || isFetchingNextPage) return
|
||||
|
||||
void fetchNextPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<Field name="institution" className="mb-7">
|
||||
<FieldLabel
|
||||
@@ -89,6 +90,7 @@ const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => {
|
||||
onValueChange={handleValueChange}
|
||||
filter={null}
|
||||
mode="list"
|
||||
loopFocus={false}
|
||||
open={shouldOpenPopup}
|
||||
onOpenChange={setIsPopupOpen}
|
||||
>
|
||||
@@ -102,27 +104,53 @@ const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => {
|
||||
<AutocompleteContent
|
||||
popupClassName="w-(--anchor-width) max-w-(--available-width)"
|
||||
portalProps={{ keepMounted: true }}
|
||||
popupProps={{ 'aria-busy': isLoading || undefined }}
|
||||
popupProps={{ 'aria-busy': (shouldOpenPopup && isLoading) || undefined }}
|
||||
>
|
||||
<AutocompleteList<string> onScroll={handleScroll}>
|
||||
{(institution) => (
|
||||
<AutocompleteItem key={institution} value={institution} title={institution}>
|
||||
<AutocompleteItemText>{institution}</AutocompleteItemText>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
<AutocompleteList ref={listRef}>
|
||||
<AutocompleteCollection<string>>
|
||||
{(institution) => (
|
||||
<AutocompleteItem key={institution} value={institution} title={institution}>
|
||||
<AutocompleteItemText>{institution}</AutocompleteItemText>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
{footerState !== null ? (
|
||||
<div
|
||||
className="relative flex h-10 items-center justify-center px-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{hasNextPage ? (
|
||||
<InfiniteScrollSentinel
|
||||
className="absolute inset-x-0 top-0"
|
||||
canLoadMore={canLoadMore}
|
||||
onLoadMore={() => {
|
||||
void fetchNextPage({ cancelRefetch: false })
|
||||
}}
|
||||
preloadDistance={5}
|
||||
scrollContainerRef={listRef}
|
||||
/>
|
||||
) : null}
|
||||
{footerState === 'error' ? (
|
||||
<span className="system-sm-regular text-text-destructive">
|
||||
{t(($) => $['dynamicSelect.error'], { ns: 'common' })}
|
||||
</span>
|
||||
) : (
|
||||
<Loading className="h-10" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>
|
||||
{shouldShowEmpty ? t(($) => $['form.schoolName.noResults'], { ns: 'education' }) : null}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteStatus className="p-0">
|
||||
{shouldShowLoading ? (
|
||||
<>
|
||||
<span className="sr-only">{t(($) => $.loading, { ns: 'appApi' })}</span>
|
||||
<div aria-hidden="true">
|
||||
<Loading className="h-10" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<span className="sr-only">
|
||||
{shouldOpenPopup && isLoading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: footerState === 'error'
|
||||
? t(($) => $['dynamicSelect.error'], { ns: 'common' })
|
||||
: null}
|
||||
</span>
|
||||
</AutocompleteStatus>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
|
||||
Reference in New Issue
Block a user