Skip to content

Commit

Permalink
When manually exporting a report to PDF, the report header would not …
Browse files Browse the repository at this point in the history
…be collapsed before generating the PDF. Prevent the need for collapsing the header by moving the PDF button to the menu bar. Fixes #8054.
  • Loading branch information
fniessink committed Feb 8, 2024
1 parent b7ca675 commit a1dcfa7
Show file tree
Hide file tree
Showing 13 changed files with 158 additions and 134 deletions.
2 changes: 1 addition & 1 deletion components/frontend/src/AppUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ export function AppUI({
<DarkMode.Provider value={darkMode}>
<HashLinkObserver />
<Menubar
atReportsOverview={atReportsOverview}
email={email}
handleDateChange={handleDateChange}
openReportsOverview={openReportsOverview}
onDate={handleDateChange}
report_date={report_date}
report_uuid={report_uuid}
set_user={set_user}
user={user}
panel={<SettingsPanel
Expand Down
2 changes: 1 addition & 1 deletion components/frontend/src/header_footer/CollapseButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function CollapseButton({ expandedItems }) {
onClick={() => expandedItems.reset()}
inverted
>
<Icon name="angle double up" />
<Icon name="angle double up" /> Collapse all
</Button>
</span>
}
Expand Down
62 changes: 62 additions & 0 deletions components/frontend/src/header_footer/DownloadAsPDFButton.js
Original file line number Diff line number Diff line change
@@ -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 (
<Popup
on={["hover", "focus"]}
trigger={
<Button
aria-label={label}
basic
icon
loading={loading}
onClick={() => {
console.log(loading)
if (!loading) {
setLoading(true);
download_pdf(report_uuid, `?${query.toString()}`, () => { setLoading(false) })
}
}}
inverted
>
<Icon name="file pdf" /> Download as PDF
</Button>
}
content={`Generate a PDF version of the ${itemType} as currently displayed. This may take some time.`}
/>
)
}
DownloadAsPDFButton.propTypes = {
report_uuid: PropTypes.string,
}
71 changes: 71 additions & 0 deletions components/frontend/src/header_footer/DownloadAsPDFButton.test.js
Original file line number Diff line number Diff line change
@@ -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(<DownloadAsPDFButton />);
expect(screen.getAllByLabelText(/reports overview as PDF/).length).toBe(1);

});

test("DownloadAsPDFButton has the correct label for a report", () => {
render(<DownloadAsPDFButton report_uuid={"report_uuid"}/>);
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(<DownloadAsPDFButton report={test_report} report_uuid="report_uuid" />);
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(<DownloadAsPDFButton report={test_report} report_uuid="report_uuid" />);
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(<DownloadAsPDFButton report={test_report} />);
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(<DownloadAsPDFButton report={test_report} />);
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(<DownloadAsPDFButton report={test_report} />);
await act(async () => {
fireEvent.click(screen.getByLabelText(/Download/));
});
expect(screen.getByLabelText(/Download/).className).not.toContain("loading")
});
9 changes: 7 additions & 2 deletions components/frontend/src/header_footer/Menubar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -87,6 +88,7 @@ export function Menubar({
return () => { window.removeEventListener('keydown', closePanels) };
}, []);

const atReportsOverview = report_uuid === ""
return (
<>
<Menu fluid className="menubar" inverted fixed="top">
Expand Down Expand Up @@ -122,6 +124,9 @@ export function Menubar({
<Menu.Item>
<CollapseButton expandedItems={settings.expandedItems} />
</Menu.Item>
<Menu.Item>
<DownloadAsPDFButton report_uuid={report_uuid} />
</Menu.Item>
</Menu.Menu>
<Menu.Menu position='right'>
<Popup content="Show the report as it was on the selected date" position="left center" trigger={
Expand Down Expand Up @@ -155,13 +160,13 @@ export function Menubar({
)
}
Menubar.propTypes = {
atReportsOverview: PropTypes.bool,
email: PropTypes.string,
handleDateChange: PropTypes.func,
onDate: PropTypes.func,
openReportsOverview: PropTypes.func,
panel: PropTypes.element,
report_date: optionalDatePropType,
report_uuid: PropTypes.string,
settings: settingsPropType,
set_user: PropTypes.func,
setUIMode: PropTypes.func,
Expand Down
15 changes: 7 additions & 8 deletions components/frontend/src/header_footer/Menubar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import history from 'history/browser';
import { Menubar } from './Menubar';
import * as auth from '../api/auth';
import { createTestableSettings } from '../__fixtures__/fixtures';
import { createTestableSettings, report } from '../__fixtures__/fixtures';

jest.mock("../api/auth.js")

Expand All @@ -14,21 +14,20 @@ beforeEach(() => {

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(
<Menubar
atReportsOverview={atReportsOverview}
onDate={() => {/* Dummy handler */ }}
openReportsOverview={openReportsOverview}
panel={panel}
report_date_string="2019-10-10"
report_uuid={report_uuid}
settings={settings}
set_user={set_user}
user={user}
Expand Down Expand Up @@ -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();
});
Expand All @@ -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: <div>Hello</div> })
renderMenubar({ report_uuid: "", panel: <div>Hello</div> })
await userEvent.type(screen.getByText(/Settings/), " ")
expect(screen.getAllByText(/Hello/).length).toBe(1)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function ResetSettingsButton({ atReportsOverview, handleDateChange, repor
<Icon name="undo alternate" />
<Icon name="setting" size="tiny" />
</Icon.Group>
Reset settings
</Button>
</span>
}
Expand Down
3 changes: 1 addition & 2 deletions components/frontend/src/report/ReportTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -247,7 +247,6 @@ ReactionTimes.propTypes = {
function ButtonRow({ report_uuid, openReportsOverview }) {
return (
<>
<DownloadAsPDFButton report_uuid={report_uuid} />
<ReadOnlyOrEditable requiredPermissions={[EDIT_REPORT_PERMISSION]} editableComponent={
<DeleteButton
item_type='report'
Expand Down
4 changes: 0 additions & 4 deletions components/frontend/src/report/ReportsOverviewTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { StringInput } from '../fields/StringInput';
import { MultipleChoiceInput } from '../fields/MultipleChoiceInput';
import { set_reports_attribute } from '../api/report';
import { EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION } from '../context/Permissions';
import { DownloadAsPDFButton } from '../widgets/Button';
import { FocusableTab } from '../widgets/FocusableTab';
import { dropdownOptions } from '../utils';
import { reportsOverviewPropType, settingsPropType } from '../sharedPropTypes';
Expand Down Expand Up @@ -128,9 +127,6 @@ export function ReportsOverviewTitle({ reports_overview, reload, settings }) {
onTabChange={tabChangeHandler(settings.expandedItems, uuid)}
panes={panes}
/>
<div style={{ marginTop: "20px" }}>
<DownloadAsPDFButton />
</div>
</HeaderWithDetails>
)
}
Expand Down
48 changes: 2 additions & 46 deletions components/frontend/src/widgets/Button.js
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -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 (
<ActionButton
action='Download'
icon="file pdf"
item_type={`${itemType} as PDF`}
loading={loading}
onClick={() => {
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];
Expand Down
Loading

0 comments on commit a1dcfa7

Please sign in to comment.