ソースを参照

Merge pull request #7722 from overleaf/ii-add-email-ui-without-affiliations

Add emails without affiliations

GitOrigin-RevId: 13d53b604f8d7cf0f36b2c5caea85ecc15cfc6d5
Timothée Alby 4 年 前
コミット
d3dc83b776

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

@@ -2,7 +2,9 @@
   "access_your_projects_with_git": "",
   "account_not_linked_to_dropbox": "",
   "account_settings": "",
+  "add_another_email": "",
   "add_files": "",
+  "add_new_email": "",
   "add_role_and_department": "",
   "also": "",
   "anyone_with_link_can_edit": "",
@@ -203,6 +205,7 @@
   "invalid_filename": "",
   "invalid_request": "",
   "invite_not_accepted": "",
+  "is_email_affiliated": "",
   "last_name": "",
   "layout": "",
   "layout_processing": "",
@@ -210,6 +213,7 @@
   "learn_more": "",
   "learn_more_about_link_sharing": "",
   "learn_more_about_the_symbol_palette": "",
+  "let_us_know": "",
   "link": "",
   "link_sharing_is_off": "",
   "link_sharing_is_on": "",

+ 24 - 24
services/web/frontend/js/features/settings/components/emails-section.tsx

@@ -7,6 +7,7 @@ import {
 } from '../context/user-email-context'
 import EmailsHeader from './emails/header'
 import EmailsRow from './emails/row'
+import AddEmail from './emails/add-email'
 import Icon from '../../../shared/components/icon'
 import { Alert } from 'react-bootstrap'
 import { ExposedSettings } from '../../../../../types/exposed-settings'
@@ -16,7 +17,6 @@ function EmailsSectionContent() {
   const {
     state: { data: userEmailsData },
     isInitializing,
-    isInitializingSuccess,
     isInitializingError,
   } = useUserEmailsContext()
   const userEmails = Object.values(userEmailsData.byId)
@@ -32,30 +32,30 @@ function EmailsSectionContent() {
           <a href="/learn/how-to/Keeping_your_account_secure" />
         </Trans>
       </p>
-      {isInitializing && (
-        <div className="text-center">
-          <Icon type="refresh" fw spin /> {t('loading')}...
-        </div>
-      )}
-      {isInitializingSuccess && (
-        <>
-          <EmailsHeader />
-          {userEmails?.map((userEmail, i) => (
-            <Fragment key={userEmail.email}>
-              <EmailsRow userEmailData={userEmail} />
-              {i + 1 !== userEmails.length && (
+      <div className="table">
+        <EmailsHeader />
+        {isInitializing ? (
+          <div className="text-center">
+            <Icon type="refresh" fw spin /> {t('loading')}...
+          </div>
+        ) : (
+          <>
+            {userEmails?.map(userEmail => (
+              <Fragment key={userEmail.email}>
+                <EmailsRow userEmailData={userEmail} />
                 <div className="horizontal-divider" />
-              )}
-            </Fragment>
-          ))}
-        </>
-      )}
-      {isInitializingError && (
-        <Alert bsStyle="danger" className="text-center">
-          <Icon type="exclamation-triangle" fw />{' '}
-          {t('error_performing_request')}
-        </Alert>
-      )}
+              </Fragment>
+            ))}
+          </>
+        )}
+        <AddEmail />
+        {isInitializingError && (
+          <Alert bsStyle="danger" className="text-center">
+            <Icon type="exclamation-triangle" fw />{' '}
+            {t('error_performing_request')}
+          </Alert>
+        )}
+      </div>
     </>
   )
 }

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

@@ -0,0 +1,145 @@
+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'
+
+const isValidEmail = (email: string) => {
+  return Boolean(email)
+}
+
+function AddEmail() {
+  const { t } = useTranslation()
+  const [isFormVisible, setIsFormVisible] = useState(
+    () => window.location.hash === '#add-email'
+  )
+  const [newEmail, setNewEmail] = useState('')
+  const [isInstitutionFieldsVisible, setIsInstitutionFieldsVisible] =
+    useState(false)
+  const { isLoading, isError, runAsync } = useAsync()
+  const {
+    state,
+    setLoading: setUserEmailsContextLoading,
+    getEmails,
+  } = useUserEmailsContext()
+
+  useEffect(() => {
+    setUserEmailsContextLoading(isLoading)
+  }, [setUserEmailsContextLoading, isLoading])
+
+  const handleShowAddEmailForm = () => {
+    setIsFormVisible(true)
+  }
+
+  const handleShowInstitutionFields = () => {
+    setIsInstitutionFieldsVisible(true)
+  }
+
+  const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setNewEmail(event.target.value)
+  }
+
+  const handleAddNewEmail = () => {
+    runAsync(
+      postJSON('/user/emails', {
+        body: {
+          email: newEmail,
+        },
+      })
+    )
+      .then(() => {
+        getEmails()
+        setIsFormVisible(false)
+        setNewEmail('')
+      })
+      .catch(error => {
+        console.error(error)
+      })
+  }
+
+  return (
+    <div className="affiliations-table-row--highlighted">
+      <Row>
+        {!isFormVisible ? (
+          <Col md={4}>
+            <Cell>
+              <Button
+                className="btn-inline-link"
+                onClick={handleShowAddEmailForm}
+              >
+                {t('add_another_email')}
+              </Button>
+            </Cell>
+          </Col>
+        ) : (
+          <form>
+            <Col md={4}>
+              <Cell>
+                <label htmlFor="affiliations-email" className="sr-only">
+                  {t('email')}
+                </label>
+                <input
+                  id="affiliations-email"
+                  className="form-control"
+                  type="email"
+                  onChange={handleEmailChange}
+                  placeholder="e.g. johndoe@mit.edu"
+                />
+              </Cell>
+            </Col>
+            <Col md={4}>
+              <Cell>
+                {isInstitutionFieldsVisible ? (
+                  <>
+                    <div className="form-group mb-2">
+                      <input className="form-control" />
+                    </div>
+                    <div className="form-group mb-0">
+                      <input className="form-control" />
+                    </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={4}>
+              <Cell className="text-md-right">
+                <Button
+                  bsSize="small"
+                  bsStyle="success"
+                  disabled={
+                    !isValidEmail(newEmail) || isLoading || state.isLoading
+                  }
+                  onClick={handleAddNewEmail}
+                >
+                  {t('add_new_email')}
+                </Button>
+                {isError && (
+                  <div className="text-danger small">
+                    <Icon type="exclamation-triangle" fw />{' '}
+                    {t('error_performing_request')}
+                  </div>
+                )}
+              </Cell>
+            </Col>
+          </form>
+        )}
+      </Row>
+    </div>
+  )
+}
+
+export default AddEmail

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

@@ -99,7 +99,7 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
               setValue={setDepartment}
             />
             <Button
-              bsSize="sm"
+              bsSize="small"
               bsStyle="success"
               type="submit"
               disabled={!role || !department || isLoading || state.isLoading}

+ 1 - 1
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -17,7 +17,7 @@ function EmailsRow({ userEmailData }: EmailsRowProps) {
           <Email userEmailData={userEmailData} />
         </EmailCell>
       </Col>
-      <Col sm={4}>
+      <Col md={4}>
         {userEmailData.affiliation?.institution && (
           <EmailCell>
             <InstitutionAndRole userEmailData={userEmailData} />

+ 9 - 4
services/web/frontend/js/features/settings/context/user-email-context.tsx

@@ -196,9 +196,9 @@ const reducer = (state: State, action: Action) => {
 function useUserEmails() {
   const [state, unsafeDispatch] = useReducer(reducer, initialState)
   const dispatch = useSafeDispatch(unsafeDispatch)
-  const { isLoading, isSuccess, isError, runAsync } = useAsync()
+  const { data, isLoading, isError, runAsync } = useAsync()
 
-  useEffect(() => {
+  const getEmails = useCallback(() => {
     runAsync<UserEmailData[]>(getJSON('/user/emails?ensureAffiliation=true'))
       .then(data => {
         dispatch(ActionCreators.setData(data))
@@ -206,11 +206,16 @@ function useUserEmails() {
       .catch(() => {})
   }, [runAsync, dispatch])
 
+  // Get emails on page load
+  useEffect(() => {
+    getEmails()
+  }, [getEmails])
+
   return {
     state,
-    isInitializing: isLoading,
-    isInitializingSuccess: isSuccess,
+    isInitializing: isLoading && !data,
     isInitializingError: isError,
+    getEmails,
     setLoading: useCallback(
       (flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
       [dispatch]

+ 3 - 0
services/web/frontend/stylesheets/app/account-settings.less

@@ -26,6 +26,9 @@
 .affiliations-table-cell {
   padding: 0.5rem;
 }
+.affiliations-table-row--highlighted {
+  background-color: tint(@content-alt-bg-color, 6%);
+}
 .affiliations-table-email {
   width: 40%;
 }

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

@@ -0,0 +1,159 @@
+import {
+  render,
+  screen,
+  fireEvent,
+  waitForElementToBeRemoved,
+} from '@testing-library/react'
+import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import { UserEmailData } from '../../../../../../types/user-email'
+
+const userEmailData: UserEmailData = {
+  affiliation: {
+    cachedConfirmedAt: null,
+    cachedPastReconfirmDate: false,
+    cachedReconfirmedAt: null,
+    department: 'Art History',
+    institution: {
+      commonsAccount: false,
+      confirmed: true,
+      id: 1,
+      isUniversity: false,
+      name: 'Overleaf',
+      ssoEnabled: false,
+      ssoBeta: false,
+    },
+    inReconfirmNotificationPeriod: false,
+    inferred: false,
+    licence: 'pro_plus',
+    pastReconfirmDate: false,
+    portal: { slug: '', templates_count: 1 },
+    role: 'Reader',
+  },
+  email: 'baz@overleaf.com',
+  default: false,
+}
+
+describe('<EmailsSection />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-ExposedSettings', {
+      hasAffiliationsFeature: true,
+    })
+    fetchMock.reset()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    fetchMock.reset()
+  })
+
+  it('renders "add another email" button', function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    render(<EmailsSection />)
+
+    screen.getByRole('button', { name: /add another email/i })
+  })
+
+  it('renders input', async function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    render(<EmailsSection />)
+
+    const addAnotherEmailBtn = (await screen.findByRole('button', {
+      name: /add another email/i,
+    })) as HTMLButtonElement
+    fireEvent.click(addAnotherEmailBtn)
+
+    screen.getByLabelText(/email/i)
+  })
+
+  it('renders "add new email" button', async function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    render(<EmailsSection />)
+
+    const addAnotherEmailBtn = (await screen.findByRole('button', {
+      name: /add another email/i,
+    })) as HTMLButtonElement
+    fireEvent.click(addAnotherEmailBtn)
+
+    screen.getByRole('button', { name: /add new email/i })
+  })
+
+  it('adds new email address', async function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    render(<EmailsSection />)
+
+    await fetchMock.flush(true)
+    fetchMock.reset()
+    fetchMock
+      .get('/user/emails?ensureAffiliation=true', [userEmailData])
+      .post('/user/emails', 200)
+
+    const addAnotherEmailBtn = await screen.findByRole('button', {
+      name: /add another email/i,
+    })
+
+    fireEvent.click(addAnotherEmailBtn)
+    const input = screen.getByLabelText(/email/i)
+
+    fireEvent.change(input, {
+      target: { value: userEmailData.email },
+    })
+
+    const submitBtn = screen.getByRole('button', {
+      name: /add new email/i,
+    }) as HTMLButtonElement
+
+    expect(submitBtn.disabled).to.be.false
+
+    fireEvent.click(submitBtn)
+
+    expect(submitBtn.disabled).to.be.true
+
+    await waitForElementToBeRemoved(() =>
+      screen.getByRole('button', {
+        name: /add new email/i,
+      })
+    )
+
+    screen.getByText(userEmailData.email)
+  })
+
+  it('fails to add add new email address', async function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    render(<EmailsSection />)
+
+    await fetchMock.flush(true)
+    fetchMock.reset()
+    fetchMock
+      .get('/user/emails?ensureAffiliation=true', [])
+      .post('/user/emails', 500)
+
+    const addAnotherEmailBtn = await screen.findByRole('button', {
+      name: /add another email/i,
+    })
+
+    fireEvent.click(addAnotherEmailBtn)
+    const input = screen.getByLabelText(/email/i)
+
+    fireEvent.change(input, {
+      target: { value: userEmailData.email },
+    })
+
+    const submitBtn = screen.getByRole('button', {
+      name: /add new email/i,
+    }) as HTMLButtonElement
+
+    expect(submitBtn.disabled).to.be.false
+
+    fireEvent.click(submitBtn)
+
+    expect(submitBtn.disabled).to.be.true
+
+    await screen.findByText(
+      /an error has occurred while performing your request/i
+    )
+    expect(submitBtn).to.not.be.null
+    expect(submitBtn.disabled).to.be.false
+  })
+})