Преглед на файлове

Always use mockable location methods (#11929)

* Always use mockable location methods
* Add eslint rules for window.location calls/assignment
* Add useLocation hook
* Update tests

GitOrigin-RevId: eafb846db89f884a7a9a8570cce7745be605152c
Alf Eaton преди 3 години
родител
ревизия
f375362894
променени са 48 файла, в които са добавени 381 реда и са изтрити 265 реда
  1. 26 0
      services/web/.eslintrc
  2. 2 1
      services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js
  3. 4 3
      services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx
  4. 4 3
      services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js
  5. 3 5
      services/web/frontend/js/features/file-tree/components/file-tree-error.js
  6. 7 5
      services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js
  7. 3 1
      services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx
  8. 3 1
      services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx
  9. 3 1
      services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
  10. 4 2
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx
  11. 4 4
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx
  12. 5 1
      services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx
  13. 3 1
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx
  14. 5 1
      services/web/frontend/js/features/settings/components/emails/row.tsx
  15. 3 1
      services/web/frontend/js/features/settings/components/leave/modal-form.tsx
  16. 3 3
      services/web/frontend/js/features/settings/context/sso-context.tsx
  17. 3 2
      services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js
  18. 4 2
      services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
  19. 3 2
      services/web/frontend/js/features/subscription/components/dashboard/reactivate-subscription.tsx
  20. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
  21. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx
  22. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx
  23. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
  24. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
  25. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
  26. 4 2
      services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx
  27. 3 4
      services/web/frontend/js/main/new-subscription.js
  28. 2 0
      services/web/frontend/js/shared/components/location.js
  29. 25 0
      services/web/frontend/js/shared/hooks/use-location.ts
  30. 1 0
      services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.js
  31. 7 3
      services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js
  32. 10 12
      services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
  33. 11 14
      services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx
  34. 10 9
      services/web/test/frontend/features/project-list/components/notifications.test.tsx
  35. 9 10
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  36. 10 11
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
  37. 5 0
      services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx
  38. 80 84
      services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx
  39. 7 2
      services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx
  40. 19 19
      services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
  41. 9 12
      services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
  42. 9 0
      services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx
  43. 6 5
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
  44. 6 7
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  45. 17 7
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  46. 6 3
      services/web/test/frontend/features/subscription/components/dashboard/premium-features-link.test.tsx
  47. 8 9
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  48. 7 7
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

+ 26 - 0
services/web/.eslintrc

@@ -111,6 +111,32 @@
         "plugin:cypress/recommended"
         "plugin:cypress/recommended"
       ]
       ]
     },
     },
+    {
+      // React component specific rules
+      //
+      "files": ["**/frontend/js/**/components/**/*.{js,ts,tsx}", "**/frontend/js/**/hooks/**/*.{js,ts,tsx}"],
+      "rules": {
+        // https://astexplorer.net/
+        "no-restricted-syntax": [
+          "error",
+          // prohibit direct calls to methods of window.location
+          {
+            "selector": "CallExpression[callee.object.object.name='window'][callee.object.property.name='location']",
+            "message": "Modify location via useLocation instead of calling window.location methods directly"
+          },
+          // prohibit assignment to window.location
+          {
+            "selector": "AssignmentExpression[left.object.name='window'][left.property.name='location']",
+            "message": "Modify location via useLocation instead of calling window.location methods directly"
+          },
+          // prohibit assignment to window.location.href
+          {
+            "selector": "AssignmentExpression[left.object.object.name='window'][left.object.property.name='location'][left.property.name='href']",
+            "message": "Modify location via useLocation instead of calling window.location methods directly"
+          }
+        ]
+      }
+    },
     {
     {
       // Frontend specific rules
       // Frontend specific rules
       "files": ["**/frontend/js/**/*.{js,ts,tsx}", "**/frontend/stories/**/*.{js,ts,tsx}", "**/*.stories.{js,ts,tsx}", "**/test/frontend/**/*.{js,ts,tsx}", "**/test/frontend/components/**/*.spec.{js,ts,tsx}"],
       "files": ["**/frontend/js/**/*.{js,ts,tsx}", "**/frontend/stories/**/*.{js,ts,tsx}", "**/*.stories.{js,ts,tsx}", "**/test/frontend/**/*.{js,ts,tsx}", "**/test/frontend/components/**/*.spec.{js,ts,tsx}"],

+ 2 - 1
services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js

@@ -2,6 +2,7 @@ import App from '../../../base'
 import { react2angular } from 'react2angular'
 import { react2angular } from 'react2angular'
 import EditorCloneProjectModalWrapper from '../components/editor-clone-project-modal-wrapper'
 import EditorCloneProjectModalWrapper from '../components/editor-clone-project-modal-wrapper'
 import { rootContext } from '../../../shared/context/root-context'
 import { rootContext } from '../../../shared/context/root-context'
+import { assign } from '../../../shared/components/location'
 
 
 export default App.controller(
 export default App.controller(
   'LeftMenuCloneProjectModalController',
   'LeftMenuCloneProjectModalController',
@@ -21,7 +22,7 @@ export default App.controller(
     }
     }
 
 
     $scope.openProject = project => {
     $scope.openProject = project => {
-      window.location.assign(`/project/${project.project_id}`)
+      assign(`/project/${project.project_id}`)
     }
     }
   }
   }
 )
 )

+ 4 - 3
services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx

@@ -1,8 +1,8 @@
 import { useCallback, useState } from 'react'
 import { useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
-import { assign } from '../../../shared/components/location'
 import EditorCloneProjectModalWrapper from '../../clone-project-modal/components/editor-clone-project-modal-wrapper'
 import EditorCloneProjectModalWrapper from '../../clone-project-modal/components/editor-clone-project-modal-wrapper'
 import LeftMenuButton from './left-menu-button'
 import LeftMenuButton from './left-menu-button'
+import { useLocation } from '../../../shared/hooks/use-location'
 
 
 type ProjectCopyResponse = {
 type ProjectCopyResponse = {
   project_id: string
   project_id: string
@@ -11,12 +11,13 @@ type ProjectCopyResponse = {
 export default function ActionsCopyProject() {
 export default function ActionsCopyProject() {
   const [showModal, setShowModal] = useState(false)
   const [showModal, setShowModal] = useState(false)
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const location = useLocation()
 
 
   const openProject = useCallback(
   const openProject = useCallback(
     ({ project_id: projectId }: ProjectCopyResponse) => {
     ({ project_id: projectId }: ProjectCopyResponse) => {
-      assign(`/project/${projectId}`)
+      location.assign(`/project/${projectId}`)
     },
     },
-    []
+    [location]
   )
   )
 
 
   return (
   return (

+ 4 - 3
services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js

@@ -2,12 +2,13 @@ import { useState, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import PropTypes from 'prop-types'
 import { Trans } from 'react-i18next'
 import { Trans } from 'react-i18next'
 import { useProjectContext } from '../../../../shared/context/project-context'
 import { useProjectContext } from '../../../../shared/context/project-context'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 // handle "not-logged-in" errors by redirecting to the login page
 // handle "not-logged-in" errors by redirecting to the login page
 export default function RedirectToLogin() {
 export default function RedirectToLogin() {
   const { _id: projectId } = useProjectContext(projectContextPropTypes)
   const { _id: projectId } = useProjectContext(projectContextPropTypes)
-
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
+  const location = useLocation()
 
 
   useEffect(() => {
   useEffect(() => {
     setSecondsToRedirect(10)
     setSecondsToRedirect(10)
@@ -16,7 +17,7 @@ export default function RedirectToLogin() {
       setSecondsToRedirect(value => {
       setSecondsToRedirect(value => {
         if (value === 0) {
         if (value === 0) {
           window.clearInterval(timer)
           window.clearInterval(timer)
-          window.location.assign(`/login?redir=/project/${projectId}`)
+          location.assign(`/login?redir=/project/${projectId}`)
           return 0
           return 0
         }
         }
 
 
@@ -27,7 +28,7 @@ export default function RedirectToLogin() {
     return () => {
     return () => {
       window.clearInterval(timer)
       window.clearInterval(timer)
     }
     }
-  }, [projectId])
+  }, [projectId, location])
 
 
   return (
   return (
     <Trans
     <Trans

+ 3 - 5
services/web/frontend/js/features/file-tree/components/file-tree-error.js

@@ -1,18 +1,16 @@
 import { Button } from 'react-bootstrap'
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
+import { useLocation } from '../../../shared/hooks/use-location'
 
 
 function FileTreeError() {
 function FileTreeError() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-
-  function reload() {
-    location.reload()
-  }
+  const { reload: handleClick } = useLocation()
 
 
   return (
   return (
     <div className="file-tree-error">
     <div className="file-tree-error">
       <p>{t('generic_something_went_wrong')}</p>
       <p>{t('generic_something_went_wrong')}</p>
       <p>{t('please_refresh')}</p>
       <p>{t('please_refresh')}</p>
-      <Button bsStyle="primary" onClick={reload}>
+      <Button bsStyle="primary" onClick={handleClick}>
         {t('refresh')}
         {t('refresh')}
       </Button>
       </Button>
     </div>
     </div>

+ 7 - 5
services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js

@@ -1,14 +1,16 @@
 import { Button } from 'react-bootstrap'
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
-import { memo } from 'react'
+import { memo, useCallback } from 'react'
 import { buildUrlWithDetachRole } from '../../../shared/utils/url-helper'
 import { buildUrlWithDetachRole } from '../../../shared/utils/url-helper'
-
-const redirect = function () {
-  window.location = buildUrlWithDetachRole(null).toString()
-}
+import { useLocation } from '../../../shared/hooks/use-location'
 
 
 function PdfOrphanRefreshButton() {
 function PdfOrphanRefreshButton() {
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const location = useLocation()
+
+  const redirect = useCallback(() => {
+    location.assign(buildUrlWithDetachRole(null).toString())
+  }, [location])
 
 
   return (
   return (
     <Button
     <Button

+ 3 - 1
services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx

@@ -7,6 +7,7 @@ import {
   postJSON,
   postJSON,
 } from '../../../../infrastructure/fetch-json'
 } from '../../../../infrastructure/fetch-json'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 type NewProjectData = {
 type NewProjectData = {
   project_id: string
   project_id: string
@@ -29,6 +30,7 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
   const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
   const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
   const [projectName, setProjectName] = useState('')
   const [projectName, setProjectName] = useState('')
   const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
   const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
+  const location = useLocation()
 
 
   const createNewProject = () => {
   const createNewProject = () => {
     runAsync(
     runAsync(
@@ -42,7 +44,7 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
     )
     )
       .then(data => {
       .then(data => {
         if (data.project_id) {
         if (data.project_id) {
-          window.location.assign(`/project/${data.project_id}`)
+          location.assign(`/project/${data.project_id}`)
         }
         }
       })
       })
       .catch(() => {})
       .catch(() => {})

+ 3 - 1
services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx

@@ -10,6 +10,7 @@ import { ExposedSettings } from '../../../../../../types/exposed-settings'
 
 
 import '@uppy/core/dist/style.css'
 import '@uppy/core/dist/style.css'
 import '@uppy/dashboard/dist/style.css'
 import '@uppy/dashboard/dist/style.css'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 type UploadResponse = {
 type UploadResponse = {
   project_id: string
   project_id: string
@@ -24,6 +25,7 @@ function UploadProjectModal({ onHide }: UploadProjectModalProps) {
   const { maxUploadSize } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { maxUploadSize } = getMeta('ol-ExposedSettings') as ExposedSettings
   const [ableToUpload, setAbleToUpload] = useState(true)
   const [ableToUpload, setAbleToUpload] = useState(true)
   const [correctfileAdded, setCorrectFileAdded] = useState(false)
   const [correctfileAdded, setCorrectFileAdded] = useState(false)
+  const location = useLocation()
 
 
   const uppy: Uppy.Uppy<Uppy.StrictTypes> = useUppy(() => {
   const uppy: Uppy.Uppy<Uppy.StrictTypes> = useUppy(() => {
     return Uppy({
     return Uppy({
@@ -55,7 +57,7 @@ function UploadProjectModal({ onHide }: UploadProjectModalProps) {
         const { project_id: projectId }: UploadResponse = response.body
         const { project_id: projectId }: UploadResponse = response.body
 
 
         if (projectId) {
         if (projectId) {
-          window.location.assign(`/project/${projectId}`)
+          location.assign(`/project/${projectId}`)
         }
         }
       })
       })
       .on('restriction-failed', () => {
       .on('restriction-failed', () => {

+ 3 - 1
services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx

@@ -8,6 +8,7 @@ import { postJSON } from '../../../../../../infrastructure/fetch-json'
 import { UserEmailData } from '../../../../../../../../types/user-email'
 import { UserEmailData } from '../../../../../../../../types/user-email'
 import { ExposedSettings } from '../../../../../../../../types/exposed-settings'
 import { ExposedSettings } from '../../../../../../../../types/exposed-settings'
 import { Institution } from '../../../../../../../../types/institution'
 import { Institution } from '../../../../../../../../types/institution'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
 
 
 type ReconfirmAffiliationProps = {
 type ReconfirmAffiliationProps = {
   email: UserEmailData['email']
   email: UserEmailData['email']
@@ -24,6 +25,7 @@ function ReconfirmAffiliation({
   const [hasSent, setHasSent] = useState(false)
   const [hasSent, setHasSent] = useState(false)
   const [isPending, setIsPending] = useState(false)
   const [isPending, setIsPending] = useState(false)
   const ssoEnabled = institution.ssoEnabled
   const ssoEnabled = institution.ssoEnabled
+  const location = useLocation()
 
 
   useEffect(() => {
   useEffect(() => {
     if (isSuccess) {
     if (isSuccess) {
@@ -34,7 +36,7 @@ function ReconfirmAffiliation({
   const handleRequestReconfirmation = () => {
   const handleRequestReconfirmation = () => {
     if (ssoEnabled) {
     if (ssoEnabled) {
       setIsPending(true)
       setIsPending(true)
-      window.location.assign(
+      location.assign(
         `${samlInitPath}?university_id=${institution.id}&reconfirm=/project`
         `${samlInitPath}?university_id=${institution.id}&reconfirm=/project`
       )
       )
     } else {
     } else {

+ 4 - 2
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx

@@ -4,6 +4,7 @@ import { Project } from '../../../../../../../../types/project/dashboard/api'
 import Icon from '../../../../../../shared/components/icon'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
 
 
 type DownloadProjectButtonProps = {
 type DownloadProjectButtonProps = {
   project: Project
   project: Project
@@ -16,6 +17,7 @@ function DownloadProjectButton({
 }: DownloadProjectButtonProps) {
 }: DownloadProjectButtonProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const text = t('download')
   const text = t('download')
+  const location = useLocation()
 
 
   const downloadProject = useCallback(() => {
   const downloadProject = useCallback(() => {
     eventTracking.send(
     eventTracking.send(
@@ -23,8 +25,8 @@ function DownloadProjectButton({
       'project action',
       'project action',
       'Download Zip'
       'Download Zip'
     )
     )
-    window.location.assign(`/project/${project.id}/download/zip`)
-  }, [project])
+    location.assign(`/project/${project.id}/download/zip`)
+  }, [project, location])
 
 
   return children(text, downloadProject)
   return children(text, downloadProject)
 }
 }

+ 4 - 4
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx

@@ -4,12 +4,14 @@ import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { useProjectListContext } from '../../../../context/project-list-context'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
 
 
 function DownloadProjectsButton() {
 function DownloadProjectsButton() {
   const { selectedProjects, selectOrUnselectAllProjects } =
   const { selectedProjects, selectOrUnselectAllProjects } =
     useProjectListContext()
     useProjectListContext()
   const { t } = useTranslation()
   const { t } = useTranslation()
   const text = t('download')
   const text = t('download')
+  const location = useLocation()
 
 
   const projectIds = selectedProjects.map(p => p.id)
   const projectIds = selectedProjects.map(p => p.id)
 
 
@@ -20,13 +22,11 @@ function DownloadProjectsButton() {
       'Download Zip'
       'Download Zip'
     )
     )
 
 
-    window.location.assign(
-      `/project/download/zip?project_ids=${projectIds.join(',')}`
-    )
+    location.assign(`/project/download/zip?project_ids=${projectIds.join(',')}`)
 
 
     const selected = false
     const selected = false
     selectOrUnselectAllProjects(selected)
     selectOrUnselectAllProjects(selected)
-  }, [projectIds, selectOrUnselectAllProjects])
+  }, [projectIds, selectOrUnselectAllProjects, location])
 
 
   return (
   return (
     <Tooltip
     <Tooltip

+ 5 - 1
services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx

@@ -4,6 +4,7 @@ import { Trans, useTranslation } from 'react-i18next'
 import { DomainInfo } from './input'
 import { DomainInfo } from './input'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import getMeta from '../../../../../utils/meta'
 import getMeta from '../../../../../utils/meta'
+import { useLocation } from '../../../../../shared/hooks/use-location'
 
 
 type SSOLinkingInfoProps = {
 type SSOLinkingInfoProps = {
   domainInfo: DomainInfo
   domainInfo: DomainInfo
@@ -13,13 +14,16 @@ type SSOLinkingInfoProps = {
 function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
 function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const location = useLocation()
 
 
   const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
   const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
     useState(false)
     useState(false)
 
 
   function handleLinkAccountsButtonClick() {
   function handleLinkAccountsButtonClick() {
     setLinkAccountsButtonDisabled(true)
     setLinkAccountsButtonDisabled(true)
-    window.location.href = `${samlInitPath}?university_id=${domainInfo.university.id}&auto=/user/settings&email=${email}`
+    location.assign(
+      `${samlInitPath}?university_id=${domainInfo.university.id}&auto=/user/settings&email=${email}`
+    )
   }
   }
 
 
   return (
   return (

+ 3 - 1
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx

@@ -9,6 +9,7 @@ import getMeta from '../../../../../utils/meta'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import { ssoAvailableForInstitution } from '../../../utils/sso'
 import { ssoAvailableForInstitution } from '../../../utils/sso'
 import Icon from '../../../../../shared/components/icon'
 import Icon from '../../../../../shared/components/icon'
+import { useLocation } from '../../../../../shared/hooks/use-location'
 
 
 type ReconfirmationInfoPromptProps = {
 type ReconfirmationInfoPromptProps = {
   email: string
   email: string
@@ -29,6 +30,7 @@ function ReconfirmationInfoPrompt({
   const [isPending, setIsPending] = useState(false)
   const [isPending, setIsPending] = useState(false)
   const [hasSent, setHasSent] = useState(false)
   const [hasSent, setHasSent] = useState(false)
   const ssoAvailable = Boolean(ssoAvailableForInstitution(institution))
   const ssoAvailable = Boolean(ssoAvailableForInstitution(institution))
+  const location = useLocation()
 
 
   useEffect(() => {
   useEffect(() => {
     setUserEmailsContextLoading(isLoading)
     setUserEmailsContextLoading(isLoading)
@@ -43,7 +45,7 @@ function ReconfirmationInfoPrompt({
   const handleRequestReconfirmation = () => {
   const handleRequestReconfirmation = () => {
     if (ssoAvailable) {
     if (ssoAvailable) {
       setIsPending(true)
       setIsPending(true)
-      window.location.assign(
+      location.assign(
         `${samlInitPath}?university_id=${institution.id}&reconfirm=/user/settings`
         `${samlInitPath}?university_id=${institution.id}&reconfirm=/user/settings`
       )
       )
     } else {
     } else {

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

@@ -12,6 +12,7 @@ import getMeta from '../../../../utils/meta'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
 import { ssoAvailableForInstitution } from '../../utils/sso'
 import { ssoAvailableForInstitution } from '../../utils/sso'
 import ReconfirmationInfo from './reconfirmation-info'
 import ReconfirmationInfo from './reconfirmation-info'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 type EmailsRowProps = {
 type EmailsRowProps = {
   userEmailData: UserEmailData
   userEmailData: UserEmailData
@@ -61,13 +62,16 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { state } = useUserEmailsContext()
   const { state } = useUserEmailsContext()
+  const location = useLocation()
 
 
   const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
   const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
     useState(false)
     useState(false)
 
 
   function handleLinkAccountsButtonClick() {
   function handleLinkAccountsButtonClick() {
     setLinkAccountsButtonDisabled(true)
     setLinkAccountsButtonDisabled(true)
-    window.location.href = `${samlInitPath}?university_id=${userEmailData.affiliation?.institution?.id}&auto=/user/settings&email=${userEmailData.email}`
+    location.assign(
+      `${samlInitPath}?university_id=${userEmailData.affiliation?.institution?.id}&auto=/user/settings&email=${userEmailData.email}`
+    )
   }
   }
 
 
   if (
   if (

+ 3 - 1
services/web/frontend/js/features/settings/components/leave/modal-form.tsx

@@ -4,6 +4,7 @@ import { useTranslation, Trans } from 'react-i18next'
 import { postJSON, FetchError } from '../../../../infrastructure/fetch-json'
 import { postJSON, FetchError } from '../../../../infrastructure/fetch-json'
 import getMeta from '../../../../utils/meta'
 import getMeta from '../../../../utils/meta'
 import LeaveModalFormError from './modal-form-error'
 import LeaveModalFormError from './modal-form-error'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 export type LeaveModalFormProps = {
 export type LeaveModalFormProps = {
   setInFlight: Dispatch<SetStateAction<boolean>>
   setInFlight: Dispatch<SetStateAction<boolean>>
@@ -18,6 +19,7 @@ function LeaveModalForm({
 }: LeaveModalFormProps) {
 }: LeaveModalFormProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const userDefaultEmail = getMeta('ol-usersEmail') as string
   const userDefaultEmail = getMeta('ol-usersEmail') as string
+  const location = useLocation()
 
 
   const [email, setEmail] = useState('')
   const [email, setEmail] = useState('')
   const [password, setPassword] = useState('')
   const [password, setPassword] = useState('')
@@ -53,7 +55,7 @@ function LeaveModalForm({
       },
       },
     })
     })
       .then(() => {
       .then(() => {
-        window.location.assign('/login')
+        location.assign('/login')
       })
       })
       .catch(setError)
       .catch(setError)
       .finally(() => {
       .finally(() => {

+ 3 - 3
services/web/frontend/js/features/settings/context/sso-context.tsx

@@ -34,7 +34,7 @@ type SSOProviderProps = {
 }
 }
 
 
 export function SSOProvider({ children }: SSOProviderProps) {
 export function SSOProvider({ children }: SSOProviderProps) {
-  const isMountedRef = useIsMounted()
+  const isMounted = useIsMounted()
   const oauthProviders = getMeta('ol-oauthProviders', {}) as OAuthProviders
   const oauthProviders = getMeta('ol-oauthProviders', {}) as OAuthProviders
   const thirdPartyIds = getMeta('ol-thirdPartyIds') as ThirdPartyIds
   const thirdPartyIds = getMeta('ol-thirdPartyIds') as ThirdPartyIds
 
 
@@ -66,14 +66,14 @@ export function SSOProvider({ children }: SSOProviderProps) {
       }
       }
 
 
       return postJSON('/user/oauth-unlink', { body, signal }).then(() => {
       return postJSON('/user/oauth-unlink', { body, signal }).then(() => {
-        if (isMountedRef.current) {
+        if (isMounted.current) {
           setSubscriptions(subs =>
           setSubscriptions(subs =>
             set(cloneDeep(subs), `${providerId}.linked`, false)
             set(cloneDeep(subs), `${providerId}.linked`, false)
           )
           )
         }
         }
       })
       })
     },
     },
-    [isMountedRef, subscriptions]
+    [isMounted, subscriptions]
   )
   )
 
 
   const value = useMemo<SSOContextValue>(
   const value = useMemo<SSOContextValue>(

+ 3 - 2
services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js

@@ -5,12 +5,13 @@ import PropTypes from 'prop-types'
 import Icon from '../../../shared/components/icon'
 import Icon from '../../../shared/components/icon'
 import { transferProjectOwnership } from '../utils/api'
 import { transferProjectOwnership } from '../utils/api'
 import AccessibleModal from '../../../shared/components/accessible-modal'
 import AccessibleModal from '../../../shared/components/accessible-modal'
-import { reload } from '../../../shared/components/location'
 import { useProjectContext } from '../../../shared/context/project-context'
 import { useProjectContext } from '../../../shared/context/project-context'
+import { useLocation } from '../../../shared/hooks/use-location'
 
 
 export default function TransferOwnershipModal({ member, cancel }) {
 export default function TransferOwnershipModal({ member, cancel }) {
   const [inflight, setInflight] = useState(false)
   const [inflight, setInflight] = useState(false)
   const [error, setError] = useState(false)
   const [error, setError] = useState(false)
+  const location = useLocation()
 
 
   const { _id: projectId, name: projectName } = useProjectContext()
   const { _id: projectId, name: projectName } = useProjectContext()
 
 
@@ -20,7 +21,7 @@ export default function TransferOwnershipModal({ member, cancel }) {
 
 
     transferProjectOwnership(projectId, member)
     transferProjectOwnership(projectId, member)
       .then(() => {
       .then(() => {
-        reload()
+        location.reload()
       })
       })
       .catch(() => {
       .catch(() => {
         setError(true)
         setError(true)

+ 4 - 2
services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
 import { deleteJSON } from '../../../../infrastructure/fetch-json'
 import { deleteJSON } from '../../../../infrastructure/fetch-json'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 export const LEAVE_GROUP_MODAL_ID = 'leave-group'
 export const LEAVE_GROUP_MODAL_ID = 'leave-group'
 
 
@@ -12,6 +13,7 @@ export default function LeaveGroupModal() {
   const { handleCloseModal, modalIdShown, leavingGroupId } =
   const { handleCloseModal, modalIdShown, leavingGroupId } =
     useSubscriptionDashboardContext()
     useSubscriptionDashboardContext()
   const [inflight, setInflight] = useState(false)
   const [inflight, setInflight] = useState(false)
+  const location = useLocation()
 
 
   const handleConfirmLeaveGroup = useCallback(async () => {
   const handleConfirmLeaveGroup = useCallback(async () => {
     if (!leavingGroupId) {
     if (!leavingGroupId) {
@@ -22,12 +24,12 @@ export default function LeaveGroupModal() {
       const params = new URLSearchParams()
       const params = new URLSearchParams()
       params.set('subscriptionId', leavingGroupId)
       params.set('subscriptionId', leavingGroupId)
       await deleteJSON(`/subscription/group/user?${params}`)
       await deleteJSON(`/subscription/group/user?${params}`)
-      window.location.reload()
+      location.reload()
     } catch (error) {
     } catch (error) {
       console.log('something went wrong', error)
       console.log('something went wrong', error)
       setInflight(false)
       setInflight(false)
     }
     }
-  }, [leavingGroupId])
+  }, [location, leavingGroupId])
 
 
   if (modalIdShown !== LEAVE_GROUP_MODAL_ID || !leavingGroupId) {
   if (modalIdShown !== LEAVE_GROUP_MODAL_ID || !leavingGroupId) {
     return null
     return null

+ 3 - 2
services/web/frontend/js/features/subscription/components/dashboard/reactivate-subscription.tsx

@@ -1,19 +1,20 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { postJSON } from '../../../../infrastructure/fetch-json'
 import { postJSON } from '../../../../infrastructure/fetch-json'
 import { reactivateSubscriptionUrl } from '../../data/subscription-url'
 import { reactivateSubscriptionUrl } from '../../data/subscription-url'
-import { reload } from '../../../../shared/components/location'
 import useAsync from '../../../../shared/hooks/use-async'
 import useAsync from '../../../../shared/hooks/use-async'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 
 function ReactivateSubscription() {
 function ReactivateSubscription() {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { isLoading, isSuccess, runAsync } = useAsync()
   const { isLoading, isSuccess, runAsync } = useAsync()
+  const location = useLocation()
 
 
   const handleReactivate = () => {
   const handleReactivate = () => {
     runAsync(postJSON(reactivateSubscriptionUrl)).catch(console.error)
     runAsync(postJSON(reactivateSubscriptionUrl)).catch(console.error)
   }
   }
 
 
   if (isSuccess) {
   if (isSuccess) {
-    reload()
+    location.reload()
   }
   }
 
 
   return (
   return (

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx

@@ -14,6 +14,7 @@ import ActionButtonText from '../../../action-button-text'
 import GenericErrorAlert from '../../../generic-error-alert'
 import GenericErrorAlert from '../../../generic-error-alert'
 import DowngradePlanButton from './downgrade-plan-button'
 import DowngradePlanButton from './downgrade-plan-button'
 import ExtendTrialButton from './extend-trial-button'
 import ExtendTrialButton from './extend-trial-button'
+import { useLocation } from '../../../../../../../shared/hooks/use-location'
 
 
 const planCodeToDowngradeTo = 'paid-personal'
 const planCodeToDowngradeTo = 'paid-personal'
 
 
@@ -140,6 +141,7 @@ function NotCancelOption({
 
 
 export function CancelSubscription() {
 export function CancelSubscription() {
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const location = useLocation()
   const { personalSubscription, plans } = useSubscriptionDashboardContext()
   const { personalSubscription, plans } = useSubscriptionDashboardContext()
   const {
   const {
     isLoading: isLoadingCancel,
     isLoading: isLoadingCancel,
@@ -176,7 +178,7 @@ export function CancelSubscription() {
   async function handleCancelSubscription() {
   async function handleCancelSubscription() {
     try {
     try {
       await runAsyncCancel(postJSON(cancelSubscriptionUrl))
       await runAsyncCancel(postJSON(cancelSubscriptionUrl))
-      window.location.assign(redirectAfterCancelSubscriptionUrl)
+      location.assign(redirectAfterCancelSubscriptionUrl)
     } catch (e) {
     } catch (e) {
       console.error(e)
       console.error(e)
     }
     }

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx

@@ -3,6 +3,7 @@ import { Plan } from '../../../../../../../../../types/subscription/plan'
 import { postJSON } from '../../../../../../../infrastructure/fetch-json'
 import { postJSON } from '../../../../../../../infrastructure/fetch-json'
 import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
 import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
 import ActionButtonText from '../../../action-button-text'
 import ActionButtonText from '../../../action-button-text'
+import { useLocation } from '../../../../../../../shared/hooks/use-location'
 
 
 export default function DowngradePlanButton({
 export default function DowngradePlanButton({
   isButtonDisabled,
   isButtonDisabled,
@@ -18,6 +19,7 @@ export default function DowngradePlanButton({
   runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
   runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const location = useLocation()
   const buttonText = t('yes_move_me_to_personal_plan')
   const buttonText = t('yes_move_me_to_personal_plan')
 
 
   async function handleDowngradePlan() {
   async function handleDowngradePlan() {
@@ -27,7 +29,7 @@ export default function DowngradePlanButton({
           body: { plan_code: planToDowngradeTo.planCode },
           body: { plan_code: planToDowngradeTo.planCode },
         })
         })
       )
       )
-      window.location.reload()
+      location.reload()
     } catch (e) {
     } catch (e) {
       console.error(e)
       console.error(e)
     }
     }

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
 import { putJSON } from '../../../../../../../infrastructure/fetch-json'
 import { putJSON } from '../../../../../../../infrastructure/fetch-json'
 import { extendTrialUrl } from '../../../../../data/subscription-url'
 import { extendTrialUrl } from '../../../../../data/subscription-url'
 import ActionButtonText from '../../../action-button-text'
 import ActionButtonText from '../../../action-button-text'
+import { useLocation } from '../../../../../../../shared/hooks/use-location'
 
 
 export default function ExtendTrialButton({
 export default function ExtendTrialButton({
   isButtonDisabled,
   isButtonDisabled,
@@ -16,11 +17,12 @@ export default function ExtendTrialButton({
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const buttonText = t('ill_take_it')
   const buttonText = t('ill_take_it')
+  const location = useLocation()
 
 
   async function handleExtendTrial() {
   async function handleExtendTrial() {
     try {
     try {
       await runAsyncSecondaryAction(putJSON(extendTrialUrl))
       await runAsyncSecondaryAction(putJSON(extendTrialUrl))
-      window.location.reload()
+      location.reload()
     } catch (e) {
     } catch (e) {
       console.error(e)
       console.error(e)
     }
     }

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx

@@ -11,6 +11,7 @@ import { useSubscriptionDashboardContext } from '../../../../../../context/subsc
 import GenericErrorAlert from '../../../../generic-error-alert'
 import GenericErrorAlert from '../../../../generic-error-alert'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
 import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
 import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
 
 
 const educationalPercentDiscount = 40
 const educationalPercentDiscount = 40
 const groupSizeForEducationalDiscount = 10
 const groupSizeForEducationalDiscount = 10
@@ -143,6 +144,7 @@ export function ChangeToGroupModal() {
   const personalSubscription: Subscription = getMeta('ol-subscription')
   const personalSubscription: Subscription = getMeta('ol-subscription')
   const [error, setError] = useState(false)
   const [error, setError] = useState(false)
   const [inflight, setInflight] = useState(false)
   const [inflight, setInflight] = useState(false)
+  const location = useLocation()
 
 
   async function upgrade() {
   async function upgrade() {
     setError(false)
     setError(false)
@@ -158,7 +160,7 @@ export function ChangeToGroupModal() {
           ),
           ),
         },
         },
       })
       })
-      window.location.reload()
+      location.reload()
     } catch (e) {
     } catch (e) {
       setError(true)
       setError(true)
       setInflight(false)
       setInflight(false)

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx

@@ -7,6 +7,7 @@ import AccessibleModal from '../../../../../../../../shared/components/accessibl
 import getMeta from '../../../../../../../../utils/meta'
 import getMeta from '../../../../../../../../utils/meta'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
 
 
 export function ConfirmChangePlanModal() {
 export function ConfirmChangePlanModal() {
   const modalId: SubscriptionDashModalIds = 'change-to-plan'
   const modalId: SubscriptionDashModalIds = 'change-to-plan'
@@ -16,6 +17,7 @@ export function ConfirmChangePlanModal() {
   const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } =
   const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } =
     useSubscriptionDashboardContext()
     useSubscriptionDashboardContext()
   const planCodesChangingAtTermEnd = getMeta('ol-planCodesChangingAtTermEnd')
   const planCodesChangingAtTermEnd = getMeta('ol-planCodesChangingAtTermEnd')
+  const location = useLocation()
 
 
   async function handleConfirmChange() {
   async function handleConfirmChange() {
     setError(false)
     setError(false)
@@ -27,7 +29,7 @@ export function ConfirmChangePlanModal() {
           plan_code: planCodeToChangeTo,
           plan_code: planCodeToChangeTo,
         },
         },
       })
       })
-      window.location.reload()
+      location.reload()
     } catch (e) {
     } catch (e) {
       setError(true)
       setError(true)
       setInflight(false)
       setInflight(false)

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx

@@ -6,12 +6,14 @@ import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
 import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
 import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
 import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url'
 import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
 
 
 export function KeepCurrentPlanModal() {
 export function KeepCurrentPlanModal() {
   const modalId: SubscriptionDashModalIds = 'keep-current-plan'
   const modalId: SubscriptionDashModalIds = 'keep-current-plan'
   const [error, setError] = useState(false)
   const [error, setError] = useState(false)
   const [inflight, setInflight] = useState(false)
   const [inflight, setInflight] = useState(false)
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const location = useLocation()
   const { modalIdShown, handleCloseModal, personalSubscription } =
   const { modalIdShown, handleCloseModal, personalSubscription } =
     useSubscriptionDashboardContext()
     useSubscriptionDashboardContext()
 
 
@@ -21,7 +23,7 @@ export function KeepCurrentPlanModal() {
 
 
     try {
     try {
       await postJSON(cancelPendingSubscriptionChangeUrl)
       await postJSON(cancelPendingSubscriptionChangeUrl)
-      window.location.reload()
+      location.reload()
     } catch (e) {
     } catch (e) {
       setError(true)
       setError(true)
       setInflight(false)
       setInflight(false)

+ 4 - 2
services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx

@@ -18,7 +18,6 @@ import SubmitButton from './submit-button'
 import ThreeDSecure from './three-d-secure'
 import ThreeDSecure from './three-d-secure'
 import getMeta from '../../../../../utils/meta'
 import getMeta from '../../../../../utils/meta'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
-import { assign } from '../../../../../shared/components/location'
 import * as eventTracking from '../../../../../infrastructure/event-tracking'
 import * as eventTracking from '../../../../../infrastructure/event-tracking'
 import classnames from 'classnames'
 import classnames from 'classnames'
 import {
 import {
@@ -30,6 +29,7 @@ import {
 import { PricingFormState } from '../../../context/types/payment-context-value'
 import { PricingFormState } from '../../../context/types/payment-context-value'
 import { CreateError } from '../../../../../../../types/subscription/api'
 import { CreateError } from '../../../../../../../types/subscription/api'
 import { CardElementChangeState } from '../../../../../../../types/recurly/elements'
 import { CardElementChangeState } from '../../../../../../../types/recurly/elements'
+import { useLocation } from '../../../../../shared/hooks/use-location'
 
 
 function CheckoutPanel() {
 function CheckoutPanel() {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -59,6 +59,7 @@ function CheckoutPanel() {
   const [formIsValid, setFormIsValid] = useState<boolean>()
   const [formIsValid, setFormIsValid] = useState<boolean>()
   const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] =
   const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] =
     useState<string>()
     useState<string>()
+  const location = useLocation()
 
 
   const isCreditCardPaymentMethod = paymentMethod === 'credit_card'
   const isCreditCardPaymentMethod = paymentMethod === 'credit_card'
   const isPayPalPaymentMethod = paymentMethod === 'paypal'
   const isPayPalPaymentMethod = paymentMethod === 'paypal'
@@ -154,7 +155,7 @@ function CheckoutPanel() {
             'subscription-submission-success',
             'subscription-submission-success',
             planCode
             planCode
           )
           )
-          assign('/user/subscription/thank-you')
+          location.assign('/user/subscription/thank-you')
         } catch (error) {
         } catch (error) {
           setIsProcessing(false)
           setIsProcessing(false)
 
 
@@ -175,6 +176,7 @@ function CheckoutPanel() {
       ITMReferrer,
       ITMReferrer,
       isAddCompanyDetailsChecked,
       isAddCompanyDetailsChecked,
       isPayPalPaymentMethod,
       isPayPalPaymentMethod,
+      location,
       planCode,
       planCode,
       pricing,
       pricing,
       pricingFormState,
       pricingFormState,

+ 3 - 4
services/web/frontend/js/main/new-subscription.js

@@ -7,6 +7,7 @@ import _ from 'lodash'
 /* global recurly */
 /* global recurly */
 import App from '../base'
 import App from '../base'
 import getMeta from '../utils/meta'
 import getMeta from '../utils/meta'
+import { assign } from '../shared/components/location'
 
 
 export default App.controller(
 export default App.controller(
   'NewSubscriptionController',
   'NewSubscriptionController',
@@ -775,14 +776,12 @@ App.controller(
     $scope.browsePlans = () => {
     $scope.browsePlans = () => {
       if (document.referrer?.includes('/user/subscription/choose-your-plan')) {
       if (document.referrer?.includes('/user/subscription/choose-your-plan')) {
         // redirect to interstitial page with `itm_referrer` param
         // redirect to interstitial page with `itm_referrer` param
-        window.location.assign(
+        assign(
           '/user/subscription/choose-your-plan?itm_referrer=student-status-declined'
           '/user/subscription/choose-your-plan?itm_referrer=student-status-declined'
         )
         )
       } else {
       } else {
         // redirect to plans page with `itm_referrer` param
         // redirect to plans page with `itm_referrer` param
-        window.location.assign(
-          '/user/subscription/plans?itm_referrer=student-status-declined'
-        )
+        assign('/user/subscription/plans?itm_referrer=student-status-declined')
       }
       }
     }
     }
 
 

+ 2 - 0
services/web/frontend/js/shared/components/location.js

@@ -1,9 +1,11 @@
 // window location-related functions in a separate module so they can be mocked/stubbed in tests
 // window location-related functions in a separate module so they can be mocked/stubbed in tests
 
 
 export function reload() {
 export function reload() {
+  // eslint-disable-next-line no-restricted-syntax
   window.location.reload()
   window.location.reload()
 }
 }
 
 
 export function assign(url) {
 export function assign(url) {
+  // eslint-disable-next-line no-restricted-syntax
   window.location.assign(url)
   window.location.assign(url)
 }
 }

+ 25 - 0
services/web/frontend/js/shared/hooks/use-location.ts

@@ -0,0 +1,25 @@
+import { useCallback, useMemo } from 'react'
+import useIsMounted from './use-is-mounted'
+
+export const useLocation = () => {
+  const isMounted = useIsMounted()
+
+  const assign = useCallback(
+    url => {
+      if (isMounted.current) {
+        // eslint-disable-next-line no-restricted-syntax
+        window.location.assign(url)
+      }
+    },
+    [isMounted]
+  )
+
+  const reload = useCallback(() => {
+    if (isMounted.current) {
+      // eslint-disable-next-line no-restricted-syntax
+      window.location.reload()
+    }
+  }, [isMounted])
+
+  return useMemo(() => ({ assign, reload }), [assign, reload])
+}

+ 1 - 0
services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.js

@@ -125,6 +125,7 @@ describe('UserActivateRegister', function () {
       const body = JSON.parse(req.body)
       const body = JSON.parse(req.body)
       if (body.email === 'abc@gmail.com') return endPointResponse1
       if (body.email === 'abc@gmail.com') return endPointResponse1
       else if (body.email === 'def@gmail.com') return endPointResponse2
       else if (body.email === 'def@gmail.com') return endPointResponse2
+      else return 500
     })
     })
     const registerInput = screen.getByLabelText('emails to register')
     const registerInput = screen.getByLabelText('emails to register')
     const registerButton = screen.getByRole('button', { name: /register/i })
     const registerButton = screen.getByRole('button', { name: /register/i })

+ 7 - 3
services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js

@@ -4,18 +4,22 @@ import sinon from 'sinon'
 import { expect } from 'chai'
 import { expect } from 'chai'
 import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
 import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import * as locationModule from '../../../../../frontend/js/shared/components/location'
 import { waitFor } from '@testing-library/react'
 import { waitFor } from '@testing-library/react'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<ActionsCopyProject />', function () {
 describe('<ActionsCopyProject />', function () {
   let assignStub
   let assignStub
 
 
   beforeEach(function () {
   beforeEach(function () {
-    assignStub = sinon.stub(locationModule, 'assign')
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
+    })
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
-    assignStub.restore()
+    this.locationStub.restore()
     fetchMock.reset()
     fetchMock.reset()
   })
   })
 
 

+ 10 - 12
services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx

@@ -3,23 +3,21 @@ import { expect } from 'chai'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
 import sinon from 'sinon'
 import sinon from 'sinon'
 import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
 import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<ModalContentNewProjectForm />', function () {
 describe('<ModalContentNewProjectForm />', function () {
-  const locationStub = sinon.stub()
-  const originalLocation = window.location
+  let assignStub: sinon.SinonStub
 
 
   beforeEach(function () {
   beforeEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: {
-        assign: locationStub,
-      },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
     })
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
     fetchMock.reset()
     fetchMock.reset()
   })
   })
 
 
@@ -52,8 +50,8 @@ describe('<ModalContentNewProjectForm />', function () {
     expect(newProjectMock.called()).to.be.true
     expect(newProjectMock.called()).to.be.true
 
 
     await waitFor(() => {
     await waitFor(() => {
-      sinon.assert.calledOnce(locationStub)
-      sinon.assert.calledWith(locationStub, `/project/${projectId}`)
+      sinon.assert.calledOnce(assignStub)
+      sinon.assert.calledWith(assignStub, `/project/${projectId}`)
     })
     })
   })
   })
 
 
@@ -131,7 +129,7 @@ describe('<ModalContentNewProjectForm />', function () {
       Feugiat in fermentum posuere urna nec. Elementum eu facilisis sed odio morbi quis commodo. Vel fringilla est ullamcorper eget nulla facilisi. Nunc sed blandit libero volutpat sed cras ornare arcu dui. Tortor id aliquet lectus proin nibh nisl condimentum id venenatis. Sapien pellentesque habitant morbi tristique senectus et. Quam elementum pulvinar etiam non quam lacus suspendisse faucibus. Sem nulla pharetra diam sit amet nisl suscipit adipiscing bibendum. Porttitor leo a diam sollicitudin tempor id. In iaculis nunc sed augue.
       Feugiat in fermentum posuere urna nec. Elementum eu facilisis sed odio morbi quis commodo. Vel fringilla est ullamcorper eget nulla facilisi. Nunc sed blandit libero volutpat sed cras ornare arcu dui. Tortor id aliquet lectus proin nibh nisl condimentum id venenatis. Sapien pellentesque habitant morbi tristique senectus et. Quam elementum pulvinar etiam non quam lacus suspendisse faucibus. Sem nulla pharetra diam sit amet nisl suscipit adipiscing bibendum. Porttitor leo a diam sollicitudin tempor id. In iaculis nunc sed augue.
       Velit euismod in pellentesque massa placerat duis ultricies lacus sed. Dictum fusce ut placerat orci nulla pellentesque dignissim enim. Dui id ornare arcu odio. Dignissim cras tincidunt lobortis feugiat vivamus at augue. Non tellus orci ac auctor. Egestas fringilla phasellus faucibus scelerisque eleifend donec. Nisi vitae suscipit tellus mauris a diam maecenas. Orci dapibus ultrices in iaculis nunc sed. Facilisi morbi tempus iaculis urna id volutpat lacus laoreet non. Aliquam etiam erat velit scelerisque in dictum. Sed enim ut sem viverra. Eleifend donec pretium vulputate sapien nec sagittis. Quisque egestas diam in arcu cursus euismod quis. Faucibus a pellentesque sit amet porttitor eget dolor. Elementum facilisis leo vel fringilla. Pellentesque habitant morbi tristique senectus et netus. Viverra tellus in hac habitasse platea dictumst vestibulum. Tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Sit amet porttitor eget dolor morbi non. Neque egestas congue quisque egestas.
       Velit euismod in pellentesque massa placerat duis ultricies lacus sed. Dictum fusce ut placerat orci nulla pellentesque dignissim enim. Dui id ornare arcu odio. Dignissim cras tincidunt lobortis feugiat vivamus at augue. Non tellus orci ac auctor. Egestas fringilla phasellus faucibus scelerisque eleifend donec. Nisi vitae suscipit tellus mauris a diam maecenas. Orci dapibus ultrices in iaculis nunc sed. Facilisi morbi tempus iaculis urna id volutpat lacus laoreet non. Aliquam etiam erat velit scelerisque in dictum. Sed enim ut sem viverra. Eleifend donec pretium vulputate sapien nec sagittis. Quisque egestas diam in arcu cursus euismod quis. Faucibus a pellentesque sit amet porttitor eget dolor. Elementum facilisis leo vel fringilla. Pellentesque habitant morbi tristique senectus et netus. Viverra tellus in hac habitasse platea dictumst vestibulum. Tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Sit amet porttitor eget dolor morbi non. Neque egestas congue quisque egestas.
       Convallis posuere morbi leo urna molestie at. Posuere sollicitudin aliquam ultrices sagittis orci. Lacus vestibulum sed arcu non odio. Sit amet dictum sit amet. Nunc scelerisque viverra mauris in aliquam sem fringilla ut morbi. Vestibulum morbi blandit cursus risus at ultrices mi. Purus gravida quis blandit turpis cursus. Diam maecenas sed enim ut. Senectus et netus et malesuada fames ac turpis. Massa tempor nec feugiat nisl pretium fusce id velit. Mollis nunc sed id semper. Elit sed vulputate mi sit. Vitae et leo duis ut diam. Pellentesque sit amet porttitor eget dolor morbi non arcu risus.
       Convallis posuere morbi leo urna molestie at. Posuere sollicitudin aliquam ultrices sagittis orci. Lacus vestibulum sed arcu non odio. Sit amet dictum sit amet. Nunc scelerisque viverra mauris in aliquam sem fringilla ut morbi. Vestibulum morbi blandit cursus risus at ultrices mi. Purus gravida quis blandit turpis cursus. Diam maecenas sed enim ut. Senectus et netus et malesuada fames ac turpis. Massa tempor nec feugiat nisl pretium fusce id velit. Mollis nunc sed id semper. Elit sed vulputate mi sit. Vitae et leo duis ut diam. Pellentesque sit amet porttitor eget dolor morbi non arcu risus.
-      Mi quis hendrerit dolor magna eget est lorem. Quam vulputate dignissim suspendisse in est ante in nibh. Nisi porta lorem mollis aliquam. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam. Euismod nisi porta lorem mollis aliquam ut porttitor leo a. Tempus imperdiet nulla malesuada pellentesque elit eget. Amet nisl purus in mollis nunc sed id. Id velit ut tortor pretium viverra suspendisse. Integer quis auctor elit sed. Tortor at risus viverra adipiscing. Ac auctor augue mauris augue neque gravida in. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. A diam sollicitudin tempor id eu nisl nunc mi. Tellus id interdum velit laoreet id donec. Lacus vestibulum sed arcu non odio euismod lacinia. Tellus at urna condimentum mattis.      
+      Mi quis hendrerit dolor magna eget est lorem. Quam vulputate dignissim suspendisse in est ante in nibh. Nisi porta lorem mollis aliquam. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam. Euismod nisi porta lorem mollis aliquam ut porttitor leo a. Tempus imperdiet nulla malesuada pellentesque elit eget. Amet nisl purus in mollis nunc sed id. Id velit ut tortor pretium viverra suspendisse. Integer quis auctor elit sed. Tortor at risus viverra adipiscing. Ac auctor augue mauris augue neque gravida in. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. A diam sollicitudin tempor id eu nisl nunc mi. Tellus id interdum velit laoreet id donec. Lacus vestibulum sed arcu non odio euismod lacinia. Tellus at urna condimentum mattis.
       `,
       `,
       },
       },
     })
     })

+ 11 - 14
services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx

@@ -2,18 +2,19 @@ import sinon from 'sinon'
 import { render, screen, fireEvent, waitFor } from '@testing-library/react'
 import { render, screen, fireEvent, waitFor } from '@testing-library/react'
 import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
 import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
 import { expect } from 'chai'
 import { expect } from 'chai'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<UploadProjectModal />', function () {
 describe('<UploadProjectModal />', function () {
   const originalWindowCSRFToken = window.csrfToken
   const originalWindowCSRFToken = window.csrfToken
-  const locationStub = sinon.stub()
-  const originalLocation = window.location
   const maxUploadSize = 10 * 1024 * 1024 // 10 MB
   const maxUploadSize = 10 * 1024 * 1024 // 10 MB
 
 
+  let assignStub: sinon.SinonStub
+
   beforeEach(function () {
   beforeEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: {
-        assign: locationStub,
-      },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
     })
     window.metaAttributesCache.set('ol-ExposedSettings', {
     window.metaAttributesCache.set('ol-ExposedSettings', {
       maxUploadSize,
       maxUploadSize,
@@ -22,13 +23,9 @@ describe('<UploadProjectModal />', function () {
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
     window.csrfToken = originalWindowCSRFToken
     window.csrfToken = originalWindowCSRFToken
-
-    locationStub.reset()
   })
   })
 
 
   it('uploads a dropped file', async function () {
   it('uploads a dropped file', async function () {
@@ -66,8 +63,8 @@ describe('<UploadProjectModal />', function () {
     )
     )
 
 
     await waitFor(() => {
     await waitFor(() => {
-      sinon.assert.calledOnce(locationStub)
-      sinon.assert.calledWith(locationStub, `/project/${projectId}`)
+      sinon.assert.calledOnce(assignStub)
+      sinon.assert.calledWith(assignStub, `/project/${projectId}`)
     })
     })
 
 
     xhr.restore()
     xhr.restore()
@@ -146,7 +143,7 @@ describe('<UploadProjectModal />', function () {
     )
     )
 
 
     await waitFor(() => {
     await waitFor(() => {
-      sinon.assert.notCalled(locationStub)
+      sinon.assert.notCalled(assignStub)
       screen.getByText('Upload failed')
       screen.getByText('Upload failed')
     })
     })
 
 

+ 10 - 9
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -29,6 +29,7 @@ import { DeepPartial } from '../../../../../types/utils'
 import { Project } from '../../../../../types/project/dashboard/api'
 import { Project } from '../../../../../types/project/dashboard/api'
 import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
 import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
 import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
 import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 
 const renderWithinProjectListProvider = (Component: React.ComponentType) => {
 const renderWithinProjectListProvider = (Component: React.ComponentType) => {
   render(<Component />, {
   render(<Component />, {
@@ -548,22 +549,22 @@ describe('<UserNotifications />', function () {
   })
   })
 
 
   describe('<Affiliation/>', function () {
   describe('<Affiliation/>', function () {
-    const locationStub = sinon.stub()
-    const originalLocation = window.location
+    let assignStub: sinon.SinonStub
+
     beforeEach(function () {
     beforeEach(function () {
       window.metaAttributesCache = window.metaAttributesCache || new Map()
       window.metaAttributesCache = window.metaAttributesCache || new Map()
       window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
       window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
-      Object.defineProperty(window, 'location', {
-        value: { assign: locationStub },
+      assignStub = sinon.stub()
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: assignStub,
+        reload: sinon.stub(),
       })
       })
       fetchMock.reset()
       fetchMock.reset()
     })
     })
 
 
     afterEach(function () {
     afterEach(function () {
       window.metaAttributesCache = new Map()
       window.metaAttributesCache = new Map()
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
       fetchMock.reset()
       fetchMock.reset()
     })
     })
 
 
@@ -619,9 +620,9 @@ describe('<UserNotifications />', function () {
       fireEvent.click(
       fireEvent.click(
         screen.getByRole('button', { name: /confirm affiliation/i })
         screen.getByRole('button', { name: /confirm affiliation/i })
       )
       )
-      sinon.assert.calledOnce(locationStub)
+      sinon.assert.calledOnce(assignStub)
       sinon.assert.calledWithMatch(
       sinon.assert.calledWithMatch(
-        locationStub,
+        assignStub,
         `${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
         `${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
       )
       )
     })
     })

+ 9 - 10
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -11,6 +11,7 @@ import {
   archivedProjects,
   archivedProjects,
   makeLongProjectList,
   makeLongProjectList,
 } from '../fixtures/projects-data'
 } from '../fixtures/projects-data'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 
 const {
 const {
   fullList,
   fullList,
@@ -24,9 +25,8 @@ const {
 const userId = owner.id
 const userId = owner.id
 
 
 describe('<ProjectListRoot />', function () {
 describe('<ProjectListRoot />', function () {
-  const originalLocation = window.location
-  const locationStub = sinon.stub()
   let sendSpy: sinon.SinonSpy
   let sendSpy: sinon.SinonSpy
+  let assignStub: sinon.SinonStub
 
 
   beforeEach(async function () {
   beforeEach(async function () {
     global.localStorage.clear()
     global.localStorage.clear()
@@ -48,9 +48,10 @@ describe('<ProjectListRoot />', function () {
       { email: 'test@overleaf.com', default: true },
       { email: 'test@overleaf.com', default: true },
     ])
     ])
     window.user_id = userId
     window.user_id = userId
-
-    Object.defineProperty(window, 'location', {
-      value: { assign: locationStub },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
     })
   })
   })
 
 
@@ -58,9 +59,7 @@ describe('<ProjectListRoot />', function () {
     sendSpy.restore()
     sendSpy.restore()
     window.user_id = undefined
     window.user_id = undefined
     fetchMock.reset()
     fetchMock.reset()
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
   })
 
 
   describe('welcome page', function () {
   describe('welcome page', function () {
@@ -117,11 +116,11 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(downloadButton)
           fireEvent.click(downloadButton)
 
 
           await waitFor(() => {
           await waitFor(() => {
-            expect(locationStub).to.have.been.called
+            expect(assignStub).to.have.been.called
           })
           })
 
 
           sinon.assert.calledWithMatch(
           sinon.assert.calledWithMatch(
-            locationStub,
+            assignStub,
             `/project/download/zip?project_ids=${project1Id},${project2Id}`
             `/project/download/zip?project_ids=${project1Id},${project2Id}`
           )
           )
 
 

+ 10 - 11
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx

@@ -3,23 +3,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import sinon from 'sinon'
 import sinon from 'sinon'
 import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
 import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
 import { projectsData } from '../../../../fixtures/projects-data'
 import { projectsData } from '../../../../fixtures/projects-data'
+import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<DownloadProjectButton />', function () {
 describe('<DownloadProjectButton />', function () {
-  const originalLocation = window.location
-  const locationStub = sinon.stub()
+  let assignStub: sinon.SinonStub
 
 
   beforeEach(function () {
   beforeEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: { assign: locationStub },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
     })
-
     render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
     render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
   })
 
 
   it('renders tooltip for button', function () {
   it('renders tooltip for button', function () {
@@ -33,13 +32,13 @@ describe('<DownloadProjectButton />', function () {
     fireEvent.click(btn)
     fireEvent.click(btn)
 
 
     await waitFor(() => {
     await waitFor(() => {
-      expect(locationStub).to.have.been.called
+      expect(assignStub).to.have.been.called
     })
     })
 
 
-    sinon.assert.calledOnce(locationStub)
+    sinon.assert.calledOnce(assignStub)
 
 
     sinon.assert.calledWithMatch(
     sinon.assert.calledWithMatch(
-      locationStub,
+      assignStub,
       `/project/${projectsData[0].id}/download/zip`
       `/project/${projectsData[0].id}/download/zip`
     )
     )
   })
   })

+ 5 - 0
services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx

@@ -33,6 +33,11 @@ describe('<EmailsRow/>', function () {
       samlInitPath: '/saml',
       samlInitPath: '/saml',
       hasSamlBeta: true,
       hasSamlBeta: true,
     })
     })
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
   })
   })
 
 
   describe('with unaffiliated email data', function () {
   describe('with unaffiliated email data', function () {

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

@@ -76,21 +76,20 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
-    await fetchMock.flush(true)
-
-    screen.getByRole('button', { name: /add another email/i })
+    await screen.findByRole('button', { name: /add another email/i })
   })
   })
 
 
   it('renders input', async function () {
   it('renders input', async function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
+    await fetchMock.flush(true)
 
 
-    const addAnotherEmailBtn = (await screen.findByRole('button', {
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
       name: /add another email/i,
-    })) as HTMLButtonElement
-    fireEvent.click(addAnotherEmailBtn)
+    })
+    fireEvent.click(button)
 
 
-    screen.getByLabelText(/email/i)
+    await screen.findByLabelText(/email/i)
   })
   })
 
 
   it('renders "Start adding your address" until a valid email is typed', async function () {
   it('renders "Start adding your address" until a valid email is typed', async function () {
@@ -98,22 +97,21 @@ describe('<EmailsSection />', function () {
     fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
     fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
     fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
     fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
     render(<EmailsSection />)
     render(<EmailsSection />)
+    await fetchMock.flush(true)
 
 
-    const addAnotherEmailBtn = (await screen.findByRole('button', {
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
       name: /add another email/i,
-    })) as HTMLButtonElement
-    fireEvent.click(addAnotherEmailBtn)
+    })
+    fireEvent.click(button)
 
 
     const input = screen.getByLabelText(/email/i)
     const input = screen.getByLabelText(/email/i)
 
 
     // initially the text is displayed and the "add email" button disabled
     // initially the text is displayed and the "add email" button disabled
     screen.getByText('Start by adding your email address.')
     screen.getByText('Start by adding your email address.')
     expect(
     expect(
-      (
-        screen.getByRole('button', {
-          name: /add new email/i,
-        }) as HTMLButtonElement
-      ).disabled
+      screen.getByRole<HTMLButtonElement>('button', {
+        name: /add new email/i,
+      }).disabled
     ).to.be.true
     ).to.be.true
 
 
     // no changes while writing the email address
     // no changes while writing the email address
@@ -122,11 +120,9 @@ describe('<EmailsSection />', function () {
     })
     })
     screen.getByText('Start by adding your email address.')
     screen.getByText('Start by adding your email address.')
     expect(
     expect(
-      (
-        screen.getByRole('button', {
-          name: /add new email/i,
-        }) as HTMLButtonElement
-      ).disabled
+      screen.getByRole<HTMLButtonElement>('button', {
+        name: /add new email/i,
+      }).disabled
     ).to.be.true
     ).to.be.true
 
 
     // the text is removed when the complete email address is typed, and the "add button" is reenabled
     // the text is removed when the complete email address is typed, and the "add button" is reenabled
@@ -135,11 +131,9 @@ describe('<EmailsSection />', function () {
     })
     })
     expect(screen.queryByText('Start by adding your email address.')).to.be.null
     expect(screen.queryByText('Start by adding your email address.')).to.be.null
     expect(
     expect(
-      (
-        screen.getByRole('button', {
-          name: /add new email/i,
-        }) as HTMLButtonElement
-      ).disabled
+      screen.getByRole<HTMLButtonElement>('button', {
+        name: /add new email/i,
+      }).disabled
     ).to.be.false
     ).to.be.false
   })
   })
 
 
@@ -147,10 +141,10 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
-    const addAnotherEmailBtn = (await screen.findByRole('button', {
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
       name: /add another email/i,
-    })) as HTMLButtonElement
-    fireEvent.click(addAnotherEmailBtn)
+    })
+    fireEvent.click(button)
 
 
     screen.getByRole('button', { name: /add new email/i })
     screen.getByRole('button', { name: /add new email/i })
   })
   })
@@ -159,16 +153,17 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
+      'button',
+      { name: /add another email/i }
+    )
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     resetFetchMock()
     resetFetchMock()
     fetchMock
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailData])
       .get('/user/emails?ensureAffiliation=true', [userEmailData])
       .post('/user/emails', 200)
       .post('/user/emails', 200)
 
 
-    const addAnotherEmailBtn = await screen.findByRole('button', {
-      name: /add another email/i,
-    })
-
     fireEvent.click(addAnotherEmailBtn)
     fireEvent.click(addAnotherEmailBtn)
     const input = screen.getByLabelText(/email/i)
     const input = screen.getByLabelText(/email/i)
 
 
@@ -176,9 +171,9 @@ describe('<EmailsSection />', function () {
       target: { value: userEmailData.email },
       target: { value: userEmailData.email },
     })
     })
 
 
-    const submitBtn = screen.getByRole('button', {
+    const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
       name: /add new email/i,
       name: /add new email/i,
-    }) as HTMLButtonElement
+    })
 
 
     expect(submitBtn.disabled).to.be.false
     expect(submitBtn.disabled).to.be.false
 
 
@@ -199,16 +194,17 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
+      'button',
+      { name: /add another email/i }
+    )
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     resetFetchMock()
     resetFetchMock()
     fetchMock
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [])
       .get('/user/emails?ensureAffiliation=true', [])
       .post('/user/emails', 400)
       .post('/user/emails', 400)
 
 
-    const addAnotherEmailBtn = screen.getByRole('button', {
-      name: /add another email/i,
-    })
-
     fireEvent.click(addAnotherEmailBtn)
     fireEvent.click(addAnotherEmailBtn)
     const input = screen.getByLabelText(/email/i)
     const input = screen.getByLabelText(/email/i)
 
 
@@ -216,9 +212,9 @@ describe('<EmailsSection />', function () {
       target: { value: userEmailData.email },
       target: { value: userEmailData.email },
     })
     })
 
 
-    const submitBtn = screen.getByRole('button', {
+    const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
       name: /add new email/i,
       name: /add new email/i,
-    }) as HTMLButtonElement
+    })
 
 
     expect(submitBtn.disabled).to.be.false
     expect(submitBtn.disabled).to.be.false
 
 
@@ -237,15 +233,15 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     fetchMock.reset()
     fetchMock.reset()
     fetchMock.get('express:/institutions/domains', institutionDomainData)
     fetchMock.get('express:/institutions/domains', institutionDomainData)
 
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
 
     const input = screen.getByLabelText(/email/i)
     const input = screen.getByLabelText(/email/i)
     fireEvent.change(input, {
     fireEvent.change(input, {
@@ -261,26 +257,26 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     resetFetchMock()
     resetFetchMock()
 
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
 
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
 
 
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
 
-    const universityInput = screen.getByRole('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /university/i,
       name: /university/i,
-    }) as HTMLInputElement
+    })
 
 
     expect(universityInput.disabled).to.be.true
     expect(universityInput.disabled).to.be.true
 
 
-    fetchMock.get(/\/institutions\/list/, [
+    fetchMock.get('/institutions/list?country_code=de', [
       {
       {
         id: userEmailData.affiliation.institution.id,
         id: userEmailData.affiliation.institution.id,
         name: userEmailData.affiliation.institution.name,
         name: userEmailData.affiliation.institution.name,
@@ -306,7 +302,7 @@ describe('<EmailsSection />', function () {
     // Select the university from dropdown
     // Select the university from dropdown
     await userEvent.click(universityInput)
     await userEvent.click(universityInput)
     await userEvent.click(
     await userEvent.click(
-      screen.getByText(userEmailData.affiliation.institution.name)
+      await screen.findByText(userEmailData.affiliation.institution.name)
     )
     )
 
 
     const roleInput = screen.getByRole('textbox', { name: /role/i })
     const roleInput = screen.getByRole('textbox', { name: /role/i })
@@ -354,10 +350,14 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     resetFetchMock()
     resetFetchMock()
 
 
-    fetchMock.get(/\/institutions\/list/, [
+    fetchMock.get('/institutions/list?country_code=de', [
       {
       {
         id: 1,
         id: 1,
         name: 'University of Bonn',
         name: 'University of Bonn',
@@ -369,34 +369,30 @@ describe('<EmailsSection />', function () {
     ])
     ])
 
 
     // open "add new email" section and click "let us know" to open the Country/University form
     // open "add new email" section and click "let us know" to open the Country/University form
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
 
     // select a country
     // select a country
-    const countryInput = screen.getByRole('textbox', {
+    const countryInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /country/i,
       name: /country/i,
-    }) as HTMLInputElement
+    })
     await userEvent.click(countryInput)
     await userEvent.click(countryInput)
     await userEvent.type(countryInput, 'Germ')
     await userEvent.type(countryInput, 'Germ')
     await userEvent.click(await screen.findByText('Germany'))
     await userEvent.click(await screen.findByText('Germany'))
 
 
     // match several universities on initial typing
     // match several universities on initial typing
-    const universityInput = screen.getByRole('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /university/i,
       name: /university/i,
-    }) as HTMLInputElement
+    })
     await userEvent.click(universityInput)
     await userEvent.click(universityInput)
     await userEvent.type(universityInput, 'bo')
     await userEvent.type(universityInput, 'bo')
-    screen.getByText('University of Bonn')
-    screen.getByText('Bochum institute of Science')
+    await screen.findByText('University of Bonn')
+    await screen.findByText('Bochum institute of Science')
 
 
     // match a single university when typing to refine the search
     // match a single university when typing to refine the search
     await userEvent.type(universityInput, 'nn')
     await userEvent.type(universityInput, 'nn')
-    screen.getByText('University of Bonn')
+    await screen.findByText('University of Bonn')
     expect(screen.queryByText('Bochum institute of Science')).to.be.null
     expect(screen.queryByText('Bochum institute of Science')).to.be.null
   })
   })
 
 
@@ -407,22 +403,22 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     resetFetchMock()
     resetFetchMock()
 
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
 
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
 
 
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
 
-    const universityInput = screen.getByRole('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /university/i,
       name: /university/i,
-    }) as HTMLInputElement
+    })
 
 
     expect(universityInput.disabled).to.be.true
     expect(universityInput.disabled).to.be.true
 
 
@@ -510,6 +506,10 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     fetchMock.reset()
     fetchMock.reset()
     fetchMock.get(
     fetchMock.get(
@@ -517,11 +517,7 @@ describe('<EmailsSection />', function () {
       institutionDomainDataCopy
       institutionDomainDataCopy
     )
     )
 
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
 
     await userEvent.type(
     await userEvent.type(
       screen.getByLabelText(/email/i),
       screen.getByLabelText(/email/i),
@@ -583,6 +579,10 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     await fetchMock.flush(true)
     fetchMock.reset()
     fetchMock.reset()
     fetchMock.get(
     fetchMock.get(
@@ -590,11 +590,7 @@ describe('<EmailsSection />', function () {
       institutionDomainDataCopy
       institutionDomainDataCopy
     )
     )
 
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
 
     await userEvent.type(
     await userEvent.type(
       screen.getByLabelText(/email/i),
       screen.getByLabelText(/email/i),

+ 7 - 2
services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx

@@ -78,6 +78,7 @@ describe('user role and institution', function () {
       hasAffiliationsFeature: true,
       hasAffiliationsFeature: true,
     })
     })
     fetchMock.reset()
     fetchMock.reset()
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
@@ -120,7 +121,9 @@ describe('user role and institution', function () {
 
 
   it('fetches institution data and replaces departments dropdown on add/change', async function () {
   it('fetches institution data and replaces departments dropdown on add/change', async function () {
     const userEmailData = userData1
     const userEmailData = userData1
-    fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData])
+    fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData], {
+      overwriteRoutes: true,
+    })
     render(<EmailsSection />)
     render(<EmailsSection />)
 
 
     await fetchMock.flush(true)
     await fetchMock.flush(true)
@@ -149,7 +152,9 @@ describe('user role and institution', function () {
 
 
   it('adds new role and department', async function () {
   it('adds new role and department', async function () {
     fetchMock
     fetchMock
-      .get('/user/emails?ensureAffiliation=true', [userData1])
+      .get('/user/emails?ensureAffiliation=true', [userData1], {
+        overwriteRoutes: true,
+      })
       .get(/\/institutions\/list/, { departments: [] })
       .get(/\/institutions\/list/, { departments: [] })
       .post('/user/emails/endorse', 200)
       .post('/user/emails/endorse', 200)
     render(<EmailsSection />)
     render(<EmailsSection />)

+ 19 - 19
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -13,6 +13,7 @@ import ReconfirmationInfo from '../../../../../../frontend/js/features/settings/
 import { ssoUserData } from '../../fixtures/test-user-email-data'
 import { ssoUserData } from '../../fixtures/test-user-email-data'
 import { UserEmailData } from '../../../../../../types/user-email'
 import { UserEmailData } from '../../../../../../types/user-email'
 import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
 import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 function renderReconfirmationInfo(data: UserEmailData) {
 function renderReconfirmationInfo(data: UserEmailData) {
   return render(
   return render(
@@ -23,13 +24,21 @@ function renderReconfirmationInfo(data: UserEmailData) {
 }
 }
 
 
 describe('<ReconfirmationInfo/>', function () {
 describe('<ReconfirmationInfo/>', function () {
+  let assignStub: sinon.SinonStub
+
   beforeEach(function () {
   beforeEach(function () {
     window.metaAttributesCache = window.metaAttributesCache || new Map()
     window.metaAttributesCache = window.metaAttributesCache || new Map()
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
+    })
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
     fetchMock.reset()
     fetchMock.reset()
+    this.locationStub.restore()
   })
   })
 
 
   describe('reconfirmed via SAML', function () {
   describe('reconfirmed via SAML', function () {
@@ -54,18 +63,11 @@ describe('<ReconfirmationInfo/>', function () {
 
 
   describe('in reconfirm notification period', function () {
   describe('in reconfirm notification period', function () {
     let inReconfirmUserData: UserEmailData
     let inReconfirmUserData: UserEmailData
-    const locationStub = sinon.stub()
-    const originalLocation = window.location
 
 
     beforeEach(function () {
     beforeEach(function () {
       window.metaAttributesCache.set('ol-ExposedSettings', {
       window.metaAttributesCache.set('ol-ExposedSettings', {
         samlInitPath: '/saml',
         samlInitPath: '/saml',
       })
       })
-      Object.defineProperty(window, 'location', {
-        value: {
-          assign: locationStub,
-        },
-      })
 
 
       inReconfirmUserData = cloneDeep(ssoUserData)
       inReconfirmUserData = cloneDeep(ssoUserData)
       if (inReconfirmUserData.affiliation) {
       if (inReconfirmUserData.affiliation) {
@@ -75,9 +77,6 @@ describe('<ReconfirmationInfo/>', function () {
 
 
     afterEach(function () {
     afterEach(function () {
       window.metaAttributesCache = new Map()
       window.metaAttributesCache = new Map()
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
     })
     })
 
 
     it('renders prompt', function () {
     it('renders prompt', function () {
@@ -119,9 +118,9 @@ describe('<ReconfirmationInfo/>', function () {
         await waitFor(() => {
         await waitFor(() => {
           expect(confirmButton.disabled).to.be.true
           expect(confirmButton.disabled).to.be.true
         })
         })
-        sinon.assert.calledOnce(locationStub)
+        sinon.assert.calledOnce(assignStub)
         sinon.assert.calledWithMatch(
         sinon.assert.calledWithMatch(
-          locationStub,
+          assignStub,
           '/saml/init?university_id=2&reconfirm=/user/settings'
           '/saml/init?university_id=2&reconfirm=/user/settings'
         )
         )
       })
       })
@@ -137,9 +136,9 @@ describe('<ReconfirmationInfo/>', function () {
 
 
       it('sends and resends confirmation email', async function () {
       it('sends and resends confirmation email', async function () {
         renderReconfirmationInfo(inReconfirmUserData)
         renderReconfirmationInfo(inReconfirmUserData)
-        const confirmButton = screen.getByRole('button', {
+        const confirmButton = (await screen.findByRole('button', {
           name: 'Confirm Affiliation',
           name: 'Confirm Affiliation',
-        }) as HTMLButtonElement
+        })) as HTMLButtonElement
 
 
         await waitFor(() => {
         await waitFor(() => {
           expect(confirmButton.disabled).to.be.false
           expect(confirmButton.disabled).to.be.false
@@ -152,20 +151,21 @@ describe('<ReconfirmationInfo/>', function () {
         expect(fetchMock.called()).to.be.true
         expect(fetchMock.called()).to.be.true
 
 
         // the confirmation text should now be displayed
         // the confirmation text should now be displayed
-        screen.getByText(/Please check your email inbox to confirm/)
+        await screen.findByText(/Please check your email inbox to confirm/)
 
 
         // try the resend button
         // try the resend button
         fetchMock.resetHistory()
         fetchMock.resetHistory()
-        const resendButton = screen.getByRole('button', {
+        const resendButton = await screen.findByRole('button', {
           name: 'Resend confirmation email',
           name: 'Resend confirmation email',
-        }) as HTMLButtonElement
+        })
 
 
         fireEvent.click(resendButton)
         fireEvent.click(resendButton)
 
 
-        screen.getByText(/Sending/)
+        // commented out as it's already gone by this point
+        // await screen.findByText(/Sending/)
         expect(fetchMock.called()).to.be.true
         expect(fetchMock.called()).to.be.true
         await waitForElementToBeRemoved(() => screen.getByText(/Sending/))
         await waitForElementToBeRemoved(() => screen.getByText(/Sending/))
-        screen.getByRole('button', {
+        await screen.findByRole('button', {
           name: 'Resend confirmation email',
           name: 'Resend confirmation email',
         })
         })
       })
       })

+ 9 - 12
services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx

@@ -4,6 +4,7 @@ import { fireEvent, screen, render, waitFor } from '@testing-library/react'
 import fetchMock, { FetchMockStatic } from 'fetch-mock'
 import fetchMock, { FetchMockStatic } from 'fetch-mock'
 
 
 import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form'
 import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<LeaveModalForm />', function () {
 describe('<LeaveModalForm />', function () {
   beforeEach(function () {
   beforeEach(function () {
@@ -51,27 +52,23 @@ describe('<LeaveModalForm />', function () {
     let setInFlight: sinon.SinonStub
     let setInFlight: sinon.SinonStub
     let setIsFormValid: sinon.SinonStub
     let setIsFormValid: sinon.SinonStub
     let deleteMock: FetchMockStatic
     let deleteMock: FetchMockStatic
-    let locationStub: sinon.SinonStub
-    const originalLocation = window.location
+    let assignStub: sinon.SinonStub
 
 
     beforeEach(function () {
     beforeEach(function () {
       setInFlight = sinon.stub()
       setInFlight = sinon.stub()
       setIsFormValid = sinon.stub()
       setIsFormValid = sinon.stub()
       deleteMock = fetchMock.post('/user/delete', 200)
       deleteMock = fetchMock.post('/user/delete', 200)
-      locationStub = sinon.stub()
-      Object.defineProperty(window, 'location', {
-        value: {
-          assign: locationStub,
-        },
+      assignStub = sinon.stub()
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: assignStub,
+        reload: sinon.stub(),
       })
       })
       window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
       window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
     })
     })
 
 
     afterEach(function () {
     afterEach(function () {
       fetchMock.reset()
       fetchMock.reset()
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
     })
     })
 
 
     it('with valid form', async function () {
     it('with valid form', async function () {
@@ -91,8 +88,8 @@ describe('<LeaveModalForm />', function () {
       await waitFor(() => {
       await waitFor(() => {
         sinon.assert.calledTwice(setInFlight)
         sinon.assert.calledTwice(setInFlight)
         sinon.assert.calledWithMatch(setInFlight, false)
         sinon.assert.calledWithMatch(setInFlight, false)
-        sinon.assert.calledOnce(locationStub)
-        sinon.assert.calledWithMatch(locationStub, '/login')
+        sinon.assert.calledOnce(assignStub)
+        sinon.assert.calledWith(assignStub, '/login')
       })
       })
     })
     })
 
 

+ 9 - 0
services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx

@@ -5,6 +5,7 @@ import { UserEmailsProvider } from '../../../../../frontend/js/features/settings
 import { LeaversSurveyAlert } from '../../../../../frontend/js/features/settings/components/leavers-survey-alert'
 import { LeaversSurveyAlert } from '../../../../../frontend/js/features/settings/components/leavers-survey-alert'
 import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
 import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
+import fetchMock from 'fetch-mock'
 
 
 function renderWithProvider() {
 function renderWithProvider() {
   render(<LeaversSurveyAlert />, {
   render(<LeaversSurveyAlert />, {
@@ -15,6 +16,14 @@ function renderWithProvider() {
 }
 }
 
 
 describe('<LeaversSurveyAlert/>', function () {
 describe('<LeaversSurveyAlert/>', function () {
+  beforeEach(function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
   it('should render before the expiration date', function () {
   it('should render before the expiration date', function () {
     const tomorrow = Date.now() + 1000 * 60 * 60 * 24
     const tomorrow = Date.now() + 1000 * 60 * 60 * 24
     localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
     localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)

+ 6 - 5
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js

@@ -14,12 +14,12 @@ import {
   renderWithEditorContext,
   renderWithEditorContext,
   cleanUpContext,
   cleanUpContext,
 } from '../../../helpers/render-with-context'
 } from '../../../helpers/render-with-context'
-import * as locationModule from '../../../../../frontend/js/shared/components/location'
 import {
 import {
   EditorProviders,
   EditorProviders,
   USER_EMAIL,
   USER_EMAIL,
   USER_ID,
   USER_ID,
 } from '../../../helpers/editor-providers'
 } from '../../../helpers/editor-providers'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<ShareProjectModal/>', function () {
 describe('<ShareProjectModal/>', function () {
   const project = {
   const project = {
@@ -85,6 +85,10 @@ describe('<ShareProjectModal/>', function () {
   }
   }
 
 
   beforeEach(function () {
   beforeEach(function () {
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      reload: sinon.stub(),
+    })
     fetchMock.get('/user/contacts', { contacts })
     fetchMock.get('/user/contacts', { contacts })
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
     window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
@@ -92,6 +96,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
+    this.locationStub.restore()
     fetchMock.restore()
     fetchMock.restore()
     cleanUpContext()
     cleanUpContext()
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
@@ -525,8 +530,6 @@ describe('<ShareProjectModal/>', function () {
       )
       )
     })
     })
 
 
-    const reloadStub = sinon.stub(locationModule, 'reload')
-
     const confirmButton = screen.getByRole('button', {
     const confirmButton = screen.getByRole('button', {
       name: 'Change owner',
       name: 'Change owner',
     })
     })
@@ -537,8 +540,6 @@ describe('<ShareProjectModal/>', function () {
     expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
     expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
 
 
     expect(fetchMock.done()).to.be.true
     expect(fetchMock.done()).to.be.true
-    expect(reloadStub.calledOnce).to.be.true
-    reloadStub.restore()
   })
   })
 
 
   it('sends invites to input email addresses', async function () {
   it('sends invites to input email addresses', async function () {

+ 6 - 7
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

@@ -15,6 +15,7 @@ import {
   groupActiveSubscription,
   groupActiveSubscription,
   groupActiveSubscriptionWithPendingLicenseChange,
   groupActiveSubscriptionWithPendingLicenseChange,
 } from '../../fixtures/subscriptions'
 } from '../../fixtures/subscriptions'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 const userId = 'fff999fff999'
 const userId = 'fff999fff999'
 const memberGroupSubscriptions: MemberGroupSubscription[] = [
 const memberGroupSubscriptions: MemberGroupSubscription[] = [
@@ -71,13 +72,13 @@ describe('<GroupSubscriptionMemberships />', function () {
   })
   })
 
 
   describe('opens leave group modal when button is clicked', function () {
   describe('opens leave group modal when button is clicked', function () {
-    let reloadStub: () => void
-    const originalLocation = window.location
+    let reloadStub: sinon.SinonStub
 
 
     beforeEach(function () {
     beforeEach(function () {
       reloadStub = sinon.stub()
       reloadStub = sinon.stub()
-      Object.defineProperty(window, 'location', {
-        value: { reload: reloadStub },
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: sinon.stub(),
+        reload: reloadStub,
       })
       })
 
 
       render(
       render(
@@ -99,9 +100,7 @@ describe('<GroupSubscriptionMemberships />', function () {
     })
     })
 
 
     afterEach(function () {
     afterEach(function () {
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
     })
     })
 
 
     it('close the modal', function () {
     it('close the modal', function () {

+ 17 - 7
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -17,9 +17,9 @@ import {
   renderWithSubscriptionDashContext,
   renderWithSubscriptionDashContext,
 } from '../../helpers/render-with-subscription-dash-context'
 } from '../../helpers/render-with-subscription-dash-context'
 import { reactivateSubscriptionUrl } from '../../../../../../frontend/js/features/subscription/data/subscription-url'
 import { reactivateSubscriptionUrl } from '../../../../../../frontend/js/features/subscription/data/subscription-url'
-import * as locationModule from '../../../../../../frontend/js/shared/components/location'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
 import sinon from 'sinon'
 import sinon from 'sinon'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<PersonalSubscription />', function () {
 describe('<PersonalSubscription />', function () {
   afterEach(function () {
   afterEach(function () {
@@ -48,6 +48,20 @@ describe('<PersonalSubscription />', function () {
   })
   })
 
 
   describe('subscription states  ', function () {
   describe('subscription states  ', function () {
+    let reloadStub: sinon.SinonStub
+
+    beforeEach(function () {
+      reloadStub = sinon.stub()
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: sinon.stub(),
+        reload: reloadStub,
+      })
+    })
+
+    afterEach(function () {
+      this.locationStub.restore()
+    })
+
     it('renders the active dash', function () {
     it('renders the active dash', function () {
       renderWithSubscriptionDashContext(<PersonalSubscription />, {
       renderWithSubscriptionDashContext(<PersonalSubscription />, {
         metaTags: [
         metaTags: [
@@ -82,8 +96,6 @@ describe('<PersonalSubscription />', function () {
     })
     })
 
 
     it('reactivates canceled plan', async function () {
     it('reactivates canceled plan', async function () {
-      const reload = sinon.stub(locationModule, 'reload')
-
       renderWithSubscriptionDashContext(<PersonalSubscription />, {
       renderWithSubscriptionDashContext(<PersonalSubscription />, {
         metaTags: [{ name: 'ol-subscription', value: canceledSubscription }],
         metaTags: [{ name: 'ol-subscription', value: canceledSubscription }],
       })
       })
@@ -98,18 +110,16 @@ describe('<PersonalSubscription />', function () {
       expect(reactivateBtn.disabled).to.be.true
       expect(reactivateBtn.disabled).to.be.true
       await fetchMock.flush(true)
       await fetchMock.flush(true)
       expect(reactivateBtn.disabled).to.be.false
       expect(reactivateBtn.disabled).to.be.false
-      expect(reload).not.to.have.been.called
+      expect(reloadStub).not.to.have.been.called
       fetchMock.reset()
       fetchMock.reset()
 
 
       // 2nd click - success
       // 2nd click - success
       fetchMock.postOnce(reactivateSubscriptionUrl, 200)
       fetchMock.postOnce(reactivateSubscriptionUrl, 200)
       fireEvent.click(reactivateBtn)
       fireEvent.click(reactivateBtn)
       await fetchMock.flush(true)
       await fetchMock.flush(true)
-      expect(reload).to.have.been.calledOnce
+      expect(reloadStub).to.have.been.calledOnce
       expect(reactivateBtn.disabled).to.be.true
       expect(reactivateBtn.disabled).to.be.true
       fetchMock.reset()
       fetchMock.reset()
-
-      reload.restore()
     })
     })
 
 
     it('renders the expired dash', function () {
     it('renders the expired dash', function () {

+ 6 - 3
services/web/test/frontend/features/subscription/components/dashboard/premium-features-link.test.tsx

@@ -3,6 +3,7 @@ import sinon from 'sinon'
 import { fireEvent, render, screen, within } from '@testing-library/react'
 import { fireEvent, render, screen, within } from '@testing-library/react'
 import * as eventTracking from '../../../../../../frontend/js/infrastructure/event-tracking'
 import * as eventTracking from '../../../../../../frontend/js/infrastructure/event-tracking'
 import PremiumFeaturesLink from '../../../../../../frontend/js/features/subscription/components/dashboard/premium-features-link'
 import PremiumFeaturesLink from '../../../../../../frontend/js/features/subscription/components/dashboard/premium-features-link'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<PremiumFeaturesLink />', function () {
 describe('<PremiumFeaturesLink />', function () {
   const originalLocation = window.location
   const originalLocation = window.location
@@ -17,14 +18,16 @@ describe('<PremiumFeaturesLink />', function () {
   beforeEach(function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      reload: sinon.stub(),
+    })
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
     sendMBSpy.restore()
     sendMBSpy.restore()
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
   })
 
 
   for (const variant of variants) {
   for (const variant of variants) {

+ 8 - 9
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -21,6 +21,7 @@ import {
   extendTrialUrl,
   extendTrialUrl,
   subscriptionUpdateUrl,
   subscriptionUpdateUrl,
 } from '../../../../../../../../frontend/js/features/subscription/data/subscription-url'
 } from '../../../../../../../../frontend/js/features/subscription/data/subscription-url'
+import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<ActiveSubscription />', function () {
 describe('<ActiveSubscription />', function () {
   let sendMBSpy: sinon.SinonSpy
   let sendMBSpy: sinon.SinonSpy
@@ -195,20 +196,18 @@ describe('<ActiveSubscription />', function () {
   })
   })
 
 
   describe('cancel plan', function () {
   describe('cancel plan', function () {
-    const locationStub = sinon.stub()
+    const assignStub = sinon.stub()
     const reloadStub = sinon.stub()
     const reloadStub = sinon.stub()
-    const originalLocation = window.location
 
 
     beforeEach(function () {
     beforeEach(function () {
-      Object.defineProperty(window, 'location', {
-        value: { assign: locationStub, reload: reloadStub },
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: assignStub,
+        reload: reloadStub,
       })
       })
     })
     })
 
 
     afterEach(function () {
     afterEach(function () {
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
       fetchMock.reset()
       fetchMock.reset()
     })
     })
 
 
@@ -256,9 +255,9 @@ describe('<ActiveSubscription />', function () {
       })
       })
       fireEvent.click(button)
       fireEvent.click(button)
       await waitFor(() => {
       await waitFor(() => {
-        expect(locationStub).to.have.been.called
+        expect(assignStub).to.have.been.called
       })
       })
-      sinon.assert.calledWithMatch(locationStub, '/user/subscription/canceled')
+      sinon.assert.calledWithMatch(assignStub, '/user/subscription/canceled')
     })
     })
 
 
     it('shows an error message if canceling subscription failed', async function () {
     it('shows an error message if canceling subscription failed', async function () {

+ 7 - 7
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -20,25 +20,25 @@ import {
   subscriptionUpdateUrl,
   subscriptionUpdateUrl,
 } from '../../../../../../../../../frontend/js/features/subscription/data/subscription-url'
 } from '../../../../../../../../../frontend/js/features/subscription/data/subscription-url'
 import { renderActiveSubscription } from '../../../../../helpers/render-active-subscription'
 import { renderActiveSubscription } from '../../../../../helpers/render-active-subscription'
+import * as useLocationModule from '../../../../../../../../../frontend/js/shared/hooks/use-location'
 
 
 describe('<ChangePlanModal />', function () {
 describe('<ChangePlanModal />', function () {
-  let reloadStub: () => void
-  const originalLocation = window.location
   const plansMetaTag = { name: 'ol-plans', value: plans }
   const plansMetaTag = { name: 'ol-plans', value: plans }
 
 
+  let reloadStub: sinon.SinonStub
+
   beforeEach(function () {
   beforeEach(function () {
     reloadStub = sinon.stub()
     reloadStub = sinon.stub()
-    Object.defineProperty(window, 'location', {
-      value: { reload: reloadStub },
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      reload: reloadStub,
     })
     })
   })
   })
 
 
   afterEach(function () {
   afterEach(function () {
     cleanUpContext()
     cleanUpContext()
     fetchMock.reset()
     fetchMock.reset()
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
   })
 
 
   it('renders the individual plans table and group plans UI', async function () {
   it('renders the individual plans table and group plans UI', async function () {