refactor(web): align education autocomplete async behavior (#40500)

This commit is contained in:
yyh
2026-08-11 14:12:50 +00:00
committed by GitHub
parent 324b0f97ff
commit bee269afe8
5 changed files with 205 additions and 67 deletions
+1 -2
View File
@@ -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(
+1 -2
View File
@@ -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()
+75 -47
View File
@@ -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>