Merge pull request #7765 from overleaf/ii-add-email-ui-affiliating-with-existing-institution

Add email with existing and non existing institution

GitOrigin-RevId: 331bc06f0ea289a82b403a910491e233f4eda4bb
This commit is contained in:
Timothée Alby 2022-04-27 10:42:48 +02:00 committed by Copybot
parent f0ac0f3e7a
commit e5051bcd1d
12 changed files with 963 additions and 12 deletions

22
package-lock.json generated
View file

@ -5537,6 +5537,19 @@
"react": ">=16.13.1" "react": ">=16.13.1"
} }
}, },
"node_modules/@testing-library/user-event": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.1.1.tgz",
"integrity": "sha512-XrjH/iEUqNl9lF2HX9YhPNV7Amntkcnpw0Bo1KkRzowNDcgSN9i0nm4Q8Oi5wupgdfPaJNMAWa61A+voD6Kmwg==",
"dev": true,
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@ -34973,6 +34986,7 @@
"@testing-library/dom": "^7.31.2", "@testing-library/dom": "^7.31.2",
"@testing-library/react": "^11.2.7", "@testing-library/react": "^11.2.7",
"@testing-library/react-hooks": "^7.0.0", "@testing-library/react-hooks": "^7.0.0",
"@testing-library/user-event": "^14.1.1",
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
"@types/mocha": "^9.1.0", "@types/mocha": "^9.1.0",
@ -42943,6 +42957,7 @@
"@testing-library/dom": "^7.31.2", "@testing-library/dom": "^7.31.2",
"@testing-library/react": "^11.2.7", "@testing-library/react": "^11.2.7",
"@testing-library/react-hooks": "^7.0.0", "@testing-library/react-hooks": "^7.0.0",
"@testing-library/user-event": "*",
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
"@types/mocha": "^9.1.0", "@types/mocha": "^9.1.0",
@ -45421,6 +45436,13 @@
} }
} }
}, },
"@testing-library/user-event": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.1.1.tgz",
"integrity": "sha512-XrjH/iEUqNl9lF2HX9YhPNV7Amntkcnpw0Bo1KkRzowNDcgSN9i0nm4Q8Oi5wupgdfPaJNMAWa61A+voD6Kmwg==",
"dev": true,
"requires": {}
},
"@tootallnate/once": { "@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",

View file

@ -69,6 +69,7 @@
"copy": "", "copy": "",
"copy_project": "", "copy_project": "",
"copying": "", "copying": "",
"country": "",
"create": "", "create": "",
"create_project_in_github": "", "create_project_in_github": "",
"creating": "", "creating": "",
@ -447,6 +448,7 @@
"turn_on_link_sharing": "", "turn_on_link_sharing": "",
"unconfirmed": "", "unconfirmed": "",
"unfold_line": "", "unfold_line": "",
"university": "",
"unlimited_projects": "", "unlimited_projects": "",
"unlink": "", "unlink": "",
"unlink_dropbox_folder": "", "unlink_dropbox_folder": "",

View file

@ -2,11 +2,17 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button, Row, Col } from 'react-bootstrap' import { Button, Row, Col } from 'react-bootstrap'
import Cell from './cell' import Cell from './cell'
import Icon from '../../../../shared/components/icon'
import DownshiftInput from './downshift-input'
import CountryInput from './country-input'
import { AddEmailInput } from './add-email-input'
import useAsync from '../../../../shared/hooks/use-async' import useAsync from '../../../../shared/hooks/use-async'
import { useUserEmailsContext } from '../../context/user-email-context' import { useUserEmailsContext } from '../../context/user-email-context'
import { postJSON } from '../../../../infrastructure/fetch-json' import { getJSON, postJSON } from '../../../../infrastructure/fetch-json'
import Icon from '../../../../shared/components/icon' import { defaults as roles } from '../../roles'
import { AddEmailInput } from './add-email-input' import { defaults as departments } from '../../departments'
import { University } from '../../../../../../types/university'
import { CountryCode } from '../../../../../../types/country'
const isValidEmail = (email: string) => { const isValidEmail = (email: string) => {
return Boolean(email) return Boolean(email)
@ -18,9 +24,18 @@ function AddEmail() {
() => window.location.hash === '#add-email' () => window.location.hash === '#add-email'
) )
const [newEmail, setNewEmail] = useState('') const [newEmail, setNewEmail] = useState('')
const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
const [universities, setUniversities] = useState<
Partial<Record<CountryCode, University[]>>
>({})
const [university, setUniversity] = useState('')
const [role, setRole] = useState('')
const [department, setDepartment] = useState('')
const [isInstitutionFieldsVisible, setIsInstitutionFieldsVisible] = const [isInstitutionFieldsVisible, setIsInstitutionFieldsVisible] =
useState(false) useState(false)
const [isUniversityDirty, setIsUniversityDirty] = useState(false)
const { isLoading, isError, runAsync } = useAsync() const { isLoading, isError, runAsync } = useAsync()
const { runAsync: institutionRunAsync } = useAsync()
const { const {
state, state,
setLoading: setUserEmailsContextLoading, setLoading: setUserEmailsContextLoading,
@ -31,6 +46,29 @@ function AddEmail() {
setUserEmailsContextLoading(isLoading) setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading]) }, [setUserEmailsContextLoading, isLoading])
useEffect(() => {
if (university) {
setIsUniversityDirty(true)
}
}, [setIsUniversityDirty, university])
// Fetch country institution
useEffect(() => {
// Skip if country not selected or universities for
// that country are already fetched
if (!countryCode || universities[countryCode]) {
return
}
institutionRunAsync<University[]>(
getJSON(`/institutions/list?country_code=${countryCode}`)
)
.then(data => {
setUniversities(state => ({ ...state, [countryCode]: data }))
})
.catch(() => {})
}, [countryCode, universities, setUniversities, institutionRunAsync])
const handleShowAddEmailForm = () => { const handleShowAddEmailForm = () => {
setIsFormVisible(true) setIsFormVisible(true)
} }
@ -44,10 +82,35 @@ function AddEmail() {
} }
const handleAddNewEmail = () => { const handleAddNewEmail = () => {
const selectedKnownUniversity = countryCode
? universities[countryCode]?.find(({ name }) => name === university)
: undefined
const knownUniversityData = university &&
selectedKnownUniversity && {
university: {
id: selectedKnownUniversity.id,
},
role,
department,
}
const unknownUniversityData = university &&
!selectedKnownUniversity && {
university: {
name: university,
country_code: countryCode,
},
role,
department,
}
runAsync( runAsync(
postJSON('/user/emails', { postJSON('/user/emails', {
body: { body: {
email: newEmail, email: newEmail,
...knownUniversityData,
...unknownUniversityData,
}, },
}) })
) )
@ -55,10 +118,22 @@ function AddEmail() {
getEmails() getEmails()
setIsFormVisible(false) setIsFormVisible(false)
setNewEmail('') setNewEmail('')
setCountryCode(null)
setIsUniversityDirty(false)
setUniversity('')
setRole('')
setDepartment('')
setIsInstitutionFieldsVisible(false)
}) })
.catch(error => { .catch(() => {})
console.error(error) }
})
const getUniversityItems = () => {
if (!countryCode) {
return []
}
return universities[countryCode]?.map(({ name }) => name) ?? []
} }
return ( return (
@ -90,11 +165,43 @@ function AddEmail() {
{isInstitutionFieldsVisible ? ( {isInstitutionFieldsVisible ? (
<> <>
<div className="form-group mb-2"> <div className="form-group mb-2">
<input className="form-control" /> <CountryInput
id="new-email-country-input"
setValue={setCountryCode}
/>
</div> </div>
<div className="form-group mb-0"> <div className="form-group mb-2">
<input className="form-control" /> <DownshiftInput
items={getUniversityItems()}
inputValue={university}
placeholder={t('university')}
label={t('university')}
setValue={setUniversity}
disabled={!countryCode}
/>
</div> </div>
{isUniversityDirty && (
<>
<div className="form-group mb-2">
<DownshiftInput
items={roles}
inputValue={role}
placeholder={t('role')}
label={t('role')}
setValue={setRole}
/>
</div>
<div className="form-group mb-0">
<DownshiftInput
items={departments}
inputValue={department}
placeholder={t('department')}
label={t('department')}
setValue={setDepartment}
/>
</div>
</>
)}
</> </>
) : ( ) : (
<div className="mt-1"> <div className="mt-1">

View file

@ -0,0 +1,102 @@
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import { defaults as countries } from '../../countries-list'
import { CountryCode } from '../../../../../../types/country'
type CountryInputProps = {
setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
} & React.InputHTMLAttributes<HTMLInputElement>
const itemToString = (item: typeof countries[number] | null) => item?.name ?? ''
function CountryInput({ setValue }: CountryInputProps) {
const { t } = useTranslation()
const [inputItems, setInputItems] = useState(() => countries)
const [inputValue, setInputValue] = useState('')
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
getItemProps,
openMenu,
selectedItem,
} = useCombobox({
inputValue,
items: inputItems,
itemToString,
onSelectedItemChange: ({ selectedItem }) => {
setValue(selectedItem?.code ?? null)
setInputValue(selectedItem?.name ?? '')
},
onInputValueChange: ({ inputValue = '' }) => {
setInputItems(
countries.filter(country =>
itemToString(country).toLowerCase().includes(inputValue.toLowerCase())
)
)
},
})
return (
<div
className={classnames(
'ui-select-container ui-select-bootstrap dropdown',
{
open: isOpen && inputItems.length,
}
)}
>
<div {...getComboboxProps()} className="form-group mb-2 ui-select-toggle">
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<label {...getLabelProps()} className="sr-only">
{t('country')}
</label>
<input
{...getInputProps({
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value)
},
onFocus: () => {
if (!isOpen) {
openMenu()
}
},
})}
className="form-control"
type="text"
placeholder={t('country')}
/>
<i className="caret" />
</div>
<ul
{...getMenuProps()}
className="ui-select-choices ui-select-choices-content ui-select-dropdown dropdown-menu"
>
{inputItems.map((item, index) => (
<li
className="ui-select-choices-group"
key={`${item.name}-${index}`}
{...getItemProps({ item, index })}
>
<div
className={classnames('ui-select-choices-row', {
active: selectedItem?.name === item.name,
})}
>
<span className="ui-select-choices-row-inner">
<span>{item.name}</span>
</span>
</div>
</li>
))}
</ul>
</div>
)
}
export default CountryInput

View file

@ -1,10 +1,11 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useCombobox } from 'downshift' import { useCombobox } from 'downshift'
import classnames from 'classnames' import classnames from 'classnames'
type DownshiftInputProps = { type DownshiftInputProps = {
items: string[] items: string[]
inputValue: string inputValue: string
label: string
setValue: React.Dispatch<React.SetStateAction<string>> setValue: React.Dispatch<React.SetStateAction<string>>
} & React.InputHTMLAttributes<HTMLInputElement> } & React.InputHTMLAttributes<HTMLInputElement>
@ -17,12 +18,19 @@ function DownshiftInput({
items, items,
inputValue, inputValue,
placeholder, placeholder,
label,
setValue, setValue,
disabled,
}: DownshiftInputProps) { }: DownshiftInputProps) {
const [inputItems, setInputItems] = useState(items) const [inputItems, setInputItems] = useState(items)
useEffect(() => {
setInputItems(items)
}, [items])
const { const {
isOpen, isOpen,
getLabelProps,
getMenuProps, getMenuProps,
getInputProps, getInputProps,
getComboboxProps, getComboboxProps,
@ -56,6 +64,10 @@ function DownshiftInput({
)} )}
> >
<div {...getComboboxProps()} className="form-group mb-2"> <div {...getComboboxProps()} className="form-group mb-2">
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<label {...getLabelProps()} className="sr-only">
{label}
</label>
<input <input
{...getInputProps({ {...getInputProps({
onChange: (event: React.ChangeEvent<HTMLInputElement>) => { onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
@ -70,6 +82,7 @@ function DownshiftInput({
className="form-control" className="form-control"
type="text" type="text"
placeholder={placeholder} placeholder={placeholder}
disabled={disabled}
/> />
</div> </div>
<ul <ul

View file

@ -90,12 +90,14 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
items={roles} items={roles}
inputValue={role} inputValue={role}
placeholder={t('role')} placeholder={t('role')}
label={t('role')}
setValue={setRole} setValue={setRole}
/> />
<DownshiftInput <DownshiftInput
items={departments} items={departments}
inputValue={department} inputValue={department}
placeholder={t('department')} placeholder={t('department')}
label={t('department')}
setValue={setDepartment} setValue={setDepartment}
/> />
<Button <Button

View file

@ -0,0 +1,255 @@
import { CountryCode } from '../../../../types/country'
export const defaults: { code: CountryCode; name: string }[] = [
{ code: 'af', name: 'Afghanistan' },
{ code: 'ax', name: 'Åland Islands' },
{ code: 'al', name: 'Albania' },
{ code: 'dz', name: 'Algeria' },
{ code: 'as', name: 'American Samoa' },
{ code: 'ad', name: 'Andorra' },
{ code: 'ao', name: 'Angola' },
{ code: 'ai', name: 'Anguilla' },
{ code: 'aq', name: 'Antarctica' },
{ code: 'ag', name: 'Antigua and Barbuda' },
{ code: 'ar', name: 'Argentina' },
{ code: 'am', name: 'Armenia' },
{ code: 'aw', name: 'Aruba' },
{ code: 'au', name: 'Australia' },
{ code: 'at', name: 'Austria' },
{ code: 'az', name: 'Azerbaijan' },
{ code: 'bs', name: 'Bahamas' },
{ code: 'bh', name: 'Bahrain' },
{ code: 'bd', name: 'Bangladesh' },
{ code: 'bb', name: 'Barbados' },
{ code: 'by', name: 'Belarus' },
{ code: 'be', name: 'Belgium' },
{ code: 'bz', name: 'Belize' },
{ code: 'bj', name: 'Benin' },
{ code: 'bm', name: 'Bermuda' },
{ code: 'bt', name: 'Bhutan' },
{ code: 'bo', name: 'Bolivia' },
{ code: 'bq', name: 'Bonaire, Saint Eustatius and Saba' },
{ code: 'ba', name: 'Bosnia and Herzegovina' },
{ code: 'bw', name: 'Botswana' },
{ code: 'bv', name: 'Bouvet Island' },
{ code: 'br', name: 'Brazil' },
{ code: 'io', name: 'British Indian Ocean Territory' },
{ code: 'vg', name: 'British Virgin Islands' },
{ code: 'bn', name: 'Brunei' },
{ code: 'bg', name: 'Bulgaria' },
{ code: 'bf', name: 'Burkina Faso' },
{ code: 'bi', name: 'Burundi' },
{ code: 'kh', name: 'Cambodia' },
{ code: 'cm', name: 'Cameroon' },
{ code: 'ca', name: 'Canada' },
{ code: 'cv', name: 'Cabo Verde' },
{ code: 'ky', name: 'Cayman Islands' },
{ code: 'cf', name: 'Central African Republic' },
{ code: 'td', name: 'Chad' },
{ code: 'cl', name: 'Chile' },
{ code: 'cn', name: 'China' },
{ code: 'cx', name: 'Christmas Island' },
{ code: 'cc', name: 'Cocos (Keeling) Islands' },
{ code: 'co', name: 'Colombia' },
{ code: 'km', name: 'Comoros' },
{ code: 'cg', name: 'Congo' },
{ code: 'ck', name: 'Cook Islands' },
{ code: 'cr', name: 'Costa Rica' },
{ code: 'ci', name: "Côte d'Ivoire" },
{ code: 'hr', name: 'Croatia' },
{ code: 'cu', name: 'Cuba' },
{ code: 'cw', name: 'Curaçao' },
{ code: 'cy', name: 'Cyprus' },
{ code: 'cz', name: 'Czech Republic' },
{ code: 'kp', name: "Democratic People's Republic of Korea" },
{ code: 'cd', name: 'Democratic Republic of the Congo' },
{ code: 'dk', name: 'Denmark' },
{ code: 'dj', name: 'Djibouti' },
{ code: 'dm', name: 'Dominica' },
{ code: 'do', name: 'Dominican Republic' },
{ code: 'ec', name: 'Ecuador' },
{ code: 'eg', name: 'Egypt' },
{ code: 'sv', name: 'El Salvador' },
{ code: 'gq', name: 'Equatorial Guinea' },
{ code: 'er', name: 'Eritrea' },
{ code: 'ee', name: 'Estonia' },
{ code: 'et', name: 'Ethiopia' },
{ code: 'fk', name: 'Falkland Islands (Malvinas)' },
{ code: 'fo', name: 'Faroe Islands' },
{ code: 'fj', name: 'Fiji' },
{ code: 'fi', name: 'Finland' },
{ code: 'fr', name: 'France' },
{ code: 'gf', name: 'French Guiana' },
{ code: 'pf', name: 'French Polynesia' },
{ code: 'tf', name: 'French Southern Territories' },
{ code: 'ga', name: 'Gabon' },
{ code: 'gm', name: 'Gambia' },
{ code: 'ge', name: 'Georgia' },
{ code: 'de', name: 'Germany' },
{ code: 'gh', name: 'Ghana' },
{ code: 'gi', name: 'Gibraltar' },
{ code: 'gr', name: 'Greece' },
{ code: 'gl', name: 'Greenland' },
{ code: 'gd', name: 'Grenada' },
{ code: 'gp', name: 'Guadeloupe' },
{ code: 'gu', name: 'Guam' },
{ code: 'gt', name: 'Guatemala' },
{ code: 'gg', name: 'Guernsey' },
{ code: 'gn', name: 'Guinea' },
{ code: 'gw', name: 'Guinea-Bissau' },
{ code: 'gy', name: 'Guyana' },
{ code: 'ht', name: 'Haiti' },
{ code: 'hm', name: 'Heard Island and McDonald Islands' },
{ code: 'va', name: 'Holy See (Vatican City)' },
{ code: 'hn', name: 'Honduras' },
{ code: 'hk', name: 'Hong Kong' },
{ code: 'hu', name: 'Hungary' },
{ code: 'is', name: 'Iceland' },
{ code: 'in', name: 'India' },
{ code: 'id', name: 'Indonesia' },
{ code: 'ir', name: 'Iran' },
{ code: 'iq', name: 'Iraq' },
{ code: 'ie', name: 'Ireland' },
{ code: 'im', name: 'Isle of Man' },
{ code: 'il', name: 'Israel' },
{ code: 'it', name: 'Italy' },
{ code: 'jm', name: 'Jamaica' },
{ code: 'jp', name: 'Japan' },
{ code: 'je', name: 'Jersey' },
{ code: 'jo', name: 'Jordan' },
{ code: 'kz', name: 'Kazakhstan' },
{ code: 'ke', name: 'Kenya' },
{ code: 'ki', name: 'Kiribati' },
{ code: 'xk', name: 'Kosovo' },
{ code: 'kw', name: 'Kuwait' },
{ code: 'kg', name: 'Kyrgyzstan' },
{ code: 'la', name: 'Laos' },
{ code: 'lv', name: 'Latvia' },
{ code: 'lb', name: 'Lebanon' },
{ code: 'ls', name: 'Lesotho' },
{ code: 'lr', name: 'Liberia' },
{ code: 'ly', name: 'Libya' },
{ code: 'li', name: 'Liechtenstein' },
{ code: 'lt', name: 'Lithuania' },
{ code: 'lu', name: 'Luxembourg' },
{ code: 'mo', name: 'Macao' },
{ code: 'mk', name: 'Macedonia' },
{ code: 'mg', name: 'Madagascar' },
{ code: 'mw', name: 'Malawi' },
{ code: 'my', name: 'Malaysia' },
{ code: 'mv', name: 'Maldives' },
{ code: 'ml', name: 'Mali' },
{ code: 'mt', name: 'Malta' },
{ code: 'mh', name: 'Marshall Islands' },
{ code: 'mq', name: 'Martinique' },
{ code: 'mr', name: 'Mauritania' },
{ code: 'mu', name: 'Mauritius' },
{ code: 'yt', name: 'Mayotte' },
{ code: 'mx', name: 'Mexico' },
{ code: 'fm', name: 'Micronesia' },
{ code: 'md', name: 'Moldova' },
{ code: 'mc', name: 'Monaco' },
{ code: 'mn', name: 'Mongolia' },
{ code: 'me', name: 'Montenegro' },
{ code: 'ms', name: 'Montserrat' },
{ code: 'ma', name: 'Morocco' },
{ code: 'mz', name: 'Mozambique' },
{ code: 'mm', name: 'Myanmar' },
{ code: 'na', name: 'Namibia' },
{ code: 'nr', name: 'Nauru' },
{ code: 'np', name: 'Nepal' },
{ code: 'nl', name: 'Netherlands' },
{ code: 'an', name: 'Netherlands Antilles' },
{ code: 'nc', name: 'New Caledonia' },
{ code: 'nz', name: 'New Zealand' },
{ code: 'ni', name: 'Nicaragua' },
{ code: 'ne', name: 'Niger' },
{ code: 'ng', name: 'Nigeria' },
{ code: 'nu', name: 'Niue' },
{ code: 'nf', name: 'Norfolk Island' },
{ code: 'mp', name: 'Northern Mariana Islands' },
{ code: 'no', name: 'Norway' },
{ code: 'om', name: 'Oman' },
{ code: 'pk', name: 'Pakistan' },
{ code: 'pw', name: 'Palau' },
{ code: 'ps', name: 'Palestine' },
{ code: 'pa', name: 'Panama' },
{ code: 'pg', name: 'Papua New Guinea' },
{ code: 'py', name: 'Paraguay' },
{ code: 'pe', name: 'Peru' },
{ code: 'ph', name: 'Philippines' },
{ code: 'pn', name: 'Pitcairn' },
{ code: 'pl', name: 'Poland' },
{ code: 'pt', name: 'Portugal' },
{ code: 'pr', name: 'Puerto Rico' },
{ code: 'qa', name: 'Qatar' },
{ code: 'kr', name: 'Republic of Korea' },
{ code: 're', name: 'Réunion' },
{ code: 'ro', name: 'Romania' },
{ code: 'ru', name: 'Russia' },
{ code: 'rw', name: 'Rwanda' },
{ code: 'bl', name: 'Saint Barthélemy' },
{ code: 'sh', name: 'Saint Helena, Ascension and Tristan da Cunha' },
{ code: 'kn', name: 'Saint Kitts and Nevis' },
{ code: 'lc', name: 'Saint Lucia' },
{ code: 'mf', name: 'Saint Martin' },
{ code: 'pm', name: 'Saint Pierre and Miquelon' },
{ code: 'vc', name: 'Saint Vincent and the Grenadines' },
{ code: 'ws', name: 'Samoa' },
{ code: 'sm', name: 'San Marino' },
{ code: 'st', name: 'Sao Tome and Principe' },
{ code: 'sa', name: 'Saudi Arabia' },
{ code: 'sn', name: 'Senegal' },
{ code: 'rs', name: 'Serbia' },
{ code: 'sc', name: 'Seychelles' },
{ code: 'sl', name: 'Sierra Leone' },
{ code: 'sg', name: 'Singapore' },
{ code: 'sx', name: 'Sint Maarten' },
{ code: 'sk', name: 'Slovakia' },
{ code: 'si', name: 'Slovenia' },
{ code: 'sb', name: 'Solomon Islands' },
{ code: 'so', name: 'Somalia' },
{ code: 'za', name: 'South Africa' },
{ code: 'gs', name: 'South Georgia and the South Sandwich Islands' },
{ code: 'ss', name: 'South Sudan' },
{ code: 'es', name: 'Spain' },
{ code: 'lk', name: 'Sri Lanka' },
{ code: 'sd', name: 'Sudan' },
{ code: 'sr', name: 'Suriname' },
{ code: 'sj', name: 'Svalbard and Jan Mayen' },
{ code: 'sz', name: 'Swaziland' },
{ code: 'se', name: 'Sweden' },
{ code: 'ch', name: 'Switzerland' },
{ code: 'sy', name: 'Syria' },
{ code: 'tw', name: 'Taiwan' },
{ code: 'tj', name: 'Tajikistan' },
{ code: 'tz', name: 'Tanzania' },
{ code: 'th', name: 'Thailand' },
{ code: 'tl', name: 'Timor-Leste' },
{ code: 'tg', name: 'Togo' },
{ code: 'tk', name: 'Tokelau' },
{ code: 'to', name: 'Tonga' },
{ code: 'tt', name: 'Trinidad and Tobago' },
{ code: 'tn', name: 'Tunisia' },
{ code: 'tr', name: 'Turkey' },
{ code: 'tm', name: 'Turkmenistan' },
{ code: 'tc', name: 'Turks and Caicos Islands' },
{ code: 'tv', name: 'Tuvalu' },
{ code: 'vi', name: 'U.S. Virgin Islands' },
{ code: 'ug', name: 'Uganda' },
{ code: 'ua', name: 'Ukraine' },
{ code: 'ae', name: 'United Arab Emirates' },
{ code: 'gb', name: 'United Kingdom' },
{ code: 'us', name: 'United States of America' },
{ code: 'um', name: 'United States Minor Outlying Islands' },
{ code: 'uy', name: 'Uruguay' },
{ code: 'uz', name: 'Uzbekistan' },
{ code: 'vu', name: 'Vanuatu' },
{ code: 've', name: 'Venezuela' },
{ code: 'vn', name: 'Vietnam' },
{ code: 'wf', name: 'Wallis and Futuna' },
{ code: 'eh', name: 'Western Sahara' },
{ code: 'ye', name: 'Yemen' },
{ code: 'zm', name: 'Zambia' },
{ code: 'zw', name: 'Zimbabwe' },
]

View file

@ -37,9 +37,14 @@ const fakeUsersData = [
}, },
] ]
const fakeInstitutions = [
{ id: 9326, name: 'Unknown', country_code: 'al', departments: [] },
]
export function defaultSetupMocks(fetchMock) { export function defaultSetupMocks(fetchMock) {
fetchMock fetchMock
.get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY })
.get(/\/institutions\/list/, fakeInstitutions, { delay: MOCK_DELAY })
.post(/\/user\/emails\/*/, 200, { .post(/\/user\/emails\/*/, 200, {
delay: MOCK_DELAY, delay: MOCK_DELAY,
}) })

View file

@ -217,6 +217,7 @@
"@testing-library/dom": "^7.31.2", "@testing-library/dom": "^7.31.2",
"@testing-library/react": "^11.2.7", "@testing-library/react": "^11.2.7",
"@testing-library/react-hooks": "^7.0.0", "@testing-library/react-hooks": "^7.0.0",
"@testing-library/user-event": "^14.1.1",
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
"@types/mocha": "^9.1.0", "@types/mocha": "^9.1.0",

View file

@ -4,6 +4,7 @@ import {
fireEvent, fireEvent,
waitForElementToBeRemoved, waitForElementToBeRemoved,
} from '@testing-library/react' } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section' import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
import { expect } from 'chai' import { expect } from 'chai'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
@ -19,7 +20,7 @@ const userEmailData: UserEmailData = {
commonsAccount: false, commonsAccount: false,
confirmed: true, confirmed: true,
id: 1, id: 1,
isUniversity: false, isUniversity: true,
name: 'Overleaf', name: 'Overleaf',
ssoEnabled: false, ssoEnabled: false,
ssoBeta: false, ssoBeta: false,
@ -129,7 +130,7 @@ describe('<EmailsSection />', function () {
.get('/user/emails?ensureAffiliation=true', []) .get('/user/emails?ensureAffiliation=true', [])
.post('/user/emails', 500) .post('/user/emails', 500)
const addAnotherEmailBtn = await screen.findByRole('button', { const addAnotherEmailBtn = screen.getByRole('button', {
name: /add another email/i, name: /add another email/i,
}) })
@ -156,4 +157,185 @@ describe('<EmailsSection />', function () {
expect(submitBtn).to.not.be.null expect(submitBtn).to.not.be.null
expect(submitBtn.disabled).to.be.false expect(submitBtn.disabled).to.be.false
}) })
it('adds new email address with existing institution', async function () {
const country = 'Germany'
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
await fetchMock.flush(true)
fetchMock.reset()
await userEvent.click(
screen.getByRole('button', {
name: /add another email/i,
})
)
await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
const universityInput = screen.getByRole('textbox', {
name: /university/i,
}) as HTMLInputElement
expect(universityInput.disabled).to.be.true
fetchMock.get(/\/institutions\/list/, [
{
id: userEmailData.affiliation.institution.id,
name: userEmailData.affiliation.institution.name,
country_code: 'de',
departments: [],
},
])
// Select the country from dropdown
await userEvent.type(
screen.getByRole('textbox', {
name: /country/i,
}),
country
)
await userEvent.click(screen.getByText(country))
expect(universityInput.disabled).to.be.false
await fetchMock.flush(true)
fetchMock.reset()
// Select the university from dropdown
await userEvent.click(universityInput)
await userEvent.click(
screen.getByText(userEmailData.affiliation.institution.name)
)
const roleInput = screen.getByRole('textbox', { name: /role/i })
await userEvent.type(roleInput, userEmailData.affiliation.role)
const departmentInput = screen.getByRole('textbox', { name: /department/i })
await userEvent.type(departmentInput, userEmailData.affiliation.department)
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post(/\/user\/emails/, 200)
await userEvent.click(
screen.getByRole('button', {
name: /add new email/i,
})
)
const [[, request]] = fetchMock.calls(/\/user\/emails/)
expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.equal({
email: userEmailData.email,
university: {
id: userEmailData.affiliation?.institution.id,
},
role: userEmailData.affiliation?.role,
department: userEmailData.affiliation?.department,
})
screen.getByText(userEmailData.email)
screen.getByText(userEmailData.affiliation.institution.name)
screen.getByText(userEmailData.affiliation.role, { exact: false })
screen.getByText(userEmailData.affiliation.department, { exact: false })
})
it('adds new email address without existing institution', async function () {
const country = 'Germany'
const countryCode = 'de'
const newUniversity = 'Abcdef'
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
await fetchMock.flush(true)
fetchMock.reset()
await userEvent.click(
screen.getByRole('button', {
name: /add another email/i,
})
)
await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
const universityInput = screen.getByRole('textbox', {
name: /university/i,
}) as HTMLInputElement
expect(universityInput.disabled).to.be.true
fetchMock.get(/\/institutions\/list/, [
{
id: userEmailData.affiliation.institution.id,
name: userEmailData.affiliation.institution.name,
country_code: 'de',
departments: [],
},
])
// Select the country from dropdown
await userEvent.type(
screen.getByRole('textbox', {
name: /country/i,
}),
country
)
await userEvent.click(screen.getByText(country))
expect(universityInput.disabled).to.be.false
await fetchMock.flush(true)
fetchMock.reset()
// Enter the university manually
await userEvent.type(universityInput, newUniversity)
const roleInput = screen.getByRole('textbox', { name: /role/i })
await userEvent.type(roleInput, userEmailData.affiliation.role)
const departmentInput = screen.getByRole('textbox', { name: /department/i })
await userEvent.type(departmentInput, userEmailData.affiliation.department)
const userEmailDataCopy = {
...userEmailData,
affiliation: {
...userEmailData.affiliation,
institution: {
...userEmailData.affiliation.institution,
name: newUniversity,
},
},
}
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post(/\/user\/emails/, 200)
await userEvent.click(
screen.getByRole('button', {
name: /add new email/i,
})
)
const [[, request]] = fetchMock.calls(/\/user\/emails/)
expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.equal({
email: userEmailData.email,
university: {
name: newUniversity,
country_code: countryCode,
},
role: userEmailData.affiliation?.role,
department: userEmailData.affiliation?.department,
})
screen.getByText(userEmailData.email)
screen.getByText(newUniversity)
screen.getByText(userEmailData.affiliation.role, { exact: false })
screen.getByText(userEmailData.affiliation.department, { exact: false })
})
}) })

View file

@ -0,0 +1,252 @@
export type CountryCode =
| 'af'
| 'ax'
| 'al'
| 'dz'
| 'as'
| 'ad'
| 'ao'
| 'ai'
| 'aq'
| 'ag'
| 'ar'
| 'am'
| 'aw'
| 'au'
| 'at'
| 'az'
| 'bs'
| 'bh'
| 'bd'
| 'bb'
| 'by'
| 'be'
| 'bz'
| 'bj'
| 'bm'
| 'bt'
| 'bo'
| 'bq'
| 'ba'
| 'bw'
| 'bv'
| 'br'
| 'io'
| 'vg'
| 'bn'
| 'bg'
| 'bf'
| 'bi'
| 'kh'
| 'cm'
| 'ca'
| 'cv'
| 'ky'
| 'cf'
| 'td'
| 'cl'
| 'cn'
| 'cx'
| 'cc'
| 'co'
| 'km'
| 'cg'
| 'ck'
| 'cr'
| 'ci'
| 'hr'
| 'cu'
| 'cw'
| 'cy'
| 'cz'
| 'kp'
| 'cd'
| 'dk'
| 'dj'
| 'dm'
| 'do'
| 'ec'
| 'eg'
| 'sv'
| 'gq'
| 'er'
| 'ee'
| 'et'
| 'fk'
| 'fo'
| 'fj'
| 'fi'
| 'fr'
| 'gf'
| 'pf'
| 'tf'
| 'ga'
| 'gm'
| 'ge'
| 'de'
| 'gh'
| 'gi'
| 'gr'
| 'gl'
| 'gd'
| 'gp'
| 'gu'
| 'gt'
| 'gg'
| 'gn'
| 'gw'
| 'gy'
| 'ht'
| 'hm'
| 'va'
| 'hn'
| 'hk'
| 'hu'
| 'is'
| 'in'
| 'id'
| 'ir'
| 'iq'
| 'ie'
| 'im'
| 'il'
| 'it'
| 'jm'
| 'jp'
| 'je'
| 'jo'
| 'kz'
| 'ke'
| 'ki'
| 'xk'
| 'kw'
| 'kg'
| 'la'
| 'lv'
| 'lb'
| 'ls'
| 'lr'
| 'ly'
| 'li'
| 'lt'
| 'lu'
| 'mo'
| 'mk'
| 'mg'
| 'mw'
| 'my'
| 'mv'
| 'ml'
| 'mt'
| 'mh'
| 'mq'
| 'mr'
| 'mu'
| 'yt'
| 'mx'
| 'fm'
| 'md'
| 'mc'
| 'mn'
| 'me'
| 'ms'
| 'ma'
| 'mz'
| 'mm'
| 'na'
| 'nr'
| 'np'
| 'nl'
| 'an'
| 'nc'
| 'nz'
| 'ni'
| 'ne'
| 'ng'
| 'nu'
| 'nf'
| 'mp'
| 'no'
| 'om'
| 'pk'
| 'pw'
| 'ps'
| 'pa'
| 'pg'
| 'py'
| 'pe'
| 'ph'
| 'pn'
| 'pl'
| 'pt'
| 'pr'
| 'qa'
| 'kr'
| 're'
| 'ro'
| 'ru'
| 'rw'
| 'bl'
| 'sh'
| 'kn'
| 'lc'
| 'mf'
| 'pm'
| 'vc'
| 'ws'
| 'sm'
| 'st'
| 'sa'
| 'sn'
| 'rs'
| 'sc'
| 'sl'
| 'sg'
| 'sx'
| 'sk'
| 'si'
| 'sb'
| 'so'
| 'za'
| 'gs'
| 'ss'
| 'es'
| 'lk'
| 'sd'
| 'sr'
| 'sj'
| 'sz'
| 'se'
| 'ch'
| 'sy'
| 'tw'
| 'tj'
| 'tz'
| 'th'
| 'tl'
| 'tg'
| 'tk'
| 'to'
| 'tt'
| 'tn'
| 'tr'
| 'tm'
| 'tc'
| 'tv'
| 'vi'
| 'ug'
| 'ua'
| 'ae'
| 'gb'
| 'us'
| 'um'
| 'uy'
| 'uz'
| 'vu'
| 've'
| 'vn'
| 'wf'
| 'eh'
| 'ye'
| 'zm'
| 'zw'

View file

@ -0,0 +1,8 @@
import { CountryCode } from './country'
export type University = {
id: number
name: string
country_code: CountryCode
departments: unknown[]
}