ol-autocomplete.spec.tsx 19 KB

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