[Lens] Duplicate layers (#140603)

* [Lens] Should allow a copy from visualisation layer

* some changes

* fix some CI steps

* fix some CI steps

* add renewIDs method

* update data-test-subjs

* push some changes

* cleanup

* clenaup

* cleanup

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Update x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_buttons/clone_layer_button.tsx

Co-authored-by: Michael Marcialis <michael@marcial.is>

* Update x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_buttons/layer_buttons.tsx

Co-authored-by: Michael Marcialis <michael@marcial.is>

* do some renaming

* wrap text

* fix JEST

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Michael Marcialis <michael@marcial.is>
This commit is contained in:
Alexey Antonov 2022-09-20 11:03:04 +03:00 committed by GitHub
parent b86cef59c0
commit 260ea9a07b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 651 additions and 147 deletions

View file

@ -29,6 +29,16 @@ import { createIndexPatternServiceMock } from '../../../mocks/data_views_service
jest.mock('../../../id_generator');
jest.mock('@kbn/kibana-utils-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-utils-plugin/public');
return {
...original,
Storage: class Storage {
get = () => ({ skipDeleteModal: true });
},
};
});
const waitMs = (time: number) => new Promise((r) => setTimeout(r, time));
let container: HTMLDivElement | undefined;
@ -169,9 +179,7 @@ describe('ConfigPanel', () => {
instance.find('[data-test-subj="lnsLayerRemove--0"]').first().simulate('click');
});
instance.update();
act(() => {
instance.find('[data-test-subj="lnsLayerRemoveConfirmButton"]').first().simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl).toEqual(firstLayerFocusable);
});
@ -196,9 +204,7 @@ describe('ConfigPanel', () => {
instance.find('[data-test-subj="lnsLayerRemove--0"]').first().simulate('click');
});
instance.update();
act(() => {
instance.find('[data-test-subj="lnsLayerRemoveConfirmButton"]').first().simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl).toEqual(secondLayerFocusable);
});
@ -222,9 +228,7 @@ describe('ConfigPanel', () => {
instance.find('[data-test-subj="lnsLayerRemove--1"]').first().simulate('click');
});
instance.update();
act(() => {
instance.find('[data-test-subj="lnsLayerRemoveConfirmButton"]').first().simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl).toEqual(firstLayerFocusable);
});

View file

@ -22,6 +22,7 @@ import {
setLayerDefaultDimension,
useLensDispatch,
removeOrClearLayer,
cloneLayer,
addLayer,
updateState,
updateDatasourceState,
@ -223,6 +224,13 @@ export function LayerPanels(
);
}
}}
onCloneLayer={() => {
dispatchLens(
cloneLayer({
layerId,
})
);
}}
onRemoveLayer={() => {
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId];
const datasourceId = datasourcePublicAPI?.datasourceId;

View file

@ -0,0 +1,30 @@
/*
* 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 { Visualization } from '../../../..';
import { LayerAction } from './types';
interface CloneLayerAction {
execute: () => void;
layerIndex: number;
activeVisualization: Visualization;
}
export const getCloneLayerAction = (props: CloneLayerAction): LayerAction => {
const displayName = i18n.translate('xpack.lens.cloneLayerAriaLabel', {
defaultMessage: 'Duplicate layer',
});
return {
execute: props.execute,
displayName,
isCompatible: Boolean(props.activeVisualization.cloneLayer),
icon: 'copy',
'data-test-subj': `lnsLayerClone--${props.layerIndex}`,
};
};

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export { LayerActions } from './layer_actions';
export type { LayerActionsProps } from './layer_actions';

View file

@ -0,0 +1,149 @@
/*
* 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 React, { useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import type { CoreStart } from '@kbn/core/public';
import {
EuiButtonIcon,
EuiContextMenuPanel,
EuiPopover,
useGeneratedHtmlId,
EuiContextMenuItem,
EuiIcon,
EuiText,
EuiOutsideClickDetector,
} from '@elastic/eui';
import type { LayerType, Visualization } from '../../../..';
import type { LayerAction } from './types';
import { getCloneLayerAction } from './clone_layer_action';
import { getRemoveLayerAction } from './remove_layer_action';
export interface LayerActionsProps {
onRemoveLayer: () => void;
onCloneLayer: () => void;
layerIndex: number;
isOnlyLayer: boolean;
activeVisualization: Visualization;
layerType?: LayerType;
core: Pick<CoreStart, 'overlays' | 'theme'>;
}
/** @internal **/
const InContextMenuActions = (
props: LayerActionsProps & {
actions: LayerAction[];
}
) => {
const dataTestSubject = `lnsLayerSplitButton--${props.layerIndex}`;
const [isPopoverOpen, setPopover] = useState(false);
const splitButtonPopoverId = useGeneratedHtmlId({
prefix: dataTestSubject,
});
const onButtonClick = useCallback(() => {
setPopover(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopover = useCallback(() => {
if (isPopoverOpen) {
setPopover(false);
}
}, [isPopoverOpen]);
return (
<EuiOutsideClickDetector onOutsideClick={closePopover}>
<EuiPopover
id={splitButtonPopoverId}
button={
<EuiButtonIcon
display="empty"
color="text"
size="s"
iconType="boxesHorizontal"
aria-label={i18n.translate('xpack.lens.layer.actions.contextMenuAriaLabel', {
defaultMessage: `Layer actions`,
})}
onClick={onButtonClick}
data-test-subj={dataTestSubject}
/>
}
ownFocus={true}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
items={props.actions.map((i) => (
<EuiContextMenuItem
icon={<EuiIcon type={i.icon} title={i.displayName} color={i.color} />}
data-test-subj={i['data-test-subj']}
aria-label={i.displayName}
title={i.displayName}
onClick={() => {
closePopover();
i.execute();
}}
>
<EuiText size={'s'} color={i.color}>
{i.displayName}
</EuiText>
</EuiContextMenuItem>
))}
/>
</EuiPopover>
</EuiOutsideClickDetector>
);
};
export const LayerActions = (props: LayerActionsProps) => {
const compatibleActions = useMemo(
() =>
[
getCloneLayerAction({
execute: props.onCloneLayer,
layerIndex: props.layerIndex,
activeVisualization: props.activeVisualization,
}),
getRemoveLayerAction({
execute: props.onRemoveLayer,
layerIndex: props.layerIndex,
activeVisualization: props.activeVisualization,
layerType: props.layerType,
isOnlyLayer: props.isOnlyLayer,
core: props.core,
}),
].filter((i) => i.isCompatible),
[props]
);
if (!compatibleActions.length) {
return null;
}
if (compatibleActions.length > 1) {
return <InContextMenuActions {...props} actions={compatibleActions} />;
} else {
const [{ displayName, execute, icon, color, 'data-test-subj': dataTestSubj }] =
compatibleActions;
return (
<EuiButtonIcon
size="xs"
iconType={icon}
color={color}
data-test-subj={dataTestSubj}
aria-label={displayName}
title={displayName}
onClick={execute}
/>
);
}
};

View file

@ -5,30 +5,42 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import type { CoreStart } from '@kbn/core/public';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonIcon,
EuiCheckbox,
EuiCheckboxProps,
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { Visualization } from '../../../types';
import { LocalStorageLens, LOCAL_STORAGE_LENS_KEY } from '../../../settings_storage';
import { LayerType, layerTypes } from '../../..';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { LayerAction } from './types';
import { Visualization } from '../../../../types';
import { LOCAL_STORAGE_LENS_KEY } from '../../../../settings_storage';
import { LayerType, layerTypes } from '../../../..';
interface RemoveLayerAction {
execute: () => void;
layerIndex: number;
activeVisualization: Visualization;
layerType?: LayerType;
isOnlyLayer: boolean;
core: Pick<CoreStart, 'overlays' | 'theme'>;
}
const SKIP_DELETE_MODAL_KEY = 'skipDeleteModal';
const modalDescClear = i18n.translate('xpack.lens.layer.confirmModal.clearVis', {
defaultMessage: `Clearing this layer removes the visualization and its configurations. `,
});
const modalDescVis = i18n.translate('xpack.lens.layer.confirmModal.deleteVis', {
defaultMessage: `Deleting this layer removes the visualization and its configurations. `,
});
@ -39,12 +51,7 @@ const modalDescAnnotation = i18n.translate('xpack.lens.layer.confirmModal.delete
defaultMessage: `Deleting this layer removes the annotations and their configurations. `,
});
const getButtonCopy = (
layerIndex: number,
layerType: LayerType,
canBeRemoved?: boolean,
isOnlyLayer?: boolean
) => {
const getButtonCopy = (layerType: LayerType, canBeRemoved?: boolean, isOnlyLayer?: boolean) => {
let ariaLabel;
const layerTypeCopy =
@ -86,13 +93,11 @@ const getButtonCopy = (
});
} else if (isOnlyLayer) {
ariaLabel = i18n.translate('xpack.lens.resetLayerAriaLabel', {
defaultMessage: 'Reset layer {index}',
values: { index: layerIndex + 1 },
defaultMessage: 'Clear layer',
});
} else {
ariaLabel = i18n.translate('xpack.lens.deleteLayerAriaLabel', {
defaultMessage: `Delete layer {index}`,
values: { index: layerIndex + 1 },
defaultMessage: `Delete layer`,
});
}
@ -103,105 +108,39 @@ const getButtonCopy = (
};
};
export function RemoveLayerButton({
onRemoveLayer,
layerIndex,
isOnlyLayer,
activeVisualization,
layerType,
}: {
onRemoveLayer: () => void;
layerIndex: number;
isOnlyLayer: boolean;
activeVisualization: Visualization;
layerType?: LayerType;
}) {
const { ariaLabel, modalTitle, modalDesc } = getButtonCopy(
layerIndex,
layerType || layerTypes.DATA,
!!activeVisualization.removeLayer,
isOnlyLayer
);
const [isModalVisible, setIsModalVisible] = useState(false);
const [lensLocalStorage, setLensLocalStorage] = useLocalStorage<LocalStorageLens>(
LOCAL_STORAGE_LENS_KEY,
{}
);
const onChangeShouldShowModal = () =>
setLensLocalStorage({
...lensLocalStorage,
skipDeleteModal: !lensLocalStorage?.skipDeleteModal,
});
const closeModal = () => setIsModalVisible(false);
const showModal = () => setIsModalVisible(true);
const removeLayer = () => {
// If we don't blur the remove / clear button, it remains focused
// which is a strange UX in this case. e.target.blur doesn't work
// due to who knows what, but probably event re-writing. Additionally,
// activeElement does not have blur so, we need to do some casting + safeguards.
const el = document.activeElement as unknown as { blur: () => void };
if (el?.blur) {
el.blur();
}
onRemoveLayer();
};
return (
<>
<EuiButtonIcon
size="xs"
iconType={isOnlyLayer ? 'eraser' : 'trash'}
color="danger"
data-test-subj={`lnsLayerRemove--${layerIndex}`}
aria-label={ariaLabel}
title={ariaLabel}
onClick={() => {
if (lensLocalStorage?.skipDeleteModal) {
return removeLayer();
}
return showModal();
}}
/>
{isModalVisible ? (
<RemoveConfirmModal
modalTitle={modalTitle}
isDeletable={!!activeVisualization.removeLayer && !isOnlyLayer}
modalDesc={modalDesc}
closeModal={closeModal}
skipDeleteModal={lensLocalStorage?.skipDeleteModal}
onChangeShouldShowModal={onChangeShouldShowModal}
removeLayer={removeLayer}
/>
) : null}
</>
);
}
const RemoveConfirmModal = ({
modalTitle,
modalDesc,
closeModal,
skipDeleteModal,
onChangeShouldShowModal,
isDeletable,
removeLayer,
execute,
closeModal,
updateLensLocalStorage,
}: {
modalTitle: string;
modalDesc: string;
closeModal: () => void;
skipDeleteModal?: boolean;
skipDeleteModal: boolean;
isDeletable?: boolean;
onChangeShouldShowModal: () => void;
removeLayer: () => void;
execute: () => void;
closeModal: () => void;
updateLensLocalStorage: (partial: Record<string, unknown>) => void;
}) => {
const [skipDeleteModalLocal, setSkipDeleteModalLocal] = useState(skipDeleteModal);
const onChangeShouldShowModal: EuiCheckboxProps['onChange'] = useCallback(
({ target }) => setSkipDeleteModalLocal(target.checked),
[]
);
const onRemove = useCallback(() => {
updateLensLocalStorage({
[SKIP_DELETE_MODAL_KEY]: skipDeleteModalLocal,
});
closeModal();
execute();
}, [closeModal, execute, skipDeleteModalLocal, updateLensLocalStorage]);
return (
<EuiModal data-test-subj="lnsLayerRemoveModal" onClose={closeModal}>
<>
<EuiModalHeader>
<EuiModalHeaderTitle>{modalTitle}</EuiModalHeaderTitle>
</EuiModalHeader>
@ -222,7 +161,7 @@ const RemoveConfirmModal = ({
label={i18n.translate('xpack.lens.layer.confirmModal.dontAskAgain', {
defaultMessage: `Don't ask me again`,
})}
indeterminate={skipDeleteModal}
checked={skipDeleteModalLocal}
onChange={onChangeShouldShowModal}
/>
</EuiFlexItem>
@ -238,13 +177,10 @@ const RemoveConfirmModal = ({
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="lnsLayerRemoveConfirmButton"
onClick={() => {
closeModal();
removeLayer();
}}
fill
onClick={onRemove}
color="danger"
iconType={isDeletable ? 'trash' : 'eraser'}
fill
>
{isDeletable
? i18n.translate('xpack.lens.layer.confirmDelete', {
@ -259,6 +195,56 @@ const RemoveConfirmModal = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
</>
);
};
export const getRemoveLayerAction = (props: RemoveLayerAction): LayerAction => {
const { ariaLabel, modalTitle, modalDesc } = getButtonCopy(
props.layerType || layerTypes.DATA,
!!props.activeVisualization.removeLayer,
props.isOnlyLayer
);
return {
execute: async () => {
const storage = new Storage(localStorage);
const lensLocalStorage = storage.get(LOCAL_STORAGE_LENS_KEY) ?? {};
const updateLensLocalStorage = (partial: Record<string, unknown>) => {
storage.set(LOCAL_STORAGE_LENS_KEY, {
...lensLocalStorage,
...partial,
});
};
if (!lensLocalStorage.skipDeleteModal) {
const modal = props.core.overlays.openModal(
toMountPoint(
<RemoveConfirmModal
modalTitle={modalTitle}
isDeletable={!!props.activeVisualization.removeLayer && !props.isOnlyLayer}
modalDesc={modalDesc}
skipDeleteModal={lensLocalStorage[LOCAL_STORAGE_LENS_KEY] ?? false}
execute={props.execute}
closeModal={() => modal.close()}
updateLensLocalStorage={updateLensLocalStorage}
/>,
{ theme$: props.core.theme.theme$ }
),
{
'data-test-subj': 'lnsLayerRemoveModal',
}
);
await modal.onClose;
} else {
props.execute();
}
},
displayName: ariaLabel,
isCompatible: true,
icon: props.isOnlyLayer ? 'eraser' : 'trash',
color: 'danger',
'data-test-subj': `lnsLayerRemove--${props.layerIndex}`,
};
};

View file

@ -0,0 +1,17 @@
/*
* 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 type { IconType, EuiButtonIconColor } from '@elastic/eui';
/** @internal **/
export interface LayerAction {
displayName: string;
execute: () => void | Promise<void>;
icon: IconType;
color?: EuiButtonIconColor;
isCompatible: boolean;
'data-test-subj'?: string;
}

View file

@ -24,6 +24,16 @@ import { createIndexPatternServiceMock } from '../../../mocks/data_views_service
jest.mock('../../../id_generator');
jest.mock('@kbn/kibana-utils-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-utils-plugin/public');
return {
...original,
Storage: class Storage {
get = () => ({ skipDeleteModal: true });
},
};
});
let container: HTMLDivElement | undefined;
beforeEach(() => {
@ -90,6 +100,7 @@ describe('LayerPanel', () => {
framePublicAPI: frame,
isOnlyLayer: true,
onRemoveLayer: jest.fn(),
onCloneLayer: jest.fn(),
dispatch: jest.fn(),
core: coreMock.createStart(),
layerIndex: 0,
@ -138,7 +149,7 @@ describe('LayerPanel', () => {
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
expect(
instance.find('[data-test-subj="lnsLayerRemove--0"]').first().props()['aria-label']
).toContain('Reset layer');
).toContain('Clear layer');
});
it('should show the delete button when multiple layers', async () => {
@ -168,9 +179,6 @@ describe('LayerPanel', () => {
instance.find('[data-test-subj="lnsLayerRemove--0"]').first().simulate('click');
});
instance.update();
act(() => {
instance.find('[data-test-subj="lnsLayerRemoveConfirmButton"]').first().simulate('click');
});
expect(cb).toHaveBeenCalled();
});
});

View file

@ -18,6 +18,7 @@ import {
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LayerActions } from './layer_actions';
import { IndexPatternServiceAPI } from '../../../data_views_service/service';
import { NativeRenderer } from '../../../native_renderer';
import {
@ -31,7 +32,6 @@ import { DragDropIdentifier, ReorderProvider } from '../../../drag_drop';
import { LayerSettings } from './layer_settings';
import { LayerPanelProps, ActiveDimensionState } from './types';
import { DimensionContainer } from './dimension_container';
import { RemoveLayerButton } from './remove_layer_button';
import { EmptyDimensionButton } from './buttons/empty_dimension_button';
import { DimensionButton } from './buttons/dimension_button';
import { DraggableDimensionButton } from './buttons/draggable_dimension_button';
@ -63,6 +63,7 @@ export function LayerPanel(
newVisualizationState: unknown
) => void;
onRemoveLayer: () => void;
onCloneLayer: () => void;
registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void;
toggleFullscreen: () => void;
onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void;
@ -85,6 +86,7 @@ export function LayerPanel(
layerId,
isOnlyLayer,
onRemoveLayer,
onCloneLayer,
registerNewLayerRef,
layerIndex,
activeVisualization,
@ -95,6 +97,7 @@ export function LayerPanel(
updateDatasourceAsync,
visualizationState,
onChangeIndexPattern,
core,
} = props;
const datasourceStates = useLensSelector(selectDatasourceStates);
@ -327,12 +330,14 @@ export function LayerPanel(
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RemoveLayerButton
onRemoveLayer={onRemoveLayer}
<LayerActions
layerIndex={layerIndex}
isOnlyLayer={isOnlyLayer}
activeVisualization={activeVisualization}
layerType={activeVisualization.getLayerType(layerId, visualizationState)}
onRemoveLayer={onRemoveLayer}
onCloneLayer={onCloneLayer}
core={core}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`indexpattern_datasource utils cloneLayer should clone layer with renewing ids 1`] = `
Object {
"a": Object {
"columnOrder": Array [
"899ee4b6-3147-4d45-94bf-ea9c02e55d28",
"ae62cfc8-faa5-4096-a30c-f92ac59922a0",
],
"columns": Object {
"899ee4b6-3147-4d45-94bf-ea9c02e55d28": Object {
"params": Object {
"orderBy": Object {
"columnId": "ae62cfc8-faa5-4096-a30c-f92ac59922a0",
"type": "column",
},
"orderDirection": "desc",
},
},
"ae62cfc8-faa5-4096-a30c-f92ac59922a0": Object {
"params": Object {
"emptyAsNull": true,
},
},
},
"incompleteColumns": Object {},
"indexPatternId": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
},
"b": Object {
"columnOrder": Array [
"899ee4b6-3147-4d45-94bf-ea9c02e55d28C",
"ae62cfc8-faa5-4096-a30c-f92ac59922a0C",
],
"columns": Object {
"899ee4b6-3147-4d45-94bf-ea9c02e55d28C": Object {
"params": Object {
"orderBy": Object {
"columnId": "ae62cfc8-faa5-4096-a30c-f92ac59922a0C",
"type": "column",
},
"orderDirection": "desc",
},
},
"ae62cfc8-faa5-4096-a30c-f92ac59922a0C": Object {
"params": Object {
"emptyAsNull": true,
},
},
},
"incompleteColumns": Object {},
"indexPatternId": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
},
}
`;

View file

@ -29,7 +29,7 @@ import {
IUiSettingsClient,
SavedObjectsClientContract,
HttpSetup,
CoreSetup,
CoreStart,
} from '@kbn/core/public';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { generateId } from '../../id_generator';
@ -240,7 +240,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
},
} as unknown as DataPublicPluginStart,
core: {} as CoreSetup,
core: {} as CoreStart,
dimensionGroups: [],
groupId: 'a',
isFullscreen: false,

View file

@ -19,7 +19,7 @@ import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
describe('Fields Accordion', () => {
let defaultProps: FieldsAccordionProps;
let indexPattern: IndexPattern;
let core: ReturnType<typeof coreMock['createSetup']>;
let core: ReturnType<typeof coreMock['createStart']>;
let fieldProps: FieldItemSharedProps;
beforeEach(() => {
@ -42,7 +42,7 @@ describe('Fields Accordion', () => {
},
],
} as IndexPattern;
core = coreMock.createSetup();
core = coreMock.createStart();
core.http.post.mockClear();
fieldProps = {

View file

@ -66,6 +66,7 @@ import {
getTSDBRollupWarningMessages,
getVisualDefaultsForLayer,
isColumnInvalid,
cloneLayer,
} from './utils';
import { normalizeOperationDataType, isDraggedField } from './pure_utils';
import { LayerPanel } from './layerpanel';
@ -189,6 +190,13 @@ export function getIndexPatternDatasource({
};
},
cloneLayer(state, layerId, newLayerId, getNewId) {
return {
...state,
layers: cloneLayer(state.layers, layerId, newLayerId, getNewId),
};
},
removeLayer(state: IndexPatternPrivateState, layerId: string) {
const newLayers = { ...state.layers };
delete newLayers[layerId];

View file

@ -8,7 +8,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { createDatatableUtilitiesMock } from '@kbn/data-plugin/common/mocks';
import { getPrecisionErrorWarningMessages } from './utils';
import { getPrecisionErrorWarningMessages, cloneLayer } from './utils';
import type { IndexPatternPrivateState, GenericIndexPatternColumn } from './types';
import type { FramePublicAPI } from '../types';
import type { DocLinksStart } from '@kbn/core/public';
@ -16,6 +16,7 @@ import { EuiButton } from '@elastic/eui';
import { TermsIndexPatternColumn } from './operations';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { FormattedMessage } from '@kbn/i18n-react';
import { IndexPatternLayer } from './types';
describe('indexpattern_datasource utils', () => {
describe('getPrecisionErrorWarningMessages', () => {
@ -196,4 +197,42 @@ describe('indexpattern_datasource utils', () => {
});
});
});
describe('cloneLayer', () => {
test('should clone layer with renewing ids', () => {
expect(
cloneLayer(
{
a: {
columns: {
'899ee4b6-3147-4d45-94bf-ea9c02e55d28': {
params: {
orderBy: {
type: 'column',
columnId: 'ae62cfc8-faa5-4096-a30c-f92ac59922a0',
},
orderDirection: 'desc',
},
},
'ae62cfc8-faa5-4096-a30c-f92ac59922a0': {
params: {
emptyAsNull: true,
},
},
},
columnOrder: [
'899ee4b6-3147-4d45-94bf-ea9c02e55d28',
'ae62cfc8-faa5-4096-a30c-f92ac59922a0',
],
incompleteColumns: {},
indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
},
} as unknown as Record<string, IndexPatternLayer>,
'a',
'b',
(id) => id + 'C'
)
).toMatchSnapshot();
});
});
});

View file

@ -18,6 +18,7 @@ import { groupBy, escape, uniq } from 'lodash';
import type { Query } from '@kbn/data-plugin/common';
import { SearchResponseWarning } from '@kbn/data-plugin/public/search/types';
import type { FramePublicAPI, IndexPattern, StateSetter } from '../types';
import { renewIDs } from '../utils';
import type {
IndexPatternLayer,
IndexPatternPersistedState,
@ -622,3 +623,22 @@ export function getFiltersInLayer(
},
};
}
export const cloneLayer = (
layers: Record<string, IndexPatternLayer>,
layerId: string,
newLayerId: string,
getNewId: (id: string) => string
) => {
if (layers[layerId]) {
return {
...layers,
[newLayerId]: renewIDs(
layers[layerId],
Object.keys(layers[layerId]?.columns ?? {}),
getNewId
),
};
}
return layers;
};

View file

@ -44,6 +44,7 @@ export function createMockDatasource(id: string): DatasourceMock {
toExpression: jest.fn((_frame, _state, _indexPatterns) => null),
insertLayer: jest.fn((_state, _newLayerId) => ({})),
removeLayer: jest.fn((_state, _layerId) => {}),
cloneLayer: jest.fn((_state, _layerId, _newLayerId, getNewId) => {}),
removeColumn: jest.fn((props) => {}),
getLayers: jest.fn((_state) => []),
uniqueLabels: jest.fn((_state) => ({})),

View file

@ -40,6 +40,7 @@ export const {
editVisualizationAction,
removeLayers,
removeOrClearLayer,
cloneLayer,
addLayer,
setLayerDefaultDimension,
} = lensActions;

View file

@ -161,6 +161,14 @@ export const removeOrClearLayer = createAction<{
layerId: string;
layerIds: string[];
}>('lens/removeOrClearLayer');
export const cloneLayer = createAction(
'cloneLayer',
function prepare({ layerId }: { layerId: string }) {
return { payload: { newLayerId: generateId(), layerId } };
}
);
export const addLayer = createAction<{
layerId: string;
layerType: LayerType;
@ -210,6 +218,7 @@ export const lensActions = {
removeLayers,
removeOrClearLayer,
addLayer,
cloneLayer,
setLayerDefaultDimension,
updateIndexPatterns,
replaceIndexpattern,
@ -275,6 +284,53 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
stagedPreview: undefined,
};
},
[cloneLayer.type]: (
state,
{
payload: { layerId, newLayerId },
}: {
payload: {
layerId: string;
newLayerId: string;
};
}
) => {
const clonedIDsMap = new Map<string, string>();
const getNewId = (prevId: string) => {
const inMapValue = clonedIDsMap.get(prevId);
if (!inMapValue) {
const newId = generateId();
clonedIDsMap.set(prevId, newId);
return newId;
}
return inMapValue;
};
if (!state.activeDatasourceId || !state.visualization.activeId) {
return state;
}
state.datasourceStates = mapValues(state.datasourceStates, (datasourceState, datasourceId) =>
datasourceId
? {
...datasourceState,
state: datasourceMap[datasourceId].cloneLayer(
datasourceState.state,
layerId,
newLayerId,
getNewId
),
}
: datasourceState
);
state.visualization.state = visualizationMap[state.visualization.activeId].cloneLayer!(
state.visualization.state,
layerId,
newLayerId,
clonedIDsMap
);
},
[removeOrClearLayer.type]: (
state,
{

View file

@ -6,7 +6,7 @@
*/
import type { Ast } from '@kbn/interpreter';
import type { IconType } from '@elastic/eui/src/components/icon/icon';
import type { CoreSetup, SavedObjectReference, ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { CoreStart, SavedObjectReference, ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { PaletteOutput } from '@kbn/coloring';
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
import type { MutableRefObject } from 'react';
@ -89,8 +89,6 @@ export type IndexPatternField = FieldSpec & {
runtime?: boolean;
};
export type ErrorCallback = (e: { message: string }) => void;
export interface PublicAPIProps<T> {
state: T;
layerId: string;
@ -265,6 +263,12 @@ export interface Datasource<T = unknown, P = unknown> {
insertLayer: (state: T, newLayerId: string) => T;
removeLayer: (state: T, layerId: string) => T;
clearLayer: (state: T, layerId: string) => T;
cloneLayer: (
state: T,
layerId: string,
newLayerId: string,
getNewId: (id: string) => string
) => T;
getLayers: (state: T) => string[];
removeColumn: (props: {
prevState: T;
@ -491,7 +495,7 @@ export interface DatasourceDataPanelProps<T = unknown> {
dragDropContext: DragContextState;
setState: StateSetter<T, { applyImmediately?: boolean }>;
showNoDataPopover: () => void;
core: Pick<CoreSetup, 'http' | 'notifications' | 'uiSettings'>;
core: Pick<CoreStart, 'http' | 'notifications' | 'uiSettings' | 'overlays' | 'theme'>;
query: Query;
dateRange: DateRange;
filters: Filter[];
@ -545,7 +549,7 @@ export type DatasourceDimensionEditorProps<T = unknown> = DatasourceDimensionPro
forceRender?: boolean;
}
>;
core: Pick<CoreSetup, 'http' | 'notifications' | 'uiSettings'>;
core: Pick<CoreStart, 'http' | 'notifications' | 'uiSettings' | 'overlays' | 'theme'>;
dateRange: DateRange;
dimensionGroups: VisualizationDimensionGroupConfig[];
toggleFullscreen: () => void;
@ -918,6 +922,14 @@ export interface Visualization<T = unknown, P = unknown> {
getLayerIds: (state: T) => string[];
/** Reset button on each layer triggers this */
clearLayer: (state: T, layerId: string, indexPatternId: string) => T;
/** Reset button on each layer triggers this */
cloneLayer?: (
state: T,
layerId: string,
newLayerId: string,
/** @param contains map old -> new id **/
clonedIDsMap: Map<string, string>
) => T;
/** Optional, if the visualization supports multiple layers */
removeLayer?: (state: T, layerId: string) => T;
/** Track added layers in internal state */

View file

@ -7,7 +7,7 @@
import { createDatatableUtilitiesMock } from '@kbn/data-plugin/common/mocks';
import { Datatable } from '@kbn/expressions-plugin/public';
import { inferTimeField } from './utils';
import { inferTimeField, renewIDs } from './utils';
const datatableUtilities = createDatatableUtilitiesMock();
@ -119,4 +119,42 @@ describe('utils', () => {
).toEqual(undefined);
});
});
describe('renewIDs', () => {
test('should renew ids for multiple cases', () => {
expect(
renewIDs(
{
r1: {
a: [
'r2',
'b',
{
b: 'r3',
r3: 'test',
},
],
},
r2: 'r3',
},
['r1', 'r2', 'r3'],
(i) => i + '_test'
)
).toMatchInlineSnapshot(`
Object {
"r1_test": Object {
"a": Array [
"r2_test",
"b",
Object {
"b": "r3_test",
"r3_test": "test",
},
],
},
"r2_test": "r3_test",
}
`);
});
});
});

View file

@ -4,9 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { uniq } from 'lodash';
import { set, uniq, cloneDeep } from 'lodash';
import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import type { Serializable } from '@kbn/utility-types';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public';
@ -195,6 +196,46 @@ export function inferTimeField(
.find(Boolean);
}
export function renewIDs<T = unknown>(
obj: T,
forRenewIds: string[],
getNewId: (id: string) => string | undefined
): T {
obj = cloneDeep(obj);
const recursiveFn = (
item: Serializable,
parent?: Record<string, Serializable> | Serializable[],
key?: string | number
) => {
if (typeof item === 'object') {
if (Array.isArray(item)) {
item.forEach((a, k, ref) => recursiveFn(a, ref, k));
} else {
if (item) {
Object.keys(item).forEach((k) => {
let newId = k;
if (forRenewIds.includes(k)) {
newId = getNewId(k) ?? k;
item[newId] = item[k];
delete item[k];
}
recursiveFn(item[newId], item, newId);
});
}
}
} else if (
parent &&
key !== undefined &&
typeof item === 'string' &&
forRenewIds.includes(item)
) {
set(parent, key, getNewId(item) ?? item);
}
};
recursiveFn(obj as unknown as Serializable);
return obj;
}
/**
* The dimension container is set up to close when it detects a click outside it.
* Use this CSS class to exclude particular elements from this behavior.

View file

@ -19,6 +19,8 @@ import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { generateId } from '../../id_generator';
import { renewIDs } from '../../utils';
import { getSuggestions } from './xy_suggestions';
import { XyToolbar } from './xy_config_panel';
import { DimensionEditor } from './xy_config_panel/dimension_editor';
@ -129,6 +131,24 @@ export const getXyVisualization = ({
};
},
cloneLayer(state, layerId, newLayerId, clonedIDsMap) {
const toCopyLayer = state.layers.find((l) => l.layerId === layerId);
if (toCopyLayer) {
if (isAnnotationsLayer(toCopyLayer)) {
toCopyLayer.annotations.forEach((i) => clonedIDsMap.set(i.id, generateId()));
}
const newLayer = renewIDs(toCopyLayer, [...clonedIDsMap.keys()], (id: string) =>
clonedIDsMap.get(id)
);
newLayer.layerId = newLayerId;
return {
...state,
layers: [...state.layers, newLayer],
};
}
return state;
},
appendLayer(state, layerId, layerType, indexPatternId) {
const firstUsedSeriesType = getDataLayers(state.layers)?.[0]?.seriesType;
return {

View file

@ -16886,7 +16886,6 @@
"xpack.lens.confirmModal.saveDuplicateButtonLabel": "Enregistrer {name}",
"xpack.lens.confirmModal.saveDuplicateConfirmationMessage": "Il y a déjà une occurrence de {name} avec le titre \"{title}\". Voulez-vous tout de même enregistrer ?",
"xpack.lens.datatable.visualizationOf": "Tableau {operations}",
"xpack.lens.deleteLayerAriaLabel": "Supprimer le calque {index}",
"xpack.lens.dragDrop.announce.cancelled": "Mouvement annulé. {label} revenu à sa position initiale",
"xpack.lens.dragDrop.announce.cancelledItem": "Mouvement annulé. {label} revenu au groupe {groupLabel} à la position {position}",
"xpack.lens.dragDrop.announce.dropped.combineCompatible": "Combinaisons de {label} dans le {groupLabel} vers {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}",
@ -17028,7 +17027,6 @@
"xpack.lens.modalTitle.title.delete": "Supprimer le calque {layerType} ?",
"xpack.lens.pie.arrayValues": "{label} contient des valeurs de tableau. Le rendu de votre visualisation peut ne pas se présenter comme attendu.",
"xpack.lens.pie.suggestionLabel": "Comme {chartName}",
"xpack.lens.resetLayerAriaLabel": "Réinitialiser le calque {index}",
"xpack.lens.shared.legend.filterOptionsLegend": "{legendDataLabel}, options de filtre",
"xpack.lens.table.tableCellFilter.filterForValueAriaLabel": "Filtrer sur la valeur : {cellContent}",
"xpack.lens.table.tableCellFilter.filterOutValueAriaLabel": "Exclure la valeur : {cellContent}",

View file

@ -16871,7 +16871,6 @@
"xpack.lens.confirmModal.saveDuplicateButtonLabel": "{name} を保存",
"xpack.lens.confirmModal.saveDuplicateConfirmationMessage": "「{title}」というタイトルの {name} がすでに存在します。保存しますか?",
"xpack.lens.datatable.visualizationOf": "テーブル {operations}",
"xpack.lens.deleteLayerAriaLabel": "レイヤー {index} を削除",
"xpack.lens.dragDrop.announce.cancelled": "移動がキャンセルされました。{label}は初期位置に戻りました",
"xpack.lens.dragDrop.announce.cancelledItem": "移動がキャンセルされました。{label}は位置{position}の{groupLabel}グループに戻りました",
"xpack.lens.dragDrop.announce.dropped.combineCompatible": "レイヤー{dropLayerNumber}の位置{dropPosition}で、グループ{groupLabel}の{label}をグループ{dropGroupLabel}の{dropLabel}と結合しました",
@ -17009,7 +17008,6 @@
"xpack.lens.modalTitle.title.delete": "{layerType}レイヤーを削除しますか?",
"xpack.lens.pie.arrayValues": "{label}には配列値が含まれます。可視化が想定通りに表示されない場合があります。",
"xpack.lens.pie.suggestionLabel": "{chartName}として",
"xpack.lens.resetLayerAriaLabel": "レイヤー {index} をリセット",
"xpack.lens.shared.legend.filterOptionsLegend": "{legendDataLabel}、フィルターオプション",
"xpack.lens.table.tableCellFilter.filterForValueAriaLabel": "値のフィルター:{cellContent}",
"xpack.lens.table.tableCellFilter.filterOutValueAriaLabel": "値の除外:{cellContent}",

View file

@ -16889,7 +16889,6 @@
"xpack.lens.confirmModal.saveDuplicateButtonLabel": "保存“{name}”",
"xpack.lens.confirmModal.saveDuplicateConfirmationMessage": "具有标题“{title}”的 {name} 已存在。是否确定要保存?",
"xpack.lens.datatable.visualizationOf": "表{operations}",
"xpack.lens.deleteLayerAriaLabel": "删除图层 {index}",
"xpack.lens.dragDrop.announce.cancelled": "移动已取消。{label} 将返回至其初始位置",
"xpack.lens.dragDrop.announce.cancelledItem": "移动已取消。{label} 返回至 {groupLabel} 组中的位置 {position}",
"xpack.lens.dragDrop.announce.dropped.combineCompatible": "已将组 {groupLabel} 中的 {label} 组合到图层 {dropLayerNumber} 的组 {dropGroupLabel} 中的位置 {dropPosition} 上的 {dropLabel}",
@ -17031,7 +17030,6 @@
"xpack.lens.modalTitle.title.delete": "删除 {layerType} 图层?",
"xpack.lens.pie.arrayValues": "{label} 包含数组值。您的可视化可能无法正常渲染。",
"xpack.lens.pie.suggestionLabel": "为 {chartName}",
"xpack.lens.resetLayerAriaLabel": "重置图层 {index}",
"xpack.lens.shared.legend.filterOptionsLegend": "{legendDataLabel}, 筛选选项",
"xpack.lens.table.tableCellFilter.filterForValueAriaLabel": "筛留值:{cellContent}",
"xpack.lens.table.tableCellFilter.filterOutValueAriaLabel": "筛除值:{cellContent}",

View file

@ -1300,6 +1300,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
/** resets visualization/layer or removes a layer */
async removeLayer(index: number = 0) {
await retry.try(async () => {
if (await testSubjects.exists(`lnsLayerSplitButton--${index}`)) {
await testSubjects.click(`lnsLayerSplitButton--${index}`);
}
await testSubjects.click(`lnsLayerRemove--${index}`);
if (await testSubjects.exists('lnsLayerRemoveModal')) {
await testSubjects.exists('lnsLayerRemoveConfirmButton');