mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[controls] Refactor control group settings functional tests (#190999)
Refactor control group settings tests 1. move non-functional test cases to unit tests 2. use pre-built dashboard to avoid time of building dashboard in test ### Before Tests take 4 minutes to run locally <img width="400" alt="Screenshot 2024-08-21 at 3 12 31 PM" src="https://github.com/user-attachments/assets/96cf584c-2b32-4281-86ee-9791544bd5fa"> ### After Tests take 1 minute to run locally <img width="400" alt="Screenshot 2024-08-21 at 2 53 37 PM" src="https://github.com/user-attachments/assets/853a6f3a-74c5-4ca8-a488-99dd24477b1e"> --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
b6ce155bb5
commit
79051d46f7
8 changed files with 221 additions and 140 deletions
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ControlGroupEditor } from './control_group_editor';
|
||||
import { ControlGroupApi, ControlStyle, ParentIgnoreSettings } from '../../..';
|
||||
import { ControlGroupChainingSystem, DEFAULT_CONTROL_STYLE } from '../../../../common';
|
||||
import { DefaultControlApi } from '../../controls/types';
|
||||
|
||||
describe('render', () => {
|
||||
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
|
||||
const props = {
|
||||
api: {
|
||||
children$,
|
||||
} as unknown as ControlGroupApi,
|
||||
onCancel: () => {},
|
||||
onSave: () => {},
|
||||
onDeleteAll: () => {},
|
||||
stateManager: {
|
||||
chainingSystem: new BehaviorSubject<ControlGroupChainingSystem>('HIERARCHICAL'),
|
||||
labelPosition: new BehaviorSubject<ControlStyle>(DEFAULT_CONTROL_STYLE),
|
||||
autoApplySelections: new BehaviorSubject<boolean>(true),
|
||||
ignoreParentSettings: new BehaviorSubject<ParentIgnoreSettings | undefined>(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
children$.next({});
|
||||
});
|
||||
|
||||
test('should not display delete all controls button when there are no controls', () => {
|
||||
const editor = render(<ControlGroupEditor {...props} />);
|
||||
expect(editor.queryByTestId('delete-all-controls-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display delete all controls button when there are controls', () => {
|
||||
children$.next({
|
||||
alpha: {} as unknown as DefaultControlApi,
|
||||
});
|
||||
const editor = render(<ControlGroupEditor {...props} />);
|
||||
expect(editor.queryByTestId('delete-all-controls-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -27,11 +27,11 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { ControlStyle, ParentIgnoreSettings } from '../..';
|
||||
import { ControlStyle, ParentIgnoreSettings } from '../../..';
|
||||
|
||||
import { ControlStateManager } from '../controls/types';
|
||||
import { ControlGroupStrings } from './control_group_strings';
|
||||
import { ControlGroupApi, ControlGroupEditorState } from './types';
|
||||
import { ControlStateManager } from '../../controls/types';
|
||||
import { ControlGroupStrings } from '../control_group_strings';
|
||||
import { ControlGroupApi, ControlGroupEditorState } from '../types';
|
||||
|
||||
const CONTROL_LAYOUT_OPTIONS = [
|
||||
{
|
||||
|
@ -46,7 +46,7 @@ const CONTROL_LAYOUT_OPTIONS = [
|
|||
},
|
||||
];
|
||||
|
||||
interface EditControlGroupProps {
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onDeleteAll: () => void;
|
||||
|
@ -54,13 +54,7 @@ interface EditControlGroupProps {
|
|||
api: ControlGroupApi; // controls must always have a parent API
|
||||
}
|
||||
|
||||
export const ControlGroupEditor = ({
|
||||
onCancel,
|
||||
onSave,
|
||||
onDeleteAll,
|
||||
stateManager,
|
||||
api,
|
||||
}: EditControlGroupProps) => {
|
||||
export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager, api }: Props) => {
|
||||
const [
|
||||
children,
|
||||
selectedLabelPosition,
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useImperativeHandle } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { ControlPanel } from './control_panel';
|
||||
import { registry as presentationUtilServicesRegistry } from '@kbn/presentation-util-plugin/public/services/plugin_services.story';
|
||||
import { pluginServices as presentationUtilPluginServices } from '@kbn/presentation-util-plugin/public/services';
|
||||
import { ControlStyle, ControlWidth } from '../../..';
|
||||
|
||||
describe('render', () => {
|
||||
let mockApi = {};
|
||||
const Component = React.forwardRef((_, ref) => {
|
||||
// expose the api into the imperative handle
|
||||
useImperativeHandle(ref, () => mockApi, []);
|
||||
|
||||
return <div />;
|
||||
}) as any;
|
||||
|
||||
beforeAll(() => {
|
||||
presentationUtilServicesRegistry.start({});
|
||||
presentationUtilPluginServices.setRegistry(presentationUtilServicesRegistry);
|
||||
presentationUtilPluginServices.getServices().uiActions.getTriggerCompatibleActions = jest
|
||||
.fn()
|
||||
.mockImplementation(() => {
|
||||
return [
|
||||
{
|
||||
isCompatible: jest.fn().mockResolvedValue(true),
|
||||
id: 'testAction',
|
||||
MenuItem: () => <div>test1</div>,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockApi = {};
|
||||
});
|
||||
|
||||
describe('control width', () => {
|
||||
test('defaults to medium and grow enabled', async () => {
|
||||
const controlPanel = render(<ControlPanel uuid="control1" Component={Component} />);
|
||||
await waitFor(() => {
|
||||
const controlFrame = controlPanel.getByTestId('control-frame');
|
||||
expect(controlFrame.getAttribute('class')).toContain('controlFrameWrapper--medium');
|
||||
expect(controlFrame.getAttribute('class')).toContain('controlFrameWrapper--grow');
|
||||
});
|
||||
});
|
||||
|
||||
test('should use small class when using small width', async () => {
|
||||
mockApi = {
|
||||
uuid: 'control1',
|
||||
width: new BehaviorSubject<ControlWidth>('small'),
|
||||
};
|
||||
const controlPanel = render(<ControlPanel uuid="control1" Component={Component} />);
|
||||
await waitFor(() => {
|
||||
const controlFrame = controlPanel.getByTestId('control-frame');
|
||||
expect(controlFrame.getAttribute('class')).toContain('controlFrameWrapper--small');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('label position', () => {
|
||||
test('should use one line layout class when using one line layout', async () => {
|
||||
mockApi = {
|
||||
uuid: 'control1',
|
||||
parentApi: {
|
||||
labelPosition: new BehaviorSubject<ControlStyle>('oneLine'),
|
||||
},
|
||||
};
|
||||
const controlPanel = render(<ControlPanel uuid="control1" Component={Component} />);
|
||||
await waitFor(() => {
|
||||
const floatingActions = controlPanel.getByTestId(
|
||||
'presentationUtil__floatingActions__control1'
|
||||
);
|
||||
expect(floatingActions.getAttribute('class')).toContain(
|
||||
'controlFrameFloatingActions--oneLine'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should use two line layout class when using two line layout', async () => {
|
||||
mockApi = {
|
||||
uuid: 'control1',
|
||||
parentApi: {
|
||||
labelPosition: new BehaviorSubject<ControlStyle>('twoLine'),
|
||||
},
|
||||
};
|
||||
const controlPanel = render(<ControlPanel uuid="control1" Component={Component} />);
|
||||
await waitFor(() => {
|
||||
const floatingActions = controlPanel.getByTestId(
|
||||
'presentationUtil__floatingActions__control1'
|
||||
);
|
||||
expect(floatingActions.getAttribute('class')).toContain(
|
||||
'controlFrameFloatingActions--twoLine'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -27,7 +27,7 @@ import {
|
|||
useBatchedOptionalPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { FloatingActions } from '@kbn/presentation-util-plugin/public';
|
||||
import { DEFAULT_CONTROL_WIDTH } from '../../../../common';
|
||||
import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../../../common';
|
||||
|
||||
import { ControlPanelProps, DefaultControlApi } from '../../controls/types';
|
||||
import { ControlError } from './control_error';
|
||||
|
@ -121,17 +121,18 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
|
|||
const viewMode = (rawViewMode ?? ViewMode.VIEW) as ViewMode;
|
||||
const isEditable = viewMode === ViewMode.EDIT;
|
||||
const controlWidth = width ?? DEFAULT_CONTROL_WIDTH;
|
||||
const controlGrow = grow ?? DEFAULT_CONTROL_GROW;
|
||||
|
||||
return (
|
||||
<EuiFlexItem
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
grow={grow}
|
||||
grow={controlGrow}
|
||||
data-control-id={uuid}
|
||||
data-test-subj={`control-frame`}
|
||||
data-test-subj="control-frame"
|
||||
data-render-complete="true"
|
||||
className={classNames('controlFrameWrapper', {
|
||||
'controlFrameWrapper--grow': grow,
|
||||
'controlFrameWrapper--grow': controlGrow,
|
||||
'controlFrameWrapper--small': controlWidth === 'small',
|
||||
'controlFrameWrapper--medium': controlWidth === 'medium',
|
||||
'controlFrameWrapper--large': controlWidth === 'large',
|
||||
|
|
|
@ -16,7 +16,7 @@ import React from 'react';
|
|||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ControlStateManager } from '../controls/types';
|
||||
import { ControlGroupEditor } from './control_group_editor';
|
||||
import { ControlGroupEditor } from './components/control_group_editor';
|
||||
import { ControlGroupApi, ControlGroupEditorState } from './types';
|
||||
|
||||
export const openEditControlGroupFlyout = (
|
||||
|
|
|
@ -6,13 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
|
||||
import expect from '@kbn/expect';
|
||||
|
||||
import { FtrProviderContext } from '../../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const find = getService('find');
|
||||
const queryBar = getService('queryBar');
|
||||
const filterBar = getService('filterBar');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
@ -24,70 +21,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
describe('Dashboard control group settings', () => {
|
||||
before(async () => {
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.gotoDashboardLandingPage();
|
||||
await dashboard.clickNewDashboard();
|
||||
await timePicker.setDefaultDataRange();
|
||||
await dashboard.saveDashboard('Test Control Group Settings');
|
||||
});
|
||||
|
||||
it('adjust layout of controls', async () => {
|
||||
await dashboard.loadSavedDashboard('control group settings test dashboard');
|
||||
await dashboard.switchToEditMode();
|
||||
await dashboardControls.createControl({
|
||||
controlType: OPTIONS_LIST_CONTROL,
|
||||
dataViewTitle: 'animals-*',
|
||||
fieldName: 'sound.keyword',
|
||||
});
|
||||
await dashboardControls.adjustControlsLayout('twoLine');
|
||||
const controlGroupWrapper = await testSubjects.find('controls-group-wrapper');
|
||||
expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true);
|
||||
});
|
||||
|
||||
describe('apply new default width and grow', async () => {
|
||||
it('defaults to medium width and grow enabled', async () => {
|
||||
await dashboardControls.openCreateControlFlyout();
|
||||
const mediumWidthButton = await testSubjects.find('control-editor-width-medium');
|
||||
expect(await mediumWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be(
|
||||
true
|
||||
);
|
||||
const growSwitch = await testSubjects.find('control-editor-grow-switch');
|
||||
expect(await growSwitch.getAttribute('aria-checked')).to.be('true');
|
||||
await testSubjects.click('control-editor-cancel');
|
||||
});
|
||||
|
||||
it('sets default to width and grow of last created control', async () => {
|
||||
await dashboardControls.createControl({
|
||||
controlType: OPTIONS_LIST_CONTROL,
|
||||
dataViewTitle: 'animals-*',
|
||||
fieldName: 'name.keyword',
|
||||
width: 'small',
|
||||
grow: false,
|
||||
});
|
||||
|
||||
const controlIds = await dashboardControls.getAllControlIds();
|
||||
const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`);
|
||||
expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(true);
|
||||
expect(await firstControl.getAttribute('class')).not.to.contain('euiFlexItem-growZero');
|
||||
const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`);
|
||||
expect(await secondControl.elementHasClass('controlFrameWrapper--small')).to.be(true);
|
||||
expect(await secondControl.getAttribute('class')).to.contain('euiFlexItem-growZero');
|
||||
|
||||
await dashboardControls.openCreateControlFlyout();
|
||||
const smallWidthButton = await testSubjects.find('control-editor-width-small');
|
||||
expect(await smallWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be(
|
||||
true
|
||||
);
|
||||
const growSwitch = await testSubjects.find('control-editor-grow-switch');
|
||||
expect(await growSwitch.getAttribute('aria-checked')).to.be('false');
|
||||
await testSubjects.click('control-editor-cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering settings', async () => {
|
||||
let firstOptionsListId: string;
|
||||
const firstOptionsListId = 'bcb81550-0843-44ea-9020-6c1ebf3228ac';
|
||||
let beforeCount: number;
|
||||
|
||||
let rangeSliderId: string;
|
||||
const rangeSliderId = '15925456-9e12-4b08-b2e6-4ae6ac27114d';
|
||||
let beforeRange: number;
|
||||
|
||||
const getRange = async () => {
|
||||
|
@ -106,19 +48,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
};
|
||||
|
||||
before(async () => {
|
||||
await dashboardControls.createControl({
|
||||
controlType: RANGE_SLIDER_CONTROL,
|
||||
dataViewTitle: 'animals-*',
|
||||
fieldName: 'weightLbs',
|
||||
});
|
||||
await dashboard.clickQuickSave();
|
||||
|
||||
firstOptionsListId = (await dashboardControls.getAllControlIds())[0];
|
||||
await dashboardControls.optionsListWaitForLoading(firstOptionsListId);
|
||||
await dashboardControls.optionsListOpenPopover(firstOptionsListId);
|
||||
beforeCount = await dashboardControls.optionsListPopoverGetAvailableOptionsCount();
|
||||
|
||||
rangeSliderId = (await dashboardControls.getAllControlIds())[2];
|
||||
beforeRange = await getRange();
|
||||
});
|
||||
|
||||
|
@ -172,65 +105,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
});
|
||||
});
|
||||
|
||||
describe('flyout only show settings that are relevant', async () => {
|
||||
before(async () => {
|
||||
await dashboard.switchToEditMode();
|
||||
});
|
||||
|
||||
it('when no controls', async () => {
|
||||
await dashboardControls.deleteAllControls();
|
||||
await dashboardControls.openControlGroupSettingsFlyout();
|
||||
await testSubjects.missingOrFail('delete-all-controls-button');
|
||||
});
|
||||
|
||||
it('when at least one control', async () => {
|
||||
await dashboardControls.createControl({
|
||||
controlType: OPTIONS_LIST_CONTROL,
|
||||
dataViewTitle: 'animals-*',
|
||||
fieldName: 'sound.keyword',
|
||||
});
|
||||
await dashboardControls.openControlGroupSettingsFlyout();
|
||||
await testSubjects.existOrFail('delete-all-controls-button');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testSubjects.click('euiFlyoutCloseButton');
|
||||
if (await testSubjects.exists('confirmModalConfirmButton')) {
|
||||
await testSubjects.click('confirmModalConfirmButton');
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await dashboardControls.deleteAllControls();
|
||||
});
|
||||
});
|
||||
|
||||
describe('control group settings flyout closes', async () => {
|
||||
it('on save', async () => {
|
||||
await dashboardControls.openControlGroupSettingsFlyout();
|
||||
await dashboard.saveDashboard('Test Control Group Settings', {
|
||||
saveAsNew: false,
|
||||
exitFromEditMode: false,
|
||||
});
|
||||
await testSubjects.missingOrFail('control-group-settings-flyout');
|
||||
});
|
||||
|
||||
it('on view mode change', async () => {
|
||||
await dashboardControls.openControlGroupSettingsFlyout();
|
||||
await dashboard.clickCancelOutOfEditMode();
|
||||
await testSubjects.missingOrFail('control-group-settings-flyout');
|
||||
});
|
||||
|
||||
it('when navigating away from dashboard', async () => {
|
||||
await dashboard.switchToEditMode();
|
||||
await dashboardControls.openControlGroupSettingsFlyout();
|
||||
await dashboard.gotoDashboardLandingPage();
|
||||
await testSubjects.missingOrFail('control-group-settings-flyout');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await dashboard.loadSavedDashboard('Test Control Group Settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ export default function ({ loadTestFile, getService, getPageObjects }: FtrProvid
|
|||
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
|
||||
});
|
||||
|
||||
// enable the controls lab and navigate to the dashboard listing page to start
|
||||
await dashboard.navigateToApp();
|
||||
await dashboard.preserveCrossAppState();
|
||||
}
|
||||
|
|
|
@ -3175,3 +3175,53 @@
|
|||
"coreMigrationVersion": "8.8.0",
|
||||
"typeMigrationVersion": "8.9.0"
|
||||
}
|
||||
|
||||
{
|
||||
"id": "153e3302-1b37-4c45-9b11-91deec40ab47",
|
||||
"type": "dashboard",
|
||||
"namespaces": [
|
||||
"default"
|
||||
],
|
||||
"updated_at": "2024-08-21T20:48:22.388Z",
|
||||
"created_at": "2024-08-21T20:46:21.250Z",
|
||||
"version": "WzQ4MCwxXQ==",
|
||||
"attributes": {
|
||||
"version": 2,
|
||||
"controlGroupInput": {
|
||||
"controlStyle": "oneLine",
|
||||
"chainingSystem": "HIERARCHICAL",
|
||||
"showApplySelections": false,
|
||||
"panelsJSON": "{\"bcb81550-0843-44ea-9020-6c1ebf3228ac\":{\"type\":\"optionsListControl\",\"order\":0,\"grow\":true,\"width\":\"medium\",\"explicitInput\":{\"id\":\"bcb81550-0843-44ea-9020-6c1ebf3228ac\",\"fieldName\":\"sound.keyword\",\"title\":\"sound.keyword\",\"grow\":true,\"width\":\"medium\",\"searchTechnique\":\"prefix\",\"enhancements\":{}}},\"15925456-9e12-4b08-b2e6-4ae6ac27114d\":{\"type\":\"rangeSliderControl\",\"order\":1,\"grow\":true,\"width\":\"medium\",\"explicitInput\":{\"fieldName\":\"weightLbs\",\"title\":\"weightLbs\",\"searchTechnique\":\"exact\",\"id\":\"15925456-9e12-4b08-b2e6-4ae6ac27114d\",\"enhancements\":{}}}}",
|
||||
"ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}"
|
||||
},
|
||||
"kibanaSavedObjectMeta": {
|
||||
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
|
||||
},
|
||||
"description": "",
|
||||
"refreshInterval": {
|
||||
"pause": true,
|
||||
"value": 60000
|
||||
},
|
||||
"timeRestore": true,
|
||||
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
|
||||
"panelsJSON": "[]",
|
||||
"timeFrom": "2018-01-01T00:00:00.000Z",
|
||||
"title": "control group settings test dashboard",
|
||||
"timeTo": "2018-04-13T00:00:00.000Z"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"name": "controlGroup_bcb81550-0843-44ea-9020-6c1ebf3228ac:optionsListDataView",
|
||||
"type": "index-pattern",
|
||||
"id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c"
|
||||
},
|
||||
{
|
||||
"name": "controlGroup_15925456-9e12-4b08-b2e6-4ae6ac27114d:rangeSliderDataView",
|
||||
"type": "index-pattern",
|
||||
"id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c"
|
||||
}
|
||||
],
|
||||
"managed": false,
|
||||
"coreMigrationVersion": "8.8.0",
|
||||
"typeMigrationVersion": "10.2.0"
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue