Parcourir la source

[Settings] Autocomplete input for Add Email Form (#7747)

* [Settings] Autocomplete input for Add Email Form

* Applied PR Feedback

GitOrigin-RevId: 27d2ef97deb836e92283e89675dfa3866f44904f
Miguel Serrano il y a 4 ans
Parent
commit
5ed9987345

+ 126 - 0
services/web/frontend/js/features/settings/components/emails/add-email-input.tsx

@@ -0,0 +1,126 @@
+import {
+  ChangeEvent,
+  KeyboardEvent,
+  useCallback,
+  useEffect,
+  useState,
+} from 'react'
+import { getJSON } from '../../../../infrastructure/fetch-json'
+import useAbortController from '../../../../shared/hooks/use-abort-controller'
+
+const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
+
+function matchLocalAndDomain(emailHint: string) {
+  const match = emailHint.match(LOCAL_AND_DOMAIN_REGEX)
+  if (match) {
+    return { local: match[1], domain: match[2] }
+  } else {
+    return { local: null, domain: null }
+  }
+}
+
+type InstitutionInfo = { hostname: string; university: { id: number } }
+
+let domainCache = new Map<string, InstitutionInfo>()
+
+export function clearDomainCache() {
+  domainCache = new Map<string, InstitutionInfo>()
+}
+
+type AddEmailInputProps = {
+  onChange: (value: string, institution?: InstitutionInfo) => void
+}
+
+export function AddEmailInput({ onChange }: AddEmailInputProps) {
+  const { signal } = useAbortController()
+
+  const [suggestion, setSuggestion] = useState<string>(null)
+  const [inputValue, setInputValue] = useState<string>(null)
+  const [matchedInstitution, setMatchedInstitution] =
+    useState<InstitutionInfo>(null)
+
+  useEffect(() => {
+    if (inputValue == null) {
+      return
+    }
+    if (matchedInstitution && suggestion === inputValue) {
+      onChange(inputValue, matchedInstitution)
+    } else {
+      onChange(inputValue)
+    }
+  }, [onChange, inputValue, suggestion, matchedInstitution])
+
+  const handleEmailChange = useCallback(
+    (event: ChangeEvent<HTMLInputElement>) => {
+      const hint = event.target.value
+      setInputValue(hint)
+      const match = matchLocalAndDomain(hint)
+      if (!matchedInstitution?.hostname.startsWith(match.domain)) {
+        setSuggestion(null)
+      }
+      if (!match.domain) {
+        return
+      }
+      if (domainCache.has(match.domain)) {
+        const cachedDomain = domainCache.get(match.domain)
+        setSuggestion(`${match.local}@${cachedDomain.hostname}`)
+        setMatchedInstitution(cachedDomain)
+        return
+      }
+      const query = `?hostname=${match.domain}&limit=1`
+      getJSON(`/institutions/domains${query}`, { signal })
+        .then(data => {
+          if (!(data && data[0])) {
+            return
+          }
+          const hostname = data[0]?.hostname
+          if (hostname) {
+            domainCache.set(match.domain, data[0])
+            setSuggestion(`${match.local}@${hostname}`)
+            setMatchedInstitution(data[0])
+          } else {
+            setSuggestion(null)
+            setMatchedInstitution(null)
+          }
+        })
+        .catch(error => {
+          setSuggestion(null)
+          setMatchedInstitution(null)
+          console.error(error)
+        })
+    },
+    [signal, matchedInstitution]
+  )
+
+  const handleKeyDownEvent = useCallback(
+    (event: KeyboardEvent) => {
+      if (event.key === 'Tab' || event.key === 'Enter') {
+        event.preventDefault()
+        if (suggestion) {
+          setInputValue(suggestion)
+        }
+      }
+    },
+    [suggestion]
+  )
+
+  return (
+    <div className="input-suggestions">
+      <div className="form-control input-suggestions-shadow">
+        <div className="input-suggestions-shadow-suggested">
+          {suggestion || ''}
+        </div>
+      </div>
+
+      <input
+        id="affiliations-email"
+        className="form-control input-suggestions-main"
+        type="email"
+        onChange={handleEmailChange}
+        onKeyDown={handleKeyDownEvent}
+        value={inputValue || ''}
+        placeholder="e.g. johndoe@mit.edu"
+      />
+    </div>
+  )
+}

+ 4 - 9
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -6,6 +6,7 @@ import useAsync from '../../../../shared/hooks/use-async'
 import { useUserEmailsContext } from '../../context/user-email-context'
 import { postJSON } from '../../../../infrastructure/fetch-json'
 import Icon from '../../../../shared/components/icon'
+import { AddEmailInput } from './add-email-input'
 
 const isValidEmail = (email: string) => {
   return Boolean(email)
@@ -38,8 +39,8 @@ function AddEmail() {
     setIsInstitutionFieldsVisible(true)
   }
 
-  const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    setNewEmail(event.target.value)
+  const handleEmailChange = (value: string) => {
+    setNewEmail(value)
   }
 
   const handleAddNewEmail = () => {
@@ -81,13 +82,7 @@ function AddEmail() {
                 <label htmlFor="affiliations-email" className="sr-only">
                   {t('email')}
                 </label>
-                <input
-                  id="affiliations-email"
-                  className="form-control"
-                  type="email"
-                  onChange={handleEmailChange}
-                  placeholder="e.g. johndoe@mit.edu"
-                />
+                <AddEmailInput onChange={handleEmailChange} />
               </Cell>
             </Col>
             <Col md={4}>

+ 27 - 0
services/web/frontend/stories/settings/add-email-input.stories.tsx

@@ -0,0 +1,27 @@
+import useFetchMock from './../hooks/use-fetch-mock'
+import { AddEmailInput } from '../../js/features/settings/components/emails/add-email-input'
+
+export const EmailInput = args => {
+  useFetchMock(fetchMock =>
+    fetchMock.get(/\/institutions\/domains/, [
+      { hostname: 'autocomplete.edu', id: 123 },
+    ])
+  )
+  return (
+    <>
+      <AddEmailInput {...args} />
+      <br />
+      <div>
+        Use <code>autocomplete.edu</code> as domain to trigger an autocomplete
+      </div>
+    </>
+  )
+}
+
+export default {
+  title: 'Account Settings / Emails and Affiliations',
+  component: AddEmailInput,
+  argTypes: {
+    onChange: { action: 'change' },
+  },
+}

+ 257 - 0
services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx

@@ -0,0 +1,257 @@
+import {
+  fireEvent,
+  render,
+  screen,
+  waitForElementToBeRemoved,
+} from '@testing-library/react'
+import { expect } from 'chai'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+import {
+  AddEmailInput,
+  clearDomainCache,
+} from '../../../../../../frontend/js/features/settings/components/emails/add-email-input'
+
+const testInstitutionData = [
+  { university: { id: 124 }, hostname: 'domain.edu' },
+]
+
+describe('<AddEmailInput/>', function () {
+  const defaultProps = {
+    onChange: (value: string) => {},
+  }
+
+  beforeEach(function () {
+    clearDomainCache()
+    fetchMock.reset()
+  })
+
+  describe('on initial render', function () {
+    it('should render an input with a placeholder', function () {
+      render(<AddEmailInput {...defaultProps} />)
+      screen.getByPlaceholderText('e.g. johndoe@mit.edu')
+    })
+
+    it('should not dispatch any `change` event', function () {
+      const onChangeStub = sinon.stub()
+      render(<AddEmailInput {...defaultProps} onChange={onChangeStub} />)
+      expect(onChangeStub.called).to.equal(false)
+    })
+  })
+
+  describe('when typing text that does not contain any potential domain match', function () {
+    let onChangeStub
+
+    beforeEach(function () {
+      fetchMock.get('express:/institutions/domains', 200)
+      onChangeStub = sinon.stub()
+      render(<AddEmailInput {...defaultProps} onChange={onChangeStub} />)
+      fireEvent.change(screen.getByRole('textbox'), {
+        target: { value: 'user' },
+      })
+    })
+
+    it('should render the text being typed', function () {
+      const input = screen.getByRole('textbox') as HTMLInputElement
+      expect(input.value).to.equal('user')
+    })
+
+    it('should dispatch a `change` event on every stroke', function () {
+      expect(onChangeStub.calledWith('user')).to.equal(true)
+      fireEvent.change(screen.getByRole('textbox'), {
+        target: { value: 's' },
+      })
+      expect(onChangeStub.calledWith('s')).to.equal(true)
+    })
+
+    it('should not make any request for institution domains', function () {
+      expect(fetchMock.called()).to.be.false
+    })
+  })
+
+  describe('when typing text that contains a potential domain match', function () {
+    let onChangeStub
+
+    beforeEach(function () {
+      onChangeStub = sinon.stub()
+      render(<AddEmailInput onChange={onChangeStub} />)
+    })
+
+    describe('when there are no matches', function () {
+      beforeEach(function () {
+        fetchMock.get('express:/institutions/domains', 200)
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@d' },
+        })
+      })
+
+      it('should render the text being typed', function () {
+        const input = screen.getByRole('textbox') as HTMLInputElement
+        expect(input.value).to.equal('user@d')
+      })
+    })
+
+    describe('when there is a domain match', function () {
+      beforeEach(function () {
+        fetchMock.get('express:/institutions/domains', testInstitutionData)
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@d' },
+        })
+      })
+
+      it('should render the text being typed along with the suggestion', async function () {
+        const input = screen.getByRole('textbox') as HTMLInputElement
+        expect(input.value).to.equal('user@d')
+        await screen.findByText('user@domain.edu')
+      })
+
+      it('should dispatch a `change` event with the typed text', function () {
+        expect(onChangeStub.calledWith('user@d')).to.equal(true)
+      })
+
+      it('should dispatch a `change` event with institution data when the typed email contains the institution domain', async function () {
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@domain.edu' },
+        })
+        await fetchMock.flush(true)
+        expect(
+          onChangeStub.calledWith(
+            'user@domain.edu',
+            sinon.match(testInstitutionData[0])
+          )
+        ).to.equal(true)
+      })
+
+      it('should clear the suggestion when the potential domain match is completely deleted', function () {
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@' },
+        })
+        expect(onChangeStub.calledWith('user@')).to.equal(true)
+        expect(screen.queryByText('user@domain.edu')).to.be.null
+      })
+
+      describe('when there is a suggestion and "Tab" key is pressed', function () {
+        beforeEach(async function () {
+          await screen.findByText('user@domain.edu') // wait until autocompletion available
+          fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' })
+        })
+
+        it('it should autocomplete the input', async function () {
+          const input = screen.getByRole('textbox') as HTMLInputElement
+          expect(input.value).to.equal('user@domain.edu')
+        })
+
+        it('should dispatch a `change` event with the domain matched', async function () {
+          expect(
+            onChangeStub.calledWith(
+              'user@domain.edu',
+              sinon.match(testInstitutionData[0])
+            )
+          ).to.equal(true)
+        })
+      })
+
+      describe('when there is a suggestion and "Enter" key is pressed', function () {
+        beforeEach(async function () {
+          await screen.findByText('user@domain.edu') // wait until autocompletion available
+          fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
+        })
+
+        it('it should autocomplete the input', async function () {
+          const input = screen.getByRole('textbox') as HTMLInputElement
+          expect(input.value).to.equal('user@domain.edu')
+        })
+
+        it('should dispatch a `change` event with the domain matched', async function () {
+          expect(
+            onChangeStub.calledWith(
+              'user@domain.edu',
+              sinon.match(testInstitutionData[0])
+            )
+          ).to.equal(true)
+        })
+      })
+
+      it('should cache the result and skip subsequent requests', async function () {
+        fetchMock.reset()
+
+        // clear input
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: '' },
+        })
+        // type a hint to trigger the domain search
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@d' },
+        })
+
+        expect(fetchMock.called()).to.be.false
+        expect(onChangeStub.calledWith('user@d')).to.equal(true)
+        await screen.findByText('user@domain.edu')
+      })
+    })
+
+    describe('while waiting for a response', function () {
+      beforeEach(async function () {
+        // type an initial suggestion
+        fetchMock.get('express:/institutions/domains', testInstitutionData)
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@d' },
+        })
+        await screen.findByText('user@domain.edu')
+
+        // make sure the next suggestions are delayed
+        clearDomainCache()
+        fetchMock.reset()
+        fetchMock.get('express:/institutions/domains', 200, { delay: 1000 })
+      })
+
+      it('should keep the suggestion if the hint matches the previously matched domain', async function () {
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@do' },
+        })
+        screen.getByText('user@domain.edu')
+      })
+
+      it('should remove the suggestion if the hint does not match the previously matched domain', async function () {
+        fireEvent.change(screen.getByRole('textbox'), {
+          target: { value: 'user@foo' },
+        })
+        expect(screen.queryByText('user@domain.edu')).to.be.null
+      })
+    })
+  })
+
+  describe('when the request to fetch institution domains fail', function () {
+    let onChangeStub
+
+    beforeEach(async function () {
+      // initial request populates the suggestion
+      fetchMock.get('express:/institutions/domains', testInstitutionData)
+      onChangeStub = sinon.stub()
+      render(<AddEmailInput {...defaultProps} onChange={onChangeStub} />)
+      fireEvent.change(screen.getByRole('textbox'), {
+        target: { value: 'user@d' },
+      })
+      await screen.findByText('user@domain.edu')
+
+      // subsequent requests fail
+      fetchMock.reset()
+      fetchMock.get('express:/institutions/domains', 500)
+    })
+
+    it('should clear suggestions', async function () {
+      fireEvent.change(screen.getByRole('textbox'), {
+        target: { value: 'user@dom' },
+      })
+
+      const input = screen.getByRole('textbox') as HTMLInputElement
+      expect(input.value).to.equal('user@dom')
+
+      await waitForElementToBeRemoved(() =>
+        screen.queryByText('user@domain.edu')
+      )
+
+      expect(fetchMock.called()).to.be.true // ensures `domainCache` hasn't been hit
+    })
+  })
+})