From 20a4144cf9994ac544e870928fd6113b9d102fc5 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:29:20 +0200 Subject: [PATCH] [web] add events for out of sync modals (#36132) GitOrigin-RevId: f9673defab0d06d8e814bf2d43cbb7e19eb249a7 --- .../components/modals/out-of-sync-modal.tsx | 9 +- .../modals/unable-to-sync-modal.tsx | 19 ++- .../modals/out-of-sync-modal.test.tsx | 56 ++++++++ .../modals/unable-to-sync-modal.test.tsx | 130 ++++++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 services/web/test/frontend/features/ide-react/components/modals/out-of-sync-modal.test.tsx create mode 100644 services/web/test/frontend/features/ide-react/components/modals/unable-to-sync-modal.test.tsx diff --git a/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx b/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx index 5909f5eef2..30c60c4e90 100644 --- a/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx +++ b/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx @@ -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() { diff --git a/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx b/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx index d840e2af5a..c729b1a263 100644 --- a/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx +++ b/services/web/frontend/js/features/ide-react/components/modals/unable-to-sync-modal.tsx @@ -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({ )} - + {t('discard_changes')} ', function () { + let sendMBSpy: sinon.SinonSpy + let reloadStub: sinon.SinonStub + let onHide: sinon.SinonStub + + const renderModal = (props: { show?: boolean } = {}) => + render( + + ) + + 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 + }) +}) diff --git a/services/web/test/frontend/features/ide-react/components/modals/unable-to-sync-modal.test.tsx b/services/web/test/frontend/features/ide-react/components/modals/unable-to-sync-modal.test.tsx new file mode 100644 index 0000000000..a395ccb509 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/components/modals/unable-to-sync-modal.test.tsx @@ -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('', function () { + let sendMBSpy: sinon.SinonSpy + let onHide: sinon.SinonStub + + const renderModal = (props: { show?: boolean } = {}) => + render( + + ) + + 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).createObjectURL + delete (window.URL as Partial).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)) + }) + }) +})