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
This commit is contained in:
Jakob Ackermann 2023-11-28 10:20:43 +01:00 committed by Copybot
parent 7a87bf4288
commit 4636f40f03
12 changed files with 396 additions and 6 deletions

View file

@ -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'

View file

@ -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": "",

View file

@ -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

View file

@ -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 }

View file

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

View file

@ -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} />

View file

@ -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>
),
],
}

View file

@ -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%;
}
}
}

View file

@ -423,7 +423,7 @@
"dont_have_account": "Dont 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",

View file

@ -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
})
})

View file

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

View file

@ -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')