diff --git a/package-lock.json b/package-lock.json index ab836380b4..4972d3589a 100644 --- a/package-lock.json +++ b/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", diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index bc8a2a7599..2b1b4ac9da 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/settings/components/emails/add-email.tsx b/services/web/frontend/js/features/settings/components/emails/add-email.tsx index 25c19223c1..b64f0401f7 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email.tsx @@ -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(null) + const [universities, setUniversities] = useState< + Partial> + >({}) + 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( + 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 ? ( <>
- +
-
- +
+
+ {isUniversityDirty && ( + <> +
+ +
+
+ +
+ + )} ) : (
diff --git a/services/web/frontend/js/features/settings/components/emails/country-input.tsx b/services/web/frontend/js/features/settings/components/emails/country-input.tsx new file mode 100644 index 0000000000..1c07869c1f --- /dev/null +++ b/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.InputHTMLAttributes + +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 ( +
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-for */} + + ) => { + setInputValue(event.target.value) + }, + onFocus: () => { + if (!isOpen) { + openMenu() + } + }, + })} + className="form-control" + type="text" + placeholder={t('country')} + /> + +
+
    + {inputItems.map((item, index) => ( +
  • +
    + + {item.name} + +
    +
  • + ))} +
+
+ ) +} + +export default CountryInput diff --git a/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx b/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx index 2f9e7bba40..0f29495f16 100644 --- a/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx +++ b/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.InputHTMLAttributes @@ -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({ )} >
+ {/* eslint-disable-next-line jsx-a11y/label-has-for */} + ) => { @@ -70,6 +82,7 @@ function DownshiftInput({ className="form-control" type="text" placeholder={placeholder} + disabled={disabled} />