From a1dcfa7ec2afc1cb46b0be6dc1ded5bb07b2cbdb Mon Sep 17 00:00:00 2001 From: Frank Niessink Date: Thu, 8 Feb 2024 15:51:59 +0100 Subject: [PATCH] When manually exporting a report to PDF, the report header would not be collapsed before generating the PDF. Prevent the need for collapsing the header by moving the PDF button to the menu bar. Fixes #8054. --- components/frontend/src/AppUI.js | 2 +- .../src/header_footer/CollapseButton.js | 2 +- .../src/header_footer/DownloadAsPDFButton.js | 62 ++++++++++++++++ .../header_footer/DownloadAsPDFButton.test.js | 71 +++++++++++++++++++ .../frontend/src/header_footer/Menubar.js | 9 ++- .../src/header_footer/Menubar.test.js | 15 ++-- .../src/header_footer/ResetSettingsButton.js | 1 + components/frontend/src/report/ReportTitle.js | 3 +- .../src/report/ReportsOverviewTitle.js | 4 -- components/frontend/src/widgets/Button.js | 48 +------------ .../frontend/src/widgets/Button.test.js | 68 +----------------- docs/src/changelog.md | 3 +- docs/src/usage.md | 4 +- 13 files changed, 158 insertions(+), 134 deletions(-) create mode 100644 components/frontend/src/header_footer/DownloadAsPDFButton.js create mode 100644 components/frontend/src/header_footer/DownloadAsPDFButton.test.js diff --git a/components/frontend/src/AppUI.js b/components/frontend/src/AppUI.js index 81fa80d8b6..ac56281166 100644 --- a/components/frontend/src/AppUI.js +++ b/components/frontend/src/AppUI.js @@ -75,12 +75,12 @@ export function AppUI({ expandedItems.reset()} inverted > - + Collapse all } diff --git a/components/frontend/src/header_footer/DownloadAsPDFButton.js b/components/frontend/src/header_footer/DownloadAsPDFButton.js new file mode 100644 index 0000000000..935ee0ef02 --- /dev/null +++ b/components/frontend/src/header_footer/DownloadAsPDFButton.js @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from 'semantic-ui-react'; +import { Button, Popup } from '../semantic_ui_react_wrappers'; +import { get_report_pdf } from '../api/report'; +import { registeredURLSearchParams } from '../hooks/url_search_query'; +import { showMessage } from '../widgets/toast'; + +function download_pdf(report_uuid, query_string, callback) { + const reportId = report_uuid ? `report-${report_uuid}` : "reports-overview" + get_report_pdf(report_uuid, query_string) + .then(response => { + if (response.ok === false) { + showMessage("error", "PDF rendering failed", "HTTP code " + response.status + ": " + response.statusText) + } else { + let url = window.URL.createObjectURL(response); + let a = document.createElement('a'); + a.href = url; + const now = new Date(); + const local_now = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)); + a.download = `Quality-time-${reportId}-${local_now.toISOString().split(".")[0]}.pdf`; + a.click(); + } + }).finally(() => callback()); +} + +export function DownloadAsPDFButton({ report_uuid }) { + const [loading, setLoading] = useState(false); + // Make sure the report_url contains only registered query parameters + const query = registeredURLSearchParams(); + const queryString = query.toString() ? ("?" + query.toString()) : "" + query.set("report_url", window.location.origin + window.location.pathname + queryString + window.location.hash); + const itemType = report_uuid ? "report" : "reports overview" + const label = `Download ${itemType} as PDF` + return ( + { + console.log(loading) + if (!loading) { + setLoading(true); + download_pdf(report_uuid, `?${query.toString()}`, () => { setLoading(false) }) + } + }} + inverted + > + Download as PDF + + } + content={`Generate a PDF version of the ${itemType} as currently displayed. This may take some time.`} + /> + ) +} +DownloadAsPDFButton.propTypes = { + report_uuid: PropTypes.string, +} diff --git a/components/frontend/src/header_footer/DownloadAsPDFButton.test.js b/components/frontend/src/header_footer/DownloadAsPDFButton.test.js new file mode 100644 index 0000000000..5a45768043 --- /dev/null +++ b/components/frontend/src/header_footer/DownloadAsPDFButton.test.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import history from 'history/browser'; +import { DownloadAsPDFButton } from './DownloadAsPDFButton'; +import * as fetch_server_api from '../api/fetch_server_api'; + +test("DownloadAsPDFButton has the correct label for reports overview", () => { + render(); + expect(screen.getAllByLabelText(/reports overview as PDF/).length).toBe(1); + +}); + +test("DownloadAsPDFButton has the correct label for a report", () => { + render(); + expect(screen.getAllByLabelText(/report as PDF/).length).toBe(1); + +}); + +const test_report = { report_uuid: "report_uuid" }; + +test("DownloadAsPDFButton indicates loading on click", async () => { + fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({ then: jest.fn().mockReturnValue({ finally: jest.fn() }) }); + render(); + await act(async () => { + fireEvent.click(screen.getByLabelText(/Download/)); + }); + expect(screen.getByLabelText(/Download/).className).toContain("loading") + expect(fetch_server_api.fetch_server_api).toHaveBeenCalledWith("get", "report/report_uuid/pdf?report_url=http%3A%2F%2Flocalhost%2F", {}, "application/pdf") +}); + +test("DownloadAsPDFButton ignores unregistered query parameters", async () => { + fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({ then: jest.fn().mockReturnValue({ finally: jest.fn() }) }); + history.push("?unregister_key=value&nr_dates=4"); + render(); + await act(async () => { + fireEvent.click(screen.getByLabelText(/Download/)); + }); + expect(fetch_server_api.fetch_server_api).toHaveBeenCalledWith("get", "report/report_uuid/pdf?nr_dates=4&report_url=http%3A%2F%2Flocalhost%2F%3Fnr_dates%3D4", {}, "application/pdf") +}); + +test("DownloadAsPDFButton ignores a second click", async () => { + fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({ then: jest.fn().mockReturnValue({ finally: jest.fn() }) }); + render(); + await act(async () => { + fireEvent.click(screen.getByLabelText(/Download/)); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText(/Download/)); + }); + expect(screen.getByLabelText(/Download/).className).toContain("loading") +}); + +test("DownloadAsPDFButton stops loading after returning pdf", async () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue("pdf"); + HTMLAnchorElement.prototype.click = jest.fn() // Prevent "Not implemented: navigation (except hash changes)" + window.URL.createObjectURL = jest.fn(); + render(); + await act(async () => { + fireEvent.click(screen.getByLabelText(/Download/)); + }); + expect(screen.getByLabelText(/Download/).className).not.toContain("loading") +}); + +test("DownloadAsPDFButton stops loading after receiving error", async () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: false }); + render(); + await act(async () => { + fireEvent.click(screen.getByLabelText(/Download/)); + }); + expect(screen.getByLabelText(/Download/).className).not.toContain("loading") +}); diff --git a/components/frontend/src/header_footer/Menubar.js b/components/frontend/src/header_footer/Menubar.js index ef53b50cc9..10c4b61b34 100644 --- a/components/frontend/src/header_footer/Menubar.js +++ b/components/frontend/src/header_footer/Menubar.js @@ -5,6 +5,7 @@ import { Form, Modal, Popup } from '../semantic_ui_react_wrappers'; import FocusLock from 'react-focus-lock'; import { login, logout } from '../api/auth'; import { Avatar } from '../widgets/Avatar'; +import { DownloadAsPDFButton } from './DownloadAsPDFButton'; import { DatePicker } from '../widgets/DatePicker'; import { optionalDatePropType, settingsPropType, uiModePropType } from '../sharedPropTypes'; import { CollapseButton } from './CollapseButton'; @@ -67,13 +68,13 @@ function Logout({ user, email, set_user }) { } export function Menubar({ - atReportsOverview, email, handleDateChange, onDate, openReportsOverview, panel, report_date, + report_uuid, settings, set_user, setUIMode, @@ -87,6 +88,7 @@ export function Menubar({ return () => { window.removeEventListener('keydown', closePanels) }; }, []); + const atReportsOverview = report_uuid === "" return ( <> @@ -122,6 +124,9 @@ export function Menubar({ + + + { function renderMenubar( { - atReportsOverview = false, - set_user = null, - user = null, openReportsOverview = null, panel = null, + report_uuid = "report_uuid", + set_user = null, + user = null, } = {} ) { const settings = createTestableSettings() render( {/* Dummy handler */ }} openReportsOverview={openReportsOverview} panel={panel} - report_date_string="2019-10-10" + report_uuid={report_uuid} settings={settings} set_user={set_user} user={user} @@ -94,7 +93,7 @@ it('logs out', async () => { it('does not go to home page if on reports overview', async () => { const openReportsOverview = jest.fn(); - renderMenubar({ atReportsOverview: true, openReportsOverview: openReportsOverview }) + renderMenubar({ report_uuid: "", openReportsOverview: openReportsOverview }) act(() => { fireEvent.click(screen.getByAltText(/Go home/)) }); expect(openReportsOverview).not.toHaveBeenCalled(); }); @@ -120,7 +119,7 @@ it('shows the view panel on menu item click', () => { }); it('shows the view panel on space', async () => { - renderMenubar({ atReportsOverview: true, panel:
Hello
}) + renderMenubar({ report_uuid: "", panel:
Hello
}) await userEvent.type(screen.getByText(/Settings/), " ") expect(screen.getAllByText(/Hello/).length).toBe(1) }) diff --git a/components/frontend/src/header_footer/ResetSettingsButton.js b/components/frontend/src/header_footer/ResetSettingsButton.js index 050188944c..939f48c9e1 100644 --- a/components/frontend/src/header_footer/ResetSettingsButton.js +++ b/components/frontend/src/header_footer/ResetSettingsButton.js @@ -27,6 +27,7 @@ export function ResetSettingsButton({ atReportsOverview, handleDateChange, repor + Reset settings } diff --git a/components/frontend/src/report/ReportTitle.js b/components/frontend/src/report/ReportTitle.js index 5f9432b2a1..f71a61af7b 100644 --- a/components/frontend/src/report/ReportTitle.js +++ b/components/frontend/src/report/ReportTitle.js @@ -11,7 +11,7 @@ import { HeaderWithDetails } from '../widgets/HeaderWithDetails'; import { LabelWithHelp } from '../widgets/LabelWithHelp'; import { ChangeLog } from '../changelog/ChangeLog'; import { Share } from '../share/Share'; -import { DeleteButton, DownloadAsPDFButton } from '../widgets/Button'; +import { DeleteButton } from '../widgets/Button'; import { delete_report, set_report_attribute } from '../api/report'; import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from '../context/Permissions'; import { NotificationDestinations } from '../notification/NotificationDestinations'; @@ -247,7 +247,6 @@ ReactionTimes.propTypes = { function ButtonRow({ report_uuid, openReportsOverview }) { return ( <> - -
- -
) } diff --git a/components/frontend/src/widgets/Button.js b/components/frontend/src/widgets/Button.js index bba1121557..84228064f3 100644 --- a/components/frontend/src/widgets/Button.js +++ b/components/frontend/src/widgets/Button.js @@ -1,15 +1,13 @@ import React, { useRef, useState } from 'react'; -import { PropTypes } from 'prop-types'; +import PropTypes from 'prop-types'; import { Icon, Input } from 'semantic-ui-react'; import { Button, Dropdown, Label, Popup } from '../semantic_ui_react_wrappers'; -import { get_report_pdf } from '../api/report'; -import { registeredURLSearchParams } from '../hooks/url_search_query'; import { showMessage } from '../widgets/toast'; import { ItemBreadcrumb } from './ItemBreadcrumb'; export function ActionButton(props) { const { action, disabled, icon, item_type, floated, fluid, popup, position, ...other } = props; - const label = `${action} ${item_type}`; + const label = `${action} ${item_type}` // Put the button in a span so that a disabled button can still have a popup // See https://github.com/Semantic-Org/Semantic-UI-React/issues/2804 const button = ( @@ -148,48 +146,6 @@ export function DeleteButton(props) { ) } -function download_pdf(report_uuid, query_string, callback) { - const reportId = report_uuid ? `report-${report_uuid}` : "reports-overview" - get_report_pdf(report_uuid, query_string) - .then(response => { - if (response.ok === false) { - showMessage("error", "PDF rendering failed", "HTTP code " + response.status + ": " + response.statusText) - } else { - let url = window.URL.createObjectURL(response); - let a = document.createElement('a'); - a.href = url; - const now = new Date(); - const local_now = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)); - a.download = `Quality-time-${reportId}-${local_now.toISOString().split(".")[0]}.pdf`; - a.click(); - } - }).finally(() => callback()); -} - -export function DownloadAsPDFButton({ report_uuid }) { - const [loading, setLoading] = useState(false); - // Make sure the report_url contains only registered query parameters - const query = registeredURLSearchParams(); - const queryString = query.toString() ? ("?" + query.toString()) : "" - query.set("report_url", window.location.origin + window.location.pathname + queryString + window.location.hash); - const itemType = report_uuid ? "report" : "reports overview" - return ( - { - if (!loading) { - setLoading(true); - download_pdf(report_uuid, `?${query.toString()}`, () => { setLoading(false) }) - } - }} - popup={`Generate a PDF version of the ${itemType} as currently displayed. This may take some time.`} - /> - ) -} - function ReorderButton(props) { const label = `Move ${props.moveable} to the ${props.direction} ${props.slot || 'position'}`; const icon = { "first": "double up", "last": "double down", "previous": "up", "next": "down" }[props.direction]; diff --git a/components/frontend/src/widgets/Button.test.js b/components/frontend/src/widgets/Button.test.js index 8b6c601d68..549e358e71 100644 --- a/components/frontend/src/widgets/Button.test.js +++ b/components/frontend/src/widgets/Button.test.js @@ -1,9 +1,7 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import history from 'history/browser'; -import { AddButton, AddDropdownButton, CopyButton, DeleteButton, DownloadAsPDFButton, MoveButton, PermLinkButton, ReorderButtonGroup } from './Button'; -import * as fetch_server_api from '../api/fetch_server_api'; +import { AddButton, AddDropdownButton, CopyButton, DeleteButton, MoveButton, PermLinkButton, ReorderButtonGroup } from './Button'; import * as toast from './toast'; function renderAddDropdownButton(nr_items = 2) { @@ -154,70 +152,6 @@ test('DeleteButton has the correct label', () => { }); }); -test("DownloadAsPDFButton has the correct label for reports overview", () => { - render(); - expect(screen.getAllByText(/reports overview as PDF/).length).toBe(1); - -}); - -test("DownloadAsPDFButton has the correct label for a report", () => { - render(); - expect(screen.getAllByText(/report as PDF/).length).toBe(1); - -}); - -const test_report = { report_uuid: "report_uuid" }; - -test("DownloadAsPDFButton indicates loading on click", async () => { - fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({ then: jest.fn().mockReturnValue({ finally: jest.fn() }) }); - render(); - await act(async () => { - fireEvent.click(screen.getByText(new RegExp(/Download/))); - }); - expect(screen.getByText(/Download/).className).toContain("loading") - expect(fetch_server_api.fetch_server_api).toHaveBeenCalledWith("get", "report/report_uuid/pdf?report_url=http%3A%2F%2Flocalhost%2F", {}, "application/pdf") -}); - -test("DownloadAsPDFButton ignores unregistered query parameters", async () => { - fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({ then: jest.fn().mockReturnValue({ finally: jest.fn() }) }); - history.push("?unregister_key=value&nr_dates=4"); - render(); - await act(async () => { - fireEvent.click(screen.getByText(new RegExp(/Download/))); - }); - expect(fetch_server_api.fetch_server_api).toHaveBeenCalledWith("get", "report/report_uuid/pdf?nr_dates=4&report_url=http%3A%2F%2Flocalhost%2F%3Fnr_dates%3D4", {}, "application/pdf") -}); - -test("DownloadAsPDFButton ignores a second click", async () => { - fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({ then: jest.fn().mockReturnValue({ finally: jest.fn() }) }); - render(); - await act(async () => { - fireEvent.click(screen.getByText(new RegExp(/Download/))); - fireEvent.click(screen.getByText(new RegExp(/Download/))); - }); - expect(screen.getByText(/Download/).className).toContain("loading") -}); - -test("DownloadAsPDFButton stops loading after returning pdf", async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue("pdf"); - HTMLAnchorElement.prototype.click = jest.fn() // Prevent "Not implemented: navigation (except hash changes)" - window.URL.createObjectURL = jest.fn(); - render(); - await act(async () => { - fireEvent.click(screen.getByText(new RegExp(/Download/))); - }); - expect(screen.getByText(/Download/).className).not.toContain("loading") -}); - -test("DownloadAsPDFButton stops loading after receiving error", async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: false }); - render(); - await act(async () => { - fireEvent.click(screen.getByText(new RegExp(/Download/))); - }); - expect(screen.getByText(/Download/).className).not.toContain("loading") -}); - ["first", "last", "previous", "next"].forEach((direction) => { test("ReorderButtonGroup calls the callback on click direction", async () => { const mockCallBack = jest.fn(); diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 7bc1643204..060bd8c9fb 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) -## v5.8.0-rc.1 - 2024-02-06 +## [Unreleased] ### Deployment notes @@ -17,6 +17,7 @@ If your currently installed *Quality-time* version is v4.10.0 or older, please r ### Fixed - When showing many toaster messages, collapse similar messages to prevent a long list of messages. Fixes [#7625](https://github.com/ICTU/quality-time/issues/7625). +- When manually exporting a report to PDF, the report header would not be collapsed before generating the PDF. Prevent the need for collapsing the header by moving the PDF button to the menu bar. Fixes [#8054](https://github.com/ICTU/quality-time/issues/8054). ## v5.7.0 - 2024-01-31 diff --git a/docs/src/usage.md b/docs/src/usage.md index a6019cb1a3..150babf3cc 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -433,12 +433,12 @@ As *Quality-time* has to open the report in a (headless) browser and load all th The report title in the footer of the PDF will link to the online version of the same report. ``` -To manually download a PDF version of a report, navigate to the report and expand the report's title. Click the "Download report as PDF" button to create and download the PDF report. +To manually download a PDF version of a report, navigate to the report and click the "Download report as PDF" button in the menu bar to create and download the PDF report. The exported PDF report has the same metric table rows and columns hidden as in the user interface, and has the same metrics expanded as in the user interface. The exported PDF report also has the same date as the report visible in the user interface. ```{tip} -It is also possible to download a PDF version of the reports overview. Navigate to the reports overview and expand the title of the reports overview. Click the "Download overview as PDF" button to create and download the PDF report. +It is also possible to download a PDF version of the reports overview. Navigate to the reports overview and click the "Download overview as PDF" button in the menu bar to create and download the PDF report. ``` ```{seealso}