Ver código fonte

Merge pull request #18763 from overleaf/ii-bs5-projects-notifications

[web] BS5 notifications in projects and welcome pages

GitOrigin-RevId: 25780bb64660ef41c41c007f94f70df273cac716
ilkin-overleaf 2 anos atrás
pai
commit
7b47acc486
35 arquivos alterados com 389 adições e 528 exclusões
  1. 0 17
      services/web/app/src/Features/Project/ProjectListController.js
  2. 0 1
      services/web/app/views/project/list-react.pug
  3. 5 5
      services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx
  4. 5 5
      services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx
  5. 5 5
      services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx
  6. 30 46
      services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx
  7. 8 16
      services/web/frontend/js/features/project-list/components/modals/rename-project-modal.tsx
  8. 8 14
      services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx
  9. 6 5
      services/web/frontend/js/features/project-list/components/notifications/accessibility-survey-banner.tsx
  10. 0 9
      services/web/frontend/js/features/project-list/components/notifications/action.tsx
  11. 0 9
      services/web/frontend/js/features/project-list/components/notifications/body.tsx
  12. 6 10
      services/web/frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner.tsx
  13. 92 75
      services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
  14. 5 12
      services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info.tsx
  15. 40 56
      services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
  16. 18 16
      services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx
  17. 13 19
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx
  18. 6 10
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx
  19. 2 12
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-successful.tsx
  20. 17 34
      services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx
  21. 2 3
      services/web/frontend/js/features/project-list/components/notifications/ieee-retirement-banner.tsx
  22. 17 105
      services/web/frontend/js/features/project-list/components/notifications/notification.tsx
  23. 1 7
      services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
  24. 6 6
      services/web/frontend/js/features/project-list/components/notifications/writefull-premium-promo-banner.tsx
  25. 12 4
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  26. 8 1
      services/web/frontend/js/features/project-list/components/survey-widget.tsx
  27. 2 0
      services/web/frontend/js/features/ui/components/ol/ol-button.tsx
  28. 2 0
      services/web/frontend/js/features/ui/components/types/button-props.ts
  29. 6 2
      services/web/frontend/js/shared/components/notification.tsx
  30. 0 1
      services/web/frontend/js/utils/meta.ts
  31. 0 21
      services/web/frontend/stories/project-list/notifications-new.stories.tsx
  32. 1 0
      services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
  33. 51 0
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
  34. 13 0
      services/web/frontend/stylesheets/bootstrap-5/pages/sidebar-v2-dash-pane.scss
  35. 2 2
      services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx

+ 0 - 17
services/web/app/src/Features/Project/ProjectListController.js

@@ -415,22 +415,6 @@ async function projectListPage(req, res, next) {
     logger.error({ err: error }, 'Failed to get individual subscription')
   }
 
-  let newNotificationStyle
-  try {
-    const newNotificationStyleAssignment =
-      await SplitTestHandler.promises.getAssignment(
-        req,
-        res,
-        'new-notification-style'
-      )
-    newNotificationStyle = newNotificationStyleAssignment.variant === 'enabled'
-  } catch (error) {
-    logger.error(
-      { err: error },
-      'failed to get "new-notification-style" split test assignment'
-    )
-  }
-
   try {
     await SplitTestHandler.promises.getAssignment(req, res, 'paywall-cta')
   } catch (error) {
@@ -473,7 +457,6 @@ async function projectListPage(req, res, next) {
         groupName: subscription.teamName,
       })),
     hasIndividualRecurlySubscription,
-    newNotificationStyle,
   })
 }
 

+ 0 - 1
services/web/app/views/project/list-react.pug

@@ -35,7 +35,6 @@ block append meta
 	meta(name="ol-showLATAMBanner" data-type="boolean" content=showLATAMBanner)
 	meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment)
 	meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription)
-	meta(name="ol-newNotificationStyle" data-type="boolean" content=newNotificationStyle)
 	meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess)
 
 block content

+ 5 - 5
services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx

@@ -1,9 +1,9 @@
 import { useEffect, useState, useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 import ProjectsActionModal from './projects-action-modal'
-import Icon from '../../../../shared/components/icon'
 import ProjectsList from './projects-list'
 import { isLeavableProject, isDeletableProject } from '../../util/project'
+import Notification from '@/shared/components/notification'
 
 type DeleteLeaveProjectModalProps = Pick<
   React.ComponentProps<typeof ProjectsActionModal>,
@@ -70,10 +70,10 @@ function DeleteLeaveProjectModal({
         projects={projectsToLeave}
         projectsToDisplay={projectsToLeaveDisplay}
       />
-      <div className="project-action-alert alert alert-warning">
-        <Icon type="exclamation-triangle" fw />{' '}
-        {t('this_action_cannot_be_undone')}
-      </div>
+      <Notification
+        content={t('this_action_cannot_be_undone')}
+        type="warning"
+      />
     </ProjectsActionModal>
   )
 }

+ 5 - 5
services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import ProjectsActionModal from './projects-action-modal'
-import Icon from '../../../../shared/components/icon'
 import ProjectsList from './projects-list'
+import Notification from '@/shared/components/notification'
 
 type DeleteProjectModalProps = Pick<
   React.ComponentProps<typeof ProjectsActionModal>,
@@ -41,10 +41,10 @@ function DeleteProjectModal({
     >
       <p>{t('about_to_delete_projects')}</p>
       <ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
-      <div className="project-action-alert alert alert-warning">
-        <Icon type="exclamation-triangle" fw />{' '}
-        {t('this_action_cannot_be_undone')}
-      </div>
+      <Notification
+        content={t('this_action_cannot_be_undone')}
+        type="warning"
+      />
     </ProjectsActionModal>
   )
 }

+ 5 - 5
services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import ProjectsActionModal from './projects-action-modal'
-import Icon from '../../../../shared/components/icon'
 import ProjectsList from './projects-list'
+import Notification from '@/shared/components/notification'
 
 type LeaveProjectModalProps = Pick<
   React.ComponentProps<typeof ProjectsActionModal>,
@@ -41,10 +41,10 @@ function LeaveProjectModal({
     >
       <p>{t('about_to_leave_projects')}</p>
       <ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
-      <div className="project-action-alert alert alert-warning">
-        <Icon type="exclamation-triangle" fw />{' '}
-        {t('this_action_cannot_be_undone')}
-      </div>
+      <Notification
+        content={t('this_action_cannot_be_undone')}
+        type="warning"
+      />
     </ProjectsActionModal>
   )
 }

+ 30 - 46
services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx

@@ -1,14 +1,18 @@
 import { memo, useEffect, useState } from 'react'
-import { Alert, Modal } from 'react-bootstrap'
+import { Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Project } from '../../../../../../types/project/dashboard/api'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
 import useIsMounted from '../../../../shared/hooks/use-is-mounted'
 import * as eventTracking from '../../../../infrastructure/event-tracking'
 import { isSmallDevice } from '../../../../infrastructure/event-tracking'
-import getMeta from '@/utils/meta'
 import Notification from '@/shared/components/notification'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+} from '@/features/ui/components/ol/ol-modal'
 
 type ProjectsActionModalProps = {
   title?: string
@@ -68,64 +72,44 @@ function ProjectsActionModal({
   }, [action, showModal])
 
   return (
-    <AccessibleModal
+    <OLModal
       animation
       show={showModal}
       onHide={handleCloseModal}
       id="action-project-modal"
       backdrop="static"
     >
-      <Modal.Header closeButton>
+      <OLModalHeader closeButton>
         <Modal.Title>{title}</Modal.Title>
-      </Modal.Header>
-      <Modal.Body>{children}</Modal.Body>
-      <Modal.Footer>
+      </OLModalHeader>
+      <OLModalBody>
+        {children}
         {!isProcessing &&
           errors.length > 0 &&
-          errors.map((e, i) => <ErrorNotification error={e} key={i} />)}
-        <button className="btn btn-secondary" onClick={handleCloseModal}>
+          errors.map((error, i) => (
+            <div className="notification-list" key={i}>
+              <Notification
+                type="error"
+                title={error.projectName}
+                content={getUserFacingMessage(error.error) as string}
+              />
+            </div>
+          ))}
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={handleCloseModal}>
           {t('cancel')}
-        </button>
-        <button
-          className="btn btn-danger"
+        </OLButton>
+        <OLButton
+          variant="danger"
           onClick={() => handleActionForProjects(projects)}
           disabled={isProcessing}
         >
           {t('confirm')}
-        </button>
-      </Modal.Footer>
-    </AccessibleModal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 
-type ErrorNotificationProps = {
-  error: any
-}
-
-function ErrorNotification({ error }: ErrorNotificationProps) {
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
-
-  if (newNotificationStyle) {
-    return (
-      // `notification-list` sets the margin-bottom correctly also when used individually in each notification.
-      // Once the legacy alerts are cleaned up we should move the styled div up to the notification list container.
-      <div className="notification-list">
-        <Notification
-          type="error"
-          title={error.projectName}
-          content={getUserFacingMessage(error.error) as string}
-        />
-      </div>
-    )
-  } else {
-    return (
-      <Alert bsStyle="danger" className="text-center" aria-live="polite">
-        <b>{error.projectName}</b>
-        <br />
-        {getUserFacingMessage(error.error)}
-      </Alert>
-    )
-  }
-}
-
 export default memo(ProjectsActionModal)

+ 8 - 16
services/web/frontend/js/features/project-list/components/modals/rename-project-modal.tsx

@@ -1,6 +1,5 @@
 import { memo, useCallback, useEffect, useMemo, useState } from 'react'
 import {
-  Alert,
   Button,
   ControlLabel,
   FormControl,
@@ -18,7 +17,6 @@ import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
 import { debugConsole } from '@/utils/debugging'
 import { isSmallDevice } from '../../../../infrastructure/event-tracking'
 import Notification from '@/shared/components/notification'
-import getMeta from '@/utils/meta'
 
 type RenameProjectModalProps = {
   handleCloseModal: () => void
@@ -36,7 +34,6 @@ function RenameProjectModal({
   const { error, isError, isLoading, runAsync } = useAsync()
   const { toggleSelectedProject, updateProjectViewData } =
     useProjectListContext()
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
 
   useEffect(() => {
     if (showModal) {
@@ -99,19 +96,14 @@ function RenameProjectModal({
         <Modal.Title>{t('rename_project')}</Modal.Title>
       </Modal.Header>
       <Modal.Body>
-        {isError &&
-          (newNotificationStyle ? (
-            <div className="notification-list">
-              <Notification
-                type="error"
-                content={getUserFacingMessage(error) as string}
-              />
-            </div>
-          ) : (
-            <Alert bsStyle="danger" className="text-center" aria-live="polite">
-              {getUserFacingMessage(error)}
-            </Alert>
-          ))}
+        {isError && (
+          <div className="notification-list">
+            <Notification
+              type="error"
+              content={getUserFacingMessage(error) as string}
+            />
+          </div>
+        )}
         <form id="rename-project-form" onSubmit={handleSubmit}>
           <FormGroup>
             <ControlLabel htmlFor="rename-project-form-name">

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

@@ -1,5 +1,4 @@
 import React, { useState } from 'react'
-import { Alert } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import useAsync from '../../../../shared/hooks/use-async'
 import {
@@ -8,7 +7,6 @@ import {
 } from '../../../../infrastructure/fetch-json'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
 import { useLocation } from '../../../../shared/hooks/use-location'
-import getMeta from '@/utils/meta'
 import Notification from '@/shared/components/notification'
 import {
   OLModalBody,
@@ -42,7 +40,6 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
   const [projectName, setProjectName] = useState('')
   const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
   const location = useLocation()
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
 
   const createNewProject = () => {
     runAsync(
@@ -77,17 +74,14 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
       </OLModalHeader>
 
       <OLModalBody>
-        {isError &&
-          (newNotificationStyle ? (
-            <div className="notification-list">
-              <Notification
-                type="error"
-                content={getUserFacingMessage(error) as string}
-              />
-            </div>
-          ) : (
-            <Alert bsStyle="danger">{getUserFacingMessage(error)}</Alert>
-          ))}
+        {isError && (
+          <div className="notification-list">
+            <Notification
+              type="error"
+              content={getUserFacingMessage(error) as string}
+            />
+          </div>
+        )}
         <OLForm onSubmit={handleSubmit}>
           <OLFormControl
             type="text"

+ 6 - 5
services/web/frontend/js/features/project-list/components/notifications/accessibility-survey-banner.tsx

@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react'
 import Notification from './notification'
 import customLocalStorage from '@/infrastructure/local-storage'
 import { useTranslation } from 'react-i18next'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 function AccessibilitySurveyBanner() {
   const { t } = useTranslation()
@@ -27,18 +28,18 @@ function AccessibilitySurveyBanner() {
   return (
     <Notification
       className="sr-only"
-      bsStyle="info"
+      type="info"
       onDismiss={handleClose}
-      body={<p>{t('help_improve_screen_reader_fill_out_this_survey')}</p>}
+      content={<p>{t('help_improve_screen_reader_fill_out_this_survey')}</p>}
       action={
-        <a
-          className="btn btn-secondary btn-sm pull-right btn-info"
+        <OLButton
+          variant="secondary"
           href="https://docs.google.com/forms/d/e/1FAIpQLSdxKP_biRXvrkmJzlBjMwI_qPSuv4NbBvYUzSOc3OOTIOTmnQ/viewform"
           target="_blank"
           rel="noreferrer"
         >
           {t('take_survey')}
-        </a>
+        </OLButton>
       }
     />
   )

+ 0 - 9
services/web/frontend/js/features/project-list/components/notifications/action.tsx

@@ -1,9 +0,0 @@
-import classnames from 'classnames'
-
-function Action({ className, ...props }: React.ComponentProps<'div'>) {
-  return (
-    <div className={classnames('notification-action', className)} {...props} />
-  )
-}
-
-export default Action

+ 0 - 9
services/web/frontend/js/features/project-list/components/notifications/body.tsx

@@ -1,9 +0,0 @@
-import classnames from 'classnames'
-
-function Body({ className, ...props }: React.ComponentProps<'div'>) {
-  return (
-    <div className={classnames('notification-body', className)} {...props} />
-  )
-}
-
-export default Body

+ 6 - 10
services/web/frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner.tsx

@@ -9,6 +9,7 @@ import {
   GroupsAndEnterpriseBannerVariant,
   GroupsAndEnterpriseBannerVariants,
 } from '../../../../../../types/project/dashboard/notification'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 type urlForVariantsType = {
   [key in GroupsAndEnterpriseBannerVariant]: string // eslint-disable-line no-unused-vars
@@ -31,7 +32,6 @@ export default function GroupsAndEnterpriseBanner() {
   const groupsAndEnterpriseBannerVariant = getMeta(
     'ol-groupsAndEnterpriseBannerVariant'
   )
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
 
   const hasDismissedGroupsAndEnterpriseBanner = hasRecentlyDismissedBanner()
 
@@ -73,23 +73,19 @@ export default function GroupsAndEnterpriseBanner() {
 
   return (
     <Notification
-      bsStyle="info"
+      type="info"
       onDismiss={handleClose}
-      body={<BannerContent variant={groupsAndEnterpriseBannerVariant} />}
+      content={<BannerContent variant={groupsAndEnterpriseBannerVariant} />}
       action={
-        <a
-          className={
-            newNotificationStyle
-              ? 'btn btn-secondary btn-sm'
-              : 'pull-right btn btn-info btn-sm'
-          }
+        <OLButton
+          variant="secondary"
           href={contactSalesUrl}
           target="_blank"
           rel="noreferrer"
           onClick={handleClickContact}
         >
           {t('contact_sales')}
-        </a>
+        </OLButton>
       }
     />
   )

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

@@ -1,6 +1,5 @@
 import { useState, useEffect } from 'react'
 import { useTranslation, Trans } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import Icon from '../../../../../../shared/components/icon'
 import getMeta from '../../../../../../utils/meta'
 import useAsync from '../../../../../../shared/hooks/use-async'
@@ -12,6 +11,8 @@ import { UserEmailData } from '../../../../../../../../types/user-email'
 import { Institution } from '../../../../../../../../types/institution'
 import { useLocation } from '../../../../../../shared/hooks/use-location'
 import { debugConsole } from '@/utils/debugging'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import Notification from '@/features/project-list/components/notifications/notification'
 
 type ReconfirmAffiliationProps = {
   email: UserEmailData['email']
@@ -24,7 +25,6 @@ function ReconfirmAffiliation({
 }: ReconfirmAffiliationProps) {
   const { t } = useTranslation()
   const { samlInitPath } = getMeta('ol-ExposedSettings')
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
   const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
   const [hasSent, setHasSent] = useState(false)
   const [isPending, setIsPending] = useState(false)
@@ -57,91 +57,108 @@ function ReconfirmAffiliation({
 
   if (hasSent) {
     return (
-      <div className="w-100">
-        <Trans
-          i18nKey="please_check_your_inbox_to_confirm"
-          components={[<b />]} // eslint-disable-line react/jsx-key
-          values={{ institutionName: institution.name }}
-          shouldUnescape
-          tOptions={{ interpolation: { escapeValue: true } }}
-        />
-        &nbsp;
-        {isLoading ? (
+      <Notification
+        type="info"
+        content={
           <>
-            <Icon type="refresh" spin fw /> {t('sending')}&hellip;
+            <Trans
+              i18nKey="please_check_your_inbox_to_confirm"
+              components={[<b />]} // eslint-disable-line react/jsx-key
+              values={{ institutionName: institution.name }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />
+            &nbsp;
+            {isError && (
+              <>
+                <br />
+                <div>
+                  {rateLimited
+                    ? t('too_many_requests')
+                    : t('generic_something_went_wrong')}
+                </div>
+              </>
+            )}
           </>
-        ) : (
-          <Button
+        }
+        action={
+          <OLButton
+            variant="link"
+            onClick={handleRequestReconfirmation}
             className="btn-inline-link"
             disabled={isLoading}
-            onClick={handleRequestReconfirmation}
+            isLoading={isLoading}
+            bs3Props={{
+              loading: isLoading ? (
+                <>
+                  <Icon type="refresh" spin fw /> {t('sending')}&hellip;
+                </>
+              ) : null,
+            }}
           >
             {t('resend_confirmation_email')}
-          </Button>
-        )}
-        {isError && (
-          <>
-            <br />
-            <div>
-              {rateLimited
-                ? t('too_many_requests')
-                : t('generic_something_went_wrong')}
-            </div>
-          </>
-        )}
-      </div>
+          </OLButton>
+        }
+      />
     )
   }
 
   return (
-    <div className="w-100">
-      {!newNotificationStyle && <Icon type="warning" />}
-      <Button
-        bsStyle="info"
-        bsSize="sm"
-        className="btn-reconfirm"
-        onClick={handleRequestReconfirmation}
-        disabled={isLoading || isPending}
-      >
-        {isLoading ? (
-          <>
-            <Icon type="refresh" spin fw /> {t('sending')}&hellip;
-          </>
-        ) : (
-          t('confirm_affiliation')
-        )}
-      </Button>
-      <Trans
-        i18nKey="are_you_still_at"
-        components={[<b />]} // eslint-disable-line react/jsx-key
-        values={{ institutionName: institution.name }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-      />
-      &nbsp;
-      <Trans
-        i18nKey="please_reconfirm_institutional_email"
-        /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
-        components={[<a href={`/user/settings?remove=${email}`} />]}
-      />
-      &nbsp;
-      <a
-        href="/learn/how-to/Institutional_Email_Reconfirmation"
-        target="_blank"
-      >
-        {t('learn_more')}
-      </a>
-      {isError && (
+    <Notification
+      type="info"
+      content={
         <>
-          <br />
-          <div>
-            {rateLimited
-              ? t('too_many_requests')
-              : t('generic_something_went_wrong')}
-          </div>
+          <Trans
+            i18nKey="are_you_still_at"
+            components={[<b />]} // eslint-disable-line react/jsx-key
+            values={{ institutionName: institution.name }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+          />
+          &nbsp;
+          <Trans
+            i18nKey="please_reconfirm_institutional_email"
+            /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+            components={[<a href={`/user/settings?remove=${email}`} />]}
+          />
+          &nbsp;
+          <a
+            href="/learn/how-to/Institutional_Email_Reconfirmation"
+            target="_blank"
+          >
+            {t('learn_more')}
+          </a>
+          {isError && (
+            <>
+              <br />
+              <div>
+                {rateLimited
+                  ? t('too_many_requests')
+                  : t('generic_something_went_wrong')}
+              </div>
+            </>
+          )}
         </>
-      )}
-    </div>
+      }
+      action={
+        <OLButton
+          variant="secondary"
+          bs3Props={{
+            loading:
+              isLoading || isPending ? (
+                <>
+                  <Icon type="refresh" spin fw /> {t('sending')}&hellip;
+                </>
+              ) : null,
+          }}
+          isLoading={isLoading || isPending}
+          disabled={isLoading || isPending}
+          onClick={handleRequestReconfirmation}
+        >
+          {t('confirm_affiliation')}
+        </OLButton>
+      }
+    />
   )
 }
 

+ 5 - 12
services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info.tsx

@@ -12,17 +12,10 @@ function ReconfirmationInfo() {
     <>
       {allInReconfirmNotificationPeriods.map(userEmail =>
         userEmail.affiliation?.institution ? (
-          <Notification
+          <ReconfirmAffiliation
+            email={userEmail.email}
+            institution={userEmail.affiliation.institution}
             key={`reconfirmation-period-email-${userEmail.email}`}
-            bsStyle="info"
-            body={
-              <div className="reconfirm-notification">
-                <ReconfirmAffiliation
-                  email={userEmail.email}
-                  institution={userEmail.affiliation.institution}
-                />
-              </div>
-            }
           />
         ) : null
       )}
@@ -31,9 +24,9 @@ function ReconfirmationInfo() {
         userEmail.affiliation?.institution ? (
           <Notification
             key={`samlIdentifier-email-${userEmail.email}`}
-            bsStyle="info"
+            type="info"
             onDismiss={() => {}}
-            body={
+            content={
               <ReconfirmationInfoSuccess
                 institution={userEmail.affiliation?.institution}
               />

+ 40 - 56
services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx

@@ -1,5 +1,4 @@
 import { useTranslation, Trans } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import Notification from '../notification'
 import Icon from '../../../../../shared/components/icon'
 import getMeta from '../../../../../utils/meta'
@@ -13,6 +12,7 @@ import {
 import GroupInvitationNotification from './group-invitation/group-invitation'
 import IEEERetirementBanner from '../ieee-retirement-banner'
 import { debugConsole } from '@/utils/debugging'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 function Common() {
   const notifications = getMeta('ol-notifications') || []
@@ -37,7 +37,6 @@ function CommonNotification({ notification }: CommonNotificationProps) {
   const { t } = useTranslation()
   const { samlInitPath } = getMeta('ol-ExposedSettings')
   const user = getMeta('ol-user')
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
   const { isLoading, isSuccess, error, runAsync } = useAsync<
     never,
     FetchError
@@ -63,9 +62,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
     <>
       {templateKey === 'notification_project_invite' ? (
         <Notification
-          bsStyle="info"
+          type="info"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             accepted ? (
               <Trans
                 i18nKey="notification_project_invite_accepted_message"
@@ -89,42 +88,36 @@ function CommonNotification({ notification }: CommonNotificationProps) {
           }
           action={
             accepted ? (
-              <Button
-                bsStyle={newNotificationStyle ? null : 'info'}
-                bsSize="sm"
-                className={
-                  newNotificationStyle ? 'btn-secondary' : 'pull-right'
-                }
+              <OLButton
+                variant="secondary"
                 href={`/project/${notification.messageOpts.projectId}`}
               >
                 {t('open_project')}
-              </Button>
+              </OLButton>
             ) : (
-              <Button
-                bsStyle={newNotificationStyle ? null : 'info'}
-                className={
-                  newNotificationStyle ? 'btn-secondary' : 'pull-right'
-                }
-                bsSize="sm"
+              <OLButton
+                variant="secondary"
+                bs3Props={{
+                  loading: isLoading ? (
+                    <>
+                      <Icon type="spinner" spin /> {t('joining')}&hellip;
+                    </>
+                  ) : null,
+                }}
+                isLoading={isLoading}
                 disabled={isLoading}
                 onClick={() => handleAcceptInvite(notification)}
               >
-                {isLoading ? (
-                  <>
-                    <Icon type="spinner" spin /> {t('joining')}&hellip;
-                  </>
-                ) : (
-                  t('join_project')
-                )}
-              </Button>
+                {t('join_project')}
+              </OLButton>
             )
           }
         />
       ) : templateKey === 'wfh_2020_upgrade_offer' ? (
         <Notification
-          bsStyle="info"
+          type="info"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             <>
               Important notice: Your free WFH2020 upgrade came to an end on June
               30th 2020. We're still providing a number of special initiatives
@@ -132,21 +125,19 @@ function CommonNotification({ notification }: CommonNotificationProps) {
             </>
           }
           action={
-            <Button
-              bsStyle={newNotificationStyle ? null : 'info'}
-              bsSize="sm"
-              className={newNotificationStyle ? 'btn-secondary' : 'pull-right'}
+            <OLButton
+              variant="secondary"
               href="https://www.overleaf.com/events/wfh2020"
             >
               View
-            </Button>
+            </OLButton>
           }
         />
       ) : templateKey === 'notification_ip_matched_affiliation' ? (
         <Notification
-          bsStyle="info"
+          type="info"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             <>
               <Trans
                 i18nKey="looks_like_youre_at"
@@ -193,10 +184,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
             </>
           }
           action={
-            <Button
-              bsStyle={newNotificationStyle ? null : 'info'}
-              bsSize="sm"
-              className={newNotificationStyle ? 'btn-secondary' : 'pull-right'}
+            <OLButton
+              variant="secondary"
               href={
                 notification.messageOpts.ssoEnabled
                   ? `${samlInitPath}?university_id=${notification.messageOpts.institutionId}&auto=/project`
@@ -206,14 +195,14 @@ function CommonNotification({ notification }: CommonNotificationProps) {
               {notification.messageOpts.ssoEnabled
                 ? t('link_account')
                 : t('add_affiliation')}
-            </Button>
+            </OLButton>
           }
         />
       ) : templateKey === 'notification_tpds_file_limit' ? (
         <Notification
-          bsStyle="danger"
+          type="error"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             <>
               Error: Your project {notification.messageOpts.projectName} has
               gone over the 2000 file limit using an integration (e.g. Dropbox
@@ -223,21 +212,16 @@ function CommonNotification({ notification }: CommonNotificationProps) {
             </>
           }
           action={
-            <Button
-              bsStyle={newNotificationStyle ? null : 'danger'}
-              bsSize="sm"
-              className={newNotificationStyle ? 'btn-secondary' : 'pull-right'}
-              href="/user/settings"
-            >
+            <OLButton variant="secondary" href="/user/settings">
               Account Settings
-            </Button>
+            </OLButton>
           }
         />
       ) : templateKey === 'notification_dropbox_duplicate_project_names' ? (
         <Notification
-          bsStyle="warning"
+          type="warning"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             <>
               <p>
                 <Trans
@@ -267,9 +251,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
       ) : templateKey ===
         'notification_dropbox_unlinked_due_to_lapsed_reconfirmation' ? (
         <Notification
-          bsStyle="info"
+          type="info"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             <>
               <Trans
                 i18nKey="dropbox_unlinked_premium_feature"
@@ -299,9 +283,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
         <IEEERetirementBanner id={id} />
       ) : templateKey === 'notification_personal_and_group_subscriptions' ? (
         <Notification
-          bsStyle="warning"
+          type="warning"
           onDismiss={() => id && handleDismiss(id)}
-          body={
+          content={
             <Trans
               i18nKey="notification_personal_and_group_subscriptions"
               /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
@@ -311,9 +295,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
         />
       ) : (
         <Notification
-          bsStyle="info"
+          type="info"
           onDismiss={() => id && handleDismiss(id)}
-          body={html}
+          content={html}
         />
       )}
     </>

+ 18 - 16
services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx

@@ -1,5 +1,4 @@
 import { Trans, useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import Notification from '../notification'
 import Icon from '../../../../../shared/components/icon'
 import getMeta from '../../../../../utils/meta'
@@ -11,6 +10,7 @@ import {
 } from '../../../../../infrastructure/fetch-json'
 import { UserEmailData } from '../../../../../../../types/user-email'
 import { debugConsole } from '@/utils/debugging'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 const ssoAvailable = ({ samlProviderId, affiliation }: UserEmailData) => {
   const { hasSamlFeature, hasSamlBeta } = getMeta('ol-ExposedSettings')
@@ -96,8 +96,8 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
   if (emailHasLicenceAfterConfirming(userEmail) && isOnFreeOrIndividualPlan()) {
     return (
       <Notification
-        bsStyle="info"
-        body={
+        type="info"
+        content={
           <div data-testid="notification-body">
             {isLoading ? (
               <>
@@ -112,12 +112,6 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
                   i18nKey="one_step_away_from_professional_features"
                   components={[<strong />]} // eslint-disable-line react/jsx-key
                 />
-                <button
-                  className="pull-right btn btn-info btn-sm"
-                  onClick={() => handleResendConfirmationEmail(userEmail)}
-                >
-                  {t('resend_email')}
-                </button>
                 <br />
                 <Trans
                   i18nKey="institution_has_overleaf_subscription"
@@ -133,14 +127,22 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
             )}
           </div>
         }
+        action={
+          <OLButton
+            variant="secondary"
+            onClick={() => handleResendConfirmationEmail(userEmail)}
+          >
+            {t('resend_email')}
+          </OLButton>
+        }
       />
     )
   }
 
   return (
     <Notification
-      bsStyle="warning"
-      body={
+      type="warning"
+      content={
         <div data-testid="pro-notification-body">
           {isLoading ? (
             <>
@@ -154,13 +156,13 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
               {t('please_confirm_email', {
                 emailAddress: userEmail.email,
               })}{' '}
-              <Button
-                bsStyle="link"
-                className="btn-inline-link"
+              <OLButton
+                variant="link"
                 onClick={() => handleResendConfirmationEmail(userEmail)}
+                className="btn-inline-link"
               >
-                ({t('resend_confirmation_email')})
-              </Button>
+                {t('resend_confirmation_email')}
+              </OLButton>
             </>
           )}
         </div>

+ 13 - 19
services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx

@@ -1,10 +1,9 @@
-import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import type { Dispatch, SetStateAction } from 'react'
 import Notification from '../../notification'
 import { GroupInvitationStatus } from './hooks/use-group-invitation-notification'
 import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
-import getMeta from '@/utils/meta'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 type GroupInvitationCancelIndividualSubscriptionNotificationProps = {
   setGroupInvitationStatus: Dispatch<SetStateAction<GroupInvitationStatus>>
@@ -20,37 +19,32 @@ export default function GroupInvitationCancelIndividualSubscriptionNotification(
   notification,
 }: GroupInvitationCancelIndividualSubscriptionNotificationProps) {
   const { t } = useTranslation()
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
   const {
     messageOpts: { inviterName },
   } = notification
 
   return (
     <Notification
-      bsStyle="info"
+      type="info"
       onDismiss={dismissGroupInviteNotification}
-      body={t('invited_to_group_have_individual_subcription', { inviterName })}
+      content={t('invited_to_group_have_individual_subcription', {
+        inviterName,
+      })}
       action={
-        <div className="group-invitation-cancel-subscription-notification-buttons">
-          <Button
-            bsStyle={newNotificationStyle ? null : 'info'}
-            bsSize="sm"
-            className={newNotificationStyle ? 'me-1 btn-secondary' : 'me-1'}
+        <>
+          <OLButton
+            variant="secondary"
             onClick={() =>
               setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
             }
+            className="me-1"
           >
             {t('not_now')}
-          </Button>
-          <Button
-            bsStyle={newNotificationStyle ? null : 'info'}
-            className={newNotificationStyle ? 'btn-secondary' : ''}
-            bsSize="sm"
-            onClick={cancelPersonalSubscription}
-          >
+          </OLButton>
+          <OLButton variant="secondary" onClick={cancelPersonalSubscription}>
             {t('cancel_my_subscription')}
-          </Button>
-        </div>
+          </OLButton>
+        </>
       }
     />
   )

+ 6 - 10
services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx

@@ -1,8 +1,7 @@
-import { Button } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
 import Notification from '../../notification'
 import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
-import getMeta from '@/utils/meta'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 type GroupInvitationNotificationProps = {
   acceptGroupInvite: () => void
@@ -18,16 +17,15 @@ export default function GroupInvitationNotificationJoin({
   dismissGroupInviteNotification,
 }: GroupInvitationNotificationProps) {
   const { t } = useTranslation()
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
   const {
     messageOpts: { inviterName },
   } = notification
 
   return (
     <Notification
-      bsStyle="info"
+      type="info"
       onDismiss={dismissGroupInviteNotification}
-      body={
+      content={
         <Trans
           i18nKey="invited_to_group"
           values={{ inviterName }}
@@ -40,15 +38,13 @@ export default function GroupInvitationNotificationJoin({
         />
       }
       action={
-        <Button
-          bsStyle={newNotificationStyle ? null : 'info'}
-          bsSize="sm"
-          className={newNotificationStyle ? 'btn-secondary' : 'pull-right'}
+        <OLButton
+          variant="secondary"
           onClick={acceptGroupInvite}
           disabled={isAcceptingInvitation}
         >
           {t('join_now')}
-        </Button>
+        </OLButton>
       }
     />
   )

+ 2 - 12
services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-successful.tsx

@@ -1,7 +1,5 @@
 import Notification from '../../notification'
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../../../../shared/components/icon'
-import getMeta from '@/utils/meta'
 
 type GroupInvitationSuccessfulNotificationProps = {
   hideNotification: () => void
@@ -11,20 +9,12 @@ export default function GroupInvitationSuccessfulNotification({
   hideNotification,
 }: GroupInvitationSuccessfulNotificationProps) {
   const { t } = useTranslation()
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
 
   return (
     <Notification
-      bsStyle="success"
+      type="success"
       onDismiss={hideNotification}
-      body={
-        <>
-          {!newNotificationStyle && (
-            <Icon type="check-circle" fw aria-hidden="true" className="me-1" />
-          )}
-          {t('congratulations_youve_successfully_join_group')}
-        </>
-      }
+      content={t('congratulations_youve_successfully_join_group')}
     />
   )
 }

+ 17 - 34
services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx

@@ -1,16 +1,14 @@
 import { Fragment } from 'react'
 import { useTranslation, Trans } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import Notification from '../notification'
-import Icon from '../../../../../shared/components/icon'
 import getMeta from '../../../../../utils/meta'
 import useAsyncDismiss from '../hooks/useAsyncDismiss'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 function Institution() {
   const { t } = useTranslation()
   const { samlInitPath, appName } = getMeta('ol-ExposedSettings')
   const notificationsInstitution = getMeta('ol-notificationsInstitution') || []
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
   const { handleDismiss } = useAsyncDismiss()
 
   if (!notificationsInstitution.length) {
@@ -36,10 +34,9 @@ function Institution() {
           <Fragment key={index}>
             {templateKey === 'notification_institution_sso_available' && (
               <Notification
-                bsStyle="info"
-                body={
+                type="info"
+                content={
                   <>
-                    {' '}
                     <p>
                       <Trans
                         i18nKey="can_link_institution_email_acct_to_institution_acct"
@@ -67,22 +64,20 @@ function Institution() {
                   </>
                 }
                 action={
-                  <Button
-                    bsStyle={newNotificationStyle ? null : 'info'}
-                    className={newNotificationStyle ? 'btn-secondary' : ''}
-                    bsSize="sm"
+                  <OLButton
+                    variant="secondary"
                     href={`${samlInitPath}?university_id=${institutionId}&auto=/project&email=${email}`}
                   >
                     {t('link_account')}
-                  </Button>
+                  </OLButton>
                 }
               />
             )}
             {templateKey === 'notification_institution_sso_linked' && (
               <Notification
-                bsStyle="info"
+                type="info"
                 onDismiss={() => id && handleDismiss(id)}
-                body={
+                content={
                   <Trans
                     i18nKey="account_has_been_link_to_institution_account"
                     components={{ b: <b /> }}
@@ -95,15 +90,10 @@ function Institution() {
             )}
             {templateKey === 'notification_institution_sso_non_canonical' && (
               <Notification
-                bsStyle="warning"
+                type="warning"
                 onDismiss={() => id && handleDismiss(id)}
-                body={
+                content={
                   <>
-                    {!newNotificationStyle && (
-                      <>
-                        <Icon type="exclamation-triangle" fw />{' '}
-                      </>
-                    )}
                     <Trans
                       i18nKey="tried_to_log_in_with_email"
                       components={{ b: <b /> }}
@@ -125,9 +115,9 @@ function Institution() {
             {templateKey ===
               'notification_institution_sso_already_registered' && (
               <Notification
-                bsStyle="info"
+                type="info"
                 onDismiss={() => id && handleDismiss(id)}
-                body={
+                content={
                   <>
                     <Trans
                       i18nKey="tried_to_register_with_email"
@@ -140,29 +130,22 @@ function Institution() {
                   </>
                 }
                 action={
-                  <Button
-                    bsStyle={newNotificationStyle ? null : 'info'}
-                    className={newNotificationStyle ? 'btn-secondary' : ''}
-                    bsSize="sm"
+                  <OLButton
+                    variant="secondary"
                     href="/learn/how-to/Institutional_Login"
                     target="_blank"
                   >
                     {t('find_out_more')}
-                  </Button>
+                  </OLButton>
                 }
               />
             )}
             {templateKey === 'notification_institution_sso_error' && (
               <Notification
-                bsStyle="danger"
+                type="error"
                 onDismiss={() => id && handleDismiss(id)}
-                body={
+                content={
                   <>
-                    {!newNotificationStyle && (
-                      <>
-                        <Icon type="exclamation-triangle" fw />{' '}
-                      </>
-                    )}
                     {t('generic_something_went_wrong')}.
                     <div>
                       {error?.translatedMessage

+ 2 - 3
services/web/frontend/js/features/project-list/components/notifications/ieee-retirement-banner.tsx

@@ -49,10 +49,9 @@ export default function IEEERetirementBanner({
 
   return (
     <Notification
-      bsStyle="warning"
+      type="warning"
       onDismiss={handleClose}
-      newNotificationStyle
-      body={
+      content={
         <Trans
           i18nKey="notification_ieee_collabratec_retirement_message"
           components={[

+ 17 - 105
services/web/frontend/js/features/project-list/components/notifications/notification.tsx

@@ -1,109 +1,21 @@
-import { useState } from 'react'
-import { Alert, AlertProps } from 'react-bootstrap'
-import Body from './body'
-import Action from './action'
-import Close from '../../../../shared/components/close'
 import classnames from 'classnames'
-import NewNotification, {
-  NotificationType,
-} from '@/shared/components/notification'
-import getMeta from '@/utils/meta'
-
-type NotificationProps = {
-  bsStyle: AlertProps['bsStyle']
-  children?: React.ReactNode
-  body?: React.ReactNode
-  action?: React.ReactElement
-  onDismiss?: AlertProps['onDismiss']
-  className?: string
-  newNotificationStyle?: boolean
-}
-
-/**
- * Renders either a legacy-styled notification using Boostrap `Alert`, or a new-styled notification using
- * the shared `Notification` component.
- *
- * The content of the notification is provided either with `children` (keeping backwards compatibility),
- * or a `body` prop (along with an optional `action`).
- *
- * When the content is provided via `body` prop the notification is rendered with the new Notification component
- * if `ol-newNotificationStyle` meta is set to true.
- */
-function Notification({
-  bsStyle,
-  children,
-  onDismiss,
-  className,
-  body,
-  action,
-  newNotificationStyle,
-  ...props
-}: NotificationProps) {
-  newNotificationStyle =
-    newNotificationStyle ?? getMeta('ol-newNotificationStyle')
-
-  const [show, setShow] = useState(true)
-
-  const handleDismiss = () => {
-    if (onDismiss) {
-      onDismiss()
-    }
-
-    setShow(false)
-  }
-
-  if (!show) {
-    return null
-  }
-
-  if (newNotificationStyle && body) {
-    const newNotificationType = (
-      bsStyle === 'danger' ? 'error' : bsStyle
-    ) as NotificationType
-    return (
-      <li className={classnames('notification-entry', className)}>
-        <NewNotification
-          type={newNotificationType}
-          isDismissible={onDismiss != null}
-          onDismiss={handleDismiss}
-          content={body as React.ReactElement}
-          action={action}
-        />
-      </li>
-    )
-  }
-
-  if (body) {
-    return (
-      <li className={classnames('notification-entry', className)} {...props}>
-        <Alert bsStyle={bsStyle}>
-          <Body>{body}</Body>
-          {action && <Action>{action}</Action>}
-          {onDismiss ? (
-            <div className="notification-close">
-              <Close onDismiss={handleDismiss} />
-            </div>
-          ) : null}
-        </Alert>
-      </li>
-    )
-  } else {
-    return (
-      <li className={classnames('notification-entry', className)} {...props}>
-        <Alert bsStyle={bsStyle}>
-          {children}
-          {onDismiss ? (
-            <div className="notification-close">
-              <Close onDismiss={handleDismiss} />
-            </div>
-          ) : null}
-        </Alert>
-      </li>
-    )
-  }
+import NewNotification from '@/shared/components/notification'
+
+type NotificationProps = Pick<
+  React.ComponentProps<typeof NewNotification>,
+  'type' | 'action' | 'content' | 'onDismiss' | 'className'
+>
+
+function Notification({ className, ...props }: NotificationProps) {
+  const notificationComponent = (
+    <NewNotification isDismissible={props.onDismiss != null} {...props} />
+  )
+
+  return notificationComponent ? (
+    <li className={classnames('notification-entry', className)}>
+      {notificationComponent}
+    </li>
+  ) : null
 }
 
-Notification.Body = Body
-Notification.Action = Action
-
 export default Notification

+ 1 - 7
services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx

@@ -10,7 +10,6 @@ import getMeta from '../../../../utils/meta'
 import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
 import customLocalStorage from '../../../../infrastructure/local-storage'
 import { sendMB } from '../../../../infrastructure/event-tracking'
-import classNames from 'classnames'
 import GeoBanners from './geo-banners'
 import AccessibilitySurveyBanner from './accessibility-survey-banner'
 
@@ -23,7 +22,6 @@ const EnrollmentNotification: JSXElementConstructor<{
 }> = enrollmentNotificationModule?.import.default
 
 function UserNotifications() {
-  const newNotificationStyle = getMeta('ol-newNotificationStyle')
   const groupSubscriptionsPendingEnrollment =
     getMeta('ol-groupSubscriptionsPendingEnrollment') || []
   const user = getMeta('ol-user')
@@ -54,11 +52,7 @@ function UserNotifications() {
   const [dismissedWritefull, setDismissedWritefull] = useState(false)
 
   return (
-    <div
-      className={classNames('user-notifications', {
-        'notification-list': newNotificationStyle,
-      })}
-    >
+    <div className="user-notifications notification-list">
       <ul className="list-unstyled">
         {EnrollmentNotification &&
           groupSubscriptionsPendingEnrollment.map(subscription => (

+ 6 - 6
services/web/frontend/js/features/project-list/components/notifications/writefull-premium-promo-banner.tsx

@@ -3,6 +3,7 @@ import Notification from './notification'
 import { sendMB } from '@/infrastructure/event-tracking'
 import customLocalStorage from '@/infrastructure/local-storage'
 import WritefullLogo from '@/shared/svgs/writefull-logo'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 const eventSegmentation = {
   location: 'dashboard-banner',
@@ -33,10 +34,9 @@ function WritefullPremiumPromoBanner({
   return (
     <div data-testid="writefull-premium-promo-banner">
       <Notification
-        bsStyle="info"
-        newNotificationStyle
+        type="info"
         onDismiss={handleClose}
-        body={
+        content={
           <>
             Enjoying Writefull? Get <strong>10% off Writefull Premium</strong>,
             giving you access to TeXGPT—AI assistance to generate LaTeX code.
@@ -44,8 +44,8 @@ function WritefullPremiumPromoBanner({
           </>
         }
         action={
-          <a
-            className="btn btn-secondary btn-sm"
+          <OLButton
+            variant="secondary"
             href="https://my.writefull.com/overleaf-invite?code=OVERLEAF10&redirect=plans"
             target="_blank"
             rel="noreferrer"
@@ -55,7 +55,7 @@ function WritefullPremiumPromoBanner({
           >
             <WritefullLogo width="16" height="16" />{' '}
             <span>Get Writefull Premium</span>
-          </a>
+          </OLButton>
         }
       />
     </div>

+ 12 - 4
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -29,6 +29,7 @@ import { SplitTestProvider } from '@/shared/context/split-test-context'
 import OLCol from '@/features/ui/components/ol/ol-col'
 import { bsVersion } from '@/features/utils/bootstrap-5'
 import classnames from 'classnames'
+import Notification from '@/shared/components/notification'
 
 function ProjectListRoot() {
   const { isReady } = useWaitForI18n()
@@ -127,7 +128,12 @@ function ProjectListPageContent() {
                   />
                 </Col>
               </Row>
-              <div className="project-list-sidebar-survey-wrapper visible-xs">
+              <div
+                className={classnames(
+                  'project-list-sidebar-survey-wrapper',
+                  bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
+                )}
+              >
                 <SurveyWidget />
               </div>
               <div className="visible-xs mt-1">
@@ -199,10 +205,12 @@ function DashApiError() {
         xs={{ span: 8, offset: 2 }}
         bs3Props={{ xs: 8, xsOffset: 2 }}
         aria-live="polite"
-        className="text-center"
       >
-        <div className="alert alert-danger">
-          {t('generic_something_went_wrong')}
+        <div className="notification-list">
+          <Notification
+            content={t('generic_something_went_wrong')}
+            type="error"
+          />
         </div>
       </OLCol>
     </Row>

+ 8 - 1
services/web/frontend/js/features/project-list/components/survey-widget.tsx

@@ -2,6 +2,7 @@ import usePersistedState from '../../../shared/hooks/use-persisted-state'
 import getMeta from '../../../utils/meta'
 import { useCallback } from 'react'
 import Close from '@/shared/components/close'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 export default function SurveyWidget() {
   const survey = getMeta('ol-survey')
@@ -21,7 +22,13 @@ export default function SurveyWidget() {
   return (
     <div className="user-notifications">
       <div className="notification-entry">
-        <div role="alert" className="alert alert-info-alt">
+        <div
+          role="alert"
+          className={bsVersion({
+            bs3: 'alert alert-info-alt',
+            bs5: 'survey-notification',
+          })}
+        >
           <div className="notification-body">
             {survey.preText}&nbsp;
             <a

+ 2 - 0
services/web/frontend/js/features/ui/components/ol/ol-button.tsx

@@ -28,6 +28,8 @@ export function bs3ButtonProps(props: ButtonProps) {
     disabled: props.isLoading || props.disabled,
     form: props.form,
     href: props.href,
+    target: props.target,
+    rel: props.rel,
     onClick: props.onClick,
     type: props.type,
   }

+ 2 - 0
services/web/frontend/js/features/ui/components/types/button-props.ts

@@ -7,6 +7,8 @@ export type ButtonProps = {
   form?: string
   leadingIcon?: string
   href?: string
+  target?: string
+  rel?: string
   isLoading?: boolean
   onClick?: MouseEventHandler<HTMLButtonElement>
   size?: 'small' | 'default' | 'large'

+ 6 - 2
services/web/frontend/js/shared/components/notification.tsx

@@ -15,7 +15,7 @@ export type NotificationProps = {
   ariaLive?: 'polite' | 'off' | 'assertive'
   className?: string
   content: React.ReactNode
-  customIcon?: React.ReactElement
+  customIcon?: React.ReactElement | null
   disclaimer?: React.ReactElement | string
   isDismissible?: boolean
   isActionBelowContent?: boolean
@@ -78,6 +78,8 @@ function Notification({
     if (onDismiss) onDismiss()
   }
 
+  // return null
+
   if (!show) {
     return null
   }
@@ -89,7 +91,9 @@ function Notification({
       role="alert"
       id={id}
     >
-      <NotificationIcon notificationType={type} customIcon={customIcon} />
+      {customIcon !== null && (
+        <NotificationIcon notificationType={type} customIcon={customIcon} />
+      )}
 
       <div className="notification-content-and-cta">
         <div className="notification-content">

+ 0 - 1
services/web/frontend/js/utils/meta.ts

@@ -133,7 +133,6 @@ export interface Meta {
   'ol-memberGroupSubscriptions': MemberGroupSubscription[]
   'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[]
   'ol-members': MinimalUser[]
-  'ol-newNotificationStyle': boolean
   'ol-no-single-dollar': boolean
   'ol-notifications': NotificationType[]
   'ol-notificationsInstitution': InstitutionType[]

+ 0 - 21
services/web/frontend/stories/project-list/notifications-new.stories.tsx

@@ -25,7 +25,6 @@ export const ProjectInvite = (args: any) => {
       token: 'abcdef',
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -44,7 +43,6 @@ export const ProjectInviteNetworkError = (args: any) => {
       token: 'abcdef',
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -58,7 +56,6 @@ export const Wfh2020UpgradeOffer = (args: any) => {
     _id: 1,
     templateKey: 'wfh_2020_upgrade_offer',
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -77,7 +74,6 @@ export const IPMatchedAffiliationSsoEnabled = (args: any) => {
       ssoEnabled: true,
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -96,7 +92,6 @@ export const IPMatchedAffiliationSsoDisabled = (args: any) => {
       ssoEnabled: false,
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -113,7 +108,6 @@ export const TpdsFileLimit = (args: any) => {
       projectName: 'Abc Project',
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -130,7 +124,6 @@ export const DropBoxDuplicateProjectNames = (args: any) => {
       projectName: 'Abc Project',
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -144,7 +137,6 @@ export const DropBoxUnlinkedDueToLapsedReconfirmation = (args: any) => {
     _id: 1,
     templateKey: 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -161,7 +153,6 @@ export const NotificationGroupInvitation = (args: any) => {
       inviterName: 'John Doe',
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -178,7 +169,6 @@ export const NotificationGroupInvitationCancelSubscription = (args: any) => {
       inviterName: 'John Doe',
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   window.metaAttributesCache.set('ol-hasIndividualRecurlySubscription', true)
   return (
     <ProjectListProvider>
@@ -190,7 +180,6 @@ export const NotificationGroupInvitationCancelSubscription = (args: any) => {
 export const NonSpecificMessage = (args: any) => {
   useFetchMock(commonSetupMocks)
   setCommonMeta({ _id: 1, html: 'Non specific message' })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -204,7 +193,6 @@ export const InstitutionSsoAvailable = (args: any) => {
     _id: 1,
     templateKey: 'notification_institution_sso_available',
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -218,7 +206,6 @@ export const InstitutionSsoLinked = (args: any) => {
     _id: 1,
     templateKey: 'notification_institution_sso_linked',
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -232,7 +219,6 @@ export const InstitutionSsoNonCanonical = (args: any) => {
     _id: 1,
     templateKey: 'notification_institution_sso_non_canonical',
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -246,7 +232,6 @@ export const InstitutionSsoAlreadyRegistered = (args: any) => {
     _id: 1,
     templateKey: 'notification_institution_sso_already_registered',
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -264,7 +249,6 @@ export const InstitutionSsoError = (args: any) => {
       tryAgain: true,
     },
   })
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -275,7 +259,6 @@ export const InstitutionSsoError = (args: any) => {
 export const ResendConfirmationEmail = (args: any) => {
   useFetchMock(reconfirmationSetupMocks)
   setReconfirmationMeta()
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -286,7 +269,6 @@ export const ResendConfirmationEmail = (args: any) => {
 export const ResendConfirmationEmailNetworkError = (args: any) => {
   useFetchMock(errorsMocks)
   setReconfirmationMeta()
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -300,7 +282,6 @@ export const ReconfirmAffiliation = (args: any) => {
   window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
     fakeReconfirmationUsersData,
   ])
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -314,7 +295,6 @@ export const ReconfirmAffiliationNetworkError = (args: any) => {
   window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
     fakeReconfirmationUsersData,
   ])
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />
@@ -326,7 +306,6 @@ export const ReconfirmedAffiliationSuccess = (args: any) => {
   useFetchMock(reconfirmAffiliationSetupMocks)
   setReconfirmAffiliationMeta()
   window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
-  window.metaAttributesCache.set('ol-newNotificationStyle', true)
   return (
     <ProjectListProvider>
       <UserNotifications {...args} />

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/all.scss

@@ -1,2 +1,3 @@
 @import 'account-settings';
 @import 'project-list';
+@import 'sidebar-v2-dash-pane';

+ 51 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -168,3 +168,54 @@
     }
   }
 }
+
+.survey-notification {
+  display: flex;
+  flex-wrap: wrap;
+  padding: var(--spacing-06);
+  background-color: var(--bg-dark-tertiary);
+  border-color: transparent;
+  color: var(--neutral-20);
+  box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.25);
+  border-radius: var(--border-radius-base);
+
+  @include media-breakpoint-up(md) {
+    flex-wrap: nowrap;
+  }
+
+  button.close {
+    @extend .text-white;
+    padding: 0;
+    -webkit-appearance: none;
+  }
+}
+
+.project-list-sidebar-survey-wrapper {
+  position: sticky;
+  bottom: 0;
+
+  .survey-notification {
+    font-size: var(--font-size-02);
+
+    a {
+      text-decoration: none;
+    }
+  }
+
+  @include media-breakpoint-down(md) {
+    position: static;
+    margin-top: var(--spacing-05);
+
+    .survey-notification {
+      font-size: unset;
+
+      .project-list-sidebar-survey-link {
+        display: block;
+        align-items: center;
+        min-width: 48px;
+        min-height: 48px;
+        padding-top: var(--spacing-07);
+      }
+    }
+  }
+}

+ 13 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/sidebar-v2-dash-pane.scss

@@ -0,0 +1,13 @@
+.project-list-sidebar-survey-link {
+  @extend .text-white;
+  font-weight: bold;
+}
+
+.notification-close-button-style button {
+  &:hover {
+    background-color: transparent !important;
+  }
+  &:focus {
+    background-color: transparent !important;
+  }
+}

+ 2 - 2
services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx

@@ -67,10 +67,10 @@ describe('<ProjectsActionModal />', function () {
     await waitFor(() => {
       const alerts = screen.getAllByRole('alert')
       expect(alerts.length).to.equal(2)
-      expect(alerts[0].textContent).to.equal(
+      expect(alerts[0].textContent).to.contain(
         `${projectsData[2].name}Something went wrong. Please try again.`
       )
-      expect(alerts[1].textContent).to.equal(
+      expect(alerts[1].textContent).to.contain(
         `${projectsData[3].name}Something went wrong. Please try again.`
       )
     })