[Reporting/PDF] Layout option for generating full-page Canvas reports (#84959)

* [Reporting/PDF] Custom layout option for Canvas

* fix snapshots

* --wip-- [skip ci]

* check pdf data

* add test

* functional tests work

* add fixme comment

* read strings from pdf for test

* Update reports.ts

* function name / comment improvment

* Add Canvas toggle to choose pdf layout type

* Fix Canvas pdf panel storybook test

* Update style for new Canvas report type switch

* Update canvas share menu snapshot

* Fix tests for validating Canvas PDF using inline snapshots

Run test server with:
node scripts/functional_tests_server.js --config x-pack/test/functional/config.js

Run test suite with:
node scripts/functional_test_runner.js --config x-pack/test/functional/config.js --grep 'Canvas PDF Report'

* Fix i18n and typo

* Add a test for removing borders

* Fix i18n

* Update snapshot

Co-authored-by: Timothy Sullivan <tsullivan@elastic.co>
Co-authored-by: Tim Sullivan <tsullivan@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Poff Poffenberger 2020-12-10 13:34:47 -06:00 committed by GitHub
parent fbe48221ae
commit cda3627a79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 3354 additions and 206 deletions

View file

@ -1420,6 +1420,18 @@ export const ComponentStrings = {
workpadName,
},
}),
getPDFFullPageLayoutHelpText: () =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.FullPageLayoutHelpText', {
defaultMessage: 'Remove borders and footer logo',
}),
getPDFFullPageLayoutLabel: () =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.FullPageLayoutLabel', {
defaultMessage: 'Full page layout',
}),
getPDFPanelAdvancedOptionsLabel: () =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.pdfPanelAdvancedOptionsLabel', {
defaultMessage: 'Advanced options',
}),
getPDFPanelCopyAriaLabel: () =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.pdfPanelCopyAriaLabel', {
defaultMessage:
@ -1462,6 +1474,10 @@ export const ComponentStrings = {
PDF,
},
}),
getPDFPanelOptionsLabel: () =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.pdfPanelOptionsLabel', {
defaultMessage: 'Options',
}),
getShareableZipErrorTitle: (workpadName: string) =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', {
defaultMessage:

View file

@ -17,8 +17,78 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu/PDFPanel default 1`] = `
<div
className="euiSpacer euiSpacer--s"
/>
<h6
className="euiTitle euiTitle--xxsmall"
>
Options
</h6>
<div
className="euiSpacer euiSpacer--s"
/>
<div
className="euiFormRow"
id="generated-id-row"
>
<div
className="euiFormRow__fieldWrapper"
>
<div
className="euiSwitch"
>
<button
aria-checked={false}
aria-describedby="generated-id-help"
aria-labelledby="generated-id"
className="euiSwitch__button"
data-test-subj="reportModeToggle"
id="generated-id"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
role="switch"
type="button"
>
<span
className="euiSwitch__body"
>
<span
className="euiSwitch__thumb"
/>
<span
className="euiSwitch__track"
>
<span
className="euiSwitch__icon"
data-euiicon-type="cross"
size="m"
/>
<span
className="euiSwitch__icon euiSwitch__icon--checked"
data-euiicon-type="check"
size="m"
/>
</span>
</span>
</button>
<span
className="euiSwitch__label"
id="generated-id"
onClick={[Function]}
>
Full page layout
</span>
</div>
<div
className="euiFormHelpText euiFormRow__text"
id="generated-id-help"
>
Remove borders and footer logo
</div>
</div>
</div>
<button
className="euiButton euiButton--primary euiButton--small euiButton--fill"
data-test-subj="generateReportButton"
onClick={[Function]}
style={
Object {
@ -38,52 +108,50 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu/PDFPanel default 1`] = `
</span>
</span>
</button>
<div
className="euiSpacer euiSpacer--s"
/>
<div
className="euiText euiText--small"
>
<p>
Alternatively, copy this POST URL to call generation from outside Kibana or from Watcher.
</p>
</div>
<div
className="euiSpacer euiSpacer--s"
/>
<div
className="canvasClipboard"
onClick={[Function]}
onKeyPress={[Function]}
role="button"
tabIndex={0}
>
<button
aria-label="Alternatively, you can generate a PDF from a script or with Watcher by using this URL. Press Enter to copy the URL to clipboard."
className="euiButton euiButton--primary euiButton--small"
style={
Object {
"minWidth": undefined,
"width": "100%",
}
<hr
className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall"
style={
Object {
"marginLeft": "-16px",
"marginRight": "-16px",
"width": "auto",
}
type="button"
}
/>
<div
className="euiAccordion"
>
<div
className="euiAccordion__triggerWrapper"
>
<span
className="euiButtonContent euiButton__content"
<button
aria-controls="advanced-options"
aria-expanded={false}
className="euiAccordion__button"
id="generated-id"
onClick={[Function]}
type="button"
>
<span
className="euiButtonContent__icon"
data-euiicon-type="copy"
size="m"
/>
<span
className="euiButton__text"
className="euiAccordion__iconWrapper"
>
Copy POST URL
<span
className="euiAccordion__icon"
data-euiicon-type="arrowRight"
size="m"
/>
</span>
</span>
</button>
<span
className="euiIEFlexWrapFix"
>
Advanced options
</span>
</button>
</div>
<div
className="euiAccordion__childWrapper"
id="advanced-options"
/>
</div>
</div>
</div>

View file

@ -11,6 +11,7 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu default 1`] = `
<button
aria-label="Share this workpad"
className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall"
data-test-subj="shareTopNavButton"
onClick={[Function]}
type="button"
>

View file

@ -25,6 +25,10 @@ storiesOf('components/WorkpadHeader/ShareMenu/PDFPanel', module)
})
.add('default', () => (
<div className="euiPanel">
<PDFPanel pdfURL="pdfUrl" onCopy={action('onCopy')} onExport={action('onExport')} />
<PDFPanel
getPdfURL={() => 'PDF URL String'}
onCopy={action('onCopy')}
onExport={action('onExport')}
/>
</div>
));

View file

@ -4,18 +4,28 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui';
import React, { useState } from 'react';
import {
EuiAccordion,
EuiButton,
EuiFormRow,
EuiHorizontalRule,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { Clipboard } from '../../clipboard';
import { LayoutType } from './utils';
import { ComponentStrings } from '../../../../i18n/components';
const { WorkpadHeaderShareMenu: strings } = ComponentStrings;
interface Props {
/** The URL that will invoke PDF Report generation. */
pdfURL: string;
/** Retrieve URL that will invoke PDF Report generation. */
getPdfURL: (layout: LayoutType) => string;
/** Handler to invoke when the PDF is exported */
onExport: () => void;
onExport: (layout: LayoutType) => void;
/** Handler to invoke when the URL is copied to the clipboard. */
onCopy: () => void;
}
@ -23,29 +33,65 @@ interface Props {
/**
* A panel displayed in the Export Menu with options in which to generate PDF Reports.
*/
export const PDFPanel = ({ pdfURL, onExport, onCopy }: Props) => (
<div className="canvasShareMenu__panelContent">
<EuiText size="s">
<p>{strings.getPDFPanelGenerateDescription()}</p>
</EuiText>
<EuiSpacer size="s" />
<EuiButton fill onClick={onExport} size="s" style={{ width: '100%' }}>
{strings.getPDFPanelGenerateButtonLabel()}
</EuiButton>
<EuiSpacer size="s" />
<EuiText size="s">
<p>{strings.getPDFPanelCopyDescription()}</p>
</EuiText>
<EuiSpacer size="s" />
<Clipboard content={pdfURL} onCopy={onCopy}>
export const PDFPanel = ({ getPdfURL, onExport, onCopy }: Props) => {
const [reportLayout, setReportLayout] = useState<LayoutType>('preserve_layout');
return (
<div className="canvasShareMenu__panelContent">
<EuiText size="s">
<p>{strings.getPDFPanelGenerateDescription()}</p>
</EuiText>
<EuiSpacer size="s" />
<EuiTitle size="xxs">
<h6>{strings.getPDFPanelOptionsLabel()}</h6>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFormRow helpText={strings.getPDFFullPageLayoutHelpText()}>
<EuiSwitch
label={strings.getPDFFullPageLayoutLabel()}
checked={reportLayout === 'canvas'}
onChange={() =>
reportLayout === 'canvas'
? setReportLayout('preserve_layout')
: setReportLayout('canvas')
}
data-test-subj="reportModeToggle"
/>
</EuiFormRow>
<EuiButton
iconType="copy"
fill
onClick={() => onExport(reportLayout)}
size="s"
style={{ width: '100%' }}
aria-label={strings.getPDFPanelCopyAriaLabel()}
data-test-subj="generateReportButton"
>
{strings.getPDFPanelCopyButtonLabel()}
{strings.getPDFPanelGenerateButtonLabel()}
</EuiButton>
</Clipboard>
</div>
);
<EuiHorizontalRule
margin="s"
style={{ width: 'auto', marginLeft: '-16px', marginRight: '-16px' }}
/>
<EuiAccordion
id="advanced-options"
buttonContent={strings.getPDFPanelAdvancedOptionsLabel()}
paddingSize="none"
>
<EuiSpacer size="s" />
<EuiText size="s">
<p>{strings.getPDFPanelCopyDescription()}</p>
</EuiText>
<EuiSpacer size="s" />
<Clipboard content={getPdfURL(reportLayout)} onCopy={onCopy}>
<EuiButton
iconType="copy"
size="s"
style={{ width: '100%' }}
aria-label={strings.getPDFPanelCopyAriaLabel()}
>
{strings.getPDFPanelCopyButtonLabel()}
</EuiButton>
</Clipboard>
</EuiAccordion>
</div>
);
};

View file

@ -12,6 +12,7 @@ import { flattenPanelTree } from '../../../lib/flatten_panel_tree';
import { Popover, ClosePopoverFn } from '../../popover';
import { PDFPanel } from './pdf_panel';
import { ShareWebsiteFlyout } from './flyout';
import { LayoutType } from './utils';
const { WorkpadHeaderShareMenu: strings } = ComponentStrings;
@ -21,9 +22,9 @@ type ExportUrlTypes = 'pdf';
type CloseTypes = 'share';
export type OnCopyFn = (type: CopyTypes) => void;
export type OnExportFn = (type: ExportTypes) => void;
export type OnExportFn = (type: ExportTypes, layout?: LayoutType) => void;
export type OnCloseFn = (type: CloseTypes) => void;
export type GetExportUrlFn = (type: ExportUrlTypes) => string;
export type GetExportUrlFn = (type: ExportUrlTypes, layout: LayoutType) => string;
export interface Props {
/** Handler to invoke when an export URL is copied to the clipboard. */
@ -47,9 +48,9 @@ export const ShareMenu: FunctionComponent<Props> = ({ onCopy, onExport, getExpor
const getPDFPanel = (closePopover: ClosePopoverFn) => {
return (
<PDFPanel
pdfURL={getExportUrl('pdf')}
onExport={() => {
onExport('pdf');
getPdfURL={(layoutType: LayoutType) => getExportUrl('pdf', layoutType)}
onExport={(layoutType) => {
onExport('pdf', layoutType);
closePopover();
}}
onCopy={() => {
@ -79,6 +80,7 @@ export const ShareMenu: FunctionComponent<Props> = ({ onCopy, onExport, getExpor
title: strings.getShareDownloadPDFTitle(),
content: getPDFPanel(closePopover),
},
'data-test-subj': 'sharePanel-PDFReports',
},
{
name: strings.getShareWebsiteTitle(),
@ -92,7 +94,12 @@ export const ShareMenu: FunctionComponent<Props> = ({ onCopy, onExport, getExpor
});
const shareControl = (togglePopover: React.MouseEventHandler<any>) => (
<EuiButtonEmpty size="xs" aria-label={strings.getShareWorkpadMessage()} onClick={togglePopover}>
<EuiButtonEmpty
size="xs"
aria-label={strings.getShareWorkpadMessage()}
onClick={togglePopover}
data-test-subj="shareTopNavButton"
>
{strings.getShareMenuButtonLabel()}
</EuiButtonEmpty>
);

View file

@ -45,10 +45,11 @@ export const ShareMenu = compose<ComponentProps, {}>(
withServices,
withProps(
({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => ({
getExportUrl: (type) => {
getExportUrl: (type, layout) => {
if (type === 'pdf') {
const pdfUrl = getPdfUrl(
workpad,
layout,
{ pageCount },
services.platform.getBasePathInterface()
);
@ -69,10 +70,15 @@ export const ShareMenu = compose<ComponentProps, {}>(
throw new Error(strings.getUnknownExportErrorMessage(type));
}
},
onExport: (type) => {
onExport: (type, layout) => {
switch (type) {
case 'pdf':
return createPdf(workpad, { pageCount }, services.platform.getBasePathInterface())
return createPdf(
workpad,
layout || 'preserve_layout',
{ pageCount },
services.platform.getBasePathInterface()
)
.then(({ data }: { data: { job: { id: string } } }) => {
services.notify.info(strings.getExportPDFMessage(), {
title: strings.getExportPDFTitle(workpad.name),

View file

@ -6,7 +6,7 @@
jest.mock('../../../../common/lib/fetch');
import { getPdfUrl, createPdf } from './utils';
import { getPdfUrl, createPdf, LayoutType } from './utils';
import { workpads } from '../../../../__tests__/fixtures/workpads';
import { fetch } from '../../../../common/lib/fetch';
import { IBasePath } from 'kibana/public';
@ -18,25 +18,31 @@ const basePath = ({
} as unknown) as IBasePath;
const workpad = workpads[0];
test('getPdfUrl returns the correct url', () => {
const url = getPdfUrl(workpad, { pageCount: 2 }, basePath);
test('getPdfUrl returns the correct url for canvas layout', () => {
['canvas', 'preserve_layout'].forEach((layout) => {
const url = getPdfUrl(workpad, layout as LayoutType, { pageCount: 2 }, basePath);
expect(url).toMatchInlineSnapshot(
`"basepath/s/spacey//api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FPhoenix,layout:(dimensions:(height:0,width:0),id:preserve_layout),objectType:'canvas%20workpad',relativeUrls:!(%2Fs%2Fspacey%2Fapp%2Fcanvas%23%2Fexport%2Fworkpad%2Fpdf%2Fbase-workpad%2Fpage%2F1,%2Fs%2Fspacey%2Fapp%2Fcanvas%23%2Fexport%2Fworkpad%2Fpdf%2Fbase-workpad%2Fpage%2F2),title:'base%20workpad')"`
);
expect(url).toMatchInlineSnapshot(
`"basepath/s/spacey//api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(dimensions:(height:0,width:0),id:${layout}),objectType:'canvas%20workpad',relativeUrls:!(%2Fs%2Fspacey%2Fapp%2Fcanvas%23%2Fexport%2Fworkpad%2Fpdf%2Fbase-workpad%2Fpage%2F1,%2Fs%2Fspacey%2Fapp%2Fcanvas%23%2Fexport%2Fworkpad%2Fpdf%2Fbase-workpad%2Fpage%2F2),title:'base%20workpad')"`
);
});
});
test('createPdf posts to create the pdf', () => {
createPdf(workpad, { pageCount: 2 }, basePath);
test('createPdf posts to create the pdf with canvas layout', () => {
['canvas', 'preserve_layout'].forEach((layout, index) => {
createPdf(workpad, layout as LayoutType, { pageCount: 2 }, basePath);
expect(fetch.post).toBeCalled();
expect(fetch.post).toBeCalled();
const args = (fetch.post as jest.MockedFunction<typeof fetch.post>).mock.calls[0];
const args = (fetch.post as jest.MockedFunction<typeof fetch.post>).mock.calls[index];
expect(args[0]).toMatchInlineSnapshot(`"basepath/s/spacey//api/reporting/generate/printablePdf"`);
expect(args[1]).toMatchInlineSnapshot(`
Object {
"jobParams": "(browserTimezone:America/Phoenix,layout:(dimensions:(height:0,width:0),id:preserve_layout),objectType:'canvas workpad',relativeUrls:!(/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/1,/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/2),title:'base workpad')",
}
`);
expect(args[0]).toMatchInlineSnapshot(
`"basepath/s/spacey//api/reporting/generate/printablePdf"`
);
expect(args[1]).toMatchInlineSnapshot(`
Object {
"jobParams": "(browserTimezone:America/New_York,layout:(dimensions:(height:0,width:0),id:${layout}),objectType:'canvas workpad',relativeUrls:!(/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/1,/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/2),title:'base workpad')",
}
`);
});
});

View file

@ -6,18 +6,18 @@
import rison from 'rison-node';
import { IBasePath } from 'kibana/public';
import moment from 'moment-timezone';
import { fetch } from '../../../../common/lib/fetch';
import { CanvasWorkpad } from '../../../../types';
import { url } from '../../../../../../../src/plugins/kibana_utils/public';
// type of the desired pdf output (print or preserve_layout)
const PDF_LAYOUT_TYPE = 'preserve_layout';
interface PageCount {
pageCount: number;
}
type Arguments = [CanvasWorkpad, PageCount, IBasePath];
export type LayoutType = 'canvas' | 'preserve_layout';
type Arguments = [CanvasWorkpad, LayoutType, PageCount, IBasePath];
interface PdfUrlData {
createPdfUri: string;
@ -26,6 +26,7 @@ interface PdfUrlData {
function getPdfUrlParts(
{ id, name: title, width, height }: CanvasWorkpad,
layoutType: LayoutType,
{ pageCount }: PageCount,
basePath: IBasePath
): PdfUrlData {
@ -50,10 +51,10 @@ function getPdfUrlParts(
}
const jobParams = {
browserTimezone: 'America/Phoenix', // TODO: get browser timezone, or Kibana setting?
browserTimezone: moment.tz.guess(),
layout: {
dimensions: { width, height },
id: PDF_LAYOUT_TYPE,
id: layoutType,
},
objectType: 'canvas workpad',
relativeUrls: workpadUrls,

View file

@ -80,7 +80,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) {
let buffer: Buffer | null = null;
try {
tracker.startCompile();
logger.debug(`Compiling PDF...`);
logger.debug(`Compiling PDF using "${layout.id}" layout...`);
pdfOutput.generate();
tracker.endCompile();

View file

@ -6,10 +6,12 @@
import { BufferOptions } from 'pdfmake/interfaces';
export const REPORTING_TABLE_LAYOUT = 'noBorder';
export function getDocOptions(tableBorderWidth: number): BufferOptions {
return {
tableLayouts: {
noBorder: {
[REPORTING_TABLE_LAYOUT]: {
// format is function (i, node) { ... };
hLineWidth: () => 0,
vLineWidth: () => 0,
@ -18,17 +20,6 @@ export function getDocOptions(tableBorderWidth: number): BufferOptions {
paddingTop: () => 0,
paddingBottom: () => 0,
},
simpleBorder: {
// format is function (i, node) { ... };
hLineWidth: () => tableBorderWidth,
vLineWidth: () => tableBorderWidth,
hLineColor: () => 'silver',
vLineColor: () => 'silver',
paddingLeft: () => 0,
paddingRight: () => 0,
paddingTop: () => 0,
paddingBottom: () => 0,
},
},
};
}

View file

@ -6,8 +6,14 @@
import { i18n } from '@kbn/i18n';
import path from 'path';
import { TDocumentDefinitions } from 'pdfmake/interfaces';
import {
ContentText,
DynamicContent,
StyleDictionary,
TDocumentDefinitions,
} from 'pdfmake/interfaces';
import { LayoutInstance } from '../../../../lib/layouts';
import { REPORTING_TABLE_LAYOUT } from './get_doc_options';
import { getFont } from './get_font';
export function getTemplate(
@ -29,6 +35,79 @@ export function getTemplate(
const subheadingMarginBottom = 5;
const subheadingHeight = subheadingFontSize * 1.5 + subheadingMarginTop + subheadingMarginBottom;
const getStyle = (): StyleDictionary => ({
heading: {
alignment: 'left',
fontSize: headingFontSize,
bold: true,
margin: [headingMarginTop, 0, headingMarginBottom, 0],
},
subheading: {
alignment: 'left',
fontSize: subheadingFontSize,
italics: true,
margin: [0, 0, subheadingMarginBottom, 20],
},
warning: {
color: '#f39c12', // same as @brand-warning in Kibana colors.less
},
});
const getHeader = (): ContentText => ({
margin: [pageMarginWidth, pageMarginTop / 4, pageMarginWidth, 0],
text: title,
font: getFont(title),
style: {
color: '#aaa',
},
fontSize: 10,
alignment: 'center',
});
const getFooter = (): DynamicContent => (currentPage: number, pageCount: number) => {
const logoPath = path.resolve(assetPath, 'img', 'logo-grey.png'); // Default Elastic Logo
return {
margin: [pageMarginWidth, pageMarginBottom / 4, pageMarginWidth, 0],
layout: REPORTING_TABLE_LAYOUT,
table: {
widths: [100, '*', 100],
body: [
[
{
fit: [100, 35],
image: logo || logoPath,
},
{
alignment: 'center',
text: i18n.translate('xpack.reporting.exportTypes.printablePdf.pagingDescription', {
defaultMessage: 'Page {currentPage} of {pageCount}',
values: { currentPage: currentPage.toString(), pageCount },
}),
style: {
color: '#aaa',
},
},
'',
],
[
logo
? {
text: i18n.translate('xpack.reporting.exportTypes.printablePdf.logoDescription', {
defaultMessage: 'Powered by Elastic',
}),
fontSize: 10,
style: {
color: '#aaa',
},
margin: [0, 2, 0, 0],
}
: '',
'',
'',
],
],
},
};
};
return {
// define page size
pageOrientation: layout.getPdfPageOrientation(),
@ -40,87 +119,14 @@ export function getTemplate(
headingHeight,
subheadingHeight,
}),
pageMargins: [pageMarginWidth, pageMarginTop, pageMarginWidth, pageMarginBottom],
pageMargins: layout.useReportingBranding
? [pageMarginWidth, pageMarginTop, pageMarginWidth, pageMarginBottom]
: [0, 0, 0, 0],
header() {
return {
margin: [pageMarginWidth, pageMarginTop / 4, pageMarginWidth, 0],
text: title,
font: getFont(title),
style: {
color: '#aaa',
},
fontSize: 10,
alignment: 'center',
};
},
header: layout.hasHeader ? getHeader() : undefined,
footer: layout.hasFooter ? getFooter() : undefined,
footer(currentPage: number, pageCount: number) {
const logoPath = path.resolve(assetPath, 'img', 'logo-grey.png'); // Default Elastic Logo
return {
margin: [pageMarginWidth, pageMarginBottom / 4, pageMarginWidth, 0],
layout: 'noBorder',
table: {
widths: [100, '*', 100],
body: [
[
{
fit: [100, 35],
image: logo || logoPath,
},
{
alignment: 'center',
text: i18n.translate('xpack.reporting.exportTypes.printablePdf.pagingDescription', {
defaultMessage: 'Page {currentPage} of {pageCount}',
values: { currentPage: currentPage.toString(), pageCount },
}),
style: {
color: '#aaa',
},
},
'',
],
[
logo
? {
text: i18n.translate(
'xpack.reporting.exportTypes.printablePdf.logoDescription',
{
defaultMessage: 'Powered by Elastic',
}
),
fontSize: 10,
style: {
color: '#aaa',
},
margin: [0, 2, 0, 0],
}
: '',
'',
'',
],
],
},
};
},
styles: {
heading: {
alignment: 'left',
fontSize: headingFontSize,
bold: true,
margin: [headingMarginTop, 0, headingMarginBottom, 0],
},
subheading: {
alignment: 'left',
fontSize: subheadingFontSize,
italics: true,
margin: [0, 0, subheadingMarginBottom, 20],
},
warning: {
color: '#f39c12', // same as @brand-warning in Kibana colors.less
},
},
styles: layout.useReportingBranding ? getStyle() : undefined,
defaultStyle: {
fontSize: 12,

View file

@ -10,9 +10,9 @@ import concat from 'concat-stream';
import _ from 'lodash';
import path from 'path';
import Printer from 'pdfmake';
import { Content, ContentText } from 'pdfmake/interfaces';
import { Content, ContentImage, ContentText } from 'pdfmake/interfaces';
import { LayoutInstance } from '../../../../lib/layouts';
import { getDocOptions } from './get_doc_options';
import { getDocOptions, REPORTING_TABLE_LAYOUT } from './get_doc_options';
import { getFont } from './get_font';
import { getTemplate } from './get_template';
@ -67,7 +67,7 @@ export class PdfMaker {
this._content.push(contents);
}
addImage(base64EncodedData: string, { title = '', description = '' }) {
addBrandedImage(img: ContentImage, { title = '', description = '' }) {
const contents: Content[] = [];
if (title && title.length > 0) {
@ -88,19 +88,11 @@ export class PdfMaker {
});
}
const size = this._layout.getPdfImageSize();
const img = {
image: `data:image/png;base64,${base64EncodedData}`,
alignment: 'center',
height: size.height,
width: size.width,
};
const wrappedImg = {
table: {
body: [[img]],
},
layout: 'noBorder',
layout: REPORTING_TABLE_LAYOUT,
};
contents.push(wrappedImg);
@ -108,6 +100,22 @@ export class PdfMaker {
this._addContents(contents);
}
addImage(base64EncodedData: string, opts = { title: '', description: '' }) {
const size = this._layout.getPdfImageSize();
const img = {
image: `data:image/png;base64,${base64EncodedData}`,
alignment: 'center' as 'center',
height: size.height,
width: size.width,
};
if (this._layout.useReportingBranding) {
return this.addBrandedImage(img, opts);
}
this._addContents([img]);
}
setTitle(title: string) {
this._title = title;
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
getDefaultLayoutSelectors,
LayoutInstance,
LayoutSelectorDictionary,
LayoutTypes,
PageSizeParams,
Size,
} from './';
import { Layout } from './layout';
// FIXME - should use zoom from capture config
const ZOOM: number = 2;
/*
* This class provides a Layout definition. The PdfMaker class uses this to
* define a document layout that includes no margins or branding or added logos.
* The single image that was captured should be the only structural part of the
* PDF document definition
*/
export class CanvasLayout extends Layout implements LayoutInstance {
public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors();
public readonly groupCount = 1;
public readonly height: number;
public readonly width: number;
private readonly scaledHeight: number;
private readonly scaledWidth: number;
public hasHeader: boolean = false;
public hasFooter: boolean = false;
public useReportingBranding: boolean = false;
constructor(size: Size) {
super(LayoutTypes.CANVAS);
this.height = size.height;
this.width = size.width;
this.scaledHeight = size.height * ZOOM;
this.scaledWidth = size.width * ZOOM;
}
public getPdfPageOrientation() {
return undefined;
}
public getCssOverridesPath() {
return undefined;
}
public getBrowserViewport() {
return {
height: this.scaledHeight,
width: this.scaledWidth,
};
}
public getBrowserZoom() {
return ZOOM;
}
public getViewport() {
return {
height: this.scaledHeight,
width: this.scaledWidth,
zoom: ZOOM,
};
}
public getPdfImageSize() {
return {
height: this.height,
width: this.width,
};
}
public getPdfPageSize(pageSizeParams: PageSizeParams): Size {
return {
height: this.height,
width: this.width,
};
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReportingConfig } from '../..';
import { createMockConfig, createMockConfigSchema } from '../../test_helpers';
import { createLayout, LayoutParams, PreserveLayout } from './';
import { CanvasLayout } from './canvas_layout';
describe('Create Layout', () => {
let config: ReportingConfig;
beforeEach(() => {
config = createMockConfig(createMockConfigSchema());
});
it('creates preserve layout instance', () => {
const { id, height, width } = new PreserveLayout({ width: 16, height: 16 });
const preserveParams: LayoutParams = { id, dimensions: { height, width } };
const layout = createLayout(config.get('capture'), preserveParams);
expect(layout).toMatchInlineSnapshot(`
PreserveLayout {
"groupCount": 1,
"hasFooter": true,
"hasHeader": true,
"height": 16,
"id": "preserve_layout",
"scaledHeight": 32,
"scaledWidth": 32,
"selectors": Object {
"itemsCountAttribute": "data-shared-items-count",
"renderComplete": "[data-shared-item]",
"screenshot": "[data-shared-items-container]",
"timefilterDurationAttribute": "data-shared-timefilter-duration",
},
"useReportingBranding": true,
"width": 16,
}
`);
});
it('creates the print layout', () => {
const print = createLayout(config.get('capture'));
const printParams: LayoutParams = {
id: print.id,
};
const layout = createLayout(config.get('capture'), printParams);
expect(layout).toMatchInlineSnapshot(`
PrintLayout {
"captureConfig": Object {
"browser": Object {
"chromium": Object {
"disableSandbox": true,
},
},
},
"groupCount": 2,
"hasFooter": true,
"hasHeader": true,
"id": "print",
"selectors": Object {
"itemsCountAttribute": "data-shared-items-count",
"renderComplete": "[data-shared-item]",
"screenshot": "[data-shared-item]",
"timefilterDurationAttribute": "data-shared-timefilter-duration",
},
"useReportingBranding": true,
}
`);
});
it('creates the canvas layout', () => {
const { id, height, width } = new CanvasLayout({ width: 18, height: 18 });
const canvasParams: LayoutParams = { id, dimensions: { height, width } };
const layout = createLayout(config.get('capture'), canvasParams);
expect(layout).toMatchInlineSnapshot(`
CanvasLayout {
"groupCount": 1,
"hasFooter": false,
"hasHeader": false,
"height": 18,
"id": "canvas",
"scaledHeight": 36,
"scaledWidth": 36,
"selectors": Object {
"itemsCountAttribute": "data-shared-items-count",
"renderComplete": "[data-shared-item]",
"screenshot": "[data-shared-items-container]",
"timefilterDurationAttribute": "data-shared-timefilter-duration",
},
"useReportingBranding": false,
"width": 18,
}
`);
});
});

View file

@ -6,7 +6,8 @@
import { LAYOUT_TYPES } from '../../../common/constants';
import { CaptureConfig } from '../../types';
import { LayoutInstance, LayoutParams } from './';
import { LayoutInstance, LayoutParams, LayoutTypes } from './';
import { CanvasLayout } from './canvas_layout';
import { PreserveLayout } from './preserve_layout';
import { PrintLayout } from './print_layout';
@ -18,6 +19,10 @@ export function createLayout(
return new PreserveLayout(layoutParams.dimensions);
}
// this is the default because some jobs won't have anything specified
if (layoutParams && layoutParams.dimensions && layoutParams.id === LayoutTypes.CANVAS) {
return new CanvasLayout(layoutParams.dimensions);
}
// layoutParams is optional as PrintLayout doesn't use it
return new PrintLayout(captureConfig);
}

View file

@ -19,8 +19,22 @@ export {
export { createLayout } from './create_layout';
export { Layout } from './layout';
export { PreserveLayout } from './preserve_layout';
export { CanvasLayout } from './canvas_layout';
export { PrintLayout } from './print_layout';
export const LayoutTypes = {
PRESERVE_LAYOUT: 'preserve_layout',
PRINT: 'print',
CANVAS: 'canvas', // no margins or branding in the layout
};
export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({
screenshot: '[data-shared-items-container]',
renderComplete: '[data-shared-item]',
itemsCountAttribute: 'data-shared-items-count',
timefilterDurationAttribute: 'data-shared-timefilter-duration',
});
interface LayoutSelectors {
// Fields that are not part of Layout: the instances
// independently implement these fields on their own

View file

@ -17,6 +17,10 @@ export abstract class Layout {
public id: string = '';
public groupCount: number = 0;
public hasHeader: boolean = true;
public hasFooter: boolean = true;
public useReportingBranding: boolean = true;
constructor(id: string) {
this.id = id;
}
@ -35,5 +39,5 @@ export abstract class Layout {
public abstract getBrowserViewport(): Size;
public abstract getCssOverridesPath(): string;
public abstract getCssOverridesPath(): string | undefined;
}

View file

@ -27,6 +27,9 @@ export const injectCustomCss = async (
);
const filePath = layout.getCssOverridesPath();
if (!filePath) {
return;
}
const buffer = await fsp.readFile(filePath);
try {
await browser.evaluate(

View file

@ -26,5 +26,6 @@ export default function canvasApp({ loadTestFile, getService }) {
loadTestFile(require.resolve('./custom_elements'));
loadTestFile(require.resolve('./feature_controls/canvas_security'));
loadTestFile(require.resolve('./feature_controls/canvas_spaces'));
loadTestFile(require.resolve('./reports'));
});
}

View file

@ -0,0 +1,339 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const es = getService('es');
const esArchiver = getService('esArchiver');
const browser = getService('browser');
const log = getService('log');
const security = getService('security');
const PageObjects = getPageObjects(['reporting', 'common', 'canvas']);
describe('Canvas PDF Report Generation', () => {
before('initialize tests', async () => {
log.debug('ReportingPage:initTests');
await security.testUser.setRoles(['kibana_admin', 'reporting_user']);
await esArchiver.load('canvas/reports');
await browser.setWindowSize(1600, 850);
});
after('clean up archives', async () => {
await esArchiver.unload('canvas/reports');
await es.deleteByQuery({
index: '.reporting-*',
refresh: true,
body: { query: { match_all: {} } },
});
});
describe('Print PDF button', () => {
it('downloaded PDF base64 string is correct with borders and logo', async function () {
// Generating and then comparing reports can take longer than the default 60s timeout
this.timeout(180000);
await PageObjects.common.navigateToApp('canvas');
await PageObjects.canvas.loadFirstWorkpad('The Very Cool Workpad for PDF Tests');
await PageObjects.reporting.openPdfReportingPanel();
await PageObjects.reporting.clickGenerateReportButton();
const url = await PageObjects.reporting.getReportURL(60000);
const res = await PageObjects.reporting.getResponse(url);
expect(res.status).to.equal(200);
expect(res.get('content-type')).to.equal('application/pdf');
expect(res.get('content-disposition')).to.equal(
'inline; filename="The Very Cool Workpad for PDF Tests.pdf"'
);
/* Check the value of the PDF data that was generated
* PDF files include dynamic meta info such as creation date.
* This checks only the first few thousand bytes of the Buffer
*/
const pdfStrings = (res.body as Buffer).toString('utf8', 14); // start on byte 14 to skip non-utf8 data
const [header, , contents, , info] = pdfStrings.split('stream'); // ignore everthing from `stream` to `endstream` - the non-utf8 blocks
// PDF Header
expectSnapshot(header).toMatchInline(`
"
7 0 obj
<<
/Predictor 15
/Colors 1
/BitsPerComponent 8
/Columns 577
>>
endobj
8 0 obj
<<
/Length 149
/Filter /FlateDecode
>>
"
`);
// PDF Contents
expectSnapshot(contents).toMatchInline(`
"
endobj
12 0 obj
<<
/Type /ExtGState
/ca 1
/CA 1
>>
endobj
11 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 90 189]
/Contents 9 0 R
/Resources 10 0 R
>>
endobj
10 0 obj
<<
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
/ExtGState <<
/Gs1 12 0 R
>>
/XObject <<
/I1 5 0 R
/I2 6 0 R
>>
/Font <<
/F1 13 0 R
>>
>>
endobj
9 0 obj
<<
/Length 270
/Filter /FlateDecode
>>
"
`);
// PDF Info
// The Info section of text includes a Datestamp which will obviously not match as a snapshot between tests
// This does a .replace on the Info text to erase the dynamic date string
expectSnapshot(info.replace(/D:\d+Z/, 'D:DATESTAMP')).toMatchInline(`
"
endobj
15 0 obj
(pdfmake)
endobj
16 0 obj
(pdfmake)
endobj
17 0 obj
(D:DATESTAMP)
endobj
14 0 obj
<<
/Producer 15 0 R
/Creator 16 0 R
/CreationDate 17 0 R
>>
endobj
19 0 obj
<<
/Type /FontDescriptor
/FontName /AZZZZZ+Roboto-Regular
/Flags 4
/FontBBox [-681.152344 -270.996094 1181.640625 1047.851563]
/ItalicAngle 0
/Ascent 927.734375
/Descent -244.140625
/CapHeight 710.9375
/XHeight 528.320313
/StemV 0
/FontFile2 18 0 R
>>
endobj
20 0 obj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /AZZZZZ+Roboto-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
/Supplement 0
>>
/FontDescriptor 19 0 R
/W [0 [507 596.679688 566.40625 526.855469 247.558594 637.207031 547.851563 566.40625 561.523438 566.40625 342.773438]]
/CIDToGIDMap /Identity
>>
endobj
21 0 obj
<<
/Length 250
/Filter /FlateDecode
>>
"
`);
expectSnapshot(res.get('content-length')).toMatchInline(`"20726"`);
});
it('downloaded PDF base64 string is correct without borders and logo', async function () {
// Generating and then comparing reports can take longer than the default 60s timeout
this.timeout(180000);
await PageObjects.common.navigateToApp('canvas');
await PageObjects.canvas.loadFirstWorkpad('The Very Cool Workpad for PDF Tests');
await PageObjects.reporting.openPdfReportingPanel();
await PageObjects.reporting.toggleReportMode();
await PageObjects.reporting.clickGenerateReportButton();
const url = await PageObjects.reporting.getReportURL(60000);
const res = await PageObjects.reporting.getResponse(url);
expect(res.status).to.equal(200);
expect(res.get('content-type')).to.equal('application/pdf');
expect(res.get('content-disposition')).to.equal(
'inline; filename="The Very Cool Workpad for PDF Tests.pdf"'
);
/* Check the value of the PDF data that was generated
* PDF files include dynamic meta info such as creation date.
* This checks only the first few thousand bytes of the Buffer
*/
const pdfStrings = (res.body as Buffer).toString('utf8', 14); // start on byte 14 to skip non-utf8 data
const [header, , contents, , info] = pdfStrings.split('stream'); // ignore everthing from `stream` to `endstream` - the non-utf8 blocks
// PDF Header
expectSnapshot(header).toMatchInline(`
"
9 0 obj
<<
/Type /ExtGState
/ca 1
/CA 1
>>
endobj
8 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 8 8]
/Contents 6 0 R
/Resources 7 0 R
>>
endobj
7 0 obj
<<
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
/ExtGState <<
/Gs1 9 0 R
>>
/XObject <<
/I1 5 0 R
>>
>>
endobj
6 0 obj
<<
/Length 45
/Filter /FlateDecode
>>
"
`);
// PDF Contents
expectSnapshot(contents.replace(/D:\d+Z/, 'D:DATESTAMP')).toMatchInline(`
"
endobj
11 0 obj
(pdfmake)
endobj
12 0 obj
(pdfmake)
endobj
13 0 obj
(D:DATESTAMP)
endobj
10 0 obj
<<
/Producer 11 0 R
/Creator 12 0 R
/CreationDate 13 0 R
>>
endobj
4 0 obj
<<
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/Names 2 0 R
>>
endobj
1 0 obj
<<
/Type /Pages
/Count 1
/Kids [8 0 R]
>>
endobj
2 0 obj
<<
/Dests <<
/Names [
]
>>
>>
endobj
14 0 obj
<<
/Type /XObject
/Subtype /Image
/Height 16
/Width 16
/BitsPerComponent 8
/Filter /FlateDecode
/ColorSpace /DeviceGray
/Decode [0 1]
/Length 12
>>
"
`);
// PDF Info
expectSnapshot(info).toMatchInline(`
"
endobj
5 0 obj
<<
/Type /XObject
/Subtype /Image
/BitsPerComponent 8
/Width 16
/Height 16
/Filter /FlateDecode
/ColorSpace /DeviceRGB
/SMask 14 0 R
/Length 18
>>
"
`);
expectSnapshot(res.get('content-length')).toMatchInline(`"1599"`);
});
});
});
}

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,19 @@ export function CanvasPageProvider({ getService, getPageObjects }: FtrProviderCo
await testSubjects.findAll('canvasWorkpadPage > canvasWorkpadPageElementContent');
},
/*
* Finds the first workpad in the loader (uses find, not findAll) and
* ensures the expected name is the actual name. Then it clicks the element
* to load the workpad. Resolves once the workpad is in the DOM
*/
async loadFirstWorkpad(workpadName: string) {
const elem = await testSubjects.find('canvasWorkpadLoaderWorkpad');
const text = await elem.getVisibleText();
expect(text).to.be(workpadName);
await elem.click();
await testSubjects.existOrFail('canvasWorkpadPage');
},
async fillOutCustomElementForm(name: string, description: string) {
// Fill out the custom element form and submit it
await testSubjects.setValue('canvasCustomElementForm-name', name, {

View file

@ -119,6 +119,10 @@ export function ReportingPageProvider({ getService, getPageObjects }: FtrProvide
await testSubjects.click('generateReportButton');
}
async toggleReportMode() {
await testSubjects.click('reportModeToggle');
}
async checkForReportingToasts() {
log.debug('Reporting:checkForReportingToasts');
const isToastPresent = await testSubjects.exists('completeReportSuccess', {