add-email-input.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. import {
  2. ChangeEvent,
  3. KeyboardEvent,
  4. useCallback,
  5. useEffect,
  6. useState,
  7. } from 'react'
  8. import { getJSON } from '../../../../infrastructure/fetch-json'
  9. import useAbortController from '../../../../shared/hooks/use-abort-controller'
  10. const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
  11. function matchLocalAndDomain(emailHint: string) {
  12. const match = emailHint.match(LOCAL_AND_DOMAIN_REGEX)
  13. if (match) {
  14. return { local: match[1], domain: match[2] }
  15. } else {
  16. return { local: null, domain: null }
  17. }
  18. }
  19. type InstitutionInfo = { hostname: string; university: { id: number } }
  20. let domainCache = new Map<string, InstitutionInfo>()
  21. export function clearDomainCache() {
  22. domainCache = new Map<string, InstitutionInfo>()
  23. }
  24. type AddEmailInputProps = {
  25. onChange: (value: string, institution?: InstitutionInfo) => void
  26. }
  27. export function AddEmailInput({ onChange }: AddEmailInputProps) {
  28. const { signal } = useAbortController()
  29. const [suggestion, setSuggestion] = useState<string>(null)
  30. const [inputValue, setInputValue] = useState<string>(null)
  31. const [matchedInstitution, setMatchedInstitution] =
  32. useState<InstitutionInfo>(null)
  33. useEffect(() => {
  34. if (inputValue == null) {
  35. return
  36. }
  37. if (matchedInstitution && suggestion === inputValue) {
  38. onChange(inputValue, matchedInstitution)
  39. } else {
  40. onChange(inputValue)
  41. }
  42. }, [onChange, inputValue, suggestion, matchedInstitution])
  43. const handleEmailChange = useCallback(
  44. (event: ChangeEvent<HTMLInputElement>) => {
  45. const hint = event.target.value
  46. setInputValue(hint)
  47. const match = matchLocalAndDomain(hint)
  48. if (!matchedInstitution?.hostname.startsWith(match.domain)) {
  49. setSuggestion(null)
  50. }
  51. if (!match.domain) {
  52. return
  53. }
  54. if (domainCache.has(match.domain)) {
  55. const cachedDomain = domainCache.get(match.domain)
  56. setSuggestion(`${match.local}@${cachedDomain.hostname}`)
  57. setMatchedInstitution(cachedDomain)
  58. return
  59. }
  60. const query = `?hostname=${match.domain}&limit=1`
  61. getJSON(`/institutions/domains${query}`, { signal })
  62. .then(data => {
  63. if (!(data && data[0])) {
  64. return
  65. }
  66. const hostname = data[0]?.hostname
  67. if (hostname) {
  68. domainCache.set(match.domain, data[0])
  69. setSuggestion(`${match.local}@${hostname}`)
  70. setMatchedInstitution(data[0])
  71. } else {
  72. setSuggestion(null)
  73. setMatchedInstitution(null)
  74. }
  75. })
  76. .catch(error => {
  77. setSuggestion(null)
  78. setMatchedInstitution(null)
  79. console.error(error)
  80. })
  81. },
  82. [signal, matchedInstitution]
  83. )
  84. const handleKeyDownEvent = useCallback(
  85. (event: KeyboardEvent) => {
  86. if (event.key === 'Tab' || event.key === 'Enter') {
  87. event.preventDefault()
  88. if (suggestion) {
  89. setInputValue(suggestion)
  90. }
  91. }
  92. },
  93. [suggestion]
  94. )
  95. return (
  96. <div className="input-suggestions">
  97. <div className="form-control input-suggestions-shadow">
  98. <div className="input-suggestions-shadow-suggested">
  99. {suggestion || ''}
  100. </div>
  101. </div>
  102. <input
  103. id="affiliations-email"
  104. className="form-control input-suggestions-main"
  105. type="email"
  106. onChange={handleEmailChange}
  107. onKeyDown={handleKeyDownEvent}
  108. value={inputValue || ''}
  109. placeholder="e.g. johndoe@mit.edu"
  110. />
  111. </div>
  112. )
  113. }