hydrate-form.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import classNames from 'classnames'
  2. import { FetchError, postJSON } from '../../infrastructure/fetch-json'
  3. import { canSkipCaptcha, validateCaptchaV2 } from './captcha'
  4. import inputValidator from './input-validator'
  5. import { disableElement, enableElement } from '../utils/disableElement'
  6. // Form helper(s) to handle:
  7. // - Attaching to the relevant form elements
  8. // - Listening for submit event
  9. // - Validating captcha
  10. // - Sending fetch request
  11. // - Redirect handling
  12. // - Showing errors
  13. // - Disabled state
  14. function formSubmitHelper(formEl) {
  15. formEl.addEventListener('submit', async e => {
  16. e.preventDefault()
  17. formEl.dispatchEvent(new Event('pending'))
  18. const messageBag = []
  19. try {
  20. let data
  21. try {
  22. const captchaResponse = await validateCaptcha(formEl)
  23. data = await sendFormRequest(formEl, captchaResponse)
  24. } catch (e) {
  25. if (
  26. e instanceof FetchError &&
  27. e.data?.errorReason === 'cannot_verify_user_not_robot'
  28. ) {
  29. // Trigger captcha unconditionally.
  30. const captchaResponse = await validateCaptchaV2()
  31. if (!captchaResponse) {
  32. throw e
  33. }
  34. data = await sendFormRequest(formEl, captchaResponse)
  35. } else {
  36. throw e
  37. }
  38. }
  39. formEl.dispatchEvent(new Event('sent'))
  40. // Handle redirects
  41. if (data.redir || data.redirect) {
  42. window.location = data.redir || data.redirect
  43. return
  44. }
  45. // Show a success message (e.g. used on 2FA page)
  46. if (data.message) {
  47. messageBag.push({
  48. type: 'message',
  49. text: data.message.text || data.message,
  50. })
  51. }
  52. // Handle reloads
  53. if (formEl.hasAttribute('data-ol-reload-on-success')) {
  54. window.setTimeout(window.location.reload.bind(window.location), 1000)
  55. return
  56. }
  57. // Let the user re-submit the form.
  58. formEl.dispatchEvent(new Event('idle'))
  59. } catch (error) {
  60. let text = error.message
  61. if (error instanceof FetchError) {
  62. text = error.getUserFacingMessage()
  63. }
  64. messageBag.push({
  65. type: 'error',
  66. key: error.data?.message?.key,
  67. text,
  68. hints: error.data?.message?.hints,
  69. })
  70. // Let the user re-submit the form.
  71. formEl.dispatchEvent(new Event('idle'))
  72. } finally {
  73. // call old and new notification builder functions
  74. // but only one will be rendered
  75. showMessages(formEl, messageBag)
  76. showMessagesNewStyle(formEl, messageBag)
  77. }
  78. })
  79. }
  80. async function validateCaptcha(formEl) {
  81. let captchaResponse
  82. if (
  83. formEl.hasAttribute('captcha') &&
  84. // Disable captcha for E2E tests in dev-env.
  85. !(process.env.NODE_ENV === 'development' && window.Cypress)
  86. ) {
  87. if (
  88. formEl.getAttribute('action') === '/login' &&
  89. (await canSkipCaptcha(new FormData(formEl).get('email')))
  90. ) {
  91. // The email is present in the deviceHistory, and we can skip the display
  92. // of a captcha challenge.
  93. // The actual login POST request will be checked against the deviceHistory
  94. // again and the server can trigger the display of a captcha if needed by
  95. // sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
  96. return ''
  97. }
  98. captchaResponse = await validateCaptchaV2()
  99. }
  100. return captchaResponse
  101. }
  102. async function sendFormRequest(formEl, captchaResponse) {
  103. const formData = new FormData(formEl)
  104. if (captchaResponse) {
  105. formData.set('g-recaptcha-response', captchaResponse)
  106. }
  107. const body = Object.fromEntries(
  108. Array.from(formData.keys(), key => {
  109. // forms may have multiple keys with the same name, eg: checkboxes
  110. const val = formData.getAll(key)
  111. return [key, val.length > 1 ? val : val.pop()]
  112. })
  113. )
  114. const url = formEl.getAttribute('action')
  115. return postJSON(url, { body })
  116. }
  117. function hideFormElements(formEl) {
  118. for (const e of formEl.elements) {
  119. e.hidden = true
  120. }
  121. }
  122. // TODO: remove the showMessages function after every form alerts are updated to use the new style
  123. // TODO: rename showMessagesNewStyle to showMessages after the above is done
  124. function showMessages(formEl, messageBag) {
  125. const messagesEl = formEl.querySelector('[data-ol-form-messages]')
  126. if (!messagesEl) return
  127. // Clear content
  128. messagesEl.textContent = ''
  129. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  130. el.hidden = true
  131. })
  132. // Render messages
  133. messageBag.forEach(message => {
  134. const customErrorElements = message.key
  135. ? formEl.querySelectorAll(
  136. `[data-ol-custom-form-message="${message.key}"]`
  137. )
  138. : []
  139. if (message.key && customErrorElements.length > 0) {
  140. // Found at least one custom error element for key, show them
  141. customErrorElements.forEach(el => {
  142. el.hidden = false
  143. })
  144. } else {
  145. // No custom error element for key on page, append a new error message
  146. const messageEl = document.createElement('div')
  147. messageEl.className = classNames('alert mb-2', {
  148. 'alert-danger': message.type === 'error',
  149. 'alert-success': message.type !== 'error',
  150. })
  151. messageEl.textContent = message.text || `Error: ${message.key}`
  152. messageEl.setAttribute('aria-live', 'assertive')
  153. messageEl.setAttribute(
  154. 'role',
  155. message.type === 'error' ? 'alert' : 'status'
  156. )
  157. if (message.hints && message.hints.length) {
  158. const listEl = document.createElement('ul')
  159. message.hints.forEach(hint => {
  160. const listItemEl = document.createElement('li')
  161. listItemEl.textContent = hint
  162. listEl.append(listItemEl)
  163. })
  164. messageEl.append(listEl)
  165. }
  166. messagesEl.append(messageEl)
  167. }
  168. if (message.key) {
  169. // Hide the form elements on specific message types
  170. const hideOnError = formEl.attributes['data-ol-hide-on-error']
  171. if (
  172. hideOnError &&
  173. hideOnError.value &&
  174. hideOnError.value.match(message.key)
  175. ) {
  176. hideFormElements(formEl)
  177. }
  178. // Hide any elements with specific `data-ol-hide-on-error-message` message
  179. document
  180. .querySelectorAll(`[data-ol-hide-on-error-message="${message.key}"]`)
  181. .forEach(el => {
  182. el.hidden = true
  183. })
  184. }
  185. })
  186. }
  187. function showMessagesNewStyle(formEl, messageBag) {
  188. const messagesEl = formEl.querySelector('[data-ol-form-messages-new-style]')
  189. if (!messagesEl) return
  190. // Clear content
  191. messagesEl.textContent = ''
  192. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  193. el.hidden = true
  194. })
  195. // Render messages
  196. messageBag.forEach(message => {
  197. const customErrorElements = message.key
  198. ? formEl.querySelectorAll(
  199. `[data-ol-custom-form-message="${message.key}"]`
  200. )
  201. : []
  202. if (message.key && customErrorElements.length > 0) {
  203. // Found at least one custom error element for key, show them
  204. customErrorElements.forEach(el => {
  205. el.hidden = false
  206. })
  207. } else {
  208. // No custom error element for key on page, append a new error message
  209. const messageElContainer = document.createElement('div')
  210. messageElContainer.className = classNames('notification', {
  211. 'notification-type-error': message.type === 'error',
  212. 'notification-type-success': message.type !== 'error',
  213. })
  214. const messageEl = document.createElement('div')
  215. // create the message text
  216. messageEl.className = 'notification-content text-left'
  217. messageEl.textContent = message.text || `Error: ${message.key}`
  218. messageEl.setAttribute('aria-live', 'assertive')
  219. messageEl.setAttribute(
  220. 'role',
  221. message.type === 'error' ? 'alert' : 'status'
  222. )
  223. if (message.hints && message.hints.length) {
  224. const listEl = document.createElement('ul')
  225. message.hints.forEach(hint => {
  226. const listItemEl = document.createElement('li')
  227. listItemEl.textContent = hint
  228. listEl.append(listItemEl)
  229. })
  230. messageEl.append(listEl)
  231. }
  232. // create the left icon
  233. const icon = document.createElement('span')
  234. icon.className = 'material-symbols'
  235. icon.setAttribute('aria-hidden', 'true')
  236. icon.innerText = message.type === 'error' ? 'error' : 'check_circle'
  237. const messageIcon = document.createElement('div')
  238. messageIcon.className = 'notification-icon'
  239. messageIcon.appendChild(icon)
  240. // append icon first so it's on the left
  241. messageElContainer.appendChild(messageIcon)
  242. messageElContainer.appendChild(messageEl)
  243. messagesEl.append(messageElContainer)
  244. }
  245. if (message.key) {
  246. // Hide the form elements on specific message types
  247. const hideOnError = formEl.attributes['data-ol-hide-on-error']
  248. if (
  249. hideOnError &&
  250. hideOnError.value &&
  251. hideOnError.value.match(message.key)
  252. ) {
  253. hideFormElements(formEl)
  254. }
  255. // Hide any elements with specific `data-ol-hide-on-error-message` message
  256. document
  257. .querySelectorAll(`[data-ol-hide-on-error-message="${message.key}"]`)
  258. .forEach(el => {
  259. el.hidden = true
  260. })
  261. }
  262. })
  263. }
  264. export function inflightHelper(el) {
  265. const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
  266. const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
  267. const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
  268. el.addEventListener('pending', () => {
  269. disabledInflight.forEach(disableElement)
  270. toggleDisplay(showWhenNotInflight, showWhenInflight)
  271. })
  272. el.addEventListener('idle', () => {
  273. disabledInflight.forEach(enableElement)
  274. toggleDisplay(showWhenInflight, showWhenNotInflight)
  275. })
  276. }
  277. function formSentHelper(el) {
  278. const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
  279. const showWhenDone = el.querySelectorAll('[data-ol-sent]')
  280. if (showWhenDone.length === 0) return
  281. el.addEventListener('sent', () => {
  282. toggleDisplay(showWhenPending, showWhenDone)
  283. })
  284. }
  285. function formValidationHelper(el) {
  286. el.querySelectorAll('input').forEach(inputEl => {
  287. if (
  288. inputEl.willValidate &&
  289. !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
  290. ) {
  291. inputValidator(inputEl)
  292. }
  293. })
  294. }
  295. function formAutoSubmitHelper(el) {
  296. if (el.hasAttribute('data-ol-auto-submit')) {
  297. setTimeout(() => {
  298. el.querySelector('[type="submit"]').click()
  299. }, 0)
  300. }
  301. }
  302. export function toggleDisplay(hide, show) {
  303. hide.forEach(el => {
  304. el.hidden = true
  305. })
  306. show.forEach(el => {
  307. el.hidden = false
  308. })
  309. }
  310. function hydrateAsyncForm(el) {
  311. formSubmitHelper(el)
  312. inflightHelper(el)
  313. formSentHelper(el)
  314. formValidationHelper(el)
  315. formAutoSubmitHelper(el)
  316. }
  317. function hydrateRegularForm(el) {
  318. inflightHelper(el)
  319. formValidationHelper(el)
  320. el.addEventListener('submit', () => {
  321. el.dispatchEvent(new Event('pending'))
  322. })
  323. formAutoSubmitHelper(el)
  324. }
  325. document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
  326. document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)