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 & Pick & { 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) => { event.preventDefault() if (props.onSubmit) { const formData = new FormData(event.target as HTMLFormElement) props.onSubmit(Object.fromEntries(formData.entries())) } } cy.mount(
) } describe('', 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') }) it('calls onClose when an item is selected', function () { const closeHandler = cy.stub().as('closeHandler') render({ items: testItems, onClose: closeHandler }) cy.findByRole('combobox').click() cy.findByText('Banana').click() cy.get('@closeHandler').should('have.been.calledOnce') }) it('calls onClose when Escape is pressed', function () { const closeHandler = cy.stub().as('closeHandler') render({ items: testItems, onClose: closeHandler }) cy.findByRole('combobox').click() cy.findByRole('combobox').type('{esc}') cy.get('@closeHandler').should('have.been.calledOnce') }) }) 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) { event.preventDefault() const formData = new FormData(event.target as HTMLFormElement) onSubmit(Object.fromEntries(formData.entries())) } return ( { 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, }) } } }} /> submit ) } const submitHandler = cy.stub().as('submitHandler') cy.mount(
) 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') }) }) describe('controlled isOpen prop', function () { it('keeps dropdown open when input is blurred', function () { render({ items: testItems, isOpen: true }) cy.findByRole('combobox').click() cy.get('.dropdown-menu.show').should('exist') cy.findByRole('combobox').blur() cy.get('.dropdown-menu.show').should('exist') }) it('keeps dropdown open when input is clicked while already open', function () { render({ items: testItems, isOpen: true }) cy.findByRole('combobox').click() cy.get('.dropdown-menu.show').should('exist') cy.findByRole('combobox').click() cy.get('.dropdown-menu.show').should('exist') }) it('calls onClose when Escape is pressed', function () { const closeHandler = cy.stub().as('closeHandler') render({ items: testItems, isOpen: true, onClose: closeHandler }) cy.findByRole('combobox').type('{esc}') cy.get('@closeHandler').should('have.been.calledOnce') }) }) describe('expandUp prop', function () { it('renders search bar before results list when expandUp is false', function () { render({ items: testItems, expandUp: false }) cy.findByRole('combobox').click() cy.get('.ol-autocomplete').within(() => { cy.get('.dropdown-menu').then($menu => { cy.findByRole('combobox').then($input => { const inputTop = $input[0].getBoundingClientRect().top const menuTop = $menu[0].getBoundingClientRect().top expect(inputTop).to.be.lessThan(menuTop) }) }) }) }) it('renders results list before search bar when expandUp is true', function () { render({ items: testItems, expandUp: true }) cy.findByRole('combobox').click() cy.get('.ol-autocomplete').within(() => { cy.get('.dropdown-menu').then($menu => { cy.findByRole('combobox').then($input => { const inputTop = $input[0].getBoundingClientRect().top const menuTop = $menu[0].getBoundingClientRect().top expect(menuTop).to.be.lessThan(inputTop) }) }) }) }) it('applies correct margin class when expandUp is false', function () { render({ items: testItems, expandUp: false }) cy.get('.ol-autocomplete').within(() => { cy.get('.mb-3').should('exist') cy.get('.mt-3').should('not.exist') }) }) it('applies correct margin class when expandUp is true', function () { render({ items: testItems, expandUp: true }) cy.get('.ol-autocomplete').within(() => { cy.get('.mt-3').should('exist') cy.get('.mb-3').should('not.exist') }) }) }) })