hydrate-form.ts 13 KB

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