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)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",
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')