Merge pull request #28226 from overleaf/ls-remove-leaver-survey-alert

Remove leaver survey alert

GitOrigin-RevId: 6dbeabaff8c73d2ce9e3e382da83ce8f2177668d
This commit is contained in:
Liangjun Song
2025-09-08 14:27:07 +01:00
committed by Copybot
parent 48e726ec0d
commit 4ca1883351
11 changed files with 2 additions and 366 deletions

View File

@@ -858,7 +858,6 @@
"institution_and_role": "",
"institution_has_overleaf_subscription": "",
"institution_templates": "",
"institutional_leavers_survey_notification": "",
"integrations": "",
"integrations_like_github": "",
"interested_in_cheaper_personal_plan": "",
@@ -948,7 +947,6 @@
"library": "",
"licenses": "",
"limited_document_history": "",
"limited_offer": "",
"limited_to_n_collaborators_per_project": "",
"limited_to_n_collaborators_per_project_plural": "",
"line": "",
@@ -1764,7 +1762,6 @@
"tag_name_cannot_exceed_characters": "",
"tag_name_is_already_used": "",
"tags": "",
"take_short_survey": "",
"take_survey": "",
"tell_the_project_owner_and_ask_them_to_upgrade": "",
"template": "",

View File

@@ -10,7 +10,6 @@ import EmailsRow from './emails/row'
import AddEmail from './emails/add-email'
import OLNotification from '@/shared/components/ol/ol-notification'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import { LeaversSurveyAlert } from './leavers-survey-alert'
function EmailsSectionContent() {
const { t } = useTranslation()
@@ -76,7 +75,6 @@ function EmailsSectionContent() {
))}
</>
)}
{isInitializingSuccess && <LeaversSurveyAlert />}
{isInitializingSuccess && !hideAddSecondaryEmail && <AddEmail />}
{isInitializingError && (
<OLNotification

View File

@@ -53,8 +53,7 @@ function MakePrimary({
}: MakePrimaryProps) {
const [show, setShow] = useState(false)
const { t } = useTranslation()
const { state, makePrimary, deleteEmail, resetLeaversSurveyExpiration } =
useUserEmailsContext()
const { state, makePrimary, deleteEmail } = useUserEmailsContext()
const handleShowModal = () => setShow(true)
const handleHideModal = () => setShow(false)
@@ -76,7 +75,6 @@ function MakePrimary({
makePrimary(userEmailData.email)
if (primary && !primary.confirmedAt) {
deleteEmail(primary.email)
resetLeaversSurveyExpiration(primary)
}
})
.catch(() => {})

View File

@@ -37,8 +37,7 @@ type RemoveProps = {
function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) {
const { t } = useTranslation()
const { state, deleteEmail, resetLeaversSurveyExpiration, setLoading } =
useUserEmailsContext()
const { state, deleteEmail, setLoading } = useUserEmailsContext()
const isManaged = getMeta('ol-isManagedAccount')
const getTooltipText = () => {
@@ -61,7 +60,6 @@ function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) {
)
.then(() => {
deleteEmail(userEmailData.email)
resetLeaversSurveyExpiration(userEmailData)
// Reset the global loading state before this row is unmounted
setLoading(false)
})

View File

@@ -1,71 +0,0 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import { useUserEmailsContext } from '../context/user-email-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import OLNotification from '@/shared/components/ol/ol-notification'
function sendMetrics(segmentation: 'view' | 'click' | 'close') {
sendMB('institutional-leavers-survey-notification', { type: segmentation })
}
export function LeaversSurveyAlert() {
const { t } = useTranslation()
const {
showInstitutionalLeaversSurveyUntil,
setShowInstitutionalLeaversSurveyUntil,
} = useUserEmailsContext()
const [hide, setHide] = usePersistedState(
'hideInstitutionalLeaversSurvey',
false,
{ listen: true }
)
function handleDismiss() {
setShowInstitutionalLeaversSurveyUntil(0)
setHide(true)
sendMetrics('close')
}
function handleLinkClick() {
sendMetrics('click')
}
const shouldDisplay =
!hide && Date.now() <= showInstitutionalLeaversSurveyUntil
useEffect(() => {
if (shouldDisplay) {
sendMetrics('view')
}
}, [shouldDisplay])
if (!shouldDisplay) {
return null
}
return (
<OLNotification
type="info"
content={
<>
<strong>{t('limited_offer')}</strong>
{`: ${t('institutional_leavers_survey_notification')} `}
<a
href="https://docs.google.com/forms/d/e/1FAIpQLSfYdeeoY5p1d31r5iUx1jw0O-Gd66vcsBi_Ntu3lJRMjV2EJA/viewform"
target="_blank"
rel="noopener noreferrer"
onClick={handleLinkClick}
>
{t('take_short_survey')}
</a>
</>
}
isDismissible
onDismiss={handleDismiss}
className="mb-0"
/>
)
}

View File

@@ -13,9 +13,6 @@ import { Affiliation } from '../../../../../types/affiliation'
import { normalize, NormalizedObject } from '../../../utils/normalize'
import { getJSON } from '../../../infrastructure/fetch-json'
import useAsync from '../../../shared/hooks/use-async'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000
// eslint-disable-next-line no-unused-vars
export enum Actions {
@@ -221,12 +218,6 @@ const reducer = (state: State, action: Action) => {
}
function useUserEmails() {
const [
showInstitutionalLeaversSurveyUntil,
setShowInstitutionalLeaversSurveyUntil,
] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, {
listen: true,
})
const [state, unsafeDispatch] = useReducer(reducer, initialState)
const dispatch = useSafeDispatch(unsafeDispatch)
const { data, isLoading, isError, isSuccess, runAsync } =
@@ -247,34 +238,12 @@ function useUserEmails() {
getEmails()
}, [getEmails])
const resetLeaversSurveyExpiration = useCallback(
(deletedEmail: UserEmailData) => {
if (
deletedEmail.emailHasInstitutionLicence ||
deletedEmail.affiliation?.pastReconfirmDate
) {
const stillHasLicenseAccess = Object.values(state.data.byId).some(
userEmail =>
userEmail.email !== deletedEmail.email &&
userEmail.emailHasInstitutionLicence
)
if (!stillHasLicenseAccess) {
setShowInstitutionalLeaversSurveyUntil(Date.now() + ONE_WEEK_IN_MS)
}
}
},
[state, setShowInstitutionalLeaversSurveyUntil]
)
return {
state,
isInitializing: isLoading && !data,
isInitializingSuccess: isSuccess,
isInitializingError: isError,
getEmails,
showInstitutionalLeaversSurveyUntil,
setShowInstitutionalLeaversSurveyUntil,
resetLeaversSurveyExpiration,
setLoading: useCallback(
(flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
[dispatch]

View File

@@ -200,10 +200,6 @@ export function setDefaultMeta() {
hasSamlFeature: true,
samlInitPath: 'saml/init',
})
localStorage.setItem(
'showInstitutionalLeaversSurveyUntil',
(Date.now() - 1000 * 60 * 60).toString()
)
}
export function setReconfirmationMeta() {

View File

@@ -1,21 +0,0 @@
import EmailsSection from '../../js/features/settings/components/emails-section'
import { UserEmailsProvider } from '../../js/features/settings/context/user-email-context'
import { LeaversSurveyAlert } from '../../js/features/settings/components/leavers-survey-alert'
import localStorage from '@/infrastructure/local-storage'
export const SurveyAlert = () => {
localStorage.setItem(
'showInstitutionalLeaversSurveyUntil',
Date.now() + 1000 * 60 * 60
)
return (
<UserEmailsProvider>
<LeaversSurveyAlert />
</UserEmailsProvider>
)
}
export default {
title: 'Account Settings / Survey Alerts',
component: EmailsSection,
}

View File

@@ -1097,7 +1097,6 @@
"institution_has_overleaf_subscription": "<0>__institutionName__</0> has an Overleaf subscription. Click the confirmation link sent to __emailAddress__ to upgrade to <0>Overleaf Professional</0>.",
"institution_templates": "Institution Templates",
"institutional": "Institutional",
"institutional_leavers_survey_notification": "Provide some quick feedback to receive a 25% discount on an annual subscription!",
"institutional_login_unknown": "Sorry, we dont know which institution issued that email address. You can browse our <a href=\"__link__\">list of institutions</a> to find yours, or you can use one of the other options below.",
"integrations": "Integrations",
"integrations_like_github": "Integrations like GitHub Sync",
@@ -1232,7 +1231,6 @@
"license": "License",
"licenses": "Licenses",
"limited_document_history": "Limited document history",
"limited_offer": "Limited offer",
"limited_to_n_collaborators_per_project": "Limited to __count__ collaborator per project",
"limited_to_n_collaborators_per_project_plural": "Limited to __count__ collaborators per project",
"line_height": "Line Height",
@@ -2267,7 +2265,6 @@
"tag_name_is_already_used": "Tag \"__tagName__\" already exists",
"tags": "Tags",
"take_me_home": "Take me home!",
"take_short_survey": "Take a short survey",
"take_survey": "Take survey",
"tell_the_project_owner_and_ask_them_to_upgrade": "<0>Tell the project owner</0> and ask them to upgrade their Overleaf plan if you need more compile time.",
"template": "Template",

View File

@@ -1,109 +0,0 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, screen, render } from '@testing-library/react'
import { UserEmailsProvider } from '../../../../../frontend/js/features/settings/context/user-email-context'
import { LeaversSurveyAlert } from '../../../../../frontend/js/features/settings/components/leavers-survey-alert'
import * as eventTracking from '@/infrastructure/event-tracking'
import localStorage from '@/infrastructure/local-storage'
import fetchMock from 'fetch-mock'
function renderWithProvider() {
render(<LeaversSurveyAlert />, {
wrapper: ({ children }) => (
<UserEmailsProvider>{children}</UserEmailsProvider>
),
})
}
describe('<LeaversSurveyAlert/>', function () {
beforeEach(function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('should render before the expiration date', function () {
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
screen.getByRole('alert')
screen.getByText(/Provide some quick feedback/)
screen.getByRole('link', { name: 'Take a short survey' })
})
it('should not render after the expiration date', function () {
const yesterday = Date.now() - 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', yesterday)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
expect(screen.queryByRole('alert')).to.be.null
})
it('should not render if it has been hidden', function () {
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', true)
renderWithProvider()
expect(screen.queryByRole('alert')).to.be.null
})
it('should reset the expiration date when it is closed', function () {
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
screen.getByRole('alert')
fireEvent.click(screen.getByRole('button'))
expect(screen.queryByRole('alert')).to.be.null
expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to.be
.null
})
describe('event tracking', function () {
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
})
afterEach(function () {
sendMBSpy.restore()
localStorage.clear()
})
it('should sent a `view` event on load', function () {
expect(sendMBSpy).to.be.calledOnce
expect(sendMBSpy).calledWith(
'institutional-leavers-survey-notification',
{ type: 'view', page: '/' }
)
})
it('should sent a `click` event when the link is clicked', function () {
fireEvent.click(screen.getByRole('link'))
expect(sendMBSpy).to.be.calledTwice
expect(sendMBSpy).calledWith(
'institutional-leavers-survey-notification',
{ type: 'click', page: '/' }
)
})
it('should sent a `close` event when it is closed', function () {
fireEvent.click(screen.getByRole('button'))
expect(sendMBSpy).to.be.calledTwice
expect(sendMBSpy).calledWith(
'institutional-leavers-survey-notification',
{ type: 'close', page: '/' }
)
})
})
})

View File

@@ -15,7 +15,6 @@ import {
unconfirmedCommonsUserData,
untrustedUserData,
} from '../fixtures/test-user-email-data'
import localStorage from '@/infrastructure/local-storage'
const renderUserEmailsContext = () =>
renderHook(() => useUserEmailsContext(), {
@@ -276,120 +275,5 @@ describe('UserEmailContext', function () {
expect(result.current.state.data.byId).to.deep.equal(emails)
})
})
describe('resetLeaversSurveyExpiration()', function () {
beforeEach(function () {
localStorage.removeItem('showInstitutionalLeaversSurveyUntil')
})
it('when the leaver has institution license, and there is another email with institution license, it should not reset the survey expiration date', async function () {
const affiliatedEmail1 = cloneDeep(professionalUserData)
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.emailHasInstitutionLicence = true
const affiliatedEmail2 = cloneDeep(professionalUserData)
affiliatedEmail2.emailHasInstitutionLicence = true
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
const expiration = localStorage.getItem(
'showInstitutionalLeaversSurveyUntil'
) as number
expect(expiration).to.be.null
})
it("when the leaver's affiliation is past reconfirmation date, and there is another email with institution license, it should not reset the survey expiration date", async function () {
const affiliatedEmail1 = cloneDeep(professionalUserData)
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.affiliation.pastReconfirmDate = true
const affiliatedEmail2 = cloneDeep(professionalUserData)
affiliatedEmail2.emailHasInstitutionLicence = true
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
const expiration = localStorage.getItem(
'showInstitutionalLeaversSurveyUntil'
) as number
expect(expiration).to.be.null
})
it('when there are no other emails with institution license, it should reset the survey expiration date', async function () {
const affiliatedEmail1 = cloneDeep(professionalUserData)
affiliatedEmail1.emailHasInstitutionLicence = true
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.affiliation.pastReconfirmDate = true
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [confirmedUserData, affiliatedEmail1])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
await waitFor(() =>
expect(
localStorage.getItem('showInstitutionalLeaversSurveyUntil')
).to.be.greaterThan(Date.now())
)
})
it("when the leaver has no institution license, it shouldn't reset the survey expiration date", async function () {
const emailWithInstitutionLicense = cloneDeep(professionalUserData)
emailWithInstitutionLicense.email = 'institution-licensed@example.com'
emailWithInstitutionLicense.emailHasInstitutionLicence = false
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(emailWithInstitutionLicense.email)
result.current.resetLeaversSurveyExpiration(professionalUserData)
expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to
.be.null
})
it("when the leaver is not past its reconfirmation date, it shouldn't reset the survey expiration date", async function () {
const emailWithInstitutionLicense = cloneDeep(professionalUserData)
emailWithInstitutionLicense.email = 'institution-licensed@example.com'
emailWithInstitutionLicense.affiliation.pastReconfirmDate = false
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(emailWithInstitutionLicense.email)
result.current.resetLeaversSurveyExpiration(professionalUserData)
expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to
.be.null
})
})
})
})