input.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import {
  2. ChangeEvent,
  3. KeyboardEvent,
  4. useCallback,
  5. useEffect,
  6. useState,
  7. useRef,
  8. } from 'react'
  9. import { Nullable } from '../../../../../../../types/utils'
  10. import { getJSON } from '../../../../../infrastructure/fetch-json'
  11. import useAbortController from '../../../../../shared/hooks/use-abort-controller'
  12. import domainBlocklist from '../../../domain-blocklist'
  13. import { debugConsole } from '@/utils/debugging'
  14. import OLFormControl from '@/features/ui/components/ol/ol-form-control'
  15. const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
  16. function matchLocalAndDomain(emailHint: string) {
  17. const match = emailHint.match(LOCAL_AND_DOMAIN_REGEX)
  18. if (match) {
  19. return { local: match[1], domain: match[2] }
  20. } else {
  21. return { local: null, domain: null }
  22. }
  23. }
  24. export type DomainInfo = {
  25. hostname: string
  26. confirmed?: boolean
  27. university: {
  28. id: number
  29. name: string
  30. ssoEnabled?: boolean
  31. ssoBeta?: boolean
  32. departments?: string[]
  33. }
  34. }
  35. let domainCache = new Map<string, DomainInfo>()
  36. export function clearDomainCache() {
  37. domainCache = new Map<string, DomainInfo>()
  38. }
  39. type InputProps = {
  40. onChange: (value: string, domain?: DomainInfo) => void
  41. handleAddNewEmail: () => void
  42. }
  43. function Input({ onChange, handleAddNewEmail }: InputProps) {
  44. const { signal } = useAbortController()
  45. const inputRef = useRef<HTMLInputElement>(null)
  46. const [suggestion, setSuggestion] = useState<string | null>(null)
  47. const [inputValue, setInputValue] = useState<string | null>(null)
  48. const [matchedDomain, setMatchedDomain] = useState<DomainInfo | null>(null)
  49. useEffect(() => {
  50. inputRef.current?.focus()
  51. }, [inputRef])
  52. useEffect(() => {
  53. if (inputValue == null) {
  54. return
  55. }
  56. if (matchedDomain && inputValue.endsWith(matchedDomain.hostname)) {
  57. onChange(inputValue, matchedDomain)
  58. } else {
  59. onChange(inputValue)
  60. }
  61. }, [onChange, inputValue, suggestion, matchedDomain])
  62. const handleEmailChange = useCallback(
  63. (event: ChangeEvent<HTMLInputElement>) => {
  64. const hint = event.target.value
  65. setInputValue(hint)
  66. const { local, domain } = matchLocalAndDomain(hint)
  67. if (domain && !matchedDomain?.hostname.startsWith(domain)) {
  68. setSuggestion(null)
  69. }
  70. if (!domain) {
  71. return
  72. }
  73. if (domainCache.has(domain)) {
  74. const cachedDomain = domainCache.get(domain) as DomainInfo
  75. setSuggestion(`${local}@${cachedDomain.hostname}`)
  76. setMatchedDomain(cachedDomain)
  77. return
  78. }
  79. if (domainBlocklist.some(d => domain.endsWith(d))) {
  80. return
  81. }
  82. const query = `?hostname=${domain}&limit=1`
  83. getJSON<Nullable<DomainInfo[]>>(`/institutions/domains${query}`, {
  84. signal,
  85. })
  86. .then(data => {
  87. if (!(data && data[0])) {
  88. return
  89. }
  90. if (domainBlocklist.some(d => data[0].hostname.endsWith(d))) {
  91. return
  92. }
  93. const hostname = data[0]?.hostname
  94. if (hostname) {
  95. domainCache.set(domain, data[0])
  96. setSuggestion(`${local}@${hostname}`)
  97. setMatchedDomain(data[0])
  98. } else {
  99. setSuggestion(null)
  100. setMatchedDomain(null)
  101. }
  102. })
  103. .catch(error => {
  104. debugConsole.error(error)
  105. setSuggestion(null)
  106. setMatchedDomain(null)
  107. })
  108. },
  109. [signal, matchedDomain]
  110. )
  111. const handleKeyDownEvent = useCallback(
  112. (event: KeyboardEvent) => {
  113. const setInputValueAndResetSuggestion = () => {
  114. setInputValue(suggestion)
  115. setSuggestion(null)
  116. }
  117. if (event.key === 'Enter') {
  118. event.preventDefault()
  119. if (suggestion) {
  120. setInputValueAndResetSuggestion()
  121. return
  122. }
  123. if (!inputValue) {
  124. return
  125. }
  126. const match = matchLocalAndDomain(inputValue)
  127. if (match.local && match.domain) {
  128. handleAddNewEmail()
  129. }
  130. }
  131. if (event.key === 'Tab' && suggestion) {
  132. event.preventDefault()
  133. setInputValueAndResetSuggestion()
  134. }
  135. },
  136. [inputValue, suggestion, handleAddNewEmail]
  137. )
  138. useEffect(() => {
  139. if (!inputValue) {
  140. setSuggestion(null)
  141. } else if (suggestion && !suggestion.startsWith(inputValue)) {
  142. setSuggestion(null)
  143. }
  144. }, [suggestion, inputValue])
  145. return (
  146. <div className="input-suggestions">
  147. <OLFormControl
  148. data-testid="affiliations-email-shadow"
  149. readOnly
  150. className="input-suggestions-shadow"
  151. value={suggestion || ''}
  152. />
  153. <OLFormControl
  154. id="affiliations-email"
  155. data-testid="affiliations-email"
  156. className="input-suggestions-main"
  157. type="email"
  158. onChange={handleEmailChange}
  159. onKeyDown={handleKeyDownEvent}
  160. value={inputValue || ''}
  161. placeholder="e.g. johndoe@mit.edu"
  162. ref={inputRef}
  163. />
  164. </div>
  165. )
  166. }
  167. export default Input