ol-autocomplete.spec.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. import { FormEvent } from 'react'
  2. import OLButton from '@/shared/components/ol/ol-button'
  3. import OLForm from '@/shared/components/ol/ol-form'
  4. import OLAutocomplete, {
  5. OLAutocompleteItem,
  6. OLAutocompleteProps,
  7. } from '../../../../frontend/js/shared/components/ol/ol-autocomplete'
  8. const testItems: OLAutocompleteItem[] = [
  9. { value: 'apple', label: 'Apple' },
  10. { value: 'banana', label: 'Banana' },
  11. { value: 'cherry', label: 'Cherry' },
  12. { value: 'date', label: 'Date' },
  13. { value: 'elderberry', label: 'Elderberry' },
  14. ]
  15. const groupedTestItems: OLAutocompleteItem[] = [
  16. { value: 'apple', label: 'Apple', group: 'Fruits' },
  17. { value: 'banana', label: 'Banana', group: 'Fruits' },
  18. { value: 'carrot', label: 'Carrot', group: 'Vegetables' },
  19. { value: 'dill', label: 'Dill', group: 'Vegetables' },
  20. ]
  21. type RenderProps = Partial<OLAutocompleteProps> &
  22. Pick<OLAutocompleteProps, 'items'> & {
  23. onSubmit?: (formData: object) => void
  24. }
  25. function render(props: RenderProps) {
  26. const changeHandler = props.onChange || cy.stub().as('changeHandler')
  27. const label = props.label ?? 'Select item'
  28. const submitHandler = (event: FormEvent<HTMLFormElement>) => {
  29. event.preventDefault()
  30. if (props.onSubmit) {
  31. const formData = new FormData(event.target as HTMLFormElement)
  32. props.onSubmit(Object.fromEntries(formData.entries()))
  33. }
  34. }
  35. cy.mount(
  36. <div
  37. style={{
  38. display: 'flex',
  39. alignItems: 'center',
  40. justifyContent: 'center',
  41. height: '100vh',
  42. }}
  43. >
  44. <form onSubmit={submitHandler}>
  45. <OLAutocomplete
  46. items={props.items}
  47. onChange={changeHandler}
  48. placeholder={props.placeholder}
  49. label={label}
  50. showLabel={props.showLabel}
  51. allowCreate={props.allowCreate}
  52. disabled={props.disabled}
  53. createOptionPrefix={props.createOptionPrefix}
  54. useFuzzySearch={props.useFuzzySearch}
  55. expandUp={props.expandUp}
  56. onClose={props.onClose}
  57. isOpen={props.isOpen}
  58. />
  59. <button type="submit">submit</button>
  60. </form>
  61. </div>
  62. )
  63. }
  64. describe('<OLAutocomplete />', function () {
  65. describe('initial rendering', function () {
  66. it('renders with placeholder', function () {
  67. render({ items: testItems, placeholder: 'Search items...' })
  68. cy.findByPlaceholderText('Search items...')
  69. })
  70. it('renders with visible label', function () {
  71. render({
  72. items: testItems,
  73. label: 'Select item',
  74. showLabel: true,
  75. })
  76. cy.findByRole('combobox', { name: 'Select item' }).should('be.visible')
  77. cy.findByText('Select item').should('be.visible')
  78. })
  79. it('renders with visually hidden label', function () {
  80. render({
  81. items: testItems,
  82. label: 'Select item',
  83. showLabel: false,
  84. })
  85. cy.findByRole('combobox', { name: 'Select item' }).should('exist')
  86. cy.get('.visually-hidden').should('exist')
  87. })
  88. it('starts with empty input', function () {
  89. render({ items: testItems })
  90. cy.findByRole('combobox').should('have.value', '')
  91. })
  92. it('does not show clear button when empty', function () {
  93. render({ items: testItems })
  94. cy.findByLabelText('Delete').should('not.exist')
  95. })
  96. it('does not show dropdown initially', function () {
  97. render({ items: testItems })
  98. cy.get('.dropdown-menu.show').should('not.exist')
  99. })
  100. })
  101. describe('items rendering', function () {
  102. it('renders all items when input is focused', function () {
  103. render({ items: testItems })
  104. cy.findByRole('combobox').click()
  105. cy.findByText('Apple')
  106. cy.findByText('Banana')
  107. cy.findByText('Cherry')
  108. cy.findByText('Date')
  109. cy.findByText('Elderberry')
  110. })
  111. it('renders grouped items with headers', function () {
  112. render({ items: groupedTestItems })
  113. cy.findByRole('combobox').click()
  114. cy.contains('Fruits')
  115. cy.contains('Vegetables')
  116. cy.findByText('Apple')
  117. cy.findByText('Banana')
  118. cy.findByText('Carrot')
  119. cy.findByText('Dill')
  120. })
  121. it('separates groups with dividers', function () {
  122. render({ items: groupedTestItems })
  123. cy.findByRole('combobox').click()
  124. cy.get('.dropdown-divider').should('have.length', 1)
  125. })
  126. it('shows dropdown when typing', function () {
  127. render({ items: testItems })
  128. cy.findByRole('combobox').type('a')
  129. cy.get('.dropdown-menu.show').should('exist')
  130. })
  131. })
  132. describe('filtering', function () {
  133. it('filters items based on input', function () {
  134. render({ items: testItems })
  135. cy.findByRole('combobox').type('ba')
  136. cy.findByText('Banana').should('exist')
  137. cy.findByText('Apple').should('not.exist')
  138. cy.findByText('Cherry').should('not.exist')
  139. })
  140. it('filters items case-insensitively', function () {
  141. render({ items: testItems })
  142. cy.findByRole('combobox').type('CHERRY')
  143. cy.findByText('Cherry').should('exist')
  144. cy.findByText('Apple').should('not.exist')
  145. })
  146. it('shows all items when input is cleared', function () {
  147. render({ items: testItems })
  148. cy.findByRole('combobox').type('ba')
  149. cy.findByText('Banana').should('exist')
  150. cy.findByRole('combobox').clear()
  151. cy.findByRole('combobox').click()
  152. cy.findByText('Apple').should('exist')
  153. cy.findByText('Cherry').should('exist')
  154. })
  155. it('filters grouped items', function () {
  156. render({ items: groupedTestItems })
  157. cy.findByRole('combobox').type('car')
  158. cy.findByText('Carrot').should('exist')
  159. cy.contains('Vegetables').should('exist')
  160. cy.findByText('Apple').should('not.exist')
  161. cy.contains('Fruits').should('not.exist')
  162. })
  163. it('hides empty groups after filtering', function () {
  164. render({ items: groupedTestItems })
  165. cy.findByRole('combobox').type('app')
  166. cy.findByText('Apple').should('exist')
  167. cy.contains('Fruits').should('exist')
  168. cy.contains('Vegetables').should('not.exist')
  169. })
  170. })
  171. describe('fuzzy search', function () {
  172. it('performs fuzzy search when enabled', function () {
  173. render({ items: testItems, useFuzzySearch: true })
  174. cy.findByRole('combobox').type('aple')
  175. cy.findByText('Apple').should('exist')
  176. })
  177. it('performs fuzzy search on grouped items', function () {
  178. render({ items: groupedTestItems, useFuzzySearch: true })
  179. cy.findByRole('combobox').type('banan')
  180. cy.findByText('Banana').should('exist')
  181. })
  182. })
  183. describe('item selection', function () {
  184. it('selects an item on click', function () {
  185. const changeHandler = cy.stub().as('changeHandler')
  186. render({ items: testItems, onChange: changeHandler })
  187. cy.findByRole('combobox').click()
  188. cy.findByText('Banana').click()
  189. cy.get('@changeHandler').should('have.been.calledOnceWith', 'banana')
  190. cy.findByRole('combobox').should('have.value', 'Banana')
  191. })
  192. it('closes dropdown after selection', function () {
  193. render({ items: testItems })
  194. cy.findByRole('combobox').click()
  195. cy.findByText('Cherry').click()
  196. cy.get('.dropdown-menu.show').should('not.exist')
  197. })
  198. it('displays clear button after selection', function () {
  199. render({ items: testItems })
  200. cy.findByRole('combobox').click()
  201. cy.findByText('Apple').click()
  202. cy.findByLabelText('Delete').should('exist')
  203. })
  204. it('cannot select when disabled', function () {
  205. render({ items: testItems, disabled: true })
  206. cy.findByRole('combobox').should('be.disabled')
  207. cy.findByRole('combobox').click({ force: true })
  208. cy.get('.dropdown-menu.show').should('not.exist')
  209. })
  210. it('does not show clear button when disabled', function () {
  211. render({ items: testItems, disabled: true })
  212. cy.findByRole('combobox').type('Apple', { force: true })
  213. cy.findByLabelText('Delete').should('not.exist')
  214. })
  215. it('calls onClose when an item is selected', function () {
  216. const closeHandler = cy.stub().as('closeHandler')
  217. render({ items: testItems, onClose: closeHandler })
  218. cy.findByRole('combobox').click()
  219. cy.findByText('Banana').click()
  220. cy.get('@closeHandler').should('have.been.calledOnce')
  221. })
  222. it('calls onClose when Escape is pressed', function () {
  223. const closeHandler = cy.stub().as('closeHandler')
  224. render({ items: testItems, onClose: closeHandler })
  225. cy.findByRole('combobox').click()
  226. cy.findByRole('combobox').type('{esc}')
  227. cy.get('@closeHandler').should('have.been.calledOnce')
  228. })
  229. })
  230. describe('clear button', function () {
  231. it('clears the input when clicked', function () {
  232. const changeHandler = cy.stub().as('changeHandler')
  233. render({ items: testItems, onChange: changeHandler })
  234. cy.findByRole('combobox').type('Apple')
  235. cy.findByLabelText('Delete').click()
  236. cy.findByRole('combobox').should('have.value', '')
  237. cy.get('@changeHandler').should('have.been.calledWith', '')
  238. })
  239. it('restores all items after clearing', function () {
  240. render({ items: testItems })
  241. cy.findByRole('combobox').type('ba')
  242. cy.findByText('Banana').should('exist')
  243. cy.findByText('Apple').should('not.exist')
  244. cy.findByLabelText('Delete').click()
  245. cy.findByRole('combobox').click()
  246. cy.findByText('Apple').should('exist')
  247. cy.findByText('Banana').should('exist')
  248. cy.findByText('Cherry').should('exist')
  249. })
  250. })
  251. describe('create option', function () {
  252. it('shows create option when input does not match any item', function () {
  253. render({ items: testItems, allowCreate: true })
  254. cy.findByRole('combobox').type('grape')
  255. cy.contains("+ Create 'grape'").should('exist')
  256. })
  257. it('does not show create option when input matches an item', function () {
  258. render({ items: testItems, allowCreate: true })
  259. cy.findByRole('combobox').type('Apple')
  260. cy.contains('+ Create').should('not.exist')
  261. })
  262. it('shows create option with case-insensitive matching', function () {
  263. render({ items: testItems, allowCreate: true })
  264. cy.findByRole('combobox').type('apple')
  265. cy.contains('+ Create').should('not.exist')
  266. })
  267. it('invokes onChange with new value when create option is selected', function () {
  268. const changeHandler = cy.stub().as('changeHandler')
  269. render({ items: testItems, allowCreate: true, onChange: changeHandler })
  270. cy.findByRole('combobox').type('grape')
  271. cy.contains("+ Create 'grape'").click()
  272. cy.get('@changeHandler').should('have.been.calledWithMatch', /grape$/)
  273. cy.findByRole('combobox').should('have.value', 'grape')
  274. })
  275. it('does not show create option when allowCreate is false', function () {
  276. render({ items: testItems, allowCreate: false })
  277. cy.findByRole('combobox').type('grape')
  278. cy.contains('+ Create').should('not.exist')
  279. })
  280. it('does not show create option when allowCreate function returns false', function () {
  281. render({
  282. items: testItems,
  283. allowCreate: value => value !== 'grape',
  284. })
  285. cy.findByRole('combobox').type('grape')
  286. cy.contains('+ Create').should('not.exist')
  287. })
  288. it('shows create option when allowCreate function returns true', function () {
  289. render({
  290. items: testItems,
  291. allowCreate: value => value.length > 2,
  292. })
  293. cy.findByRole('combobox').type('grape')
  294. cy.contains("+ Create 'grape'").should('exist')
  295. })
  296. it('uses custom create option prefix', function () {
  297. render({
  298. items: testItems,
  299. allowCreate: true,
  300. createOptionPrefix: 'Add new:',
  301. })
  302. cy.findByRole('combobox').type('grape')
  303. cy.contains("Add new: 'grape'").should('exist')
  304. cy.contains('+ Create').should('not.exist')
  305. })
  306. it('shows create option with groups', function () {
  307. render({ items: groupedTestItems, allowCreate: true })
  308. cy.findByRole('combobox').type('grape')
  309. cy.contains("+ Create 'grape'").should('exist')
  310. })
  311. it('separates create option with divider in grouped view', function () {
  312. render({ items: groupedTestItems, allowCreate: true })
  313. cy.findByRole('combobox').type('a')
  314. cy.contains("+ Create 'a'").should('exist')
  315. cy.get('.dropdown-divider').should('have.length', 2)
  316. })
  317. })
  318. describe('keyboard navigation', function () {
  319. it('opens dropdown on ArrowDown key', function () {
  320. render({ items: testItems })
  321. cy.findByRole('combobox').type('{downArrow}')
  322. cy.get('.dropdown-menu.show').should('exist')
  323. })
  324. it('selects first item on Enter when no item is highlighted', function () {
  325. const changeHandler = cy.stub().as('changeHandler')
  326. render({ items: testItems, onChange: changeHandler })
  327. cy.findByRole('combobox').type('b{enter}')
  328. cy.get('@changeHandler').should('have.been.calledWith', 'banana')
  329. })
  330. it('navigates through items with arrow keys', function () {
  331. render({ items: testItems })
  332. cy.findByRole('combobox').click()
  333. cy.get('.dropdown-item-highlighted').should('contain', 'Apple')
  334. cy.findByRole('combobox').type('{downArrow}')
  335. cy.get('.dropdown-item-highlighted').should('contain', 'Banana')
  336. cy.findByRole('combobox').type('{downArrow}')
  337. cy.get('.dropdown-item-highlighted').should('contain', 'Cherry')
  338. })
  339. it('selects highlighted item on Enter', function () {
  340. const changeHandler = cy.stub().as('changeHandler')
  341. render({ items: testItems, onChange: changeHandler })
  342. cy.findByRole('combobox').click()
  343. cy.findByRole('combobox').type('{downArrow}{enter}')
  344. cy.get('@changeHandler').should('have.been.calledWith', 'banana')
  345. })
  346. it('can navigate to and select create option', function () {
  347. const changeHandler = cy.stub().as('changeHandler')
  348. render({ items: testItems, allowCreate: true, onChange: changeHandler })
  349. cy.findByRole('combobox').type('grape')
  350. // Navigate down through all items to the create option
  351. cy.findByRole('combobox').type('{downArrow}'.repeat(6) + '{enter}')
  352. cy.get('@changeHandler').should('have.been.calledWith', 'grape')
  353. })
  354. })
  355. describe('form integration', function () {
  356. it('works within a form context', function () {
  357. const FormWithAutocomplete = ({
  358. onSubmit,
  359. }: {
  360. onSubmit: (formData: object) => void
  361. }) => {
  362. const changeHandler = cy.stub().as('formChangeHandler')
  363. function handleSubmit(event: FormEvent<HTMLFormElement>) {
  364. event.preventDefault()
  365. const formData = new FormData(event.target as HTMLFormElement)
  366. onSubmit(Object.fromEntries(formData.entries()))
  367. }
  368. return (
  369. <OLForm onSubmit={handleSubmit}>
  370. <input
  371. type="hidden"
  372. name="autocomplete_value"
  373. value=""
  374. ref={ref => {
  375. if (ref) {
  376. // Update hidden input when autocomplete changes
  377. const observer = new MutationObserver(() => {
  378. const autocompleteInput = ref.form?.querySelector(
  379. 'input[type="text"]'
  380. ) as HTMLInputElement
  381. if (autocompleteInput) {
  382. ref.value = autocompleteInput.value
  383. }
  384. })
  385. if (ref.form) {
  386. observer.observe(ref.form, {
  387. subtree: true,
  388. attributes: true,
  389. })
  390. }
  391. }
  392. }}
  393. />
  394. <OLAutocomplete
  395. items={testItems}
  396. onChange={changeHandler}
  397. placeholder="Search..."
  398. label="Select item"
  399. />
  400. <OLButton type="submit">submit</OLButton>
  401. </OLForm>
  402. )
  403. }
  404. const submitHandler = cy.stub().as('submitHandler')
  405. cy.mount(
  406. <div
  407. style={{
  408. display: 'flex',
  409. alignItems: 'center',
  410. justifyContent: 'center',
  411. height: '100vh',
  412. }}
  413. >
  414. <FormWithAutocomplete onSubmit={submitHandler} />
  415. </div>
  416. )
  417. cy.findByRole('combobox').click()
  418. cy.findByText('Banana').click()
  419. cy.get('@formChangeHandler').should('have.been.calledWith', 'banana')
  420. })
  421. })
  422. describe('edge cases', function () {
  423. it('handles empty items array', function () {
  424. render({ items: [] })
  425. cy.findByRole('combobox').click()
  426. cy.get('.dropdown-menu.show').should('not.exist')
  427. })
  428. it('handles empty groups array', function () {
  429. render({ items: [] })
  430. cy.findByRole('combobox').click()
  431. cy.get('.dropdown-menu.show').should('not.exist')
  432. })
  433. it('shows only create option when no items match', function () {
  434. render({ items: testItems, allowCreate: true })
  435. cy.findByRole('combobox').type('xyz')
  436. cy.contains("+ Create 'xyz'").should('exist')
  437. cy.findByText('Apple').should('not.exist')
  438. })
  439. it('does not trim whitespace in comparisons', function () {
  440. render({ items: testItems, allowCreate: true })
  441. cy.findByRole('combobox').type(' apple ')
  442. cy.contains('+ Create').should('exist')
  443. })
  444. })
  445. describe('controlled isOpen prop', function () {
  446. it('keeps dropdown open when input is blurred', function () {
  447. render({ items: testItems, isOpen: true })
  448. cy.findByRole('combobox').click()
  449. cy.get('.dropdown-menu.show').should('exist')
  450. cy.findByRole('combobox').blur()
  451. cy.get('.dropdown-menu.show').should('exist')
  452. })
  453. it('keeps dropdown open when input is clicked while already open', function () {
  454. render({ items: testItems, isOpen: true })
  455. cy.findByRole('combobox').click()
  456. cy.get('.dropdown-menu.show').should('exist')
  457. cy.findByRole('combobox').click()
  458. cy.get('.dropdown-menu.show').should('exist')
  459. })
  460. it('calls onClose when Escape is pressed', function () {
  461. const closeHandler = cy.stub().as('closeHandler')
  462. render({ items: testItems, isOpen: true, onClose: closeHandler })
  463. cy.findByRole('combobox').type('{esc}')
  464. cy.get('@closeHandler').should('have.been.calledOnce')
  465. })
  466. })
  467. describe('expandUp prop', function () {
  468. it('renders search bar before results list when expandUp is false', function () {
  469. render({ items: testItems, expandUp: false })
  470. cy.findByRole('combobox').click()
  471. cy.get('.ol-autocomplete').within(() => {
  472. cy.get('.dropdown-menu').then($menu => {
  473. cy.findByRole('combobox').then($input => {
  474. const inputTop = $input[0].getBoundingClientRect().top
  475. const menuTop = $menu[0].getBoundingClientRect().top
  476. expect(inputTop).to.be.lessThan(menuTop)
  477. })
  478. })
  479. })
  480. })
  481. it('renders results list before search bar when expandUp is true', function () {
  482. render({ items: testItems, expandUp: true })
  483. cy.findByRole('combobox').click()
  484. cy.get('.ol-autocomplete').within(() => {
  485. cy.get('.dropdown-menu').then($menu => {
  486. cy.findByRole('combobox').then($input => {
  487. const inputTop = $input[0].getBoundingClientRect().top
  488. const menuTop = $menu[0].getBoundingClientRect().top
  489. expect(menuTop).to.be.lessThan(inputTop)
  490. })
  491. })
  492. })
  493. })
  494. it('applies correct margin class when expandUp is false', function () {
  495. render({ items: testItems, expandUp: false })
  496. cy.get('.ol-autocomplete').within(() => {
  497. cy.get('.mb-3').should('exist')
  498. cy.get('.mt-3').should('not.exist')
  499. })
  500. })
  501. it('applies correct margin class when expandUp is true', function () {
  502. render({ items: testItems, expandUp: true })
  503. cy.get('.ol-autocomplete').within(() => {
  504. cy.get('.mt-3').should('exist')
  505. cy.get('.mb-3').should('not.exist')
  506. })
  507. })
  508. })
  509. })