mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-04 06:39:02 +02:00
1be454b95c
* 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
532 lines
16 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|