From 5ed9987345c6a92d352d26afc1c496bf8d2ce87d Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Tue, 26 Apr 2022 13:26:54 +0200 Subject: [PATCH] [Settings] Autocomplete input for Add Email Form (#7747) * [Settings] Autocomplete input for Add Email Form * Applied PR Feedback GitOrigin-RevId: 27d2ef97deb836e92283e89675dfa3866f44904f --- .../components/emails/add-email-input.tsx | 126 +++++++++ .../settings/components/emails/add-email.tsx | 13 +- .../settings/add-email-input.stories.tsx | 27 ++ .../emails/add-email-input.test.tsx | 257 ++++++++++++++++++ 4 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 services/web/frontend/js/features/settings/components/emails/add-email-input.tsx create mode 100644 services/web/frontend/stories/settings/add-email-input.stories.tsx create mode 100644 services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx diff --git a/services/web/frontend/js/features/settings/components/emails/add-email-input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email-input.tsx new file mode 100644 index 0000000000..deecc803cc --- /dev/null +++ b/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() + +export function clearDomainCache() { + domainCache = new Map() +} + +type AddEmailInputProps = { + onChange: (value: string, institution?: InstitutionInfo) => void +} + +export function AddEmailInput({ onChange }: AddEmailInputProps) { + const { signal } = useAbortController() + + const [suggestion, setSuggestion] = useState(null) + const [inputValue, setInputValue] = useState(null) + const [matchedInstitution, setMatchedInstitution] = + useState(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) => { + 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 ( +
+
+
+ {suggestion || ''} +
+
+ + +
+ ) +} diff --git a/services/web/frontend/js/features/settings/components/emails/add-email.tsx b/services/web/frontend/js/features/settings/components/emails/add-email.tsx index b337e65f4f..25c19223c1 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email.tsx +++ b/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) => { - setNewEmail(event.target.value) + const handleEmailChange = (value: string) => { + setNewEmail(value) } const handleAddNewEmail = () => { @@ -81,13 +82,7 @@ function AddEmail() { - + diff --git a/services/web/frontend/stories/settings/add-email-input.stories.tsx b/services/web/frontend/stories/settings/add-email-input.stories.tsx new file mode 100644 index 0000000000..7f373e619a --- /dev/null +++ b/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 ( + <> + +
+
+ Use autocomplete.edu as domain to trigger an autocomplete +
+ + ) +} + +export default { + title: 'Account Settings / Emails and Affiliations', + component: AddEmailInput, + argTypes: { + onChange: { action: 'change' }, + }, +} diff --git a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx new file mode 100644 index 0000000000..c848a4d2ea --- /dev/null +++ b/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('', 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() + screen.getByPlaceholderText('e.g. johndoe@mit.edu') + }) + + it('should not dispatch any `change` event', function () { + const onChangeStub = sinon.stub() + render() + 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() + 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() + }) + + 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() + 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 + }) + }) +})