[web] add events for out of sync modals (#36132)

GitOrigin-RevId: f9673defab0d06d8e814bf2d43cbb7e19eb249a7
This commit is contained in:
Kristina
2026-09-17 11:47:19 +00:00
committed by Copybot
parent 591b9f5d39
commit 20a4144cf9
4 changed files with 211 additions and 3 deletions
@@ -1,7 +1,8 @@
import { Trans, useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { useLocation } from '@/shared/hooks/use-location'
import OLButton from '@/shared/components/ol/ol-button'
import { sendMB } from '@/infrastructure/event-tracking'
import {
OLModal,
OLModalBody,
@@ -22,6 +23,12 @@ function OutOfSyncModal({ editorContent, show, onHide }: OutOfSyncModalProps) {
const [editorContentShown, setEditorContentShown] = useState(false)
const editorContentRows = (editorContent.match(/\n/g)?.length || 0) + 1
useEffect(() => {
if (show) {
sendMB('out-of-sync-modal-shown')
}
}, [show])
// Reload the page to avoid staying in an inconsistent state.
// https://github.com/overleaf/issues/issues/3694
function done() {
@@ -1,5 +1,5 @@
import { Trans, useTranslation } from 'react-i18next'
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal,
@@ -15,6 +15,7 @@ import { debugConsole } from '@/utils/debugging'
import Notification from '@/shared/components/notification'
import { downloadFileContent } from '@/utils/download-file'
import { useLocation } from '@/shared/hooks/use-location'
import { sendMB } from '@/infrastructure/event-tracking'
export type UnableToSyncModalProps = {
baseContent: string
@@ -62,6 +63,12 @@ function UnableToSyncModal({
const [saveError, setSaveError] = useState(false)
const location = useLocation()
useEffect(() => {
if (show) {
sendMB('unable-to-sync-modal-shown')
}
}, [show])
const onHide = useCallback(() => {
onHideProp()
if (reloadAfterClose) {
@@ -69,11 +76,18 @@ function UnableToSyncModal({
}
}, [onHideProp, reloadAfterClose, location])
const handleDiscardChanges = useCallback(() => {
sendMB('unable-to-sync-modal-click', { action: 'discard-changes' })
onHide()
}, [onHide])
const handleDownload = useCallback(() => {
sendMB('unable-to-sync-modal-click', { action: 'download' })
downloadFileContent(targetContent, docName ?? 'document.txt')
}, [targetContent, docName])
const handleSaveAsNewFile = useCallback(async () => {
sendMB('unable-to-sync-modal-click', { action: 'save-new-file' })
const projectId = getMeta('ol-project_id')
const filename = buildOfflineFilename(docName)
@@ -98,6 +112,7 @@ function UnableToSyncModal({
onHide()
} catch (error) {
debugConsole.error('Failed to save offline changes as new file', error)
sendMB('unable-to-sync-modal-error-shown')
setSaveError(true)
} finally {
setSaving(false)
@@ -142,7 +157,7 @@ function UnableToSyncModal({
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="danger-ghost" onClick={onHide}>
<OLButton variant="danger-ghost" onClick={handleDiscardChanges}>
{t('discard_changes')}
</OLButton>
<OLButton
@@ -0,0 +1,56 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, render, screen } from '@testing-library/react'
import OutOfSyncModal from '@/features/ide-react/components/modals/out-of-sync-modal'
import * as eventTracking from '@/infrastructure/event-tracking'
import { location } from '@/shared/components/location'
describe('<OutOfSyncModal />', function () {
let sendMBSpy: sinon.SinonSpy
let reloadStub: sinon.SinonStub
let onHide: sinon.SinonStub
const renderModal = (props: { show?: boolean } = {}) =>
render(
<OutOfSyncModal
editorContent="offline content"
show
onHide={onHide}
{...props}
/>
)
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
reloadStub = sinon.stub(location, 'reload')
onHide = sinon.stub()
})
afterEach(function () {
sendMBSpy.restore()
reloadStub.restore()
})
it('emits out-of-sync-modal-shown when the modal is shown', function () {
renderModal()
expect(sendMBSpy).to.have.been.calledOnceWith('out-of-sync-modal-shown')
})
it('does not emit out-of-sync-modal-shown while hidden', function () {
renderModal({ show: false })
expect(sendMBSpy).not.to.have.been.called
})
it('does not emit a further event when reloading the editor', function () {
renderModal()
sendMBSpy.resetHistory()
fireEvent.click(screen.getByRole('button', { name: 'Reload editor' }))
expect(onHide).to.have.been.calledOnce
expect(reloadStub).to.have.been.calledOnce
expect(sendMBSpy).not.to.have.been.called
})
})
@@ -0,0 +1,130 @@
import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import UnableToSyncModal from '@/features/ide-react/components/modals/unable-to-sync-modal'
import * as eventTracking from '@/infrastructure/event-tracking'
const PROJECT_ID = 'project-123'
const ROOT_FOLDER_ID = 'root-folder-id'
const UPLOAD_URL = `express:/project/${PROJECT_ID}/upload`
describe('<UnableToSyncModal />', function () {
let sendMBSpy: sinon.SinonSpy
let onHide: sinon.SinonStub
const renderModal = (props: { show?: boolean } = {}) =>
render(
<UnableToSyncModal
baseContent="server content"
targetContent="offline content"
docName="main.tex"
rootFolderId={ROOT_FOLDER_ID}
show
onHide={onHide}
{...props}
/>
)
const clickSaveAsNewFile = () =>
fireEvent.click(screen.getByRole('button', { name: 'Save as new file' }))
beforeEach(function () {
window.metaAttributesCache.set('ol-project_id', PROJECT_ID)
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
onHide = sinon.stub()
})
afterEach(function () {
sendMBSpy.restore()
fetchMock.removeRoutes().clearHistory()
})
it('emits unable-to-sync-modal-shown when the modal is shown', function () {
renderModal()
expect(sendMBSpy).to.have.been.calledOnceWith('unable-to-sync-modal-shown')
})
it('does not emit unable-to-sync-modal-shown while hidden', function () {
renderModal({ show: false })
expect(sendMBSpy).not.to.have.been.called
})
it('emits a discard-changes click and hides the modal', function () {
renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }))
expect(sendMBSpy).to.have.been.calledWithMatch(
'unable-to-sync-modal-click',
{ action: 'discard-changes' }
)
expect(onHide).to.have.been.calledOnce
})
it('emits a save-new-file click and hides the modal when the upload succeeds', async function () {
fetchMock.post(UPLOAD_URL, { status: 200, body: {} })
renderModal()
clickSaveAsNewFile()
await waitFor(() => expect(onHide).to.have.been.calledOnce)
expect(sendMBSpy).to.have.been.calledWithMatch(
'unable-to-sync-modal-click',
{ action: 'save-new-file' }
)
})
it('emits unable-to-sync-modal-error-shown when the upload fails', async function () {
fetchMock.post(UPLOAD_URL, 500)
renderModal()
clickSaveAsNewFile()
await screen.findByRole('link', { name: 'download the file' })
expect(sendMBSpy).to.have.been.calledWith(
'unable-to-sync-modal-error-shown'
)
expect(onHide).not.to.have.been.called
})
describe('downloading', function () {
let anchorClickStub: sinon.SinonStub
beforeEach(function () {
// jsdom implements neither object URLs nor anchor-triggered downloads.
window.URL.createObjectURL = sinon.stub().returns('blob:fake')
window.URL.revokeObjectURL = sinon.stub()
anchorClickStub = sinon.stub(HTMLAnchorElement.prototype, 'click')
})
afterEach(function () {
anchorClickStub.restore()
delete (window.URL as Partial<typeof window.URL>).createObjectURL
delete (window.URL as Partial<typeof window.URL>).revokeObjectURL
})
it('emits a download click from the upload error notification', async function () {
fetchMock.post(UPLOAD_URL, 500)
renderModal()
clickSaveAsNewFile()
const downloadLink = await screen.findByRole('link', {
name: 'download the file',
})
fireEvent.click(downloadLink)
expect(sendMBSpy).to.have.been.calledWithMatch(
'unable-to-sync-modal-click',
{ action: 'download' }
)
expect(anchorClickStub).to.have.been.calledOnce
// Let the deferred revokeObjectURL run before the shims are removed.
await new Promise(resolve => setTimeout(resolve, 0))
})
})
})