Преглед изворни кода

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
Jakob Ackermann пре 2 година
родитељ
комит
4636f40f03

+ 13 - 0
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'

+ 2 - 0
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": "",

+ 31 - 0
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 (
+    <CompileAndDownloadProjectPDFButton project={project}>
+      {(text, pendingCompile, downloadProject) => (
+        <MenuItemButton
+          onClick={() => downloadProject(onClick)}
+          className="projects-action-menu-item"
+        >
+          {pendingCompile ? (
+            <Icon type="spinner" spin className="menu-item-button-icon" />
+          ) : (
+            <Icon type="file-pdf-o" className="menu-item-button-icon" />
+          )}{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </CompileAndDownloadProjectPDFButton>
+  )
+}
+
 function DownloadProjectButtonMenuItem({
   project,
   onClick,
@@ -194,6 +219,12 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
           project={project}
           onClick={handleClose}
         />
+        {isSplitTestEnabled('download-pdf-dashboard') && (
+          <CompileAndDownloadProjectPDFButtonMenuItem
+            project={project}
+            onClick={handleClose}
+          />
+        )}
         <ArchiveProjectButtonMenuItem project={project} onClick={handleClose} />
         <TrashProjectButtonMenuItem project={project} onClick={handleClose} />
         <UnarchiveProjectButtonMenuItem

+ 163 - 0
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx

@@ -0,0 +1,163 @@
+import { useTranslation } from 'react-i18next'
+import { memo, useCallback, useState } from 'react'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
+import Icon from '../../../../../../shared/components/icon'
+import Tooltip from '../../../../../../shared/components/tooltip'
+import * as eventTracking from '../../../../../../infrastructure/event-tracking'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
+import useAbortController from '../../../../../../shared/hooks/use-abort-controller'
+import { postJSON } from '../../../../../../infrastructure/fetch-json'
+import AccessibleModal from '../../../../../../shared/components/accessible-modal'
+import { Button, Modal } from 'react-bootstrap'
+import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
+
+type CompileAndDownloadProjectPDFButtonProps = {
+  project: Project
+  children: (
+    text: string,
+    pendingDownload: boolean,
+    downloadProject: (fn: () => 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 && (
+        <CompileErrorModal
+          project={project}
+          handleClose={() => {
+            setShowErrorModal(false)
+          }}
+        />
+      )}
+    </>
+  )
+}
+
+function CompileErrorModal({
+  project,
+  handleClose,
+}: { project: Project } & { handleClose: () => void }) {
+  const { t } = useTranslation()
+  return (
+    <>
+      <AccessibleModal show onHide={handleClose}>
+        <Modal.Header closeButton>
+          <Modal.Title>
+            {project.name}: {t('pdf_unavailable_for_download')}
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>{t('generic_linked_file_compile_error')}</Modal.Body>
+        <Modal.Footer>
+          <a href={`/project/${project.id}`}>
+            <Button bsStyle="primary">{t('open_project')}</Button>
+          </a>
+        </Modal.Footer>
+      </AccessibleModal>
+    </>
+  )
+}
+
+const CompileAndDownloadProjectPDFButtonTooltip = memo(
+  function CompileAndDownloadProjectPDFButtonTooltip({
+    project,
+  }: Pick<CompileAndDownloadProjectPDFButtonProps, 'project'>) {
+    return (
+      <CompileAndDownloadProjectPDFButton project={project}>
+        {(text, pendingCompile, compileAndDownloadProject) => (
+          <Tooltip
+            key={`tooltip-compile-and-download-project-${project.id}`}
+            id={`compile-and-download-project-${project.id}`}
+            description={text}
+            overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+          >
+            <button
+              className="btn btn-link action-btn"
+              aria-label={text}
+              onClick={() => compileAndDownloadProject(() => {})}
+            >
+              {pendingCompile ? (
+                <Icon type="spinner" spin />
+              ) : (
+                <Icon type="file-pdf-o" />
+              )}
+            </button>
+          </Tooltip>
+        )}
+      </CompileAndDownloadProjectPDFButton>
+    )
+  }
+)
+
+export default memo(CompileAndDownloadProjectPDFButton)
+export { CompileAndDownloadProjectPDFButtonTooltip }

+ 1 - 1
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(() => {

+ 5 - 0
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) {
     <>
       <CopyProjectButtonTooltip project={project} />
       <DownloadProjectButtonTooltip project={project} />
+      {isSplitTestEnabled('download-pdf-dashboard') && (
+        <CompileAndDownloadProjectPDFButtonTooltip project={project} />
+      )}
       <ArchiveProjectButtonTooltip project={project} />
       <TrashProjectButtonTooltip project={project} />
       <UnarchiveProjectButtonTooltip project={project} />

+ 71 - 0
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 (
+    <ProjectListProvider>
+      <ProjectListTable {...args} />
+    </ProjectListProvider>
+  )
+}
+
+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 (
+    <ProjectListProvider>
+      <ProjectListTable {...args} />
+    </ProjectListProvider>
+  )
+}
+
+export default {
+  title: 'Project List / PDF download',
+  component: ProjectListTable,
+  decorators: [
+    (Story: any) => (
+      <div className="project-list-react">
+        <Story />
+      </div>
+    ),
+  ],
+}

+ 10 - 0
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%;
+    }
   }
 }
 

+ 2 - 1
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)</0>",
   "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",

+ 89 - 0
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('<CompileAndDownloadProjectPDFButton />', 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(
+      <CompileAndDownloadProjectPDFButtonTooltip project={projectsData[0]} />
+    )
+  })
+
+  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
+  })
+})

+ 3 - 3
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx

@@ -22,13 +22,13 @@ describe('<DownloadProjectButton />', 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(() => {

+ 6 - 1
services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx

@@ -11,6 +11,9 @@ describe('<ProjectListTable />', 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('<ProjectListTable />', 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')