hydrate-form.js 13 KB

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