))
- .add('without controls', () => (
-
- ))
- .add('with controls', () => (
+ .add('default', () => (
))
.add('with text filter', () => (
-
- ))
- .add('with tags filter', () => (
-
- ))
- .add('with controls and filter', () => (
diff --git a/x-pack/legacy/plugins/canvas/public/components/element_types/__examples__/fixtures/test_elements.tsx b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/__examples__/fixtures/test_elements.tsx
similarity index 93%
rename from x-pack/legacy/plugins/canvas/public/components/element_types/__examples__/fixtures/test_elements.tsx
rename to x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/__examples__/fixtures/test_elements.tsx
index eec7a86d52f25..d1ff565b4955a 100644
--- a/x-pack/legacy/plugins/canvas/public/components/element_types/__examples__/fixtures/test_elements.tsx
+++ b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/__examples__/fixtures/test_elements.tsx
@@ -6,41 +6,6 @@
import { elasticLogo } from '../../../../lib/elastic_logo';
-export const testElements = [
- {
- name: 'areaChart',
- displayName: 'Area chart',
- help: 'A line chart with a filled body',
- tags: ['chart'],
- image: elasticLogo,
- expression: `filters
- | demodata
- | pointseries x="time" y="mean(price)"
- | plot defaultStyle={seriesStyle lines=1 fill=1}
- | render`,
- },
- {
- name: 'image',
- displayName: 'Image',
- help: 'A static image',
- tags: ['graphic'],
- image: elasticLogo,
- expression: `image dataurl=null mode="contain"
- | render`,
- },
- {
- name: 'table',
- displayName: 'Data table',
- tags: ['text'],
- help: 'A scrollable grid for displaying data in a tabular format',
- image: elasticLogo,
- expression: `filters
- | demodata
- | table
- | render`,
- },
-];
-
export const testCustomElements = [
{
id: 'custom-element-10d625f5-1342-47c9-8f19-d174ea6b65d5',
diff --git a/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx
new file mode 100644
index 0000000000000..4941d8cb2efa7
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import { SavedElementsModal } from '../saved_elements_modal';
+import { testCustomElements } from './fixtures/test_elements';
+import { CustomElement } from '../../../../types';
+
+storiesOf('components/SavedElementsModal', module)
+ .add('no custom elements', () => (
+
+ ))
+ .add('with custom elements', () => (
+
+ ))
+ .add('with text filter', () => (
+
+ ));
diff --git a/x-pack/legacy/plugins/canvas/public/components/element_types/element_controls.tsx b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/element_controls.tsx
similarity index 85%
rename from x-pack/legacy/plugins/canvas/public/components/element_types/element_controls.tsx
rename to x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/element_controls.tsx
index a23274296f64f..998b15c15f487 100644
--- a/x-pack/legacy/plugins/canvas/public/components/element_types/element_controls.tsx
+++ b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/element_controls.tsx
@@ -30,7 +30,12 @@ export const ElementControls: FunctionComponent
= ({ onDelete, onEdit })
>
-
+
@@ -40,6 +45,7 @@ export const ElementControls: FunctionComponent = ({ onDelete, onEdit })
iconType="trash"
aria-label={strings.getDeleteAriaLabel()}
onClick={onDelete}
+ data-test-subj="canvasElementCard__deleteButton"
/>
diff --git a/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/element_grid.tsx b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/element_grid.tsx
new file mode 100644
index 0000000000000..f86e2c0147035
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/element_grid.tsx
@@ -0,0 +1,81 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { map } from 'lodash';
+import { EuiFlexItem, EuiFlexGrid } from '@elastic/eui';
+import { ElementControls } from './element_controls';
+import { CustomElement } from '../../../types';
+import { ElementCard } from '../element_card';
+
+export interface Props {
+ /**
+ * list of elements to generate cards for
+ */
+ elements: CustomElement[];
+ /**
+ * text to filter out cards
+ */
+ filterText: string;
+ /**
+ * handler invoked when clicking a card
+ */
+ onClick: (element: CustomElement) => void;
+ /**
+ * click handler for the edit button
+ */
+ onEdit: (element: CustomElement) => void;
+ /**
+ * click handler for the delete button
+ */
+ onDelete: (element: CustomElement) => void;
+}
+
+export const ElementGrid = ({ elements, filterText, onClick, onEdit, onDelete }: Props) => {
+ filterText = filterText.toLowerCase();
+
+ return (
+
+ {map(elements, (element: CustomElement, index) => {
+ const { name, displayName = '', help = '', image } = element;
+ const whenClicked = () => onClick(element);
+
+ if (
+ filterText.length &&
+ !name.toLowerCase().includes(filterText) &&
+ !displayName.toLowerCase().includes(filterText) &&
+ !help.toLowerCase().includes(filterText)
+ ) {
+ return null;
+ }
+
+ return (
+
+
+ onEdit(element)} onDelete={() => onDelete(element)} />
+
+ );
+ })}
+
+ );
+};
+
+ElementGrid.propTypes = {
+ elements: PropTypes.array.isRequired,
+ filterText: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onEdit: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+};
+
+ElementGrid.defaultProps = {
+ filterText: '',
+};
diff --git a/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/index.ts b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/index.ts
new file mode 100644
index 0000000000000..bb088ad4e0de1
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/index.ts
@@ -0,0 +1,128 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import { compose, withState } from 'recompose';
+import { camelCase } from 'lodash';
+// @ts-ignore Untyped local
+import { cloneSubgraphs } from '../../lib/clone_subgraphs';
+import * as customElementService from '../../lib/custom_element_service';
+// @ts-ignore Untyped local
+import { notify } from '../../lib/notify';
+// @ts-ignore Untyped local
+import { selectToplevelNodes } from '../../state/actions/transient';
+// @ts-ignore Untyped local
+import { insertNodes } from '../../state/actions/elements';
+import { getSelectedPage } from '../../state/selectors/workpad';
+import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric';
+import { SavedElementsModal as Component, Props as ComponentProps } from './saved_elements_modal';
+import { State, PositionedElement, CustomElement } from '../../../types';
+
+const customElementAdded = 'elements-custom-added';
+
+interface OwnProps {
+ onClose: () => void;
+}
+
+interface OwnPropsWithState extends OwnProps {
+ customElements: CustomElement[];
+ setCustomElements: (customElements: CustomElement[]) => void;
+ search: string;
+ setSearch: (search: string) => void;
+}
+
+interface DispatchProps {
+ selectToplevelNodes: (nodes: PositionedElement[]) => void;
+ insertNodes: (selectedNodes: PositionedElement[], pageId: string) => void;
+}
+
+interface StateProps {
+ pageId: string;
+}
+
+const mapStateToProps = (state: State): StateProps => ({
+ pageId: getSelectedPage(state),
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
+ selectToplevelNodes: (nodes: PositionedElement[]) =>
+ dispatch(
+ selectToplevelNodes(
+ nodes
+ .filter((e: PositionedElement): boolean => !e.position.parent)
+ .map((e: PositionedElement): string => e.id)
+ )
+ ),
+ insertNodes: (selectedNodes: PositionedElement[], pageId: string) =>
+ dispatch(insertNodes(selectedNodes, pageId)),
+});
+
+const mergeProps = (
+ stateProps: StateProps,
+ dispatchProps: DispatchProps,
+ ownProps: OwnPropsWithState
+): ComponentProps => {
+ const { pageId } = stateProps;
+ const { onClose, search, setCustomElements } = ownProps;
+
+ const findCustomElements = async () => {
+ const { customElements } = await customElementService.find(search);
+ setCustomElements(customElements);
+ };
+
+ return {
+ ...ownProps,
+ // add custom element to the page
+ addCustomElement: (customElement: CustomElement) => {
+ const { selectedNodes = [] } = JSON.parse(customElement.content) || {};
+ const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes);
+ if (clonedNodes) {
+ dispatchProps.insertNodes(clonedNodes, pageId); // first clone and persist the new node(s)
+ dispatchProps.selectToplevelNodes(clonedNodes); // then select the cloned node(s)
+ }
+ onClose();
+ trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded);
+ },
+ // custom element search
+ findCustomElements: async (text?: string) => {
+ try {
+ await findCustomElements();
+ } catch (err) {
+ notify.error(err, { title: `Couldn't find custom elements` });
+ }
+ },
+ // remove custom element
+ removeCustomElement: async (id: string) => {
+ try {
+ await customElementService.remove(id);
+ await findCustomElements();
+ } catch (err) {
+ notify.error(err, { title: `Couldn't delete custom elements` });
+ }
+ },
+ // update custom element
+ updateCustomElement: async (id: string, name: string, description: string, image: string) => {
+ try {
+ await customElementService.update(id, {
+ name: camelCase(name),
+ displayName: name,
+ image,
+ help: description,
+ });
+ await findCustomElements();
+ } catch (err) {
+ notify.error(err, { title: `Couldn't update custom elements` });
+ }
+ },
+ };
+};
+
+export const SavedElementsModal = compose(
+ withState('search', 'setSearch', ''),
+ withState('customElements', 'setCustomElements', []),
+ connect(mapStateToProps, mapDispatchToProps, mergeProps)
+)(Component);
diff --git a/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx
new file mode 100644
index 0000000000000..dba97a15fee5c
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx
@@ -0,0 +1,217 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { Fragment, ChangeEvent, FunctionComponent, useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import {
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiEmptyPrompt,
+ EuiFieldSearch,
+ EuiSpacer,
+ EuiOverlayMask,
+ EuiButton,
+} from '@elastic/eui';
+import { map, sortBy } from 'lodash';
+import { ComponentStrings } from '../../../i18n';
+import { CustomElement } from '../../../types';
+import { ConfirmModal } from '../confirm_modal/confirm_modal';
+import { CustomElementModal } from '../custom_element_modal';
+import { ElementGrid } from './element_grid';
+
+const { SavedElementsModal: strings } = ComponentStrings;
+
+export interface Props {
+ /**
+ * Adds the custom element to the workpad
+ */
+ addCustomElement: (customElement: CustomElement) => void;
+ /**
+ * Queries ES for custom element saved objects
+ */
+ findCustomElements: () => void;
+ /**
+ * Handler invoked when the modal closes
+ */
+ onClose: () => void;
+ /**
+ * Deletes the custom element
+ */
+ removeCustomElement: (id: string) => void;
+ /**
+ * Saved edits to the custom element
+ */
+ updateCustomElement: (id: string, name: string, description: string, image: string) => void;
+ /**
+ * Array of custom elements to display
+ */
+ customElements: CustomElement[];
+ /**
+ * Text used to filter custom elements list
+ */
+ search: string;
+ /**
+ * Setter for search text
+ */
+ setSearch: (search: string) => void;
+}
+
+export const SavedElementsModal: FunctionComponent = ({
+ search,
+ setSearch,
+ customElements,
+ addCustomElement,
+ findCustomElements,
+ onClose,
+ removeCustomElement,
+ updateCustomElement,
+}) => {
+ const [elementToDelete, setElementToDelete] = useState(null);
+ const [elementToEdit, setElementToEdit] = useState(null);
+
+ useEffect(() => {
+ findCustomElements();
+ });
+
+ const showEditModal = (element: CustomElement) => setElementToEdit(element);
+ const hideEditModal = () => setElementToEdit(null);
+
+ const handleEdit = async (name: string, description: string, image: string) => {
+ if (elementToEdit) {
+ await updateCustomElement(elementToEdit.id, name, description, image);
+ }
+ hideEditModal();
+ };
+
+ const showDeleteModal = (element: CustomElement) => setElementToDelete(element);
+ const hideDeleteModal = () => setElementToDelete(null);
+
+ const handleDelete = async () => {
+ if (elementToDelete) {
+ await removeCustomElement(elementToDelete.id);
+ }
+ hideDeleteModal();
+ };
+
+ const renderEditModal = () => {
+ if (!elementToEdit) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ const renderDeleteModal = () => {
+ if (!elementToDelete) {
+ return null;
+ }
+
+ return (
+
+ );
+ };
+
+ const sortElements = (elements: CustomElement[]): CustomElement[] =>
+ sortBy(
+ map(elements, (element, name) => ({ name, ...element })),
+ 'displayName'
+ );
+
+ const onSearch = (e: ChangeEvent) => setSearch(e.target.value);
+
+ let customElementContent = (
+ {strings.getAddNewElementTitle()}}
+ body={{strings.getAddNewElementDescription()}
}
+ titleSize="s"
+ />
+ );
+
+ if (customElements.length) {
+ customElementContent = (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {strings.getModalTitle()}
+
+
+
+
+
+
+ {customElementContent}
+
+
+
+ {strings.getSavedElementsModalCloseButtonLabel()}
+
+
+
+
+
+ {renderDeleteModal()}
+ {renderEditModal()}
+
+ );
+};
+
+SavedElementsModal.propTypes = {
+ addCustomElement: PropTypes.func.isRequired,
+ findCustomElements: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ removeCustomElement: PropTypes.func.isRequired,
+ updateCustomElement: PropTypes.func.isRequired,
+};
diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot
index d46a509251d35..ac25cbe0b6832 100644
--- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot
+++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot
@@ -37,6 +37,7 @@ exports[`Storyshots components/Sidebar/SidebarHeader default 1`] = `