| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633 |
- 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}
- expandUp={props.expandUp}
- onClose={props.onClose}
- isOpen={props.isOpen}
- />
- <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')
- })
- 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<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')
- })
- })
- 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')
- })
- })
- })
- })
|