hydrate-form.js 13 KB

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