diff --git a/services/web/frontend/js/features/settings/context/user-email-context.tsx b/services/web/frontend/js/features/settings/context/user-email-context.tsx index 7f77f96288..f34f8aeefa 100644 --- a/services/web/frontend/js/features/settings/context/user-email-context.tsx +++ b/services/web/frontend/js/features/settings/context/user-email-context.tsx @@ -105,6 +105,9 @@ const setLoadingAction = (state: State, action: ActionSetLoading) => ({ }) const makePrimaryAction = (state: State, action: ActionMakePrimary) => { + if (!state.data.byId[action.payload]) { + return state + } const byId: State['data']['byId'] = {} for (const id of Object.keys(state.data.byId)) { byId[id] = { @@ -137,19 +140,29 @@ const deleteEmailAction = (state: State, action: ActionDeleteEmail) => { const setEmailAffiliationBeingEditedAction = ( state: State, action: ActionSetEmailAffiliationBeingEdited -) => ({ - ...state, - data: { - ...state.data, - emailAffiliationBeingEdited: action.payload, - }, -}) +) => { + if (action.payload && !state.data.byId[action.payload]) { + return state + } + return { + ...state, + data: { + ...state.data, + emailAffiliationBeingEdited: action.payload, + }, + } +} const updateAffiliationAction = ( state: State, action: ActionUpdateAffiliation ) => { const { email, role, department } = action.payload + + if (action.payload && !state.data.byId[email]) { + return state + } + const affiliation = state.data.byId[email].affiliation return { @@ -213,11 +226,13 @@ function useUserEmails() { useAsync() const getEmails = useCallback(() => { + dispatch(ActionCreators.setLoading(true)) runAsync(getJSON('/user/emails?ensureAffiliation=true')) .then(data => { dispatch(ActionCreators.setData(data)) }) .catch(() => {}) + .finally(() => dispatch(ActionCreators.setLoading(false))) }, [runAsync, dispatch]) // Get emails on page load @@ -308,4 +323,6 @@ const useUserEmailsContext = () => { return context } -export { UserEmailsProvider, useUserEmailsContext } +type EmailContextType = ReturnType + +export { UserEmailsProvider, useUserEmailsContext, EmailContextType } diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx index aae3d5d886..e0862f03ec 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -9,54 +9,12 @@ import { 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 confirmedUserData: UserEmailData = { - confirmedAt: '2022-03-10T10:59:44.139Z', - email: 'bar@overleaf.com', - default: false, -} - -const unconfirmedUserData: UserEmailData = { - email: 'baz@overleaf.com', - default: false, -} - -const professionalUserData: UserEmailData = { - affiliation: { - cachedConfirmedAt: null, - cachedEntitlement: null, - cachedLastDayToReconfirm: null, - cachedPastReconfirmDate: false, - cachedReconfirmedAt: null, - department: 'Art History', - institution: { - commonsAccount: false, - confirmed: true, - id: 1, - isUniversity: false, - maxConfirmationMonths: null, - name: 'Overleaf', - ssoEnabled: false, - ssoBeta: false, - }, - inReconfirmNotificationPeriod: false, - inferred: false, - licence: 'pro_plus', - pastReconfirmDate: false, - portal: { slug: '', templates_count: 1 }, - role: 'Reader', - }, - confirmedAt: '2022-03-09T10:59:44.139Z', - email: 'foo@overleaf.com', - default: true, -} - -const fakeUsersData = [ - { ...confirmedUserData }, - { ...unconfirmedUserData }, - { ...professionalUserData }, -] +import { + confirmedUserData, + fakeUsersData, + professionalUserData, + unconfirmedUserData, +} from '../../fixtures/test-user-email-data' describe('', function () { beforeEach(function () { diff --git a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx new file mode 100644 index 0000000000..e84fa7e4b0 --- /dev/null +++ b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx @@ -0,0 +1,340 @@ +import { expect } from 'chai' +import { cloneDeep } from 'lodash' +import { renderHook } from '@testing-library/react-hooks' +import { + EmailContextType, + UserEmailsProvider, + useUserEmailsContext, +} from '../../../../../frontend/js/features/settings/context/user-email-context' +import fetchMock from 'fetch-mock' +import { + confirmedUserData, + professionalUserData, + unconfirmedUserData, + fakeUsersData, +} from '../fixtures/test-user-email-data' +import localStorage from '../../../../../frontend/js/infrastructure/local-storage' + +const renderUserEmailsContext = () => + renderHook(() => useUserEmailsContext(), { + wrapper: ({ children }) => ( + {children} + ), + }) + +describe('UserEmailContext', function () { + beforeEach(function () { + fetchMock.reset() + }) + + describe('context bootstrap', function () { + it('should start with an "in progress" initialisation state', function () { + const { result } = renderUserEmailsContext() + + expect(result.current.isInitializing).to.equal(true) + expect(result.current.isInitializingSuccess).to.equal(false) + expect(result.current.isInitializingError).to.equal(false) + }) + + it('should start with an empty state', function () { + const { result } = renderUserEmailsContext() + + expect(result.current.state.data.byId).to.deep.equal({}) + expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null + expect(result.current.state.data.linkedInstitutionIds).to.have.length(0) + }) + + it('should load all user emails and update the initialisation state to "success"', async function () { + fetchMock.get(/\/user\/emails/, fakeUsersData) + const { result } = renderUserEmailsContext() + await fetchMock.flush(true) + expect(fetchMock.calls()).to.have.lengthOf(1) + expect(result.current.state.data.byId).to.deep.equal({ + 'bar@overleaf.com': confirmedUserData, + 'baz@overleaf.com': unconfirmedUserData, + 'foo@overleaf.com': professionalUserData, + }) + + expect(result.current.isInitializing).to.equal(false) + expect(result.current.isInitializingSuccess).to.equal(true) + }) + + it('when loading user email fails, it should update the initialisation state to "failed"', async function () { + fetchMock.get(/\/user\/emails/, 500) + const { result } = renderUserEmailsContext() + await fetchMock.flush() + + expect(result.current.isInitializing).to.equal(false) + expect(result.current.isInitializingError).to.equal(true) + }) + + describe('state.isLoading', function () { + it('should be `true` on bootstrap', function () { + const { result } = renderUserEmailsContext() + expect(result.current.state.isLoading).to.equal(true) + }) + + it('should be updated with `setLoading`', function () { + const { result } = renderUserEmailsContext() + result.current.setLoading(true) + expect(result.current.state.isLoading).to.equal(true) + result.current.setLoading(false) + expect(result.current.state.isLoading).to.equal(false) + }) + }) + }) + describe('context initialised', function () { + let result: { current: EmailContextType } + + beforeEach(async function () { + fetchMock.get(/\/user\/emails/, fakeUsersData) + const value = renderUserEmailsContext() + result = value.result + await fetchMock.flush(true) + }) + + describe('getEmails()', function () { + beforeEach(async function () { + fetchMock.reset() + fetchMock.get(/\/user\/emails/, [ + { + email: 'new@email.com', + default: true, + }, + ]) + }) + + it('should set `isLoading === true`', function () { + result.current.getEmails() + expect(result.current.state.isLoading).to.be.true + }) + + it('requests a new set of emails', async function () { + result.current.getEmails() + await fetchMock.flush(true) + expect(result.current.state.data.byId).to.deep.equal({ + 'new@email.com': { + email: 'new@email.com', + default: true, + }, + }) + }) + }) + + describe('makePrimary()', function () { + it('sets an email as `default`', function () { + expect(result.current.state.data.byId['bar@overleaf.com'].default).to.be + .false + result.current.makePrimary('bar@overleaf.com') + expect(result.current.state.data.byId['bar@overleaf.com'].default).to.be + .true + }) + + it('sets `default=false` for the current primary email ', function () { + expect(result.current.state.data.byId['foo@overleaf.com'].default).to.be + .true + result.current.makePrimary('bar@overleaf.com') + expect(result.current.state.data.byId['foo@overleaf.com'].default).to.be + .false + }) + + it('produces no effect when passing a non-existing email', function () { + const emails = cloneDeep(result.current.state.data.byId) + result.current.makePrimary('non-existing@email.com') + expect(result.current.state.data.byId).to.deep.equal(emails) + }) + }) + + describe('deleteEmail()', function () { + it('removes data from the deleted email', function () { + result.current.deleteEmail('bar@overleaf.com') + expect(result.current.state.data.byId['bar@overleaf.com']).to.be + .undefined + }) + + it('produces no effect when passing a non-existing email', function () { + const emails = cloneDeep(result.current.state.data.byId) + result.current.deleteEmail('non-existing@email.com') + expect(result.current.state.data.byId).to.deep.equal(emails) + }) + }) + + describe('setEmailAffiliationBeingEdited()', function () { + it('sets an email as currently being edited', function () { + result.current.setEmailAffiliationBeingEdited('bar@overleaf.com') + expect(result.current.state.data.emailAffiliationBeingEdited).to.equal( + 'bar@overleaf.com' + ) + + result.current.setEmailAffiliationBeingEdited(null) + expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null + }) + + it('produces no effect when passing a non-existing email', function () { + expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null + result.current.setEmailAffiliationBeingEdited('non-existing@email.com') + expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null + }) + }) + + describe('updateAffiliation()', function () { + it('updates affiliation data for an email', function () { + result.current.updateAffiliation( + 'foo@overleaf.com', + 'new role', + 'new department' + ) + expect( + result.current.state.data.byId['foo@overleaf.com'].affiliation.role + ).to.equal('new role') + expect( + result.current.state.data.byId['foo@overleaf.com'].affiliation + .department + ).to.equal('new department') + }) + + it('clears an email from currently being edited', function () { + result.current.setEmailAffiliationBeingEdited('foo@overleaf.com') + result.current.updateAffiliation( + 'foo@overleaf.com', + 'new role', + 'new department' + ) + expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null + }) + + it('produces no effect when passing an email with no affiliation', function () { + const emails = cloneDeep(result.current.state.data.byId) + result.current.updateAffiliation( + 'bar@overleaf.com', + 'new role', + 'new department' + ) + expect(result.current.state.data.byId).to.deep.equal(emails) + }) + + it('produces no effect when passing a non-existing email', function () { + const emails = cloneDeep(result.current.state.data.byId) + result.current.updateAffiliation( + 'non-existing@email.com', + 'new role', + 'new department' + ) + expect(result.current.state.data.byId).to.deep.equal(emails) + }) + }) + + describe('resetLeaversSurveyExpiration()', function () { + beforeEach(function () { + localStorage.removeItem('showInstitutionalLeaversSurveyUntil') + }) + + it('when the leaver has institution license, and there is another email with institution license, it should not reset the survey expiration date', async function () { + const affiliatedEmail1 = cloneDeep(professionalUserData) + affiliatedEmail1.email = 'institution-test@example.com' + affiliatedEmail1.emailHasInstitutionLicence = true + + const affiliatedEmail2 = cloneDeep(professionalUserData) + affiliatedEmail2.emailHasInstitutionLicence = true + + fetchMock.reset() + fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2]) + + result.current.getEmails() + await fetchMock.flush(true) + + // `resetLeaversSurveyExpiration` always happens after deletion + result.current.deleteEmail(affiliatedEmail1.email) + result.current.resetLeaversSurveyExpiration(affiliatedEmail1) + + const expiration = localStorage.getItem( + 'showInstitutionalLeaversSurveyUntil' + ) as number + expect(expiration).to.be.null + }) + + it("when the leaver's affiliation is past reconfirmation date, and there is another email with institution license, it should not reset the survey expiration date", async function () { + const affiliatedEmail1 = cloneDeep(professionalUserData) + affiliatedEmail1.email = 'institution-test@example.com' + affiliatedEmail1.affiliation.pastReconfirmDate = true + + const affiliatedEmail2 = cloneDeep(professionalUserData) + affiliatedEmail2.emailHasInstitutionLicence = true + + fetchMock.reset() + fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2]) + + result.current.getEmails() + await fetchMock.flush(true) + + // `resetLeaversSurveyExpiration` always happens after deletion + result.current.deleteEmail(affiliatedEmail1.email) + result.current.resetLeaversSurveyExpiration(affiliatedEmail1) + + const expiration = localStorage.getItem( + 'showInstitutionalLeaversSurveyUntil' + ) as number + expect(expiration).to.be.null + }) + + it('when there are no other emails with institution license, it should reset the survey expiration date', async function () { + const affiliatedEmail1 = cloneDeep(professionalUserData) + affiliatedEmail1.emailHasInstitutionLicence = true + affiliatedEmail1.email = 'institution-test@example.com' + affiliatedEmail1.affiliation.pastReconfirmDate = true + + fetchMock.reset() + fetchMock.get(/\/user\/emails/, [confirmedUserData, affiliatedEmail1]) + + result.current.getEmails() + await fetchMock.flush(true) + + // `resetLeaversSurveyExpiration` always happens after deletion + result.current.deleteEmail(affiliatedEmail1.email) + result.current.resetLeaversSurveyExpiration(affiliatedEmail1) + + expect( + localStorage.getItem('showInstitutionalLeaversSurveyUntil') + ).to.be.greaterThan(Date.now()) + }) + + it("when the leaver has no institution license, it shouldn't reset the survey expiration date", async function () { + const emailWithInstitutionLicense = cloneDeep(professionalUserData) + emailWithInstitutionLicense.email = 'institution-licensed@example.com' + emailWithInstitutionLicense.emailHasInstitutionLicence = false + + fetchMock.reset() + fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense]) + + result.current.getEmails() + await fetchMock.flush(true) + + // `resetLeaversSurveyExpiration` always happens after deletion + result.current.deleteEmail(emailWithInstitutionLicense.email) + result.current.resetLeaversSurveyExpiration(professionalUserData) + + expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to + .be.null + }) + + it("when the leaver is not past its reconfirmation date, it shouldn't reset the survey expiration date", async function () { + const emailWithInstitutionLicense = cloneDeep(professionalUserData) + emailWithInstitutionLicense.email = 'institution-licensed@example.com' + emailWithInstitutionLicense.affiliation.pastReconfirmDate = false + + fetchMock.reset() + fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense]) + + result.current.getEmails() + await fetchMock.flush(true) + + // `resetLeaversSurveyExpiration` always happens after deletion + result.current.deleteEmail(emailWithInstitutionLicense.email) + result.current.resetLeaversSurveyExpiration(professionalUserData) + + expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to + .be.null + }) + }) + }) +}) diff --git a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts b/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts new file mode 100644 index 0000000000..83a07639cd --- /dev/null +++ b/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts @@ -0,0 +1,48 @@ +import { UserEmailData } from '../../../../../types/user-email' + +export const confirmedUserData: UserEmailData = { + confirmedAt: '2022-03-10T10:59:44.139Z', + email: 'bar@overleaf.com', + default: false, +} + +export const unconfirmedUserData: UserEmailData = { + email: 'baz@overleaf.com', + default: false, +} + +export const professionalUserData: UserEmailData = { + affiliation: { + cachedConfirmedAt: null, + cachedEntitlement: null, + cachedLastDayToReconfirm: null, + cachedPastReconfirmDate: false, + cachedReconfirmedAt: null, + department: 'Art History', + institution: { + commonsAccount: false, + confirmed: true, + id: 1, + isUniversity: false, + maxConfirmationMonths: null, + name: 'Overleaf', + ssoEnabled: false, + ssoBeta: false, + }, + inReconfirmNotificationPeriod: false, + inferred: false, + licence: 'pro_plus', + pastReconfirmDate: false, + portal: { slug: '', templates_count: 1 }, + role: 'Reader', + }, + confirmedAt: '2022-03-09T10:59:44.139Z', + email: 'foo@overleaf.com', + default: true, +} + +export const fakeUsersData = [ + { ...confirmedUserData }, + { ...unconfirmedUserData }, + { ...professionalUserData }, +]