mirror of
https://github.com/overleaf/overleaf.git
synced 2025-01-23 18:44:05 +00:00
[Settings] Autocomplete input for Add Email Form (#7747)
* [Settings] Autocomplete input for Add Email Form * Applied PR Feedback GitOrigin-RevId: 27d2ef97deb836e92283e89675dfa3866f44904f
This commit is contained in:
parent
ef0e475b04
commit
5ed9987345
4 changed files with 414 additions and 9 deletions
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}>
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
}
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue