ol-autocomplete.spec.tsx 16 KB

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