Răsfoiți Sursa

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
Timothée Alby 4 ani în urmă
părinte
comite
e5051bcd1d

+ 22 - 0
package-lock.json

@@ -5537,6 +5537,19 @@
         "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": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -34973,6 +34986,7 @@
         "@testing-library/dom": "^7.31.2",
         "@testing-library/react": "^11.2.7",
         "@testing-library/react-hooks": "^7.0.0",
+        "@testing-library/user-event": "^14.1.1",
         "@types/chai": "^4.3.0",
         "@types/events": "^3.0.0",
         "@types/mocha": "^9.1.0",
@@ -42943,6 +42957,7 @@
         "@testing-library/dom": "^7.31.2",
         "@testing-library/react": "^11.2.7",
         "@testing-library/react-hooks": "^7.0.0",
+        "@testing-library/user-event": "*",
         "@types/chai": "^4.3.0",
         "@types/events": "^3.0.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": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",

+ 2 - 0
services/web/frontend/extracted-translations.json

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

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

@@ -2,11 +2,17 @@ import { useState, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Button, Row, Col } from 'react-bootstrap'
 import Cell from './cell'
-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 DownshiftInput from './downshift-input'
+import CountryInput from './country-input'
 import { AddEmailInput } from './add-email-input'
+import useAsync from '../../../../shared/hooks/use-async'
+import { useUserEmailsContext } from '../../context/user-email-context'
+import { getJSON, postJSON } from '../../../../infrastructure/fetch-json'
+import { defaults as roles } from '../../roles'
+import { defaults as departments } from '../../departments'
+import { University } from '../../../../../../types/university'
+import { CountryCode } from '../../../../../../types/country'
 
 const isValidEmail = (email: string) => {
   return Boolean(email)
@@ -18,9 +24,18 @@ function AddEmail() {
     () => window.location.hash === '#add-email'
   )
   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] =
     useState(false)
+  const [isUniversityDirty, setIsUniversityDirty] = useState(false)
   const { isLoading, isError, runAsync } = useAsync()
+  const { runAsync: institutionRunAsync } = useAsync()
   const {
     state,
     setLoading: setUserEmailsContextLoading,
@@ -31,6 +46,29 @@ function AddEmail() {
     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 = () => {
     setIsFormVisible(true)
   }
@@ -44,10 +82,35 @@ function AddEmail() {
   }
 
   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(
       postJSON('/user/emails', {
         body: {
           email: newEmail,
+          ...knownUniversityData,
+          ...unknownUniversityData,
         },
       })
     )
@@ -55,10 +118,22 @@ function AddEmail() {
         getEmails()
         setIsFormVisible(false)
         setNewEmail('')
+        setCountryCode(null)
+        setIsUniversityDirty(false)
+        setUniversity('')
+        setRole('')
+        setDepartment('')
+        setIsInstitutionFieldsVisible(false)
       })
-      .catch(error => {
-        console.error(error)
-      })
+      .catch(() => {})
+  }
+
+  const getUniversityItems = () => {
+    if (!countryCode) {
+      return []
+    }
+
+    return universities[countryCode]?.map(({ name }) => name) ?? []
   }
 
   return (
@@ -90,11 +165,43 @@ function AddEmail() {
                 {isInstitutionFieldsVisible ? (
                   <>
                     <div className="form-group mb-2">
-                      <input className="form-control" />
+                      <CountryInput
+                        id="new-email-country-input"
+                        setValue={setCountryCode}
+                      />
                     </div>
-                    <div className="form-group mb-0">
-                      <input className="form-control" />
+                    <div className="form-group mb-2">
+                      <DownshiftInput
+                        items={getUniversityItems()}
+                        inputValue={university}
+                        placeholder={t('university')}
+                        label={t('university')}
+                        setValue={setUniversity}
+                        disabled={!countryCode}
+                      />
                     </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">

+ 102 - 0
services/web/frontend/js/features/settings/components/emails/country-input.tsx

@@ -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

+ 14 - 1
services/web/frontend/js/features/settings/components/emails/downshift-input.tsx

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

+ 2 - 0
services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx

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

+ 255 - 0
services/web/frontend/js/features/settings/countries-list.ts

@@ -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' },
+]

+ 5 - 0
services/web/frontend/stories/settings/helpers/emails.js

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

+ 1 - 0
services/web/package.json

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

+ 184 - 2
services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx

@@ -4,6 +4,7 @@ import {
   fireEvent,
   waitForElementToBeRemoved,
 } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
 import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
 import { expect } from 'chai'
 import fetchMock from 'fetch-mock'
@@ -19,7 +20,7 @@ const userEmailData: UserEmailData = {
       commonsAccount: false,
       confirmed: true,
       id: 1,
-      isUniversity: false,
+      isUniversity: true,
       name: 'Overleaf',
       ssoEnabled: false,
       ssoBeta: false,
@@ -129,7 +130,7 @@ describe('<EmailsSection />', function () {
       .get('/user/emails?ensureAffiliation=true', [])
       .post('/user/emails', 500)
 
-    const addAnotherEmailBtn = await screen.findByRole('button', {
+    const addAnotherEmailBtn = screen.getByRole('button', {
       name: /add another email/i,
     })
 
@@ -156,4 +157,185 @@ describe('<EmailsSection />', function () {
     expect(submitBtn).to.not.be.null
     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 })
+  })
 })

+ 252 - 0
services/web/types/country.ts

@@ -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'

+ 8 - 0
services/web/types/university.ts

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