Przeglądaj źródła

Merge pull request #7841 from overleaf/ii-refactor-add-email

Refactor add email section

GitOrigin-RevId: 41de440caaf3baf43673c4a5f07a18b990f28c7b
ilkin-overleaf 4 lat temu
rodzic
commit
c807bedb65
18 zmienionych plików z 377 dodań i 525 usunięć
  1. 68 225
      services/web/frontend/js/features/settings/components/emails/add-email.tsx
  2. 14 0
      services/web/frontend/js/features/settings/components/emails/add-email/add-another-email-btn.tsx
  3. 27 0
      services/web/frontend/js/features/settings/components/emails/add-email/add-new-email-btn.tsx
  4. 2 3
      services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
  5. 18 0
      services/web/frontend/js/features/settings/components/emails/add-email/email-affiliated-with-institution.tsx
  6. 12 15
      services/web/frontend/js/features/settings/components/emails/add-email/input.tsx
  7. 154 0
      services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx
  8. 24 0
      services/web/frontend/js/features/settings/components/emails/add-email/layout.tsx
  9. 7 8
      services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx
  10. 7 5
      services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx
  11. 4 3
      services/web/frontend/js/features/settings/data/countries-list.ts
  12. 3 1
      services/web/frontend/js/features/settings/data/departments.ts
  13. 3 1
      services/web/frontend/js/features/settings/data/roles.ts
  14. 23 0
      services/web/frontend/js/features/settings/utils/sso.ts
  15. 3 3
      services/web/frontend/stories/settings/add-email-input.stories.tsx
  16. 7 8
      services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx
  17. 0 252
      services/web/types/country.ts
  18. 1 1
      services/web/types/university.ts

+ 68 - 225
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -1,46 +1,25 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Button, Row, Col, Alert } from 'react-bootstrap'
+import { 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, InstitutionInfo } from './add-email-input'
+import Layout from './add-email/layout'
+import Input, { InstitutionInfo } from './add-email/input'
+import AddAnotherEmailBtn from './add-email/add-another-email-btn'
+import InstitutionFields from './add-email/institution-fields'
+import SsoLinkingInfo from './add-email/sso-linking-info'
+import AddNewEmailBtn from './add-email/add-new-email-btn'
 import useAsync from '../../../../shared/hooks/use-async'
 import { useUserEmailsContext } from '../../context/user-email-context'
-import { getJSON, postJSON } from '../../../../infrastructure/fetch-json'
-import { defaults as defaultRoles } from '../../roles'
-import { defaults as defaultDepartments } from '../../departments'
+import { isSsoAvailable } from '../../utils/sso'
+import { postJSON } from '../../../../infrastructure/fetch-json'
 import { University } from '../../../../../../types/university'
-import { CountryCode } from '../../../../../../types/country'
-import { ExposedSettings } from '../../../../../../types/exposed-settings'
-import getMeta from '../../../../utils/meta'
-import { AddEmailSSOLinkingInfo } from './add-email-sso-linking-info'
-
-const isValidEmail = (email: string) => {
-  return Boolean(email)
-}
-
-const ssoAvailableForDomain = (domain: InstitutionInfo | null) => {
-  const { hasSamlBeta, hasSamlFeature } = getMeta(
-    'ol-ExposedSettings'
-  ) as ExposedSettings
-  if (!hasSamlFeature || !domain || !domain.confirmed || !domain.university) {
-    return false
-  }
-  if (domain.university.ssoEnabled) {
-    return true
-  }
-  return hasSamlBeta && domain.university.ssoBeta
-}
+import { CountryCode } from '../../data/countries-list'
 
 function AddEmail() {
   const { t } = useTranslation()
   const [isFormVisible, setIsFormVisible] = useState(
     () => window.location.hash === '#add-email'
   )
-  const emailRef = useRef<HTMLInputElement | null>(null)
-  const countryRef = useRef<HTMLInputElement | null>(null)
   const [newEmail, setNewEmail] = useState('')
   const [newEmailMatchedInstitution, setNewEmailMatchedInstitution] =
     useState<InstitutionInfo | null>(null)
@@ -48,15 +27,10 @@ function AddEmail() {
   const [universities, setUniversities] = useState<
     Partial<Record<CountryCode, University[]>>
   >({})
-  const [university, setUniversity] = useState('')
+  const [universityName, setUniversityName] = useState('')
   const [role, setRole] = useState('')
   const [department, setDepartment] = useState('')
-  const [departments, setDepartments] = useState(defaultDepartments)
-  const [isInstitutionFieldsVisible, setIsInstitutionFieldsVisible] =
-    useState(false)
-  const [isUniversityDirty, setIsUniversityDirty] = useState(false)
   const { isLoading, isError, error, runAsync } = useAsync()
-  const { runAsync: institutionRunAsync } = useAsync()
   const {
     state,
     setLoading: setUserEmailsContextLoading,
@@ -67,61 +41,10 @@ function AddEmail() {
     setUserEmailsContextLoading(isLoading)
   }, [setUserEmailsContextLoading, isLoading])
 
-  useEffect(() => {
-    if (isFormVisible && emailRef.current) {
-      emailRef.current?.focus()
-    }
-  }, [emailRef, isFormVisible])
-
-  useEffect(() => {
-    if (isInstitutionFieldsVisible && countryRef.current) {
-      countryRef.current?.focus()
-    }
-  }, [countryRef, isInstitutionFieldsVisible])
-
-  useEffect(() => {
-    if (university) {
-      setIsUniversityDirty(true)
-    }
-  }, [setIsUniversityDirty, university])
-
-  useEffect(() => {
-    const selectedKnownUniversity = countryCode
-      ? universities[countryCode]?.find(({ name }) => name === university)
-      : undefined
-
-    if (selectedKnownUniversity && selectedKnownUniversity.departments.length) {
-      setDepartments(selectedKnownUniversity.departments)
-    } else {
-      setDepartments(defaultDepartments)
-    }
-  }, [countryCode, universities, 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)
   }
 
-  const handleShowInstitutionFields = () => {
-    setIsInstitutionFieldsVisible(true)
-  }
-
   const handleEmailChange = (value: string, institution?: InstitutionInfo) => {
     setNewEmail(value)
     setNewEmailMatchedInstitution(institution || null)
@@ -129,10 +52,10 @@ function AddEmail() {
 
   const handleAddNewEmail = () => {
     const selectedKnownUniversity = countryCode
-      ? universities[countryCode]?.find(({ name }) => name === university)
+      ? universities[countryCode]?.find(({ name }) => name === universityName)
       : undefined
 
-    const knownUniversityData = university &&
+    const knownUniversityData = universityName &&
       selectedKnownUniversity && {
         university: {
           id: selectedKnownUniversity.id,
@@ -141,10 +64,10 @@ function AddEmail() {
         department,
       }
 
-    const unknownUniversityData = university &&
+    const unknownUniversityData = universityName &&
       !selectedKnownUniversity && {
         university: {
-          name: university,
+          name: universityName,
           country_code: countryCode,
         },
         role,
@@ -162,153 +85,73 @@ function AddEmail() {
     )
       .then(() => {
         getEmails()
-        setIsFormVisible(false)
-        setNewEmail('')
-        setNewEmailMatchedInstitution(null)
-        setCountryCode(null)
-        setIsUniversityDirty(false)
-        setUniversity('')
-        setRole('')
-        setDepartment('')
-        setIsInstitutionFieldsVisible(false)
       })
       .catch(() => {})
   }
 
-  const getUniversityItems = () => {
-    if (!countryCode) {
-      return []
-    }
-
-    return universities[countryCode]?.map(({ name }) => name) ?? []
+  if (!isFormVisible) {
+    return (
+      <Layout isError={isError} error={error}>
+        <Col md={4}>
+          <Cell>
+            <AddAnotherEmailBtn onClick={handleShowAddEmailForm} />
+          </Cell>
+        </Col>
+      </Layout>
+    )
   }
 
-  const ssoAvailable =
-    newEmailMatchedInstitution &&
-    ssoAvailableForDomain(newEmailMatchedInstitution)
-
   return (
-    <div className="affiliations-table-row--highlighted">
-      <Row>
-        {!isFormVisible ? (
-          <Col md={4}>
+    <Layout isError={isError} error={error}>
+      <form>
+        <Col md={4}>
+          <Cell>
+            <label htmlFor="affiliations-email" className="sr-only">
+              {t('email')}
+            </label>
+            <Input onChange={handleEmailChange} />
+          </Cell>
+        </Col>
+        {isSsoAvailable(newEmailMatchedInstitution) ? (
+          <Col md={8}>
             <Cell>
-              <Button
-                className="btn-inline-link"
-                onClick={handleShowAddEmailForm}
-              >
-                {t('add_another_email')}
-              </Button>
+              <SsoLinkingInfo
+                email={newEmail}
+                institutionInfo={newEmailMatchedInstitution}
+              />
             </Cell>
           </Col>
         ) : (
-          <form>
-            <Col md={4}>
+          <>
+            <Col md={5}>
               <Cell>
-                <label htmlFor="affiliations-email" className="sr-only">
-                  {t('email')}
-                </label>
-                <AddEmailInput onChange={handleEmailChange} ref={emailRef} />
+                <InstitutionFields
+                  countryCode={countryCode}
+                  setCountryCode={setCountryCode}
+                  universities={universities}
+                  setUniversities={setUniversities}
+                  universityName={universityName}
+                  setUniversityName={setUniversityName}
+                  role={role}
+                  setRole={setRole}
+                  department={department}
+                  setDepartment={setDepartment}
+                />
               </Cell>
             </Col>
-
-            {ssoAvailable && (
-              <Col md={8}>
-                <Cell>
-                  <AddEmailSSOLinkingInfo
-                    email={newEmail}
-                    institutionInfo={newEmailMatchedInstitution}
-                  />
-                </Cell>
-              </Col>
-            )}
-
-            {!ssoAvailable && (
-              <>
-                <Col md={5}>
-                  <Cell>
-                    {isInstitutionFieldsVisible ? (
-                      <>
-                        <div className="form-group mb-2">
-                          <CountryInput
-                            id="new-email-country-input"
-                            setValue={setCountryCode}
-                            ref={countryRef}
-                          />
-                        </div>
-                        <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={defaultRoles}
-                                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">
-                        {t('is_email_affiliated')}
-                        <br />
-                        <Button
-                          className="btn-inline-link"
-                          onClick={handleShowInstitutionFields}
-                        >
-                          {t('let_us_know')}
-                        </Button>
-                      </div>
-                    )}
-                  </Cell>
-                </Col>
-
-                <Col md={3}>
-                  <Cell className="text-md-right">
-                    <Button
-                      bsSize="small"
-                      bsStyle="success"
-                      disabled={
-                        !isValidEmail(newEmail) || isLoading || state.isLoading
-                      }
-                      onClick={handleAddNewEmail}
-                    >
-                      {t('add_new_email')}
-                    </Button>
-                  </Cell>
-                </Col>
-              </>
-            )}
-          </form>
+            <Col md={3}>
+              <Cell className="text-md-right">
+                <AddNewEmailBtn
+                  email={newEmail}
+                  disabled={isLoading || state.isLoading}
+                  onClick={handleAddNewEmail}
+                />
+              </Cell>
+            </Col>
+          </>
         )}
-      </Row>
-      {isError && (
-        <Alert bsStyle="danger" className="text-center">
-          <Icon type="exclamation-triangle" fw /> {error.getUserFacingMessage()}
-        </Alert>
-      )}
-    </div>
+      </form>
+    </Layout>
   )
 }
 

+ 14 - 0
services/web/frontend/js/features/settings/components/emails/add-email/add-another-email-btn.tsx

@@ -0,0 +1,14 @@
+import { useTranslation } from 'react-i18next'
+import { Button, ButtonProps } from 'react-bootstrap'
+
+function AddAnotherEmailBtn({ onClick, ...props }: ButtonProps) {
+  const { t } = useTranslation()
+
+  return (
+    <Button className="btn-inline-link" onClick={onClick} {...props}>
+      {t('add_another_email')}
+    </Button>
+  )
+}
+
+export default AddAnotherEmailBtn

+ 27 - 0
services/web/frontend/js/features/settings/components/emails/add-email/add-new-email-btn.tsx

@@ -0,0 +1,27 @@
+import { useTranslation } from 'react-i18next'
+import { Button, ButtonProps } from 'react-bootstrap'
+
+const isValidEmail = (email: string) => {
+  return Boolean(email)
+}
+
+type AddNewEmailColProps = {
+  email: string
+} & ButtonProps
+
+function AddNewEmailBtn({ email, disabled, ...props }: AddNewEmailColProps) {
+  const { t } = useTranslation()
+
+  return (
+    <Button
+      bsSize="small"
+      bsStyle="success"
+      disabled={disabled || !isValidEmail(email)}
+      {...props}
+    >
+      {t('add_new_email')}
+    </Button>
+  )
+}
+
+export default AddNewEmailBtn

+ 2 - 3
services/web/frontend/js/features/settings/components/emails/country-input.tsx → services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx

@@ -2,8 +2,7 @@ import { useState, forwardRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useCombobox } from 'downshift'
 import classnames from 'classnames'
-import { defaults as countries } from '../../countries-list'
-import { CountryCode } from '../../../../../../types/country'
+import countries, { CountryCode } from '../../../data/countries-list'
 
 type CountryInputProps = {
   setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
@@ -14,7 +13,7 @@ const itemToString = (item: typeof countries[number] | null) => item?.name ?? ''
 
 function Downshift({ setValue, inputRef }: CountryInputProps) {
   const { t } = useTranslation()
-  const [inputItems, setInputItems] = useState(() => countries)
+  const [inputItems, setInputItems] = useState(() => [...countries])
   const [inputValue, setInputValue] = useState('')
 
   const {

+ 18 - 0
services/web/frontend/js/features/settings/components/emails/add-email/email-affiliated-with-institution.tsx

@@ -0,0 +1,18 @@
+import { useTranslation } from 'react-i18next'
+import { Button, ButtonProps } from 'react-bootstrap'
+
+function EmailAffiliatedWithInstitution({ onClick, ...props }: ButtonProps) {
+  const { t } = useTranslation()
+
+  return (
+    <div className="mt-1">
+      {t('is_email_affiliated')}
+      <br />
+      <Button className="btn-inline-link" onClick={onClick} {...props}>
+        {t('let_us_know')}
+      </Button>
+    </div>
+  )
+}
+
+export default EmailAffiliatedWithInstitution

+ 12 - 15
services/web/frontend/js/features/settings/components/emails/add-email-input.tsx → services/web/frontend/js/features/settings/components/emails/add-email/input.tsx

@@ -4,10 +4,10 @@ import {
   useCallback,
   useEffect,
   useState,
-  forwardRef,
+  useRef,
 } from 'react'
-import { getJSON } from '../../../../infrastructure/fetch-json'
-import useAbortController from '../../../../shared/hooks/use-abort-controller'
+import { getJSON } from '../../../../../infrastructure/fetch-json'
+import useAbortController from '../../../../../shared/hooks/use-abort-controller'
 
 const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
 
@@ -37,18 +37,22 @@ export function clearDomainCache() {
   domainCache = new Map<string, InstitutionInfo>()
 }
 
-type AddEmailInputProps = {
+type InputProps = {
   onChange: (value: string, institution?: InstitutionInfo) => void
-  inputRef?: React.ForwardedRef<HTMLInputElement>
 }
 
-function AddEmailInputBase({ onChange, inputRef }: AddEmailInputProps) {
+function Input({ onChange }: InputProps) {
   const { signal } = useAbortController()
 
+  const inputRef = useRef<HTMLInputElement | null>(null)
   const [suggestion, setSuggestion] = useState<string | null>(null)
   const [inputValue, setInputValue] = useState<string | null>(null)
   const [matchedInstitution, setMatchedInstitution] =
-    useState<InstitutionInfo>(null)
+    useState<InstitutionInfo | null>(null)
+
+  useEffect(() => {
+    inputRef.current?.focus()
+  }, [inputRef])
 
   useEffect(() => {
     if (inputValue == null) {
@@ -151,11 +155,4 @@ function AddEmailInputBase({ onChange, inputRef }: AddEmailInputProps) {
   )
 }
 
-const AddEmailInput = forwardRef<
-  HTMLInputElement,
-  Omit<AddEmailInputProps, 'inputRef'>
->((props, ref) => <AddEmailInputBase {...props} inputRef={ref} />)
-
-AddEmailInput.displayName = 'AddEmailInput'
-
-export { AddEmailInput }
+export default Input

+ 154 - 0
services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx

@@ -0,0 +1,154 @@
+import { useEffect, useState, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import CountryInput from './country-input'
+import DownshiftInput from '../downshift-input'
+import EmailAffiliatedWithInstitution from './email-affiliated-with-institution'
+import defaultRoles from '../../../data/roles'
+import defaultDepartments from '../../../data/departments'
+import { CountryCode } from '../../../data/countries-list'
+import { University } from '../../../../../../../types/university'
+import { getJSON } from '../../../../../infrastructure/fetch-json'
+import useAsync from '../../../../../shared/hooks/use-async'
+
+type InstitutionFieldsProps = {
+  countryCode: CountryCode | null
+  setCountryCode: React.Dispatch<React.SetStateAction<CountryCode | null>>
+  universities: Partial<Record<CountryCode, University[]>>
+  setUniversities: React.Dispatch<
+    React.SetStateAction<Partial<Record<CountryCode, University[]>>>
+  >
+  universityName: string
+  setUniversityName: React.Dispatch<React.SetStateAction<string>>
+  role: string
+  setRole: React.Dispatch<React.SetStateAction<string>>
+  department: string
+  setDepartment: React.Dispatch<React.SetStateAction<string>>
+}
+
+function InstitutionFields({
+  countryCode,
+  setCountryCode,
+  universities,
+  setUniversities,
+  universityName,
+  setUniversityName,
+  role,
+  setRole,
+  department,
+  setDepartment,
+}: InstitutionFieldsProps) {
+  const { t } = useTranslation()
+  const countryRef = useRef<HTMLInputElement | null>(null)
+  const [departments, setDepartments] = useState<string[]>([
+    ...defaultDepartments,
+  ])
+  const [isInstitutionFieldsVisible, setIsInstitutionFieldsVisible] =
+    useState(false)
+  const [isUniversityDirty, setIsUniversityDirty] = useState(false)
+  const { runAsync: institutionRunAsync } = useAsync()
+
+  useEffect(() => {
+    if (isInstitutionFieldsVisible && countryRef.current) {
+      countryRef.current?.focus()
+    }
+  }, [countryRef, isInstitutionFieldsVisible])
+
+  useEffect(() => {
+    if (universityName) {
+      setIsUniversityDirty(true)
+    }
+  }, [setIsUniversityDirty, universityName])
+
+  useEffect(() => {
+    const selectedKnownUniversity = countryCode
+      ? universities[countryCode]?.find(({ name }) => name === universityName)
+      : undefined
+
+    if (selectedKnownUniversity && selectedKnownUniversity.departments.length) {
+      setDepartments(selectedKnownUniversity.departments)
+    } else {
+      setDepartments([...defaultDepartments])
+    }
+  }, [countryCode, universities, universityName])
+
+  // 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 getUniversityItems = () => {
+    if (!countryCode) {
+      return []
+    }
+
+    return universities[countryCode]?.map(({ name }) => name) ?? []
+  }
+
+  const handleShowInstitutionFields = () => {
+    setIsInstitutionFieldsVisible(true)
+  }
+
+  if (!isInstitutionFieldsVisible) {
+    return (
+      <EmailAffiliatedWithInstitution onClick={handleShowInstitutionFields} />
+    )
+  }
+
+  return (
+    <>
+      <div className="form-group mb-2">
+        <CountryInput
+          id="new-email-country-input"
+          setValue={setCountryCode}
+          ref={countryRef}
+        />
+      </div>
+      <div className="form-group mb-2">
+        <DownshiftInput
+          items={getUniversityItems()}
+          inputValue={universityName}
+          placeholder={t('university')}
+          label={t('university')}
+          setValue={setUniversityName}
+          disabled={!countryCode}
+        />
+      </div>
+      {isUniversityDirty && (
+        <>
+          <div className="form-group mb-2">
+            <DownshiftInput
+              items={[...defaultRoles]}
+              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>
+        </>
+      )}
+    </>
+  )
+}
+
+export default InstitutionFields

+ 24 - 0
services/web/frontend/js/features/settings/components/emails/add-email/layout.tsx

@@ -0,0 +1,24 @@
+import { Row, Alert } from 'react-bootstrap'
+import Icon from '../../../../../shared/components/icon'
+import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
+
+type LayoutProps = {
+  children: React.ReactNode
+  isError: UseAsyncReturnType['isError']
+  error: UseAsyncReturnType['error']
+}
+
+function Layout({ isError, error, children }: LayoutProps) {
+  return (
+    <div className="affiliations-table-row--highlighted">
+      <Row>{children}</Row>
+      {isError && (
+        <Alert bsStyle="danger" className="text-center">
+          <Icon type="exclamation-triangle" fw /> {error.getUserFacingMessage()}
+        </Alert>
+      )}
+    </div>
+  )
+}
+
+export default Layout

+ 7 - 8
services/web/frontend/js/features/settings/components/emails/add-email-sso-linking-info.tsx → services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx

@@ -1,17 +1,14 @@
 import { Trans, useTranslation } from 'react-i18next'
-import { InstitutionInfo } from './add-email-input'
-import { ExposedSettings } from '../../../../../../types/exposed-settings'
-import getMeta from '../../../../utils/meta'
+import { InstitutionInfo } from './input'
+import { ExposedSettings } from '../../../../../../../types/exposed-settings'
+import getMeta from '../../../../../utils/meta'
 
-type AddEmailSSOLinkingInfoProps = {
+type SSOLinkingInfoProps = {
   institutionInfo: InstitutionInfo
   email: string
 }
 
-export function AddEmailSSOLinkingInfo({
-  institutionInfo,
-  email,
-}: AddEmailSSOLinkingInfoProps) {
+function SsoLinkingInfo({ institutionInfo, email }: SSOLinkingInfoProps) {
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { t } = useTranslation()
 
@@ -50,3 +47,5 @@ export function AddEmailSSOLinkingInfo({
     </>
   )
 }
+
+export default SsoLinkingInfo

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

@@ -7,8 +7,8 @@ import { useUserEmailsContext } from '../../context/user-email-context'
 import DownshiftInput from './downshift-input'
 import useAsync from '../../../../shared/hooks/use-async'
 import { getJSON, postJSON } from '../../../../infrastructure/fetch-json'
-import { defaults as defaultRoles } from '../../roles'
-import { defaults as defaultDepartments } from '../../departments'
+import defaultRoles from '../../data/roles'
+import defaultDepartments from '../../data/departments'
 import { University } from '../../../../../../types/university'
 
 type InstitutionAndRoleProps = {
@@ -28,7 +28,9 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
   } = useUserEmailsContext()
   const [role, setRole] = useState(affiliation?.role || '')
   const [department, setDepartment] = useState(affiliation?.department || '')
-  const [departments, setDepartments] = useState(defaultDepartments)
+  const [departments, setDepartments] = useState<string[]>(() => [
+    ...defaultDepartments,
+  ])
   const roleRef = useRef<HTMLInputElement | null>(null)
   const isChangingAffiliationInProgress = isChangingAffiliation(
     state,
@@ -62,7 +64,7 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
         }
       })
       .catch(() => {
-        setDepartments(defaultDepartments)
+        setDepartments([...defaultDepartments])
       })
   }
 
@@ -117,7 +119,7 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
         <div className="affiliation-change-container small">
           <form onSubmit={handleSubmit}>
             <DownshiftInput
-              items={defaultRoles}
+              items={[...defaultRoles]}
               inputValue={role}
               placeholder={t('role')}
               label={t('role')}

+ 4 - 3
services/web/frontend/js/features/settings/countries-list.ts → services/web/frontend/js/features/settings/data/countries-list.ts

@@ -1,6 +1,4 @@
-import { CountryCode } from '../../../../types/country'
-
-export const defaults: { code: CountryCode; name: string }[] = [
+const countries = <const>[
   { code: 'af', name: 'Afghanistan' },
   { code: 'ax', name: 'Åland Islands' },
   { code: 'al', name: 'Albania' },
@@ -253,3 +251,6 @@ export const defaults: { code: CountryCode; name: string }[] = [
   { code: 'zm', name: 'Zambia' },
   { code: 'zw', name: 'Zimbabwe' },
 ]
+
+export default countries
+export type CountryCode = typeof countries[number]['code']

+ 3 - 1
services/web/frontend/js/features/settings/departments.ts → services/web/frontend/js/features/settings/data/departments.ts

@@ -1,4 +1,4 @@
-export const defaults = [
+const departments = <const>[
   'Aeronautics & Astronautics',
   'Anesthesia',
   'Anthropology',
@@ -74,3 +74,5 @@ export const defaults = [
   'Theater and Performance Studies',
   'Urology',
 ]
+
+export default departments

+ 3 - 1
services/web/frontend/js/features/settings/roles.ts → services/web/frontend/js/features/settings/data/roles.ts

@@ -1,4 +1,4 @@
-export const defaults = [
+const roles = <const>[
   'Undergraduate Student',
   'Masters Student (MSc, MA, ...)',
   'Doctoral Student (PhD, EngD, ...)',
@@ -11,3 +11,5 @@ export const defaults = [
   'Professor',
   'Emeritus Professor',
 ]
+
+export default roles

+ 23 - 0
services/web/frontend/js/features/settings/utils/sso.ts

@@ -0,0 +1,23 @@
+import getMeta from '../../../utils/meta'
+import { InstitutionInfo } from '../components/emails/add-email/input'
+import { ExposedSettings } from '../../../../../types/exposed-settings'
+import { Nullable } from '../../../../../types/utils'
+
+const ssoAvailableForDomain = (domain: InstitutionInfo | null) => {
+  const { hasSamlBeta, hasSamlFeature } = getMeta(
+    'ol-ExposedSettings'
+  ) as ExposedSettings
+  if (!hasSamlFeature || !domain || !domain.confirmed || !domain.university) {
+    return false
+  }
+  if (domain.university.ssoEnabled) {
+    return true
+  }
+  return hasSamlBeta && domain.university.ssoBeta
+}
+
+export const isSsoAvailable = (
+  institutionInfo: Nullable<InstitutionInfo>
+): institutionInfo is InstitutionInfo => {
+  return Boolean(institutionInfo && ssoAvailableForDomain(institutionInfo))
+}

+ 3 - 3
services/web/frontend/stories/settings/add-email-input.stories.tsx

@@ -1,5 +1,5 @@
 import useFetchMock from './../hooks/use-fetch-mock'
-import { AddEmailInput } from '../../js/features/settings/components/emails/add-email-input'
+import Input from '../../js/features/settings/components/emails/add-email/input'
 
 export const EmailInput = args => {
   useFetchMock(fetchMock =>
@@ -12,7 +12,7 @@ export const EmailInput = args => {
   )
   return (
     <>
-      <AddEmailInput {...args} />
+      <Input {...args} />
       <br />
       <div>
         Use <code>autocomplete.edu</code> as domain to trigger an autocomplete
@@ -23,7 +23,7 @@ export const EmailInput = args => {
 
 export default {
   title: 'Account Settings / Emails and Affiliations',
-  component: AddEmailInput,
+  component: Input,
   argTypes: {
     onChange: { action: 'change' },
   },

+ 7 - 8
services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx

@@ -7,10 +7,9 @@ import {
 import { expect } from 'chai'
 import sinon from 'sinon'
 import fetchMock from 'fetch-mock'
-import {
-  AddEmailInput,
+import Input, {
   clearDomainCache,
-} from '../../../../../../frontend/js/features/settings/components/emails/add-email-input'
+} from '../../../../../../frontend/js/features/settings/components/emails/add-email/input'
 
 const testInstitutionData = [
   { university: { id: 124 }, hostname: 'domain.edu' },
@@ -28,13 +27,13 @@ describe('<AddEmailInput/>', function () {
 
   describe('on initial render', function () {
     it('should render an input with a placeholder', function () {
-      render(<AddEmailInput {...defaultProps} />)
+      render(<Input {...defaultProps} />)
       screen.getByPlaceholderText('e.g. johndoe@mit.edu')
     })
 
     it('should not dispatch any `change` event', function () {
       const onChangeStub = sinon.stub()
-      render(<AddEmailInput {...defaultProps} onChange={onChangeStub} />)
+      render(<Input {...defaultProps} onChange={onChangeStub} />)
       expect(onChangeStub.called).to.equal(false)
     })
   })
@@ -45,7 +44,7 @@ describe('<AddEmailInput/>', function () {
     beforeEach(function () {
       fetchMock.get('express:/institutions/domains', 200)
       onChangeStub = sinon.stub()
-      render(<AddEmailInput {...defaultProps} onChange={onChangeStub} />)
+      render(<Input {...defaultProps} onChange={onChangeStub} />)
       fireEvent.change(screen.getByRole('textbox'), {
         target: { value: 'user' },
       })
@@ -74,7 +73,7 @@ describe('<AddEmailInput/>', function () {
 
     beforeEach(function () {
       onChangeStub = sinon.stub()
-      render(<AddEmailInput onChange={onChangeStub} />)
+      render(<Input onChange={onChangeStub} />)
     })
 
     describe('when there are no matches', function () {
@@ -228,7 +227,7 @@ describe('<AddEmailInput/>', function () {
       // initial request populates the suggestion
       fetchMock.get('express:/institutions/domains', testInstitutionData)
       onChangeStub = sinon.stub()
-      render(<AddEmailInput {...defaultProps} onChange={onChangeStub} />)
+      render(<Input {...defaultProps} onChange={onChangeStub} />)
       fireEvent.change(screen.getByRole('textbox'), {
         target: { value: 'user@d' },
       })

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

@@ -1,252 +0,0 @@
-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'

+ 1 - 1
services/web/types/university.ts

@@ -1,4 +1,4 @@
-import { CountryCode } from './country'
+import { CountryCode } from '../frontend/js/features/settings/data/countries-list'
 
 export type University = {
   id: number