mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 02:09:32 -04:00
[Reporting] Add new data-render-error
attribute (#114472)
* added new data-render-error attribute, read it and store it on job object * added data-render-error to the example app * added jest test * address pr feedback - make renderErrors optional in interfaces - create separate selectors for data render error selector/attr - Tidy up mergeMap behaviour * fix observable.test.ts snapshots and browser driver mock * updated jest snapshots Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
2afb43b869
commit
45e07af1fa
14 changed files with 191 additions and 4 deletions
|
@ -244,7 +244,12 @@ export const ReportingExampleApp = ({
|
||||||
)}
|
)}
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
{logos.map((item, index) => (
|
{logos.map((item, index) => (
|
||||||
<EuiFlexItem key={index} data-shared-item>
|
<EuiFlexItem
|
||||||
|
key={index}
|
||||||
|
data-shared-item
|
||||||
|
data-shared-render-error
|
||||||
|
data-render-error="This is an example error"
|
||||||
|
>
|
||||||
<EuiCard
|
<EuiCard
|
||||||
icon={<EuiIcon size="xxl" type={`logo${item}`} />}
|
icon={<EuiIcon size="xxl" type={`logo${item}`} />}
|
||||||
title={`Elastic ${item}`}
|
title={`Elastic ${item}`}
|
||||||
|
|
|
@ -54,6 +54,9 @@ export async function generatePngObservableFactory(reporting: ReportingCore) {
|
||||||
if (current.error) {
|
if (current.error) {
|
||||||
found.push(current.error.message);
|
found.push(current.error.message);
|
||||||
}
|
}
|
||||||
|
if (current.renderErrors) {
|
||||||
|
found.push(...current.renderErrors);
|
||||||
|
}
|
||||||
return found;
|
return found;
|
||||||
}, [] as string[]),
|
}, [] as string[]),
|
||||||
})),
|
})),
|
||||||
|
|
|
@ -108,6 +108,9 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) {
|
||||||
if (current.error) {
|
if (current.error) {
|
||||||
found.push(current.error.message);
|
found.push(current.error.message);
|
||||||
}
|
}
|
||||||
|
if (current.renderErrors) {
|
||||||
|
found.push(...current.renderErrors);
|
||||||
|
}
|
||||||
return found;
|
return found;
|
||||||
}, [] as string[]),
|
}, [] as string[]),
|
||||||
};
|
};
|
||||||
|
|
|
@ -120,6 +120,9 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) {
|
||||||
if (current.error) {
|
if (current.error) {
|
||||||
found.push(current.error.message);
|
found.push(current.error.message);
|
||||||
}
|
}
|
||||||
|
if (current.renderErrors) {
|
||||||
|
found.push(...current.renderErrors);
|
||||||
|
}
|
||||||
return found;
|
return found;
|
||||||
}, [] as string[]),
|
}, [] as string[]),
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,6 +32,8 @@ describe('Create Layout', () => {
|
||||||
"selectors": Object {
|
"selectors": Object {
|
||||||
"itemsCountAttribute": "data-shared-items-count",
|
"itemsCountAttribute": "data-shared-items-count",
|
||||||
"renderComplete": "[data-shared-item]",
|
"renderComplete": "[data-shared-item]",
|
||||||
|
"renderError": "[data-render-error]",
|
||||||
|
"renderErrorAttribute": "data-render-error",
|
||||||
"screenshot": "[data-shared-items-container]",
|
"screenshot": "[data-shared-items-container]",
|
||||||
"timefilterDurationAttribute": "data-shared-timefilter-duration",
|
"timefilterDurationAttribute": "data-shared-timefilter-duration",
|
||||||
},
|
},
|
||||||
|
@ -63,6 +65,8 @@ describe('Create Layout', () => {
|
||||||
"selectors": Object {
|
"selectors": Object {
|
||||||
"itemsCountAttribute": "data-shared-items-count",
|
"itemsCountAttribute": "data-shared-items-count",
|
||||||
"renderComplete": "[data-shared-item]",
|
"renderComplete": "[data-shared-item]",
|
||||||
|
"renderError": "[data-render-error]",
|
||||||
|
"renderErrorAttribute": "data-render-error",
|
||||||
"screenshot": "[data-shared-item]",
|
"screenshot": "[data-shared-item]",
|
||||||
"timefilterDurationAttribute": "data-shared-timefilter-duration",
|
"timefilterDurationAttribute": "data-shared-timefilter-duration",
|
||||||
},
|
},
|
||||||
|
@ -91,6 +95,8 @@ describe('Create Layout', () => {
|
||||||
"selectors": Object {
|
"selectors": Object {
|
||||||
"itemsCountAttribute": "data-shared-items-count",
|
"itemsCountAttribute": "data-shared-items-count",
|
||||||
"renderComplete": "[data-shared-item]",
|
"renderComplete": "[data-shared-item]",
|
||||||
|
"renderError": "[data-render-error]",
|
||||||
|
"renderErrorAttribute": "data-render-error",
|
||||||
"screenshot": "[data-shared-items-container]",
|
"screenshot": "[data-shared-items-container]",
|
||||||
"timefilterDurationAttribute": "data-shared-timefilter-duration",
|
"timefilterDurationAttribute": "data-shared-timefilter-duration",
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,8 @@ import type { Layout } from './layout';
|
||||||
export interface LayoutSelectorDictionary {
|
export interface LayoutSelectorDictionary {
|
||||||
screenshot: string;
|
screenshot: string;
|
||||||
renderComplete: string;
|
renderComplete: string;
|
||||||
|
renderError: string;
|
||||||
|
renderErrorAttribute: string;
|
||||||
itemsCountAttribute: string;
|
itemsCountAttribute: string;
|
||||||
timefilterDurationAttribute: string;
|
timefilterDurationAttribute: string;
|
||||||
}
|
}
|
||||||
|
@ -33,6 +35,8 @@ export const LayoutTypes = {
|
||||||
export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({
|
export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({
|
||||||
screenshot: '[data-shared-items-container]',
|
screenshot: '[data-shared-items-container]',
|
||||||
renderComplete: '[data-shared-item]',
|
renderComplete: '[data-shared-item]',
|
||||||
|
renderError: '[data-render-error]',
|
||||||
|
renderErrorAttribute: 'data-render-error',
|
||||||
itemsCountAttribute: 'data-shared-items-count',
|
itemsCountAttribute: 'data-shared-items-count',
|
||||||
timefilterDurationAttribute: 'data-shared-timefilter-duration',
|
timefilterDurationAttribute: 'data-shared-timefilter-duration',
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const DEFAULT_PAGELOAD_SELECTOR = `.${APP_WRAPPER_CLASS}`;
|
||||||
export const CONTEXT_GETNUMBEROFITEMS = 'GetNumberOfItems';
|
export const CONTEXT_GETNUMBEROFITEMS = 'GetNumberOfItems';
|
||||||
export const CONTEXT_INJECTCSS = 'InjectCss';
|
export const CONTEXT_INJECTCSS = 'InjectCss';
|
||||||
export const CONTEXT_WAITFORRENDER = 'WaitForRender';
|
export const CONTEXT_WAITFORRENDER = 'WaitForRender';
|
||||||
|
export const CONTEXT_GETRENDERERRORS = 'GetVisualisationsRenderErrors';
|
||||||
export const CONTEXT_GETTIMERANGE = 'GetTimeRange';
|
export const CONTEXT_GETTIMERANGE = 'GetTimeRange';
|
||||||
export const CONTEXT_ELEMENTATTRIBUTES = 'ElementPositionAndAttributes';
|
export const CONTEXT_ELEMENTATTRIBUTES = 'ElementPositionAndAttributes';
|
||||||
export const CONTEXT_WAITFORELEMENTSTOBEINDOM = 'WaitForElementsToBeInDOM';
|
export const CONTEXT_WAITFORELEMENTSTOBEINDOM = 'WaitForElementsToBeInDOM';
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HeadlessChromiumDriver } from '../../browsers';
|
||||||
|
import {
|
||||||
|
createMockBrowserDriverFactory,
|
||||||
|
createMockConfig,
|
||||||
|
createMockConfigSchema,
|
||||||
|
createMockLayoutInstance,
|
||||||
|
createMockLevelLogger,
|
||||||
|
createMockReportingCore,
|
||||||
|
} from '../../test_helpers';
|
||||||
|
import { CaptureConfig } from '../../types';
|
||||||
|
import { LayoutInstance } from '../layouts';
|
||||||
|
import { LevelLogger } from '../level_logger';
|
||||||
|
import { getRenderErrors } from './get_render_errors';
|
||||||
|
|
||||||
|
describe('getRenderErrors', () => {
|
||||||
|
let captureConfig: CaptureConfig;
|
||||||
|
let layout: LayoutInstance;
|
||||||
|
let logger: jest.Mocked<LevelLogger>;
|
||||||
|
let browser: HeadlessChromiumDriver;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const schema = createMockConfigSchema();
|
||||||
|
const config = createMockConfig(schema);
|
||||||
|
const core = await createMockReportingCore(schema);
|
||||||
|
|
||||||
|
captureConfig = config.get('capture');
|
||||||
|
layout = createMockLayoutInstance(captureConfig);
|
||||||
|
logger = createMockLevelLogger();
|
||||||
|
|
||||||
|
await createMockBrowserDriverFactory(core, logger, {
|
||||||
|
evaluate: jest.fn(
|
||||||
|
async <T extends (...args: unknown[]) => unknown>({
|
||||||
|
fn,
|
||||||
|
args,
|
||||||
|
}: {
|
||||||
|
fn: T;
|
||||||
|
args: Parameters<T>;
|
||||||
|
}) => fn(...args)
|
||||||
|
),
|
||||||
|
getCreatePage: (driver) => {
|
||||||
|
browser = driver;
|
||||||
|
|
||||||
|
return jest.fn();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract the error messages', async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div dataRenderErrorSelector="a test error" />
|
||||||
|
<div dataRenderErrorSelector="a test error" />
|
||||||
|
<div dataRenderErrorSelector="a test error" />
|
||||||
|
<div dataRenderErrorSelector="a test error" />
|
||||||
|
`;
|
||||||
|
|
||||||
|
await expect(getRenderErrors(browser, layout, logger)).resolves.toEqual([
|
||||||
|
'a test error',
|
||||||
|
'a test error',
|
||||||
|
'a test error',
|
||||||
|
'a test error',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract the error messages, even when there are none', async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<renderedSelector />
|
||||||
|
`;
|
||||||
|
|
||||||
|
await expect(getRenderErrors(browser, layout, logger)).resolves.toEqual(undefined);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import type { HeadlessChromiumDriver } from '../../browsers';
|
||||||
|
import type { LayoutInstance } from '../layouts';
|
||||||
|
import { LevelLogger, startTrace } from '../';
|
||||||
|
import { CONTEXT_GETRENDERERRORS } from './constants';
|
||||||
|
|
||||||
|
export const getRenderErrors = async (
|
||||||
|
browser: HeadlessChromiumDriver,
|
||||||
|
layout: LayoutInstance,
|
||||||
|
logger: LevelLogger
|
||||||
|
): Promise<undefined | string[]> => {
|
||||||
|
const endTrace = startTrace('get_render_errors', 'read');
|
||||||
|
logger.debug('reading render errors');
|
||||||
|
const errorsFound: undefined | string[] = await browser.evaluate(
|
||||||
|
{
|
||||||
|
fn: (errorSelector, errorAttribute) => {
|
||||||
|
const visualizations: Element[] = Array.from(document.querySelectorAll(errorSelector));
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
visualizations.forEach((visualization) => {
|
||||||
|
const errorMessage = visualization.getAttribute(errorAttribute);
|
||||||
|
if (errorMessage) {
|
||||||
|
errors.push(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors.length ? errors : undefined;
|
||||||
|
},
|
||||||
|
args: [layout.selectors.renderError, layout.selectors.renderErrorAttribute],
|
||||||
|
},
|
||||||
|
{ context: CONTEXT_GETRENDERERRORS },
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
endTrace();
|
||||||
|
|
||||||
|
if (errorsFound?.length) {
|
||||||
|
logger.warning(
|
||||||
|
i18n.translate('xpack.reporting.screencapture.renderErrorsFound', {
|
||||||
|
defaultMessage: 'Found {count} error messages. See report object for more information.',
|
||||||
|
values: { count: errorsFound.length },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorsFound;
|
||||||
|
};
|
|
@ -53,5 +53,12 @@ export interface ScreenshotResults {
|
||||||
timeRange: string | null;
|
timeRange: string | null;
|
||||||
screenshots: Screenshot[];
|
screenshots: Screenshot[];
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual visualizations might encounter errors at runtime. If there are any they are added to this
|
||||||
|
* field. Any text captured here is intended to be shown to the user for debugging purposes, reporting
|
||||||
|
* does no further sanitization on these strings.
|
||||||
|
*/
|
||||||
|
renderErrors?: string[];
|
||||||
elementsPositionAndAttributes?: ElementsPositionAndAttribute[]; // NOTE: for testing
|
elementsPositionAndAttributes?: ElementsPositionAndAttribute[]; // NOTE: for testing
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,6 +98,7 @@ describe('Screenshot Observable Pipeline', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
|
"renderErrors": undefined,
|
||||||
"screenshots": Array [
|
"screenshots": Array [
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
|
@ -172,6 +173,7 @@ describe('Screenshot Observable Pipeline', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
|
"renderErrors": undefined,
|
||||||
"screenshots": Array [
|
"screenshots": Array [
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
|
@ -223,6 +225,7 @@ describe('Screenshot Observable Pipeline', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
|
"renderErrors": undefined,
|
||||||
"screenshots": Array [
|
"screenshots": Array [
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
|
@ -312,6 +315,7 @@ describe('Screenshot Observable Pipeline', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!],
|
"error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!],
|
||||||
|
"renderErrors": undefined,
|
||||||
"screenshots": Array [
|
"screenshots": Array [
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
|
@ -354,6 +358,7 @@ describe('Screenshot Observable Pipeline', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!],
|
"error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!],
|
||||||
|
"renderErrors": undefined,
|
||||||
"screenshots": Array [
|
"screenshots": Array [
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
|
@ -460,6 +465,7 @@ describe('Screenshot Observable Pipeline', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"error": undefined,
|
"error": undefined,
|
||||||
|
"renderErrors": undefined,
|
||||||
"screenshots": Array [
|
"screenshots": Array [
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { getElementPositionAndAttributes } from './get_element_position_data';
|
||||||
import { getNumberOfItems } from './get_number_of_items';
|
import { getNumberOfItems } from './get_number_of_items';
|
||||||
import { getScreenshots } from './get_screenshots';
|
import { getScreenshots } from './get_screenshots';
|
||||||
import { getTimeRange } from './get_time_range';
|
import { getTimeRange } from './get_time_range';
|
||||||
|
import { getRenderErrors } from './get_render_errors';
|
||||||
import { injectCustomCss } from './inject_css';
|
import { injectCustomCss } from './inject_css';
|
||||||
import { openUrl } from './open_url';
|
import { openUrl } from './open_url';
|
||||||
import { waitForRenderComplete } from './wait_for_render';
|
import { waitForRenderComplete } from './wait_for_render';
|
||||||
|
@ -28,6 +29,7 @@ const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800;
|
||||||
interface ScreenSetupData {
|
interface ScreenSetupData {
|
||||||
elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null;
|
elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null;
|
||||||
timeRange: string | null;
|
timeRange: string | null;
|
||||||
|
renderErrors?: string[];
|
||||||
error?: Error;
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,16 +100,22 @@ export function getScreenshots$(
|
||||||
return await Promise.all([
|
return await Promise.all([
|
||||||
getTimeRange(driver, layout, logger),
|
getTimeRange(driver, layout, logger),
|
||||||
getElementPositionAndAttributes(driver, layout, logger),
|
getElementPositionAndAttributes(driver, layout, logger),
|
||||||
]).then(([timeRange, elementsPositionAndAttributes]) => ({
|
getRenderErrors(driver, layout, logger),
|
||||||
|
]).then(([timeRange, elementsPositionAndAttributes, renderErrors]) => ({
|
||||||
elementsPositionAndAttributes,
|
elementsPositionAndAttributes,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
renderErrors,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
checkPageIsOpen(driver); // if browser has closed, throw a relevant error about it
|
checkPageIsOpen(driver); // if browser has closed, throw a relevant error about it
|
||||||
|
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
return Rx.of({ elementsPositionAndAttributes: null, timeRange: null, error: err });
|
return Rx.of({
|
||||||
|
elementsPositionAndAttributes: null,
|
||||||
|
timeRange: null,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -120,11 +128,12 @@ export function getScreenshots$(
|
||||||
? data.elementsPositionAndAttributes
|
? data.elementsPositionAndAttributes
|
||||||
: getDefaultElementPosition(layout.getViewport(1));
|
: getDefaultElementPosition(layout.getViewport(1));
|
||||||
const screenshots = await getScreenshots(driver, elements, logger);
|
const screenshots = await getScreenshots(driver, elements, logger);
|
||||||
const { timeRange, error: setupError } = data;
|
const { timeRange, error: setupError, renderErrors } = data;
|
||||||
return {
|
return {
|
||||||
timeRange,
|
timeRange,
|
||||||
screenshots,
|
screenshots,
|
||||||
error: setupError,
|
error: setupError,
|
||||||
|
renderErrors,
|
||||||
elementsPositionAndAttributes: elements,
|
elementsPositionAndAttributes: elements,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
@ -78,6 +78,9 @@ mockBrowserEvaluate.mockImplementation(() => {
|
||||||
if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) {
|
if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) {
|
||||||
return Promise.resolve(getMockElementsPositionAndAttributes('Default Mock Title', 'Default '));
|
return Promise.resolve(getMockElementsPositionAndAttributes('Default Mock Title', 'Default '));
|
||||||
}
|
}
|
||||||
|
if (mockCall === contexts.CONTEXT_GETRENDERERRORS) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
throw new Error(mockCall);
|
throw new Error(mockCall);
|
||||||
});
|
});
|
||||||
const mockScreenshot = jest.fn(async () => Buffer.from('screenshot'));
|
const mockScreenshot = jest.fn(async () => Buffer.from('screenshot'));
|
||||||
|
|
|
@ -18,6 +18,8 @@ export const createMockLayoutInstance = (captureConfig: CaptureConfig) => {
|
||||||
renderComplete: 'renderedSelector',
|
renderComplete: 'renderedSelector',
|
||||||
itemsCountAttribute: 'itemsSelector',
|
itemsCountAttribute: 'itemsSelector',
|
||||||
screenshot: 'screenshotSelector',
|
screenshot: 'screenshotSelector',
|
||||||
|
renderError: '[dataRenderErrorSelector]',
|
||||||
|
renderErrorAttribute: 'dataRenderErrorSelector',
|
||||||
timefilterDurationAttribute: 'timefilterDurationSelector',
|
timefilterDurationAttribute: 'timefilterDurationSelector',
|
||||||
};
|
};
|
||||||
return mockLayout;
|
return mockLayout;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue