mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
7a87bf4288
commit
4636f40f03
12 changed files with 396 additions and 6 deletions
|
@ -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'
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
|
@ -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(() => {
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
],
|
||||
}
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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(() => {
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in a new issue