hydrate-form.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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 (formEl.hasAttribute('captcha')) {
  83. if (
  84. formEl.getAttribute('action') === '/login' &&
  85. (await canSkipCaptcha(new FormData(formEl).get('email')))
  86. ) {
  87. // The email is present in the deviceHistory, and we can skip the display
  88. // of a captcha challenge.
  89. // The actual login POST request will be checked against the deviceHistory
  90. // again and the server can trigger the display of a captcha if needed by
  91. // sending a 400 with errorReason set to 'cannot_verify_user_not_robot'.
  92. return ''
  93. }
  94. captchaResponse = await validateCaptchaV2()
  95. }
  96. return captchaResponse
  97. }
  98. async function sendFormRequest(formEl, captchaResponse) {
  99. const formData = new FormData(formEl)
  100. if (captchaResponse) {
  101. formData.set('g-recaptcha-response', captchaResponse)
  102. }
  103. const body = Object.fromEntries(
  104. Array.from(formData.keys(), key => {
  105. // forms may have multiple keys with the same name, eg: checkboxes
  106. const val = formData.getAll(key)
  107. return [key, val.length > 1 ? val : val.pop()]
  108. })
  109. )
  110. const url = formEl.getAttribute('action')
  111. return postJSON(url, { body })
  112. }
  113. function hideFormElements(formEl) {
  114. for (const e of formEl.elements) {
  115. e.hidden = true
  116. }
  117. }
  118. // TODO: remove the showMessages function after every form alerts are updated to use the new style
  119. // TODO: rename showMessagesNewStyle to showMessages after the above is done
  120. function showMessages(formEl, messageBag) {
  121. const messagesEl = formEl.querySelector('[data-ol-form-messages]')
  122. if (!messagesEl) return
  123. // Clear content
  124. messagesEl.textContent = ''
  125. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  126. el.hidden = true
  127. })
  128. // Render messages
  129. messageBag.forEach(message => {
  130. const customErrorElements = message.key
  131. ? formEl.querySelectorAll(
  132. `[data-ol-custom-form-message="${message.key}"]`
  133. )
  134. : []
  135. if (message.key && customErrorElements.length > 0) {
  136. // Found at least one custom error element for key, show them
  137. customErrorElements.forEach(el => {
  138. el.hidden = false
  139. })
  140. } else {
  141. // No custom error element for key on page, append a new error message
  142. const messageEl = document.createElement('div')
  143. messageEl.className = classNames('alert mb-2', {
  144. 'alert-danger': message.type === 'error',
  145. 'alert-success': message.type !== 'error',
  146. })
  147. messageEl.textContent = message.text || `Error: ${message.key}`
  148. messageEl.setAttribute('aria-live', 'assertive')
  149. messageEl.setAttribute(
  150. 'role',
  151. message.type === 'error' ? 'alert' : 'status'
  152. )
  153. if (message.hints && message.hints.length) {
  154. const listEl = document.createElement('ul')
  155. message.hints.forEach(hint => {
  156. const listItemEl = document.createElement('li')
  157. listItemEl.textContent = hint
  158. listEl.append(listItemEl)
  159. })
  160. messageEl.append(listEl)
  161. }
  162. messagesEl.append(messageEl)
  163. }
  164. if (message.key) {
  165. // Hide the form elements on specific message types
  166. const hideOnError = formEl.attributes['data-ol-hide-on-error']
  167. if (
  168. hideOnError &&
  169. hideOnError.value &&
  170. hideOnError.value.match(message.key)
  171. ) {
  172. hideFormElements(formEl)
  173. }
  174. // Hide any elements with specific `data-ol-hide-on-error-message` message
  175. document
  176. .querySelectorAll(`[data-ol-hide-on-error-message="${message.key}"]`)
  177. .forEach(el => {
  178. el.hidden = true
  179. })
  180. }
  181. })
  182. }
  183. function showMessagesNewStyle(formEl, messageBag) {
  184. const messagesEl = formEl.querySelector('[data-ol-form-messages-new-style]')
  185. if (!messagesEl) return
  186. // Clear content
  187. messagesEl.textContent = ''
  188. formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
  189. el.hidden = true
  190. })
  191. // Render messages
  192. messageBag.forEach(message => {
  193. const customErrorElements = message.key
  194. ? formEl.querySelectorAll(
  195. `[data-ol-custom-form-message="${message.key}"]`
  196. )
  197. : []
  198. if (message.key && customErrorElements.length > 0) {
  199. // Found at least one custom error element for key, show them
  200. customErrorElements.forEach(el => {
  201. el.hidden = false
  202. })
  203. } else {
  204. // No custom error element for key on page, append a new error message
  205. const messageElContainer = document.createElement('div')
  206. messageElContainer.className = classNames('notification', {
  207. 'notification-type-error': message.type === 'error',
  208. 'notification-type-success': message.type !== 'error',
  209. })
  210. const messageEl = document.createElement('div')
  211. // create the message text
  212. messageEl.className = 'notification-content text-left'
  213. messageEl.textContent = message.text || `Error: ${message.key}`
  214. messageEl.setAttribute('aria-live', 'assertive')
  215. messageEl.setAttribute(
  216. 'role',
  217. message.type === 'error' ? 'alert' : 'status'
  218. )
  219. if (message.hints && message.hints.length) {
  220. const listEl = document.createElement('ul')
  221. message.hints.forEach(hint => {
  222. const listItemEl = document.createElement('li')
  223. listItemEl.textContent = hint
  224. listEl.append(listItemEl)
  225. })
  226. messageEl.append(listEl)
  227. }
  228. // create the left icon
  229. const icon = document.createElement('span')
  230. icon.className = 'material-symbols'
  231. icon.setAttribute('aria-hidden', 'true')
  232. icon.innerText = message.type === 'error' ? 'error' : 'check_circle'
  233. const messageIcon = document.createElement('div')
  234. messageIcon.className = 'notification-icon'
  235. messageIcon.appendChild(icon)
  236. // append icon first so it's on the left
  237. messageElContainer.appendChild(messageIcon)
  238. messageElContainer.appendChild(messageEl)
  239. messagesEl.append(messageElContainer)
  240. }
  241. if (message.key) {
  242. // Hide the form elements on specific message types
  243. const hideOnError = formEl.attributes['data-ol-hide-on-error']
  244. if (
  245. hideOnError &&
  246. hideOnError.value &&
  247. hideOnError.value.match(message.key)
  248. ) {
  249. hideFormElements(formEl)
  250. }
  251. // Hide any elements with specific `data-ol-hide-on-error-message` message
  252. document
  253. .querySelectorAll(`[data-ol-hide-on-error-message="${message.key}"]`)
  254. .forEach(el => {
  255. el.hidden = true
  256. })
  257. }
  258. })
  259. }
  260. export function inflightHelper(el) {
  261. const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
  262. const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
  263. const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
  264. el.addEventListener('pending', () => {
  265. disabledInflight.forEach(disableElement)
  266. toggleDisplay(showWhenNotInflight, showWhenInflight)
  267. })
  268. el.addEventListener('idle', () => {
  269. disabledInflight.forEach(enableElement)
  270. toggleDisplay(showWhenInflight, showWhenNotInflight)
  271. })
  272. }
  273. function formSentHelper(el) {
  274. const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
  275. const showWhenDone = el.querySelectorAll('[data-ol-sent]')
  276. if (showWhenDone.length === 0) return
  277. el.addEventListener('sent', () => {
  278. toggleDisplay(showWhenPending, showWhenDone)
  279. })
  280. }
  281. function formValidationHelper(el) {
  282. el.querySelectorAll('input').forEach(inputEl => {
  283. if (
  284. inputEl.willValidate &&
  285. !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
  286. ) {
  287. inputValidator(inputEl)
  288. }
  289. })
  290. }
  291. function formAutoSubmitHelper(el) {
  292. if (el.hasAttribute('data-ol-auto-submit')) {
  293. setTimeout(() => {
  294. el.querySelector('[type="submit"]').click()
  295. }, 0)
  296. }
  297. }
  298. export function toggleDisplay(hide, show) {
  299. hide.forEach(el => {
  300. el.hidden = true
  301. })
  302. show.forEach(el => {
  303. el.hidden = false
  304. })
  305. }
  306. function hydrateAsyncForm(el) {
  307. formSubmitHelper(el)
  308. inflightHelper(el)
  309. formSentHelper(el)
  310. formValidationHelper(el)
  311. formAutoSubmitHelper(el)
  312. }
  313. function hydrateRegularForm(el) {
  314. inflightHelper(el)
  315. formValidationHelper(el)
  316. el.addEventListener('submit', () => {
  317. el.dispatchEvent(new Event('pending'))
  318. })
  319. formAutoSubmitHelper(el)
  320. }
  321. document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
  322. document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)