form_helpers.rb 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. require_relative 'test_flags'
  3. # Form helpers below are loaded for the new stack app only and provide functions for returning the form field elements.
  4. module FormHelpers
  5. @form_context = nil
  6. # Returns the outer container element of the form field via its label.
  7. # The returned object is always an instance of `Capybara::Node::Element``, with some added sugar on top.
  8. def find_outer(label, **find_options)
  9. ZammadFormFieldCapybaraElementDelegator.new(find('.formkit-outer') { |element| element.has_css?('label', text: label, **find_options) }, @form_context)
  10. end
  11. # Usage:
  12. #
  13. # find_input('Title')
  14. # find_select('Owner')
  15. # find_treeselect('Category')
  16. # find_autocomplete('Customer')
  17. # find_editor('Text')
  18. # find_datepicker('Pending till')
  19. # find_toggle('Remember me')
  20. #
  21. # # In case of ambiguous labels, make sure to pass `exact_text` option
  22. # find_datepicker(nil, exact_text: 'Date')
  23. #
  24. alias find_input find_outer
  25. alias find_select find_outer
  26. alias find_treeselect find_outer
  27. alias find_autocomplete find_outer
  28. alias find_editor find_outer
  29. alias find_datepicker find_outer
  30. alias find_toggle find_outer
  31. # Returns the outer container element of the form field radio via its ID.
  32. # The returned object is always an instance of `Capybara::Node::Element``, with some added sugar on top.
  33. def find_radio(name, **find_options)
  34. ZammadFormFieldCapybaraElementDelegator.new(first("[name^=\"#{name}\"]", **find_options).ancestor('.formkit-outer'), @form_context)
  35. end
  36. # Provides a form context for stabilizing multiple field interactions.
  37. # This is implemented by tracking of the expected form updater and other GraphQL responses.
  38. # To define custom starting form updater response number, use the `form_updater_gql_number` argument (default: nil).
  39. #
  40. # Usage:
  41. #
  42. # within_form(form_updater_gql_number: 2) do
  43. # find_autocomplete('CC').search_for_options([email_address_1, email_address_2])
  44. # find_autocomplete('Tags').search_for_options([tag_1, tag_2, tag_3]).select_options(%w[foo bar])
  45. # find_editor('Text').type(body)
  46. # end
  47. #
  48. def within_form(form_updater_gql_number: nil)
  49. setup_form_context(form_updater_gql_number)
  50. yield
  51. demolish_form_context
  52. end
  53. private
  54. def setup_form_context(form_updater_gql_number)
  55. @form_context = ZammadFormContext.new
  56. return if form_updater_gql_number.blank?
  57. @form_context.init_form_updater(form_updater_gql_number)
  58. end
  59. def demolish_form_context
  60. @form_context = nil
  61. end
  62. end
  63. # Extension below allows for execution of custom actions on the returned form field elements.
  64. # This class delegates any missing methods upstream to `Capybara::Node::Element` class.
  65. class ZammadFormFieldCapybaraElementDelegator < SimpleDelegator
  66. attr_reader :element, :form_context
  67. include Capybara::DSL
  68. include BrowserTestHelper
  69. include TestFlags
  70. def initialize(element, form_context)
  71. @element = element
  72. @form_context = form_context
  73. super(element)
  74. end
  75. # Returns identifier of the form field.
  76. def field_id
  77. return element.find('.formkit-input', visible: :all)['id'] if input? || type_date? || type_datetime?
  78. return element.find('textarea')['id'] if type_textarea?
  79. return element.find('.formkit-fieldset')['id'] if type_radio?
  80. return element.find('[role="textbox"]')['id'] if type_editor?
  81. return element.find('[role="switch"], input[type="checkbox"]', visible: :all)['id'] if type_toggle? || type_checkbox?
  82. element.find('output', visible: :all)['id']
  83. end
  84. # Returns (hidden) input element used by several form field implementations to track the current value.
  85. # NOTE: A returned element might not be a regular INPUT field due to custom implementation.
  86. def input_element
  87. element.find("##{field_id}", visible: :all)
  88. end
  89. # Searches treeselect and autocomplete fields for supplied option via its label and selects it.
  90. #
  91. # Usage:
  92. #
  93. # find_treeselect('Tree Select').search_for_option('Parent 1::Option A')
  94. # find_autocomplete('Tags').search_for_option(tag_1)
  95. #
  96. # # To wait for a custom GraphQL response, you can provide expected `gql_filename` and/or `gql_number`.
  97. # find_autocomplete('Custom').search_for_option('foo', gql_filename: 'shared/entities/user/graphql/queries/user.graphql', gql_number: 4)
  98. #
  99. # # To select an autocomplete option with a different text than the query, provide an optional `label` parameter.
  100. # find autocomplete('Customer').search_for_option(customer.email, label: customer.fullname)
  101. #
  102. def search_for_option(query, label: query, gql_filename: '', gql_number: 1, use_action: false, **find_options)
  103. return search_for_options(query, gql_filename: gql_filename, gql_number: gql_number, **find_options) if query.is_a?(Array)
  104. return search_for_tags_option(query, gql_filename: gql_filename, gql_number: gql_number) if type_tags?
  105. return search_for_autocomplete_option(query, label: label, gql_filename: gql_filename, gql_number: gql_number, use_action: use_action, **find_options) if autocomplete?
  106. raise 'Field does not support searching for options' if !type_treeselect?
  107. element.click
  108. wait_until_opened
  109. # calculate before closing, since we cannot access it, if dialog is closed
  110. is_multi_select = multi_select?
  111. browse_for_option(query, **find_options) do |option|
  112. # Filter input in desktop view is part of the element, not the dropdown/dialog.
  113. searchbox = if desktop_view?
  114. element.find('[role="searchbox"]')
  115. else
  116. find('[role="searchbox"]')
  117. end
  118. searchbox.fill_in with: option
  119. find('[role="option"]', text: option, **find_options).click
  120. maybe_wait_for_form_updater
  121. end
  122. send_keys(:escape) if is_multi_select
  123. wait_until_closed
  124. self # support chaining
  125. end
  126. # Searches treeselect and autocomplete fields for supplied options via their labels and selects them.
  127. # NOTE: The field must support multiple selection, otherwise an error will be raised.
  128. #
  129. # Usage:
  130. #
  131. # find_treeselect('Tree Select').search_for_options(['Parent 1::Option A', 'Parent 2::Option B', 'Option C'])
  132. # find_autocomplete('Tags').search_for_options([tag_1, tag_2, tag_3])
  133. #
  134. # # To wait for a custom GraphQL response, you can provide expected `gql_filename` and/or `gql_number`.
  135. # find_autocomplete('Tags').search_for_option('foo', gql_number: 3)
  136. #
  137. def search_for_options(queries, labels: queries, gql_filename: '', gql_number: 1, use_action: false, **find_options)
  138. return search_for_tags_options(queries, gql_filename: gql_filename, gql_number: gql_number) if type_tags?
  139. return search_for_autocomplete_options(queries, labels: labels, gql_filename: gql_filename, gql_number: gql_number, use_action: use_action, **find_options) if autocomplete?
  140. raise 'Field does not support searching for options' if !type_treeselect?
  141. element.click
  142. wait_for_test_flag("field-tree-select-#{field_id}.opened")
  143. raise 'Field does not support multiple selection' if !multi_select?
  144. queries.each do |query|
  145. browse_for_option(query, **find_options) do |option, rewind|
  146. # Filter input in desktop view is part of the element, not the dropdown/dialog.
  147. searchbox = if desktop_view?
  148. element.find('[role="searchbox"]')
  149. else
  150. find('[role="searchbox"]')
  151. end
  152. searchbox.fill_in with: option
  153. find('[role="option"]', text: option, **find_options).click
  154. maybe_wait_for_form_updater
  155. rewind.call
  156. end
  157. end
  158. send_keys(:escape)
  159. wait_for_test_flag("field-tree-select-#{field_id}.closed")
  160. self # support chaining
  161. end
  162. # Selects an option in select, treeselect and autocomplete fields via its label.
  163. # NOTE: The option must be part of initial options provided by the field, no autocomplete search will occur.
  164. #
  165. # Usage:
  166. #
  167. # find_select('Owner').select_option('Test Admin Agent')
  168. # find_treeselect('Tree Select').select_option('Parent 1::Option A')
  169. # find_autocomplete('Organization').select_option(secondary_organizations.last.name)
  170. #
  171. def select_option(label, **find_options)
  172. return select_options(label, **find_options) if label.is_a?(Array)
  173. return select_treeselect_option(label, **find_options) if type_treeselect?
  174. return select_tags_option(label, **find_options) if type_tags?
  175. return select_autocomplete_option(label, **find_options) if autocomplete?
  176. raise 'Element is not a field of type select' if !type_select?
  177. element.click
  178. wait_for_test_flag('common-select.opened')
  179. # calculate before closing, since we cannot access it, if dialog is closed
  180. is_multi_select = multi_select?
  181. select_option_by_label(label, **find_options)
  182. send_keys(:escape) if is_multi_select
  183. wait_for_test_flag('common-select.closed')
  184. self # support chaining
  185. end
  186. # Selects multiple options in select, treeselect and autocomplete fields via its label.
  187. # NOTE: The option must be part of initial options provided by the field, no autocomplete search will occur.
  188. # NOTE: The field must support multiple selection, otherwise an error will be raised.
  189. #
  190. # Usage:
  191. #
  192. # find_select('Multi Select').select_options(['Option 1', 'Option 2'])
  193. # find_treeselect('Multi Tree Select').select_options(['Parent 1::Option A', 'Parent 2::Option C'])
  194. # find_autocomplete('Tags').select_options(%w[foo bar])
  195. #
  196. def select_options(labels, **find_options)
  197. return select_treeselect_options(labels, **find_options) if type_treeselect?
  198. return select_tags_options(labels, **find_options) if type_tags?
  199. return select_autocomplete_options(labels, **find_options) if autocomplete?
  200. raise 'Element is not a field of type select' if !type_select?
  201. element.click
  202. wait_for_test_flag('common-select.opened')
  203. raise 'Field does not support multiple selection' if !multi_select?
  204. labels.each do |label|
  205. select_option_by_label(label, **find_options)
  206. end
  207. send_keys(:escape)
  208. wait_for_test_flag('common-select.closed')
  209. self # support chaining
  210. end
  211. # Clears selection in select, treeselect and autocomplete fields.
  212. # NOTE: The field must support selection clearing, otherwise an error will be raised.
  213. def clear_selection
  214. raise 'Field does not support clearing selection' if !type_select? && !type_treeselect? && !autocomplete?
  215. element.find('[role="button"][aria-label="Clear Selection"]').click
  216. maybe_wait_for_form_updater
  217. self # support chaining
  218. end
  219. # Types the provided text into an input or editor field.
  220. #
  221. # Usage:
  222. #
  223. # find_input('Title').type(body)
  224. # find_editor('Text').type(body)
  225. #
  226. def type(text, **type_options)
  227. return type_editor(text, **type_options) if type_editor?
  228. input_element.fill_in with: text
  229. maybe_wait_for_form_updater
  230. self # support chaining
  231. end
  232. def type_editor(text, click: true)
  233. raise 'Field does not support typing' if !type_editor?
  234. cursor_home_shortcut = mac_platform? ? %i[command up] : %i[control home]
  235. input_element.click.send_keys(cursor_home_shortcut) if click
  236. input_element.send_keys(text)
  237. maybe_wait_for_form_updater
  238. self # support chaining
  239. end
  240. # Clears the input of text, editor, date and datetime fields.
  241. def clear
  242. return clear_date if type_date? || type_datetime?
  243. raise 'Field does not support clearing' if !input? && !type_editor?
  244. input_element.click.send_keys([magic_key, 'a'], :backspace)
  245. maybe_wait_for_form_updater
  246. self # support chaining
  247. end
  248. # Selects a date in a date picker field.
  249. #
  250. # Usage:
  251. # find_datepicker('Date Picker').select_date(Date.today)
  252. # find_datepicker('Date Picker').select_date('2023-01-01')
  253. #
  254. def select_date(date)
  255. raise 'Field does not support selecting dates' if !type_date? && !type_datetime?
  256. element.click
  257. wait_until_opened
  258. date = Date.parse(date) if !date.is_a?(Date) && !date.is_a?(DateTime) && !date.is_a?(Time)
  259. element.find('[aria-label*="Open the years overlay"]').click
  260. element.find('.dp__overlay_col', text: date.year).click
  261. element.find('[aria-label*="Open the months overlay"]').click
  262. element.find('.dp__overlay_col', text: date.strftime('%b')).click
  263. id = date.strftime('%Y-%m-%d')
  264. element.find_by_id(id).click # rubocop:disable Rails/DynamicFindBy
  265. if desktop_view?
  266. element.click
  267. wait_until_opened
  268. end
  269. yield if block_given?
  270. # close_date_picker(element)
  271. # wait_until_closed
  272. maybe_wait_for_form_updater
  273. self # support chaining
  274. end
  275. # Selects a date and enters time in a datetime picker field.
  276. #
  277. # Usage:
  278. # find_datepicker('Date Time').select_datetime(DateTime.now)
  279. # find_datepicker('Date Time').select_datetime('2023-01-01T09:00:00.000Z')
  280. #
  281. def select_datetime(datetime)
  282. raise 'Field does not support selecting datetimes' if !type_datetime?
  283. datetime = DateTime.parse(datetime) if !datetime.is_a?(DateTime) && !datetime.is_a?(Time)
  284. select_date(datetime) do
  285. element.find('[aria-label="Open the time picker"]').click
  286. element.find('[aria-label*="Open the hours overlay"]').click
  287. element.find('.dp__overlay_col', text: format('%02d', datetime.hour)).click
  288. element.find('[aria-label*="Open the minutes overlay"]').click
  289. element.find('.dp__overlay_col', text: format('%02d', datetime.min)).click
  290. meridian_indicator = element.find('[aria-label="Toggle AM/PM mode"]')
  291. meridian_indicator.click if meridian_indicator.text != datetime.strftime('%p')
  292. end
  293. end
  294. # Types date into a date field.
  295. #
  296. # Usage:
  297. # find_datepicker('Date Picker').type_date(Date.today)
  298. # find_datepicker('Date Picker').type_date('2023-01-01')
  299. #
  300. def type_date(date)
  301. raise 'Field does not support typing dates' if !type_date?
  302. date = Date.parse(date) if !date.is_a?(Date)
  303. # TODO: Support locales other than `en`, depending on the language of the current user.
  304. input_element.fill_in with: date.strftime('%m/%d/%Y')
  305. input_element.send_keys :return
  306. # wait_for_test_flag("field-date-time-#{field_id}.opened")
  307. # close_date_picker(element)
  308. # wait_for_test_flag("field-date-time-#{field_id}.closed")
  309. maybe_wait_for_form_updater
  310. self # support chaining
  311. end
  312. # Types date and time into a date field.
  313. #
  314. # Usage:
  315. # find_datepicker('Date Time').type_datetime(DateTime.now)
  316. # find_datepicker('Date Picker').type_datetime('2023-01-01T09:00:00.000Z')
  317. #
  318. def type_datetime(datetime)
  319. raise 'Field does not support typing datetimes' if !type_datetime?
  320. datetime = DateTime.parse(datetime) if !datetime.is_a?(DateTime) && !datetime.is_a?(Time)
  321. # TODO: Support locales other than `en`, depending on the language of the current user.
  322. input_element.fill_in with: datetime.strftime('%m/%d/%Y %-l:%M %P')
  323. input_element.send_keys :return
  324. # wait_for_test_flag("field-date-time-#{field_id}.opened")
  325. # close_date_picker(element)
  326. # wait_for_test_flag("field-date-time-#{field_id}.closed")
  327. maybe_wait_for_form_updater
  328. self # support chaining
  329. end
  330. # Selects a choice in a radio form field.
  331. #
  332. # Usage:
  333. #
  334. # find_radio('articleSenderType').select_option('Outbound Call')
  335. #
  336. def select_choice(choice, **find_options)
  337. raise 'Field does not support choice selection' if !type_radio?
  338. input_element.find('label', exact_text: choice, **find_options).click
  339. maybe_wait_for_form_updater
  340. self # support chaining
  341. end
  342. def toggle
  343. raise 'Field does not support toggling' if !type_toggle? && !type_checkbox?
  344. element.find('label').click
  345. self # support chaining
  346. end
  347. def toggle_on
  348. raise 'Field does not support toggling on' if !type_toggle? && !type_checkbox?
  349. element.find('label').click if input_element['aria-checked'] == 'false' || !input_element.checked?
  350. self # support chaining
  351. end
  352. def toggle_off
  353. raise 'Field does not support toggling off' if !type_toggle? && !type_checkbox?
  354. element.find('label').click if input_element['aria-checked'] == 'true' || input_element.checked?
  355. self # support chaining
  356. end
  357. def open
  358. element.click
  359. wait_until_opened
  360. self # support chaining
  361. end
  362. def close
  363. send_keys(:escape)
  364. wait_until_closed
  365. self # support chaining
  366. end
  367. def menu_element
  368. return dropdown_element if desktop_view?
  369. dialog_element
  370. end
  371. # Dropdowns are teleported to the root element, so we must search them within the document body.
  372. # In order to improve the test performance, we don't do any implicit waits here.
  373. # Instead, we do explicit waits when opening/closing dropdowns within the actions.
  374. def dropdown_element
  375. if type_select? || type_tags? || autocomplete?
  376. page.find('#common-select > [role="menu"]', wait: false)
  377. elsif type_treeselect?
  378. page.find('#field-tree-select-input-dropdown > [role="menu"]', wait: false)
  379. end
  380. end
  381. # Dialogs are teleported to the root element, so we must search them within the document body.
  382. # In order to improve the test performance, we don't do any implicit waits here.
  383. # Instead, we do explicit waits when opening/closing dialogs within the actions.
  384. def dialog_element
  385. if type_select?
  386. page.find('#common-select[role="dialog"]', wait: false)
  387. elsif type_treeselect?
  388. page.find("#dialog-field-tree-select-#{field_id}", wait: false)
  389. elsif type_tags?
  390. page.find("#dialog-field-tags-#{field_id}", wait: false)
  391. elsif autocomplete?
  392. page.find("#dialog-field-auto-complete-#{field_id}", wait: false)
  393. end
  394. end
  395. private
  396. def method_missing(method_name, *, &)
  397. # Simulate pseudo-methods in format of `#type_[name]?` in order to determine the internal type of the field.
  398. if method_name.to_s =~ %r{^type_(.+)\?$}
  399. return element['data-type'] == $1
  400. end
  401. super
  402. end
  403. def respond_to_missing?(method_name, include_private = false)
  404. method_name.to_s =~ %r{^type_(.+)\?$} || super
  405. end
  406. def input?
  407. type_text? || type_color? || type_email? || type_number? || type_tel? || type_url? || type_password?
  408. end
  409. def autocomplete?
  410. type_autocomplete? || type_customer? || type_organization? || type_recipient? || type_externalDataSource?
  411. end
  412. # Input elements in supported fields define data attribute for "multiple" state.
  413. def multi_select?
  414. input_element['data-multiple'] == 'true'
  415. end
  416. def wait_until_opened
  417. return wait_until_opened_desktop_view if desktop_view?
  418. return wait_for_test_flag('common-select.opened') if type_select?
  419. return wait_for_test_flag("field-tree-select-#{field_id}.opened") if type_treeselect?
  420. return wait_for_test_flag("field-date-time-#{field_id}.opened") if type_date? || type_datetime?
  421. return wait_for_test_flag("field-tags-#{field_id}.opened") if type_tags?
  422. return wait_for_test_flag("field-auto-complete-#{field_id}.opened") if autocomplete?
  423. raise "Couldn't detect if element was opened"
  424. end
  425. def wait_until_opened_desktop_view
  426. return wait_for_test_flag('common-select.opened') if type_select? || type_tags? || autocomplete?
  427. return wait_for_test_flag('field-tree-select-input-dropdown.opened') if type_treeselect?
  428. return wait_for_test_flag('field-date-time.opened') if type_date? || type_datetime?
  429. raise "Couldn't detect if element was opened"
  430. end
  431. def wait_until_closed
  432. return wait_until_closed_desktop_view if desktop_view?
  433. return wait_for_test_flag('common-select.closed') if type_select?
  434. return wait_for_test_flag("field-tree-select-#{field_id}.closed") if type_treeselect?
  435. return wait_for_test_flag("field-date-time-#{field_id}.closed") if type_date? || type_datetime?
  436. return wait_for_test_flag("field-tags-#{field_id}.closed") if type_tags?
  437. return wait_for_test_flag("field-auto-complete-#{field_id}.closed") if autocomplete?
  438. raise "Couldn't detect if element was closed"
  439. end
  440. def wait_until_closed_desktop_view
  441. return wait_for_test_flag('common-select.closed') if type_select? || type_tags? || autocomplete?
  442. return wait_for_test_flag('field-tree-select-input-dropdown.closed') if type_treeselect?
  443. return wait_for_test_flag('field-date-time.closed') if type_date? || type_datetime?
  444. raise "Couldn't detect if element was closed"
  445. end
  446. def select_option_by_label(label, **find_options)
  447. within menu_element do
  448. find('[role="option"]', text: label, **find_options).click
  449. maybe_wait_for_form_updater
  450. end
  451. end
  452. def browse_for_option(path, **find_options)
  453. components = path.split('::')
  454. # Goes back to the root page by clicking on back button multiple times.
  455. rewind = proc do
  456. depth = components.size - 1
  457. depth.times do
  458. find('[role="button"][aria-label="Back to previous page"]').click
  459. end
  460. end
  461. child_menu_button = if desktop_view?
  462. 'div[role="button"]'
  463. else
  464. 'svg[role=link]'
  465. end
  466. components.each_with_index do |option, index|
  467. # Child option is always the last item.
  468. if index == components.size - 1
  469. within menu_element do
  470. yield option, rewind
  471. end
  472. next
  473. end
  474. # Parents come before.
  475. within menu_element do
  476. find('[role="option"] span', text: option, **find_options).sibling(child_menu_button).click
  477. end
  478. end
  479. end
  480. def search_for_tags_option(query, gql_filename: '', gql_number: 1)
  481. element.click
  482. wait_until_opened
  483. within menu_element do
  484. # Filter input in desktop view is part of the element, not the dropdown/dialog.
  485. searchbox = if desktop_view?
  486. element.find('[role="searchbox"]')
  487. else
  488. find('[role="searchbox"]')
  489. end
  490. searchbox.fill_in with: query
  491. send_keys(:tab)
  492. wait_for_autocomplete_gql(gql_filename, gql_number)
  493. end
  494. send_keys(:escape)
  495. wait_until_closed
  496. maybe_wait_for_form_updater
  497. self # support chaining
  498. end
  499. def search_for_autocomplete_option(query, label: query, gql_filename: '', gql_number: 1, already_open: false, use_action: false, **find_options)
  500. if !already_open
  501. element.click
  502. wait_until_opened
  503. end
  504. # calculate before closing, since we cannot access it, if dialog is closed
  505. is_multi_select = multi_select?
  506. within menu_element do
  507. # Filter input in desktop view is part of the element, not the dropdown/dialog.
  508. searchbox = if desktop_view?
  509. element.find('[role="searchbox"]')
  510. else
  511. find('[role="searchbox"]')
  512. end
  513. searchbox.fill_in with: query
  514. wait_for_autocomplete_gql(gql_filename, gql_number)
  515. if use_action
  516. find('[role="button"]', **find_options).click
  517. else
  518. find('[role="option"]', text: label, **find_options).click
  519. end
  520. maybe_wait_for_form_updater
  521. end
  522. send_keys(:escape) if is_multi_select
  523. wait_until_closed
  524. self # support chaining
  525. end
  526. def search_for_tags_options(queries, gql_filename: '', gql_number: 1)
  527. element.click
  528. wait_until_opened
  529. raise 'Field does not support multiple selection' if !multi_select?
  530. within menu_element do
  531. queries.each do |query|
  532. # Filter input in desktop view is part of the element, not the dropdown/dialog.
  533. searchbox = if desktop_view?
  534. element.find('[role="searchbox"]')
  535. else
  536. find('[role="searchbox"]')
  537. end
  538. searchbox.fill_in with: query
  539. send_keys(:tab)
  540. wait_for_autocomplete_gql(gql_filename, gql_number)
  541. end
  542. end
  543. send_keys(:escape)
  544. wait_until_closed
  545. maybe_wait_for_form_updater
  546. self # support chaining
  547. end
  548. def search_for_autocomplete_options(queries, labels: queries, gql_filename: '', gql_number: 1, use_action: false, **find_options)
  549. element.click
  550. wait_until_opened
  551. within menu_element do
  552. queries.each_with_index do |query, index|
  553. # Filter input in desktop view is part of the element, not the dropdown/dialog.
  554. searchbox = if desktop_view?
  555. element.find('[role="searchbox"]')
  556. else
  557. find('[role="searchbox"]')
  558. end
  559. searchbox.fill_in with: query
  560. wait_for_autocomplete_gql(gql_filename, gql_number + index)
  561. raise 'Field does not support multiple selection' if !multi_select?
  562. if use_action
  563. find('[role="button"]', text: 'add new email address', **find_options).click
  564. else
  565. find('[role="option"]', text: labels[index], **find_options).click
  566. end
  567. maybe_wait_for_form_updater
  568. if !use_action
  569. find('[aria-label="Clear Search"]').click
  570. end
  571. end
  572. end
  573. send_keys(:escape)
  574. wait_until_closed
  575. self # support chaining
  576. end
  577. def select_treeselect_option(label, **find_options)
  578. element.click
  579. wait_until_opened
  580. # calculate before closing, since we cannot access it, if dialog is closed
  581. is_multi_select = multi_select?
  582. browse_for_option(label, **find_options) do |option|
  583. find('[role="option"]', text: option, **find_options).click
  584. maybe_wait_for_form_updater
  585. end
  586. send_keys(:escape) if is_multi_select
  587. wait_until_closed
  588. self # support chaining
  589. end
  590. def select_tags_option(label, **find_options)
  591. element.click
  592. wait_until_opened
  593. select_option_by_label(label, **find_options)
  594. send_keys(:escape)
  595. wait_until_closed
  596. self # support chaining
  597. end
  598. def select_autocomplete_option(label, **find_options)
  599. element.click
  600. wait_until_opened
  601. # calculate before closing, since we cannot access it, if dialog is closed
  602. is_multi_select = multi_select?
  603. select_option_by_label(label, **find_options)
  604. send_keys(:escape) if is_multi_select
  605. wait_until_closed
  606. self # support chaining
  607. end
  608. def select_treeselect_options(labels, **find_options)
  609. element.click
  610. wait_until_opened
  611. raise 'Field does not support multiple selection' if !multi_select?
  612. labels.each do |label|
  613. browse_for_option(label, **find_options) do |option, rewind|
  614. find('[role="option"]', text: option, **find_options).click
  615. maybe_wait_for_form_updater
  616. rewind.call
  617. end
  618. end
  619. send_keys(:escape)
  620. wait_until_closed
  621. self # support chaining
  622. end
  623. def select_tags_options(labels, **find_options)
  624. element.click
  625. wait_until_opened
  626. raise 'Field does not support multiple selection' if !multi_select?
  627. labels.each do |label|
  628. select_option_by_label(label, **find_options)
  629. end
  630. send_keys(:escape)
  631. wait_until_closed
  632. self # support chaining
  633. end
  634. def select_autocomplete_options(labels, **find_options)
  635. element.click
  636. wait_until_opened
  637. raise 'Field does not support multiple selection' if !multi_select?
  638. labels.each do |label|
  639. select_option_by_label(label, **find_options)
  640. end
  641. send_keys(:escape)
  642. wait_until_closed
  643. self # support chaining
  644. end
  645. # If a GraphQL filename is passed, we will explicitly wait for it here.
  646. # Otherwise, we will implicitly wait for a query depending on the type of the field.
  647. # If no waits are to be done, we display a friendly warning to devs, since this can lead to some instability.
  648. # In form context, expected response number will be automatically increased and tracked.
  649. def wait_for_autocomplete_gql(gql_filename, gql_number)
  650. gql_number = autocomplete_gql_number(gql_filename) || gql_number
  651. if gql_filename.present?
  652. wait_for_gql(gql_filename, number: gql_number)
  653. elsif type_customer?
  654. query_name = if desktop_view?
  655. 'shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/generic.graphql'
  656. else
  657. 'shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/user.graphql'
  658. end
  659. wait_for_gql(query_name, number: gql_number)
  660. elsif type_organization?
  661. wait_for_gql('shared/components/Form/fields/FieldOrganization/graphql/queries/autocompleteSearch/organization.graphql', number: gql_number)
  662. elsif type_recipient?
  663. wait_for_gql('shared/components/Form/fields/FieldRecipient/graphql/queries/autocompleteSearch/recipient.graphql', number: gql_number)
  664. elsif type_externalDataSource?
  665. wait_for_gql('shared/components/Form/fields/FieldExternalDataSource/graphql/queries/autocompleteSearchObjectAttributeExternalDataSource.graphql', number: gql_number)
  666. elsif type_tags?
  667. # NB: tags autocomplete query fires only once?!
  668. wait_for_gql('shared/entities/tags/graphql/queries/autocompleteTags.graphql', number: 1, skip_clearing: true)
  669. else
  670. warn 'Warning: missing `wait_for_gql` in `search_for_autocomplete_option()`, might lead to instability'
  671. end
  672. end
  673. def autocomplete_gql_number(gql_filename)
  674. return nil if form_context.nil?
  675. return form_context.form_gql_number(:autocomplete) if gql_filename.present?
  676. return form_context.form_gql_number(:customer) if type_customer?
  677. return form_context.form_gql_number(:organization) if type_organization?
  678. return form_context.form_gql_number(:recipient) if type_recipient?
  679. return form_context.form_gql_number(:externalDataSource) if type_externalDataSource?
  680. form_context.form_gql_number(:tags) if type_tags?
  681. end
  682. def triggers_form_updater?
  683. element['data-triggers-form-updater'] == 'true'
  684. end
  685. def maybe_wait_for_form_updater
  686. return if form_context.nil? || !triggers_form_updater?
  687. gql_number = form_context.form_gql_number(:form_updater)
  688. wait_for_form_updater(gql_number)
  689. end
  690. # Click on the upper left corner of the date picker field to close it.
  691. def close_date_picker(element)
  692. element_width = element.native.size.width.to_i
  693. element_height = element.native.size.height.to_i
  694. element.click(x: -element_width / 2, y: -element_height / 2)
  695. end
  696. def clear_date
  697. element.find('[role="button"][aria-label="Clear Selection"]').click
  698. maybe_wait_for_form_updater
  699. self # support chaining
  700. end
  701. def desktop_view?
  702. RSpec.current_example.metadata[:app] == :desktop_view
  703. end
  704. end
  705. class ZammadFormContext
  706. attr_reader :context
  707. def initialize
  708. @context = {}
  709. end
  710. def init_form_updater(number)
  711. context[:gql_number] = {}
  712. context[:gql_number][:form_updater] = number
  713. end
  714. def form_gql_number(name)
  715. if context[:gql_number].nil?
  716. context[:gql_number] = {}
  717. end
  718. if context[:gql_number][name].nil?
  719. context[:gql_number][name] = 1
  720. else
  721. context[:gql_number][name] += 1
  722. end
  723. context[:gql_number][name]
  724. end
  725. end
  726. RSpec.configure do |config|
  727. config.include FormHelpers, type: :system, app: :mobile
  728. config.include FormHelpers, type: :system, app: :desktop_view
  729. end