From 4636f40f031fe45b79d87de9cce2db0defe49b0e Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 28 Nov 2023 10:20:43 +0100 Subject: [PATCH] Merge pull request #12448 from overleaf/jpa-compile-and-download-pdf [web] add button to project dashboard for compiling and downloading PDF GitOrigin-RevId: c243b4a30e4720116d82d9c25bdc8be8825d6d74 --- .../Features/Project/ProjectListController.js | 13 ++ .../web/frontend/extracted-translations.json | 2 + .../components/dropdown/actions-dropdown.tsx | 31 ++++ ...ompile-and-download-project-pdf-button.tsx | 163 ++++++++++++++++++ .../download-project-button.tsx | 2 +- .../components/table/cells/actions-cell.tsx | 5 + ...mpile-and-download-project-pdf.stories.tsx | 71 ++++++++ .../stylesheets/app/project-list-react.less | 10 ++ services/web/locales/en.json | 3 +- ...e-and-download-project-pdf-button.test.tsx | 89 ++++++++++ .../download-project-button.test.tsx | 6 +- .../table/project-list-table.test.tsx | 7 +- 12 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx create mode 100644 services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx create mode 100644 services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index 163b370f25..e5bb2a6c82 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/services/web/app/src/Features/Project/ProjectListController.js @@ -335,6 +335,19 @@ async function projectListPage(req, res, next) { 'failed to get "welcome-page-redesign" split test assignment' ) } + try { + // The assignment will be picked up via 'ol-splitTestVariants' in react. + await SplitTestHandler.promises.getAssignment( + req, + res, + 'download-pdf-dashboard' + ) + } catch (err) { + logger.error( + { err }, + 'failed to get "download-pdf-dashboard" split test assignment' + ) + } const hasPaidAffiliation = userAffiliations.some( affiliation => affiliation.licence && affiliation.licence !== 'free' diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 81d5c3abfe..a9e39d0adf 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -284,6 +284,7 @@ "done": "", "download": "", "download_pdf": "", + "download_zip_file": "", "drag_here": "", "drag_here_paste_an_image_or": "", "dropbox_checking_sync_status": "", @@ -834,6 +835,7 @@ "pdf_only_hide_editor": "", "pdf_preview_error": "", "pdf_rendering_error": "", + "pdf_unavailable_for_download": "", "pdf_viewer": "", "pdf_viewer_error": "", "pending_additional_licenses": "", diff --git a/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx index 3047365398..9b2bc0462f 100644 --- a/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx @@ -11,6 +11,8 @@ import UntrashProjectButton from '../table/cells/action-buttons/untrash-project- import LeaveProjectButton from '../table/cells/action-buttons/leave-project-button' import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button' import { Project } from '../../../../../../types/project/dashboard/api' +import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' type ActionButtonProps = { project: Project @@ -30,6 +32,29 @@ function CopyProjectButtonMenuItem({ project, onClick }: ActionButtonProps) { ) } +function CompileAndDownloadProjectPDFButtonMenuItem({ + project, + onClick, +}: ActionButtonProps) { + return ( + + {(text, pendingCompile, downloadProject) => ( + downloadProject(onClick)} + className="projects-action-menu-item" + > + {pendingCompile ? ( + + ) : ( + + )}{' '} + {text} + + )} + + ) +} + function DownloadProjectButtonMenuItem({ project, onClick, @@ -194,6 +219,12 @@ function ActionsDropdown({ project }: ActionDropdownProps) { project={project} onClick={handleClose} /> + {isSplitTestEnabled('download-pdf-dashboard') && ( + + )} void) => void + ) => React.ReactElement +} + +function CompileAndDownloadProjectPDFButton({ + project, + children, +}: CompileAndDownloadProjectPDFButtonProps) { + const { t } = useTranslation() + const location = useLocation() + + const { signal } = useAbortController() + const [pendingCompile, setPendingCompile] = useState(false) + + const downloadProject = useCallback( + onDone => { + setPendingCompile(pendingCompile => { + if (pendingCompile) return true + eventTracking.sendMB('project-list-page-interaction', { + action: 'downloadPDF', + projectId: project.id, + isSmallDevice, + }) + + postJSON(`/project/${project.id}/compile`, { + body: { + check: 'silent', + draft: false, + incrementalCompilesEnabled: true, + }, + signal, + }) + .catch(() => ({ status: 'error' })) + .then(data => { + setPendingCompile(false) + if (data.status === 'success') { + const outputFile = data.outputFiles + .filter((file: { path: string }) => file.path === 'output.pdf') + .pop() + + const params = new URLSearchParams({ + compileGroup: data.compileGroup, + popupDownload: 'true', + }) + if (data.clsiServerId) { + params.set('clsiserverid', data.clsiServerId) + } + // Note: Triggering concurrent downloads does not work. + // Note: This is affecting the download of .zip files as well. + // When creating a dynamic `a` element with `download` attribute, + // another "actual" UI click is needed to trigger downloads. + // Forwarding the click `event` to the dynamic `a` element does + // not work either. + location.assign( + `/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}` + ) + onDone() + } else { + setShowErrorModal(true) + } + }) + return true + }) + }, + [project, signal, location] + ) + + const [showErrorModal, setShowErrorModal] = useState(false) + + return ( + <> + {children( + pendingCompile ? t('compiling') + '…' : t('download_pdf'), + pendingCompile, + downloadProject + )} + {showErrorModal && ( + { + setShowErrorModal(false) + }} + /> + )} + + ) +} + +function CompileErrorModal({ + project, + handleClose, +}: { project: Project } & { handleClose: () => void }) { + const { t } = useTranslation() + return ( + <> + + + + {project.name}: {t('pdf_unavailable_for_download')} + + + {t('generic_linked_file_compile_error')} + + + + + + + + ) +} + +const CompileAndDownloadProjectPDFButtonTooltip = memo( + function CompileAndDownloadProjectPDFButtonTooltip({ + project, + }: Pick) { + return ( + + {(text, pendingCompile, compileAndDownloadProject) => ( + + + + )} + + ) + } +) + +export default memo(CompileAndDownloadProjectPDFButton) +export { CompileAndDownloadProjectPDFButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx index 463299176e..9478d7eb97 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx @@ -17,7 +17,7 @@ function DownloadProjectButton({ children, }: DownloadProjectButtonProps) { const { t } = useTranslation() - const text = t('download') + const text = t('download_zip_file') const location = useLocation() const downloadProject = useCallback(() => { diff --git a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx index e6c2d8bdb4..a93d990c56 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx @@ -7,6 +7,8 @@ import { UntrashProjectButtonTooltip } from './action-buttons/untrash-project-bu import { DownloadProjectButtonTooltip } from './action-buttons/download-project-button' import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button' import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button' +import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' type ActionsCellProps = { project: Project @@ -17,6 +19,9 @@ export default function ActionsCell({ project }: ActionsCellProps) { <> + {isSplitTestEnabled('download-pdf-dashboard') && ( + + )} diff --git a/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx b/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx new file mode 100644 index 0000000000..75513df90f --- /dev/null +++ b/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx @@ -0,0 +1,71 @@ +import ProjectListTable from '../../js/features/project-list/components/table/project-list-table' +import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' +import useFetchMock from '../hooks/use-fetch-mock' +import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data' + +export const Successful = (args: any) => { + window.user_id = '624333f147cfd8002622a1d3' + window.metaAttributesCache.set('ol-splitTestVariants', { + 'download-pdf-dashboard': 'enabled', + }) + useFetchMock(fetchMock => { + fetchMock.post(/\/api\/project/, { + projects: projectsData, + totalSize: projectsData.length, + }) + fetchMock.post( + /\/compile/, + { + status: 'success', + compileGroup: 'standard', + clsiServerId: 'server-1', + outputFiles: [{ path: 'output.pdf', build: '123-321' }], + }, + { + delay: 1_000, + } + ) + }) + + return ( + + + + ) +} + +export const Failure = (args: any) => { + window.user_id = '624333f147cfd8002622a1d3' + window.metaAttributesCache.set('ol-splitTestVariants', { + 'download-pdf-dashboard': 'enabled', + }) + useFetchMock(fetchMock => { + fetchMock.post(/\/api\/project/, { + projects: projectsData, + totalSize: projectsData.length, + }) + fetchMock.post( + /\/compile/, + { status: 'failure', outputFiles: [] }, + { delay: 1_000 } + ) + }) + + return ( + + + + ) +} + +export default { + title: 'Project List / PDF download', + component: ProjectListTable, + decorators: [ + (Story: any) => ( +
+ +
+ ), + ], +} diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less index bc56951354..a7493173b1 100644 --- a/services/web/frontend/stylesheets/app/project-list-react.less +++ b/services/web/frontend/stylesheets/app/project-list-react.less @@ -702,6 +702,12 @@ } .projects-dropdown-menu { + &.dropdown-menu { + // Avoid line breaks for labels in menu items. + // There is enough space for these on mobile devices (checked DE and EN translations). + white-space: nowrap; + } + .dropdown-header { padding: 14px 20px; font-size: 13px; @@ -806,6 +812,10 @@ position: absolute; top: 50%; transform: translateY(-50%); + + &.fa-spinner { + top: 30%; + } } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 9810081284..28f1310020 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -423,7 +423,7 @@ "dont_have_account": "Don’t have an account?", "download": "Download", "download_pdf": "Download PDF", - "download_zip_file": "Download .zip File", + "download_zip_file": "Download .zip file", "drag_here": "drag here", "drag_here_paste_an_image_or": "Drag here, paste an image, or ", "drop_files_here_to_upload": "Drop files here to upload", @@ -1275,6 +1275,7 @@ "pdf_only_hide_editor": "PDF only <0>(hide editor)", "pdf_preview_error": "There was a problem displaying the compilation results for this project.", "pdf_rendering_error": "PDF Rendering Error", + "pdf_unavailable_for_download": "PDF unavailable for download", "pdf_viewer": "PDF Viewer", "pdf_viewer_error": "There was a problem displaying the PDF for this project.", "pending": "Pending", diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx new file mode 100644 index 0000000000..e736b01ab4 --- /dev/null +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx @@ -0,0 +1,89 @@ +import { expect } from 'chai' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import sinon from 'sinon' +import { projectsData } from '../../../../fixtures/projects-data' +import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location' +import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button' +import fetchMock from 'fetch-mock' +import * as eventTracking from '../../../../../../../../frontend/js/infrastructure/event-tracking' + +describe('', function () { + let assignStub: sinon.SinonStub + let locationStub: sinon.SinonStub + let sendMBSpy: sinon.SinonSpy + + beforeEach(function () { + sendMBSpy = sinon.spy(eventTracking, 'sendMB') + assignStub = sinon.stub() + locationStub = sinon.stub(useLocationModule, 'useLocation').returns({ + assign: assignStub, + reload: sinon.stub(), + }) + render( + + ) + }) + + afterEach(function () { + locationStub.restore() + fetchMock.reset() + sendMBSpy.restore() + }) + + it('renders tooltip for button', function () { + const btn = screen.getByLabelText('Download PDF') + fireEvent.mouseOver(btn) + screen.getByRole('tooltip', { name: 'Download PDF' }) + }) + + it('downloads the project PDF when clicked', async function () { + fetchMock.post( + `/project/${projectsData[0].id}/compile`, + { + status: 'success', + compileGroup: 'standard', + clsiServerId: 'server-1', + outputFiles: [{ path: 'output.pdf', build: '123-321' }], + }, + { delay: 10 } + ) + + const btn = screen.getByLabelText('Download PDF') as HTMLButtonElement + fireEvent.click(btn) + + await waitFor(() => { + screen.getByLabelText('Compiling…') + }) + + await waitFor(() => { + expect(assignStub).to.have.been.called + }) + + expect(assignStub).to.have.been.calledOnce + expect(assignStub).to.have.been.calledWith( + `/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&clsiserverid=server-1` + ) + + expect(sendMBSpy).to.have.been.calledOnce + expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', { + action: 'downloadPDF', + page: '/', + projectId: projectsData[0].id, + isSmallDevice: true, + }) + }) + + it('displays a modal when the compile failed', async function () { + fetchMock.post(`/project/${projectsData[0].id}/compile`, { + status: 'failure', + }) + + const btn = screen.getByLabelText('Download PDF') as HTMLButtonElement + fireEvent.click(btn) + + await waitFor(() => { + screen.getByText(`${projectsData[0].name}: PDF unavailable for download`) + }) + expect(assignStub).to.have.not.been.called + }) +}) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx index 03a89e7e60..c75f05f622 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx @@ -22,13 +22,13 @@ describe('', function () { }) it('renders tooltip for button', function () { - const btn = screen.getByLabelText('Download') + const btn = screen.getByLabelText('Download .zip file') fireEvent.mouseOver(btn) - screen.getByRole('tooltip', { name: 'Download' }) + screen.getByRole('tooltip', { name: 'Download .zip file' }) }) it('downloads the project when clicked', async function () { - const btn = screen.getByLabelText('Download') as HTMLButtonElement + const btn = screen.getByLabelText('Download .zip file') as HTMLButtonElement fireEvent.click(btn) await waitFor(() => { diff --git a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx index 1eb0ce5e23..fd38057744 100644 --- a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx @@ -11,6 +11,9 @@ describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() window.metaAttributesCache.set('ol-tags', []) + window.metaAttributesCache.set('ol-splitTestVariants', { + 'download-pdf-dashboard': 'enabled', + }) window.user_id = userId fetchMock.reset() }) @@ -112,8 +115,10 @@ describe('', function () { // temporary count tests until we add filtering for archived/trashed const copyButtons = screen.getAllByLabelText('Copy') expect(copyButtons.length).to.equal(currentProjects.length) - const downloadButtons = screen.getAllByLabelText('Download') + const downloadButtons = screen.getAllByLabelText('Download .zip file') expect(downloadButtons.length).to.equal(currentProjects.length) + const downloadPDFButtons = screen.getAllByLabelText('Download PDF') + expect(downloadPDFButtons.length).to.equal(currentProjects.length) const archiveButtons = screen.getAllByLabelText('Archive') expect(archiveButtons.length).to.equal(currentProjects.length) const trashButtons = screen.getAllByLabelText('Trash')