input.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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 '@/shared/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. capturedByGroup?: boolean
  28. university: {
  29. id: number
  30. name: string
  31. ssoEnabled?: boolean
  32. ssoBeta?: boolean
  33. departments?: string[]
  34. }
  35. group: {
  36. teamName?: string
  37. managedUsersEnabled?: boolean
  38. domainCaptureEnabled?: boolean
  39. ssoConfig?: {
  40. useUkamfSettings?: boolean
  41. enabled: boolean
  42. }
  43. }
  44. }
  45. let domainCache = new Map<string, DomainInfo>()
  46. export function clearDomainCache() {
  47. domainCache = new Map<string, DomainInfo>()
  48. }
  49. type InputProps = {
  50. onChange: (value: string, domain?: DomainInfo) => void
  51. handleAddNewEmail: () => void
  52. }
  53. function Input({ onChange, handleAddNewEmail }: InputProps) {
  54. const { signal } = useAbortController()
  55. const inputRef = useRef<HTMLInputElement>(null)
  56. const [suggestion, setSuggestion] = useState<string | null>(null)
  57. const [inputValue, setInputValue] = useState<string | null>(null)
  58. const [matchedDomain, setMatchedDomain] = useState<DomainInfo | null>(null)
  59. useEffect(() => {
  60. inputRef.current?.focus()
  61. }, [inputRef])
  62. useEffect(() => {
  63. if (inputValue == null) {
  64. return
  65. }
  66. if (matchedDomain && inputValue.endsWith(matchedDomain.hostname)) {
  67. onChange(inputValue, matchedDomain)
  68. } else {
  69. onChange(inputValue)
  70. }
  71. }, [onChange, inputValue, suggestion, matchedDomain])
  72. const handleEmailChange = useCallback(
  73. (event: ChangeEvent<HTMLInputElement>) => {
  74. const hint = event.target.value
  75. setInputValue(hint)
  76. const { local, domain } = matchLocalAndDomain(hint)
  77. if (domain && !matchedDomain?.hostname.startsWith(domain)) {
  78. setSuggestion(null)
  79. }
  80. if (!domain) {
  81. return
  82. }
  83. if (domainCache.has(domain)) {
  84. const cachedDomain = domainCache.get(domain) as DomainInfo
  85. setSuggestion(`${local}@${cachedDomain.hostname}`)
  86. setMatchedDomain(cachedDomain)
  87. return
  88. }
  89. if (domainBlocklist.some(d => domain.endsWith(d))) {
  90. return
  91. }
  92. const query = `?hostname=${domain}&limit=1`
  93. getJSON<Nullable<DomainInfo[]>>(`/institutions/domains${query}`, {
  94. signal,
  95. })
  96. .then(data => {
  97. if (!(data && data[0])) {
  98. return
  99. }
  100. if (domainBlocklist.some(d => data[0].hostname.endsWith(d))) {
  101. return
  102. }
  103. const hostname = data[0]?.hostname
  104. if (hostname) {
  105. domainCache.set(domain, data[0])
  106. setSuggestion(`${local}@${hostname}`)
  107. setMatchedDomain(data[0])
  108. } else {
  109. setSuggestion(null)
  110. setMatchedDomain(null)
  111. }
  112. })
  113. .catch(error => {
  114. debugConsole.error(error)
  115. setSuggestion(null)
  116. setMatchedDomain(null)
  117. })
  118. },
  119. [signal, matchedDomain]
  120. )
  121. const handleKeyDownEvent = useCallback(
  122. (event: KeyboardEvent) => {
  123. const setInputValueAndResetSuggestion = () => {
  124. setInputValue(suggestion)
  125. setSuggestion(null)
  126. }
  127. if (event.key === 'Enter') {
  128. event.preventDefault()
  129. if (suggestion) {
  130. setInputValueAndResetSuggestion()
  131. return
  132. }
  133. if (!inputValue) {
  134. return
  135. }
  136. const match = matchLocalAndDomain(inputValue)
  137. if (match.local && match.domain) {
  138. handleAddNewEmail()
  139. }
  140. }
  141. if (event.key === 'Tab' && suggestion) {
  142. event.preventDefault()
  143. setInputValueAndResetSuggestion()
  144. }
  145. },
  146. [inputValue, suggestion, handleAddNewEmail]
  147. )
  148. useEffect(() => {
  149. if (!inputValue) {
  150. setSuggestion(null)
  151. } else if (suggestion && !suggestion.startsWith(inputValue)) {
  152. setSuggestion(null)
  153. }
  154. }, [suggestion, inputValue])
  155. return (
  156. <div className="input-suggestions">
  157. <OLFormControl
  158. data-testid="affiliations-email-shadow"
  159. readOnly
  160. className="input-suggestions-shadow"
  161. value={suggestion || ''}
  162. />
  163. <OLFormControl
  164. id="affiliations-email"
  165. data-testid="affiliations-email"
  166. className="input-suggestions-main"
  167. type="email"
  168. onChange={handleEmailChange}
  169. onKeyDown={handleKeyDownEvent}
  170. value={inputValue || ''}
  171. ref={inputRef}
  172. />
  173. </div>
  174. )
  175. }
  176. export default Input