Files
overleaf-cep/services/web/test/frontend/components/shared/ol-autocomplete.spec.tsx
T
l-obrien-overleaf 1be454b95c Initial working version of auto complete button component (#31823)
* Initial working version of auto complete button component

* generalising button auto complete for use in bibtex entry form

* Adding optional fuzzy searching

* Restoring delete optional field

* Removing dropdown specific styling

* Updating item/group interface for autocomplete

* Auto complete allowing full keyboard nav functionality

* Custom class for dropdown-upward

* Adding error validation for duplicate name with standard field

* fixing type errors

* Replacing Fuse with MiniSearch

* Adding clear button and frontend tests for ol-autocomplete

* Adding fuzzysearch option to autocomplete story

* removing unused vars and noddy comment

* Fixing lint failure

* Updating fuzzy search threshold

* Using downshift natural highlight

* Required label for aria compliancy

* changing how create item is handled

* addressing review comments

* Using AutoExpandingTextArea for optional fields and hooking in validation

* Formatting

* Requiring items prop for ol-autocomplete

* Fixing type failure in test

GitOrigin-RevId: 9b8f719fbb2bdd75fc1d5a9076908559040a8a78
2026-03-06 09:16:25 +00:00

532 lines
16 KiB
TypeScript

import { FormEvent } from 'react'
import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import OLAutocomplete, {
OLAutocompleteItem,
OLAutocompleteProps,
} from '../../../../frontend/js/shared/components/ol/ol-autocomplete'
const testItems: OLAutocompleteItem[] = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
{ value: 'elderberry', label: 'Elderberry' },
]
const groupedTestItems: OLAutocompleteItem[] = [
{ value: 'apple', label: 'Apple', group: 'Fruits' },
{ value: 'banana', label: 'Banana', group: 'Fruits' },
{ value: 'carrot', label: 'Carrot', group: 'Vegetables' },
{ value: 'dill', label: 'Dill', group: 'Vegetables' },
]
type RenderProps = Partial<OLAutocompleteProps> &
Pick<OLAutocompleteProps, 'items'> & {
onSubmit?: (formData: object) => void
}
function render(props: RenderProps) {
const changeHandler = props.onChange || cy.stub().as('changeHandler')
const label = props.label ?? 'Select item'
const submitHandler = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (props.onSubmit) {
const formData = new FormData(event.target as HTMLFormElement)
props.onSubmit(Object.fromEntries(formData.entries()))
}
}
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<form onSubmit={submitHandler}>
<OLAutocomplete
items={props.items}
onChange={changeHandler}
placeholder={props.placeholder}
label={label}
showLabel={props.showLabel}
allowCreate={props.allowCreate}
disabled={props.disabled}
createOptionPrefix={props.createOptionPrefix}
useFuzzySearch={props.useFuzzySearch}
/>
<button type="submit">submit</button>
</form>
</div>
)
}
describe('<OLAutocomplete />', function () {
describe('initial rendering', function () {
it('renders with placeholder', function () {
render({ items: testItems, placeholder: 'Search items...' })
cy.findByPlaceholderText('Search items...')
})
it('renders with visible label', function () {
render({
items: testItems,
label: 'Select item',
showLabel: true,
})
cy.findByRole('combobox', { name: 'Select item' }).should('be.visible')
cy.findByText('Select item').should('be.visible')
})
it('renders with visually hidden label', function () {
render({
items: testItems,
label: 'Select item',
showLabel: false,
})
cy.findByRole('combobox', { name: 'Select item' }).should('exist')
cy.get('.visually-hidden').should('exist')
})
it('starts with empty input', function () {
render({ items: testItems })
cy.findByRole('combobox').should('have.value', '')
})
it('does not show clear button when empty', function () {
render({ items: testItems })
cy.findByLabelText('Delete').should('not.exist')
})
it('does not show dropdown initially', function () {
render({ items: testItems })
cy.get('.dropdown-menu.show').should('not.exist')
})
})
describe('items rendering', function () {
it('renders all items when input is focused', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.findByText('Apple')
cy.findByText('Banana')
cy.findByText('Cherry')
cy.findByText('Date')
cy.findByText('Elderberry')
})
it('renders grouped items with headers', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').click()
cy.contains('Fruits')
cy.contains('Vegetables')
cy.findByText('Apple')
cy.findByText('Banana')
cy.findByText('Carrot')
cy.findByText('Dill')
})
it('separates groups with dividers', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').click()
cy.get('.dropdown-divider').should('have.length', 1)
})
it('shows dropdown when typing', function () {
render({ items: testItems })
cy.findByRole('combobox').type('a')
cy.get('.dropdown-menu.show').should('exist')
})
})
describe('filtering', function () {
it('filters items based on input', function () {
render({ items: testItems })
cy.findByRole('combobox').type('ba')
cy.findByText('Banana').should('exist')
cy.findByText('Apple').should('not.exist')
cy.findByText('Cherry').should('not.exist')
})
it('filters items case-insensitively', function () {
render({ items: testItems })
cy.findByRole('combobox').type('CHERRY')
cy.findByText('Cherry').should('exist')
cy.findByText('Apple').should('not.exist')
})
it('shows all items when input is cleared', function () {
render({ items: testItems })
cy.findByRole('combobox').type('ba')
cy.findByText('Banana').should('exist')
cy.findByRole('combobox').clear()
cy.findByRole('combobox').click()
cy.findByText('Apple').should('exist')
cy.findByText('Cherry').should('exist')
})
it('filters grouped items', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').type('car')
cy.findByText('Carrot').should('exist')
cy.contains('Vegetables').should('exist')
cy.findByText('Apple').should('not.exist')
cy.contains('Fruits').should('not.exist')
})
it('hides empty groups after filtering', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').type('app')
cy.findByText('Apple').should('exist')
cy.contains('Fruits').should('exist')
cy.contains('Vegetables').should('not.exist')
})
})
describe('fuzzy search', function () {
it('performs fuzzy search when enabled', function () {
render({ items: testItems, useFuzzySearch: true })
cy.findByRole('combobox').type('aple')
cy.findByText('Apple').should('exist')
})
it('performs fuzzy search on grouped items', function () {
render({ items: groupedTestItems, useFuzzySearch: true })
cy.findByRole('combobox').type('banan')
cy.findByText('Banana').should('exist')
})
})
describe('item selection', function () {
it('selects an item on click', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').click()
cy.findByText('Banana').click()
cy.get('@changeHandler').should('have.been.calledOnceWith', 'banana')
cy.findByRole('combobox').should('have.value', 'Banana')
})
it('closes dropdown after selection', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.findByText('Cherry').click()
cy.get('.dropdown-menu.show').should('not.exist')
})
it('displays clear button after selection', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.findByText('Apple').click()
cy.findByLabelText('Delete').should('exist')
})
it('cannot select when disabled', function () {
render({ items: testItems, disabled: true })
cy.findByRole('combobox').should('be.disabled')
cy.findByRole('combobox').click({ force: true })
cy.get('.dropdown-menu.show').should('not.exist')
})
it('does not show clear button when disabled', function () {
render({ items: testItems, disabled: true })
cy.findByRole('combobox').type('Apple', { force: true })
cy.findByLabelText('Delete').should('not.exist')
})
})
describe('clear button', function () {
it('clears the input when clicked', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').type('Apple')
cy.findByLabelText('Delete').click()
cy.findByRole('combobox').should('have.value', '')
cy.get('@changeHandler').should('have.been.calledWith', '')
})
it('restores all items after clearing', function () {
render({ items: testItems })
cy.findByRole('combobox').type('ba')
cy.findByText('Banana').should('exist')
cy.findByText('Apple').should('not.exist')
cy.findByLabelText('Delete').click()
cy.findByRole('combobox').click()
cy.findByText('Apple').should('exist')
cy.findByText('Banana').should('exist')
cy.findByText('Cherry').should('exist')
})
})
describe('create option', function () {
it('shows create option when input does not match any item', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").should('exist')
})
it('does not show create option when input matches an item', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('Apple')
cy.contains('+ Create').should('not.exist')
})
it('shows create option with case-insensitive matching', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('apple')
cy.contains('+ Create').should('not.exist')
})
it('invokes onChange with new value when create option is selected', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, allowCreate: true, onChange: changeHandler })
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").click()
cy.get('@changeHandler').should('have.been.calledWithMatch', /grape$/)
cy.findByRole('combobox').should('have.value', 'grape')
})
it('does not show create option when allowCreate is false', function () {
render({ items: testItems, allowCreate: false })
cy.findByRole('combobox').type('grape')
cy.contains('+ Create').should('not.exist')
})
it('does not show create option when allowCreate function returns false', function () {
render({
items: testItems,
allowCreate: value => value !== 'grape',
})
cy.findByRole('combobox').type('grape')
cy.contains('+ Create').should('not.exist')
})
it('shows create option when allowCreate function returns true', function () {
render({
items: testItems,
allowCreate: value => value.length > 2,
})
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").should('exist')
})
it('uses custom create option prefix', function () {
render({
items: testItems,
allowCreate: true,
createOptionPrefix: 'Add new:',
})
cy.findByRole('combobox').type('grape')
cy.contains("Add new: 'grape'").should('exist')
cy.contains('+ Create').should('not.exist')
})
it('shows create option with groups', function () {
render({ items: groupedTestItems, allowCreate: true })
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").should('exist')
})
it('separates create option with divider in grouped view', function () {
render({ items: groupedTestItems, allowCreate: true })
cy.findByRole('combobox').type('a')
cy.contains("+ Create 'a'").should('exist')
cy.get('.dropdown-divider').should('have.length', 2)
})
})
describe('keyboard navigation', function () {
it('opens dropdown on ArrowDown key', function () {
render({ items: testItems })
cy.findByRole('combobox').type('{downArrow}')
cy.get('.dropdown-menu.show').should('exist')
})
it('selects first item on Enter when no item is highlighted', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').type('b{enter}')
cy.get('@changeHandler').should('have.been.calledWith', 'banana')
})
it('navigates through items with arrow keys', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.get('.dropdown-item-highlighted').should('contain', 'Apple')
cy.findByRole('combobox').type('{downArrow}')
cy.get('.dropdown-item-highlighted').should('contain', 'Banana')
cy.findByRole('combobox').type('{downArrow}')
cy.get('.dropdown-item-highlighted').should('contain', 'Cherry')
})
it('selects highlighted item on Enter', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').click()
cy.findByRole('combobox').type('{downArrow}{enter}')
cy.get('@changeHandler').should('have.been.calledWith', 'banana')
})
it('can navigate to and select create option', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, allowCreate: true, onChange: changeHandler })
cy.findByRole('combobox').type('grape')
// Navigate down through all items to the create option
cy.findByRole('combobox').type('{downArrow}'.repeat(6) + '{enter}')
cy.get('@changeHandler').should('have.been.calledWith', 'grape')
})
})
describe('form integration', function () {
it('works within a form context', function () {
const FormWithAutocomplete = ({
onSubmit,
}: {
onSubmit: (formData: object) => void
}) => {
const changeHandler = cy.stub().as('formChangeHandler')
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.target as HTMLFormElement)
onSubmit(Object.fromEntries(formData.entries()))
}
return (
<OLForm onSubmit={handleSubmit}>
<input
type="hidden"
name="autocomplete_value"
value=""
ref={ref => {
if (ref) {
// Update hidden input when autocomplete changes
const observer = new MutationObserver(() => {
const autocompleteInput = ref.form?.querySelector(
'input[type="text"]'
) as HTMLInputElement
if (autocompleteInput) {
ref.value = autocompleteInput.value
}
})
if (ref.form) {
observer.observe(ref.form, {
subtree: true,
attributes: true,
})
}
}
}}
/>
<OLAutocomplete
items={testItems}
onChange={changeHandler}
placeholder="Search..."
label="Select item"
/>
<OLButton type="submit">submit</OLButton>
</OLForm>
)
}
const submitHandler = cy.stub().as('submitHandler')
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<FormWithAutocomplete onSubmit={submitHandler} />
</div>
)
cy.findByRole('combobox').click()
cy.findByText('Banana').click()
cy.get('@formChangeHandler').should('have.been.calledWith', 'banana')
})
})
describe('edge cases', function () {
it('handles empty items array', function () {
render({ items: [] })
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('not.exist')
})
it('handles empty groups array', function () {
render({ items: [] })
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('not.exist')
})
it('shows only create option when no items match', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('xyz')
cy.contains("+ Create 'xyz'").should('exist')
cy.findByText('Apple').should('not.exist')
})
it('does not trim whitespace in comparisons', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type(' apple ')
cy.contains('+ Create').should('exist')
})
})
})