mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
f0ac0f3e7a
commit
e5051bcd1d
12 changed files with 963 additions and 12 deletions
22
package-lock.json
generated
22
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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 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 { postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { AddEmailInput } from './add-email-input'
|
||||
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">
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
services/web/frontend/js/features/settings/countries-list.ts
Normal file
255
services/web/frontend/js/features/settings/countries-list.ts
Normal 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' },
|
||||
]
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
services/web/types/country.ts
Normal file
252
services/web/types/country.ts
Normal 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'
|
8
services/web/types/university.ts
Normal file
8
services/web/types/university.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { CountryCode } from './country'
|
||||
|
||||
export type University = {
|
||||
id: number
|
||||
name: string
|
||||
country_code: CountryCode
|
||||
departments: unknown[]
|
||||
}
|
Loading…
Reference in a new issue