mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
b86cef59c0
commit
260ea9a07b
26 changed files with 651 additions and 147 deletions
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`,
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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}`,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
54
x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/utils.test.tsx.snap
generated
Normal file
54
x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/utils.test.tsx.snap
generated
Normal 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",
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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) => ({})),
|
||||
|
|
|
@ -40,6 +40,7 @@ export const {
|
|||
editVisualizationAction,
|
||||
removeLayers,
|
||||
removeOrClearLayer,
|
||||
cloneLayer,
|
||||
addLayer,
|
||||
setLayerDefaultDimension,
|
||||
} = lensActions;
|
||||
|
|
|
@ -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,
|
||||
{
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue