diff --git a/x-pack/plugins/canvas/common/lib/constants.js b/x-pack/plugins/canvas/common/lib/constants.js index d996bc5e8c4b..8e34031ddb55 100644 --- a/x-pack/plugins/canvas/common/lib/constants.js +++ b/x-pack/plugins/canvas/common/lib/constants.js @@ -5,6 +5,7 @@ */ export const CANVAS_TYPE = 'canvas-workpad'; +export const CUSTOM_ELEMENT_TYPE = 'canvas-element'; export const CANVAS_APP = 'canvas'; export const APP_ROUTE = '/app/canvas'; export const APP_ROUTE_WORKPAD = `${APP_ROUTE}#/workpad`; @@ -12,6 +13,7 @@ export const API_ROUTE = '/api/canvas'; export const API_ROUTE_WORKPAD = `${API_ROUTE}/workpad`; export const API_ROUTE_WORKPAD_ASSETS = `${API_ROUTE}/workpad-assets`; export const API_ROUTE_WORKPAD_STRUCTURES = `${API_ROUTE}/workpad-structures`; +export const API_ROUTE_CUSTOM_ELEMENT = `${API_ROUTE}/custom-element`; export const LOCALSTORAGE_PREFIX = `kibana.canvas`; export const LOCALSTORAGE_CLIPBOARD = `${LOCALSTORAGE_PREFIX}.clipboard`; export const LOCALSTORAGE_AUTOCOMPLETE_ENABLED = `${LOCALSTORAGE_PREFIX}.isAutocompleteEnabled`; diff --git a/x-pack/plugins/canvas/common/lib/dataurl.ts b/x-pack/plugins/canvas/common/lib/dataurl.ts index 85fcdc21e645..4542a6a9fccd 100644 --- a/x-pack/plugins/canvas/common/lib/dataurl.ts +++ b/x-pack/plugins/canvas/common/lib/dataurl.ts @@ -49,9 +49,9 @@ export function isValidDataUrl(str: string) { export function encode(data: any | null, type = 'text/plain') { // use FileReader if it's available, like in the browser if (FileReader) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result); + reader.onloadend = () => resolve(reader.result as string); reader.onerror = err => reject(err); reader.readAsDataURL(data); }); diff --git a/x-pack/plugins/canvas/init.js b/x-pack/plugins/canvas/init.js index 0c5d56134f57..f993c045e829 100644 --- a/x-pack/plugins/canvas/init.js +++ b/x-pack/plugins/canvas/init.js @@ -45,7 +45,7 @@ export default async function(server /*options*/) { privileges: { all: { savedObject: { - all: ['canvas-workpad'], + all: ['canvas-workpad', 'canvas-element'], read: ['index-pattern'], }, ui: ['save'], @@ -53,7 +53,7 @@ export default async function(server /*options*/) { read: { savedObject: { all: [], - read: ['index-pattern', 'canvas-workpad'], + read: ['index-pattern', 'canvas-workpad', 'canvas-element'], }, ui: [], }, diff --git a/x-pack/plugins/canvas/public/components/element_types/__examples__/__snapshots__/element_controls.examples.storyshot b/x-pack/plugins/canvas/public/components/element_types/__examples__/__snapshots__/element_controls.examples.storyshot new file mode 100644 index 000000000000..5e076ba76a9c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_types/__examples__/__snapshots__/element_controls.examples.storyshot @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/ElementTypes/ElementControls has two buttons 1`] = ` +
+
+
+ + + +
+
+ + + +
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/element_types/__examples__/element_controls.examples.tsx b/x-pack/plugins/canvas/public/components/element_types/__examples__/element_controls.examples.tsx new file mode 100644 index 000000000000..daf34479bf24 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_types/__examples__/element_controls.examples.tsx @@ -0,0 +1,24 @@ +/* + * 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 { ElementControls } from '../element_controls'; + +storiesOf('components/ElementTypes/ElementControls', module) + .addDecorator(story => ( +
+ {story()} +
+ )) + .add('has two buttons', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/element_types/element_controls.tsx b/x-pack/plugins/canvas/public/components/element_types/element_controls.tsx new file mode 100644 index 000000000000..1e95fa677f5c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_types/element_controls.tsx @@ -0,0 +1,49 @@ +/* + * 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, { FunctionComponent, MouseEvent } from 'react'; +import PropTypes from 'prop-types'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; + +interface Props { + /** + * A click handler for the delete button + */ + onDelete: (event: MouseEvent) => void; + /** + * A click handler for the edit button + */ + onEdit: (event: MouseEvent) => void; +} + +export const ElementControls: FunctionComponent = ({ onDelete, onEdit }) => ( + + + + + + + + + + + + +); + +ElementControls.propTypes = { + onDelete: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/element_types/element_types.js b/x-pack/plugins/canvas/public/components/element_types/element_types.js index ced13040a2d5..6bd7e034efe9 100644 --- a/x-pack/plugins/canvas/public/components/element_types/element_types.js +++ b/x-pack/plugins/canvas/public/components/element_types/element_types.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { EuiFieldSearch, @@ -14,72 +14,206 @@ import { EuiFlexItem, EuiModalHeader, EuiModalBody, + EuiTabbedContent, + EuiEmptyPrompt, + EuiSpacer, + EuiIcon, } from '@elastic/eui'; import lowerCase from 'lodash.lowercase'; import { map, includes, sortBy } from 'lodash'; +import { ConfirmModal } from '../confirm_modal/confirm_modal'; +import { CustomElementModal } from '../sidebar_header/custom_element_modal'; +import { ElementControls } from './element_controls'; -export const ElementTypes = ({ elements, onClick, search, setSearch }) => { - search = lowerCase(search); - elements = sortBy(map(elements, (element, name) => ({ name, ...element })), 'displayName'); - const elementList = map(elements, (element, name) => { - const { help, displayName, expression, filter, width, height, image } = element; - const whenClicked = () => onClick({ expression, filter, width, height }); +export class ElementTypes extends Component { + static propTypes = { + addCustomElement: PropTypes.func.isRequired, + addElement: PropTypes.func.isRequired, + customElements: PropTypes.array.isRequired, + elements: PropTypes.object, + findCustomElements: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + removeCustomElement: PropTypes.func.isRequired, + search: PropTypes.string, + setCustomElements: PropTypes.func.isRequired, + setSearch: PropTypes.func.isRequired, + updateCustomElement: PropTypes.func.isRequired, + }; - // Add back in icon={image} to this when Design has a full icon set - const card = ( - - - + state = { + isEditModalVisible: false, + isDeleteModalVisible: false, + elementToDelete: null, + elementToEdit: null, + }; + + componentDidMount() { + // fetch custom elements + this.props.findCustomElements(); + } + + _showEditModal = elementToEdit => this.setState({ isEditModalVisible: true, elementToEdit }); + + _hideEditModal = () => this.setState({ isEditModalVisible: false, elementToEdit: null }); + + _showDeleteModal = elementToDelete => + this.setState({ isDeleteModalVisible: true, elementToDelete }); + + _hideDeleteModal = () => this.setState({ isDeleteModalVisible: false, elementToDelete: null }); + + _getElementCards = (elements, handleClick, showControls = false) => { + const { search, onClose } = this.props; + return map(elements, (element, name) => { + const { help, displayName, image } = element; + const whenClicked = () => { + handleClick(element); + onClose(); + }; + + const card = ( + + } + title={displayName} + description={help} + onClick={whenClicked} + className={image ? 'canvasCard' : 'canvasCard canvasCard--hasIcon'} + /> + {showControls && ( + this._showEditModal(element)} + onDelete={() => this._showDeleteModal(element)} + /> + )} + + ); + + if (!search) { + return card; + } + if (includes(lowerCase(name), search)) { + return card; + } + if (includes(lowerCase(displayName), search)) { + return card; + } + if (includes(lowerCase(help), search)) { + return card; + } + return null; + }); + }; + + _renderEditModal = () => { + const { updateCustomElement } = this.props; + const { elementToEdit } = this.state; + return ( + + ); + }; + + _renderDeleteModal = () => { + const { removeCustomElement } = this.props; + const { elementToDelete } = this.state; + return ( + { + removeCustomElement(elementToDelete.id); + this._hideDeleteModal(); + }} + onCancel={this._hideDeleteModal} + /> + ); + }; + + render() { + const { setSearch, addElement, addCustomElement } = this.props; + let { elements, search, customElements } = this.props; + const { isEditModalVisible, isDeleteModalVisible, elementToEdit, elementToDelete } = this.state; + search = lowerCase(search); + elements = sortBy(map(elements, (element, name) => ({ name, ...element })), 'displayName'); + const elementList = this._getElementCards(elements, addElement); + + let customElementContent = ( + // TODO: update copy + Add new elements} + body={

Group and save workpad elements to create new elements

} + titleSize="s" + /> ); - if (!search) { - return card; + if (customElements.length) { + customElements = sortBy( + map(customElements, (element, name) => ({ name, ...element })), + 'name' + ); + customElementContent = this._getElementCards(customElements, addCustomElement, true); } - if (includes(lowerCase(name), search)) { - return card; - } - if (includes(lowerCase(displayName), search)) { - return card; - } - if (includes(lowerCase(help), search)) { - return card; - } - return null; - }); - return ( - - - - - setSearch(e.target.value)} - value={search} - /> - - - - - - {elementList} - - - - ); -}; + const tabs = [ + { + id: 'elements', + name: 'Elements', + content: ( + + + + {elementList} + + + ), + }, + { + id: 'customElements', + name: 'My elements', + content: ( + + + + {customElementContent} + + + ), + }, + ]; -ElementTypes.propTypes = { - elements: PropTypes.object, - onClick: PropTypes.func, - search: PropTypes.string, - setSearch: PropTypes.func, -}; + return ( + + + + + setSearch(e.target.value)} + value={search} + /> + + + + + + + + {isEditModalVisible && elementToEdit && this._renderEditModal()} + + {isDeleteModalVisible && elementToDelete && this._renderDeleteModal()} + + ); + } +} diff --git a/x-pack/plugins/canvas/public/components/element_types/element_types.scss b/x-pack/plugins/canvas/public/components/element_types/element_types.scss index bb58a60515c7..cc3cdec747e3 100644 --- a/x-pack/plugins/canvas/public/components/element_types/element_types.scss +++ b/x-pack/plugins/canvas/public/components/element_types/element_types.scss @@ -1,17 +1,45 @@ -.canvasCard { +.canvasElementCard { + position: relative; - .euiCard__top { - text-align: center; - width: calc(100% + #{$euiSize}*2); - height: 85px; - margin: calc(-1 * #{$euiSize}) calc(-1 * #{$euiSize}) 0; + .canvasCard { + .euiCard__top { + text-align: center; + width: calc(100% + #{$euiSize}* 2); + height: 85px; + margin: calc(-1 * #{$euiSize}) calc(-1 * #{$euiSize}) 0; + } + + .euiCard__image { + max-height: 100%; + max-width: 100%; + width: auto; + left: 0; + top: 0; + } + + &.canvasCard--hasIcon .euiCard__top { + min-width: $canvasElementCardWidth; + padding-top: $euiSize; + } } - - .euiCard__image { - max-height: 100%; - max-width: 100%; - width: auto; - left: 0; - top: 0; + + &:hover, + &:focus { + .canvasElementCard__controls { + visibility: visible; + opacity: 1; + } } -} \ No newline at end of file + + .canvasElementCard__controls { + position: absolute; + right: $euiSizeS; + top: $euiSizeS; + visibility: hidden; + opacity: 0; + transition: opacity $euiAnimSpeedFast $euiAnimSlightResistance; + transition-delay: $euiAnimSpeedNormal; + background: transparentize($euiColorGhost, 0.5); + border-radius: $euiBorderRadius; + } +} diff --git a/x-pack/plugins/canvas/public/components/element_types/index.js b/x-pack/plugins/canvas/public/components/element_types/index.js index 19ad1358b2e0..35a85516df27 100644 --- a/x-pack/plugins/canvas/public/components/element_types/index.js +++ b/x-pack/plugins/canvas/public/components/element_types/index.js @@ -4,16 +4,99 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure, compose, withProps, withState } from 'recompose'; +import PropTypes from 'prop-types'; +import { compose, withProps, withState } from 'recompose'; +import { connect } from 'react-redux'; +import { camelCase } from 'lodash'; +import { cloneSubgraphs } from '../../lib/clone_subgraphs'; +import * as customElementService from '../../lib/custom_element_service'; import { elementsRegistry } from '../../lib/elements_registry'; - +import { notify } from '../../lib/notify'; +import { selectToplevelNodes } from '../../state/actions/transient'; +import { insertNodes, addElement } from '../../state/actions/elements'; +import { getSelectedPage } from '../../state/selectors/workpad'; import { ElementTypes as Component } from './element_types'; -const elementTypesState = withState('search', 'setSearch'); +const elementTypesState = withState('search', 'setSearch', ''); +const customElementsState = withState('customElements', 'setCustomElements', []); const elementTypeProps = withProps(() => ({ elements: elementsRegistry.toJS() })); +const mapStateToProps = state => ({ pageId: getSelectedPage(state) }); + +const mapDispatchToProps = dispatch => ({ + selectToplevelNodes: nodes => + dispatch(selectToplevelNodes(nodes.filter(e => !e.position.parent).map(e => e.id))), + insertNodes: (selectedNodes, pageId) => dispatch(insertNodes(selectedNodes, pageId)), + addElement: pageId => partialElement => dispatch(addElement(pageId, partialElement)), +}); + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { pageId, ...remainingStateProps } = stateProps; + const { addElement, insertNodes, selectToplevelNodes } = dispatchProps; + const { search, setCustomElements } = ownProps; + + return { + ...remainingStateProps, + ...ownProps, + // add built-in element to the page + addElement: addElement(pageId), + // add custom element to the page + addCustomElement: customElement => { + const { selectedNodes = [] } = JSON.parse(customElement.content) || {}; + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + if (clonedNodes) { + insertNodes(clonedNodes, pageId); // first clone and persist the new node(s) + selectToplevelNodes(clonedNodes); // then select the cloned node(s) + } + }, + // custom element search + findCustomElements: async text => { + try { + const { customElements } = await customElementService.find(text); + setCustomElements(customElements); + } catch (err) { + notify.error(err, { title: `Couldn't find custom elements` }); + } + }, + // remove custom element + removeCustomElement: async id => { + try { + await customElementService.remove(id); + const { customElements } = await customElementService.find(search); + setCustomElements(customElements); + } catch (err) { + notify.error(err, { title: `Couldn't delete custom elements` }); + } + }, + // update custom element + updateCustomElement: id => async (name, description, image) => { + try { + await customElementService.update(id, { + name: camelCase(name), + displayName: name, + image, + help: description, + }); + const { customElements } = await customElementService.find(search); + setCustomElements(customElements); + } catch (err) { + notify.error(err, { title: `Couldn't update custom elements` }); + } + }, + }; +}; + export const ElementTypes = compose( - pure, elementTypesState, - elementTypeProps + elementTypeProps, + customElementsState, + connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + ) )(Component); + +ElementTypes.propTypes = { + onClose: PropTypes.func, +}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx new file mode 100644 index 000000000000..141dc33b9609 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx @@ -0,0 +1,61 @@ +/* + * 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, FunctionComponent } from 'react'; +import PropTypes from 'prop-types'; +import { EuiSpacer, EuiTabbedContent } from '@elastic/eui'; +// @ts-ignore unconverted component +import { Datasource } from '../../datasource'; +// @ts-ignore unconverted component +import { FunctionFormList } from '../../function_form_list'; +// @ts-ignore unconverted component +import { SidebarHeader } from '../../sidebar_header'; +import { PositionedElement } from '../../../lib/positioned_element'; + +interface Props { + /** + * a Canvas element used to populate config forms + */ + element: PositionedElement; +} + +export const ElementSettings: FunctionComponent = ({ element }) => { + const tabs = [ + { + id: 'edit', + name: 'Display', + content: ( +
+ +
+ +
+
+ ), + }, + { + id: 'data', + name: 'Data', + content: ( +
+ + +
+ ), + }, + ]; + + return ( + + + + + ); +}; + +ElementSettings.propTypes = { + element: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx b/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx new file mode 100644 index 000000000000..0a3439a7cab1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +// @ts-ignore unconverted local file +import { getElementById, getSelectedPage } from '../../../state/selectors/workpad'; +import { ElementSettings as Component } from './element_settings'; +import { PositionedElement } from '../../../lib/positioned_element'; + +interface State { + persistent: { workpad: { pages: Array<{ elements: PositionedElement[] }> } }; +} + +interface Props { + selectedElementId: string; +} + +const mapStateToProps = (state: State, { selectedElementId }: Props) => ({ + element: getElementById(state, selectedElementId, getSelectedPage(state)), +}); + +export const ElementSettings = connect(mapStateToProps)(Component); + +ElementSettings.propTypes = { + selectedElementId: PropTypes.string.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/global_config.js b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx similarity index 69% rename from x-pack/plugins/canvas/public/components/sidebar/global_config.js rename to x-pack/plugins/canvas/public/components/sidebar/global_config.tsx index 32536c3b7978..a5920ee19746 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/global_config.js +++ b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx @@ -4,14 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; +// @ts-ignore unconverted component import { ElementConfig } from '../element_config'; +// @ts-ignore unconverted component import { PageConfig } from '../page_config'; +// @ts-ignore unconverted component import { WorkpadConfig } from '../workpad_config'; +// @ts-ignore unconverted component import { SidebarSection } from './sidebar_section'; -export const GlobalConfig = () => ( -
+export const GlobalConfig: FunctionComponent = () => ( + @@ -21,5 +25,5 @@ export const GlobalConfig = () => ( -
+ ); diff --git a/x-pack/plugins/canvas/public/components/sidebar/group_settings.tsx b/x-pack/plugins/canvas/public/components/sidebar/group_settings.tsx new file mode 100644 index 000000000000..632fc7ccb41b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/group_settings.tsx @@ -0,0 +1,21 @@ +/* + * 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, FunctionComponent } from 'react'; +import { EuiText, EuiSpacer } from '@elastic/eui'; +// @ts-ignore unconverted component +import { SidebarHeader } from '../sidebar_header/'; + +export const GroupSettings: FunctionComponent = () => ( + + + + +

Ungroup (U) to edit individual element settings.

+

Save this group as a new element to re-use it throughout your workpad.

+
+
+); diff --git a/x-pack/plugins/canvas/public/components/sidebar/index.js b/x-pack/plugins/canvas/public/components/sidebar/index.js deleted file mode 100644 index 65204b62c379..000000000000 --- a/x-pack/plugins/canvas/public/components/sidebar/index.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { cloneSubgraphs } from '../../lib/clone_subgraphs'; -import { insertNodes, elementLayer } from '../../state/actions/elements'; -import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; -import { selectToplevelNodes } from '../../state/actions/transient'; - -import { Sidebar as Component } from './sidebar'; - -const mapStateToProps = state => ({ - selectedPage: getSelectedPage(state), - selectedElement: getSelectedElement(state), -}); - -const mapDispatchToProps = dispatch => ({ - duplicateElement: (pageId, selectedElement) => () => { - // gradually unifying code with copy/paste - // todo: more unification w/ copy/paste; group cloning - const newElements = cloneSubgraphs([selectedElement]); - dispatch(insertNodes(newElements, pageId)); - dispatch(selectToplevelNodes(newElements.map(e => e.id))); - }, - elementLayer: (pageId, selectedElement) => movement => - dispatch( - elementLayer({ - pageId, - elementId: selectedElement.id, - movement, - }) - ), -}); - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { selectedElement, selectedPage } = stateProps; - - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - elementIsSelected: Boolean(selectedElement), - duplicateElement: dispatchProps.duplicateElement(selectedPage, selectedElement), - elementLayer: dispatchProps.elementLayer(selectedPage, selectedElement), - }; -}; - -export const Sidebar = connect( - mapStateToProps, - mapDispatchToProps, - mergeProps -)(Component); diff --git a/x-pack/plugins/canvas/public/components/sidebar/index.tsx b/x-pack/plugins/canvas/public/components/sidebar/index.tsx new file mode 100644 index 000000000000..c27d9081dcc5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/index.tsx @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { Sidebar } from './sidebar'; diff --git a/x-pack/plugins/canvas/public/components/sidebar/multi_element_settings.tsx b/x-pack/plugins/canvas/public/components/sidebar/multi_element_settings.tsx new file mode 100644 index 000000000000..15c5974c9e89 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/multi_element_settings.tsx @@ -0,0 +1,24 @@ +/* + * 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, FunctionComponent } from 'react'; +import { EuiText, EuiSpacer } from '@elastic/eui'; +// @ts-ignore unconverted component +import { SidebarHeader } from '../sidebar_header/'; + +export const MultiElementSettings: FunctionComponent = () => ( + + + + +

Multiple elements are currently selected.

+

+ Deselect these elements to edit their individual settings, press (G) to group them, or save + this selection as a new element to re-use it throughout your workpad. +

+
+
+); diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar.js deleted file mode 100644 index d1a03674e81a..000000000000 --- a/x-pack/plugins/canvas/public/components/sidebar/sidebar.js +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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 { compose, branch, renderComponent } from 'recompose'; -import { SidebarComponent } from './sidebar_component'; -import { GlobalConfig } from './global_config'; - -const branches = [branch(props => !props.selectedElement, renderComponent(GlobalConfig))]; - -export const Sidebar = compose(...branches)(SidebarComponent); diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx b/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx new file mode 100644 index 000000000000..96802f2627e7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx @@ -0,0 +1,15 @@ +/* + * 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, { FunctionComponent } from 'react'; +// @ts-ignore unconverted component +import { SidebarContent } from './sidebar_content'; + +export const Sidebar: FunctionComponent = () => ( +
+ +
+); diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar_component.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar_component.js deleted file mode 100644 index 3d82a61efd1c..000000000000 --- a/x-pack/plugins/canvas/public/components/sidebar/sidebar_component.js +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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 { - EuiTitle, - EuiSpacer, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiTabbedContent, - EuiToolTip, -} from '@elastic/eui'; -import { Datasource } from '../datasource'; -import { FunctionFormList } from '../function_form_list'; - -export const SidebarComponent = ({ - selectedElement, - duplicateElement, - elementLayer, - elementIsSelected, -}) => { - const tabs = [ - { - id: 'edit', - name: 'Display', - content: ( -
- -
- -
-
- ), - }, - { - id: 'data', - name: 'Data', - content: ( -
- - -
- ), - }, - ]; - - return ( -
- {elementIsSelected && ( -
- - - -

Selected layer

-
-
- - - - - - - elementLayer(Infinity)} - aria-label="Move element to top layer" - /> - - - - - elementLayer(1)} - aria-label="Move element up one layer" - /> - - - - - elementLayer(-1)} - aria-label="Move element down one layer" - /> - - - - - elementLayer(-Infinity)} - aria-label="Move element to bottom layer" - /> - - - - - duplicateElement()} - aria-label="Clone the selected element" - /> - - - - - - -
- -
- )} -
- ); -}; - -SidebarComponent.propTypes = { - selectedElement: PropTypes.object, - duplicateElement: PropTypes.func.isRequired, - elementLayer: PropTypes.func, - elementIsSelected: PropTypes.bool.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar_content.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar_content.js new file mode 100644 index 000000000000..5e33a0810f06 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar_content.js @@ -0,0 +1,42 @@ +/* + * 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 { compose, branch, renderComponent } from 'recompose'; +import { getSelectedToplevelNodes, getSelectedElementId } from '../../state/selectors/workpad'; +import { MultiElementSettings } from './multi_element_settings'; +import { GroupSettings } from './group_settings'; +import { GlobalConfig } from './global_config'; +import { ElementSettings } from './element_settings'; + +const mapStateToProps = state => ({ + selectedToplevelNodes: getSelectedToplevelNodes(state), + selectedElementId: getSelectedElementId(state), +}); + +const branches = [ + // no elements selected + branch( + ({ selectedToplevelNodes }) => !selectedToplevelNodes.length, + renderComponent(GlobalConfig) + ), + // multiple elements selected + branch( + ({ selectedToplevelNodes }) => selectedToplevelNodes.length > 1, + renderComponent(MultiElementSettings) + ), + // a single, grouped element is selected + branch( + ({ selectedToplevelNodes }) => + selectedToplevelNodes.length === 1 && selectedToplevelNodes[0].includes('group'), + renderComponent(GroupSettings) + ), +]; + +export const SidebarContent = compose( + connect(mapStateToProps), + ...branches +)(ElementSettings); diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js b/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js index 2d9888b96945..192786ae86a4 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar_section_title.js @@ -10,7 +10,7 @@ import { EuiTitle, EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui'; export const SidebarSectionTitle = ({ title, tip, children }) => { const formattedTitle = ( - +

{title}

); @@ -27,7 +27,12 @@ export const SidebarSectionTitle = ({ title, tip, children }) => { }; return ( - + {renderTitle(tip)} {children} diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.examples.storyshot b/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.examples.storyshot new file mode 100644 index 000000000000..bd766dd480d2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.examples.storyshot @@ -0,0 +1,398 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/SidebarHeader/ default 1`] = ` +
+
+
+

+ Selected layer +

+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+
+`; + +exports[`Storyshots components/SidebarHeader/ without layer controls 1`] = ` +
+
+
+

+ Grouped element +

+
+
+
+
+ + + +
+
+ +
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx b/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx new file mode 100644 index 000000000000..b91eccef937c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx @@ -0,0 +1,29 @@ +/* + * 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 { SidebarHeader } from '../sidebar_header'; + +const handlers = { + cloneNodes: action('cloneNodes'), + copyNodes: action('copyNodes'), + cutNodes: action('cutNodes'), + pasteNodes: action('pasteNodes'), + deleteNodes: action('deleteNodes'), + bringToFront: action('bringToFront'), + bringForward: action('bringForward'), + sendBackward: action('sendBackward'), + sendToBack: action('sendToBack'), + createCustomElement: action('createCustomElement'), +}; +storiesOf('components/SidebarHeader/', module) + .addDecorator(story =>
{story()}
) + .add('default', () => ) + .add('without layer controls', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/custom_element_modal.tsx b/x-pack/plugins/canvas/public/components/sidebar_header/custom_element_modal.tsx new file mode 100644 index 000000000000..5b80f4c21ff9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar_header/custom_element_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. + */ + +/* eslint-disable react/forbid-elements */ +import React, { PureComponent } from 'react'; +import { get } from 'lodash'; +import PropTypes from 'prop-types'; +import { + EuiButton, + EuiButtonEmpty, + // @ts-ignore hasn't been converted to TypeScript yet + EuiCard, + EuiFieldText, + // @ts-ignore hasn't been converted to TypeScript yet + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +// @ts-ignore converting /libs/constants to TS breaks CI +import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; +import { encode } from '../../../common/lib/dataurl'; + +const MAX_NAME_LENGTH = 40; +const MAX_DESCRIPTION_LENGTH = 100; + +interface Props { + /** + * initial value of the name of the custom element + */ + name?: string; + /** + * initial value of the description of the custom element + */ + description?: string; + /** + * initial value of the preview image of the custom element as a base64 dataurl + */ + image?: string; + /** + * title of the modal + */ + title: string; + /** + * A click handler for the save button + */ + onSave: (name: string, description?: string, image?: string | null) => void; + /** + * A click handler for the cancel button + */ + onCancel: () => void; +} + +interface State { + /** + * name of the custom element to be saved + */ + name?: string; + /** + * description of the custom element to be saved + */ + description?: string; + /** + * image of the custom element to be saved + */ + image?: string | null; +} + +export class CustomElementModal extends PureComponent { + public static propTypes = { + name: PropTypes.string, + description: PropTypes.string, + image: PropTypes.string, + title: PropTypes.string.isRequired, + onSave: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + }; + + public state = { + name: this.props.name || '', + description: this.props.description || '', + image: this.props.image || null, + }; + + private _handleChange = (type: string, value: string | null) => { + this.setState({ [type]: value }); + }; + + private _handleUpload = (files: File[]) => { + const [file] = files; + const [type, subtype] = get(file, 'type', '').split('/'); + if (type === 'image' && VALID_IMAGE_TYPES.indexOf(subtype) >= 0) { + encode(file).then((dataurl: string) => this._handleChange('image', dataurl)); + } + }; + + public render() { + const { onSave, onCancel, title, ...rest } = this.props; + const { name, description, image } = this.state; + + return ( + + + + +

{title}

+
+
+ + + + + + e.target.value.length <= MAX_NAME_LENGTH && + this._handleChange('name', e.target.value) + } + required + /> + + + + e.target.value.length <= MAX_DESCRIPTION_LENGTH && + this._handleChange('description', e.target.value) + } + /> + + + + + +

+ Take a screenshot of your element and upload it here. This can also be done + after saving. +

+
+
+ + +

Element preview

+
+ + } + title={name} + description={description} + className={image ? 'canvasCard' : 'canvasCard canvasCard--hasIcon'} + /> +
+
+
+ + + + Cancel + + + { + onSave(name, description, image); + onCancel(); + }} + > + Save + + + + +
+
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/index.js b/x-pack/plugins/canvas/public/components/sidebar_header/index.js new file mode 100644 index 000000000000..e9b4fc71fd27 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar_header/index.js @@ -0,0 +1,66 @@ +/* + * 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 { compose, withHandlers } from 'recompose'; +import { insertNodes, elementLayer, removeElements } from '../../state/actions/elements'; +import { getSelectedPage, getNodes, getSelectedToplevelNodes } from '../../state/selectors/workpad'; +import { flatten } from '../../lib/aeroelastic/functional'; +import { + layerHandlerCreators, + clipboardHandlerCreators, + basicHandlerCreators, +} from '../../lib/element_handler_creators'; +import { crawlTree } from '../workpad_page/integration_utils'; +import { selectToplevelNodes } from './../../state/actions/transient'; +import { SidebarHeader as Component } from './sidebar_header'; + +/* + * TODO: this is all copied from interactive_workpad_page and workpad_shortcuts + */ +const mapStateToProps = state => { + const pageId = getSelectedPage(state); + const nodes = getNodes(state, pageId); + const selectedToplevelNodes = getSelectedToplevelNodes(state); + const selectedPrimaryShapeObjects = selectedToplevelNodes + .map(id => nodes.find(s => s.id === id)) + .filter(shape => shape); + const selectedPersistentPrimaryNodes = flatten( + selectedPrimaryShapeObjects.map(shape => + nodes.find(n => n.id === shape.id) // is it a leaf or a persisted group? + ? [shape.id] + : nodes.filter(s => s.parent === shape.id).map(s => s.id) + ) + ); + const selectedNodeIds = flatten(selectedPersistentPrimaryNodes.map(crawlTree(nodes))); + + return { + pageId, + selectedNodes: selectedNodeIds.map(id => nodes.find(s => s.id === id)), + }; +}; + +const mapDispatchToProps = dispatch => ({ + insertNodes: (selectedNodes, pageId) => dispatch(insertNodes(selectedNodes, pageId)), + removeNodes: (nodeIds, pageId) => dispatch(removeElements(nodeIds, pageId)), + selectToplevelNodes: nodes => + dispatch(selectToplevelNodes(nodes.filter(e => !e.position.parent).map(e => e.id))), + elementLayer: (pageId, elementId, movement) => { + dispatch(elementLayer({ pageId, elementId, movement })); + }, +}); + +export const SidebarHeader = compose( + connect( + mapStateToProps, + mapDispatchToProps + ), + withHandlers(basicHandlerCreators), + withHandlers(clipboardHandlerCreators), + withHandlers(layerHandlerCreators) + // TODO: restore when group and ungroup can be triggered outside of workpad_page + // withHandlers(groupHandlerCreators), +)(Component); diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/sidebar_header.scss b/x-pack/plugins/canvas/public/components/sidebar_header/sidebar_header.scss new file mode 100644 index 000000000000..5f31af697a73 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar_header/sidebar_header.scss @@ -0,0 +1,24 @@ +.canvasLayout__sidebarHeader { + padding: $euiSizeS 0; +} + +.canvasContextMenu--topBorder { + border-top: $euiBorderThin; +} + +.canvasCustomElementForm { + min-width: 400px; +} + +.canvasCustomElementForm__preview { + max-width: $canvasElementCardWidth; + min-height: $canvasElementCardWidth; +} + +.canvasCustomElementForm__thumbnail { + padding-bottom: 0; +} + +.canvasCustomElementForm__thumbnailHelp { + color: $euiColorDarkShade; +} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx b/x-pack/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx new file mode 100644 index 000000000000..57c0ee301ae1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx @@ -0,0 +1,377 @@ +/* + * 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, { Component, Fragment, MouseEvent } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonIcon, + EuiContextMenu, + EuiToolTip, + EuiContextMenuPanelItemDescriptor, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; +// @ts-ignore unconverted component +import { Popover } from '../popover'; +import { CustomElementModal } from './custom_element_modal'; + +const topBorderClassName = 'canvasContextMenu--topBorder'; + +interface Props { + /** + * title to display in the header + */ + title: string; + /** + * indicated whether or not layer controls should be displayed + */ + showLayerControls?: boolean; + /** + * cuts selected elements + */ + cutNodes: () => void; + /** + * copies selected elements to clipboard + */ + copyNodes: () => void; + /** + * pastes elements stored in clipboard to page + */ + pasteNodes: () => void; + /** + * clones selected elements + */ + cloneNodes: () => void; + /** + * deletes selected elements + */ + deleteNodes: () => void; + /** + * moves selected element to top layer + */ + bringToFront: () => void; + /** + * moves selected element up one layer + */ + bringForward: () => void; + /** + * moves selected element down one layer + */ + sendBackward: () => void; + /** + * moves selected element to bottom layer + */ + sendToBack: () => void; + /** + * saves the selected elements as an custom-element saved object + */ + createCustomElement: () => void; + // TODO: restore when group and ungroup can be triggered outside of workpad_page + // /** + // * indicated whether the selected element is a group or not + // */ + // groupIsSelected: boolean, + // /** + // * groups selected elements + // */ + // groupNodes: () => void; + // /** + // * ungroups selected group + // */ + // ungroupNodes: () => void; +} + +interface State { + /** + * indicates whether or not the custom element modal is open + */ + isModalVisible: boolean; +} + +const contextMenuButton = (handleClick: (event: MouseEvent) => void) => ( + +); + +export class SidebarHeader extends Component { + public static propTypes = { + title: PropTypes.string.isRequired, + showLayerControls: PropTypes.bool, // TODO: remove when we support relayering multiple elements + cutNodes: PropTypes.func.isRequired, + copyNodes: PropTypes.func.isRequired, + pasteNodes: PropTypes.func.isRequired, + cloneNodes: PropTypes.func.isRequired, + deleteNodes: PropTypes.func.isRequired, + bringToFront: PropTypes.func.isRequired, + bringForward: PropTypes.func.isRequired, + sendBackward: PropTypes.func.isRequired, + sendToBack: PropTypes.func.isRequired, + createCustomElement: PropTypes.func.isRequired, + // TODO: restore when group and ungroup can be triggered outside of workpad_page + // groupIsSelected: PropTypes.bool, + // groupNodes: PropTypes.func.isRequired, + // ungroupNodes: PropTypes.func.isRequired, + }; + + public static defaultProps = { + // TODO: restore when group and ungroup can be triggered outside of workpad_page + // groupIsSelected: false, + showLayerControls: true, + }; + + public state = { + isModalVisible: false, + }; + + private _isMounted = false; + private _showModal = () => this._isMounted && this.setState({ isModalVisible: true }); + private _hideModal = () => this._isMounted && this.setState({ isModalVisible: false }); + + public componentDidMount() { + this._isMounted = true; + } + + public componentWillUnmount() { + this._isMounted = false; + } + private _renderLayoutControls = () => { + const { bringToFront, bringForward, sendBackward, sendToBack } = this.props; + return ( + + + + + + + + + + + + + + + + + + + + + + + ); + }; + + private _getLayerMenuItems = (): { + menuItem: EuiContextMenuPanelItemDescriptor; + panel: EuiContextMenuPanelDescriptor; + } => { + const { bringToFront, bringForward, sendBackward, sendToBack } = this.props; + + return { + menuItem: { name: 'Order', className: topBorderClassName, panel: 1 }, + panel: { + id: 1, + title: 'Order', + items: [ + { + name: 'Bring to front', // TODO: check against current element position and disable if already top layer + icon: 'sortUp', + onClick: bringToFront, + }, + { + name: 'Bring forward', // TODO: same as above + icon: 'arrowUp', + onClick: bringForward, + }, + { + name: 'Send backward', // TODO: check against current element position and disable if already bottom layer + icon: 'arrowDown', + onClick: sendBackward, + }, + { + name: 'Send to back', // TODO: same as above + icon: 'sortDown', + onClick: sendToBack, + }, + ], + }, + }; + }; + + // TODO: restore when group and ungroup can be triggered outside of workpad_page + // private _getGroupMenuItem = ():EuiContextMenuPanelItemDescriptor => { + // const { groupIsSelected, ungroupNodes, groupNodes } = this.props; + // return groupIsSelected + // ? { + // name: 'Ungroup', + // className: topBorderClassName, + // onClick: close(ungroupNodes), + // } + // : { + // name: 'Group', + // className: topBorderClassName, + // onClick: close(groupNodes), + // }; + // }; + + private _getPanels = (closePopover: () => void): EuiContextMenuPanelDescriptor[] => { + const { + showLayerControls, + cutNodes, + copyNodes, + pasteNodes, + deleteNodes, + cloneNodes, + } = this.props; + + // closes popover after invoking fn + const close = (fn: () => void) => () => { + fn(); + closePopover(); + }; + + const items: EuiContextMenuPanelItemDescriptor[] = [ + { + name: 'Cut', + icon: 'cut', + onClick: close(cutNodes), + }, + { + name: 'Copy', + icon: 'copy', + onClick: copyNodes, + }, + { + name: 'Paste', // TODO: can this be disabled if clipboard is empty? + icon: 'copyClipboard', + onClick: close(pasteNodes), + }, + { + name: 'Delete', + icon: 'trash', + onClick: close(deleteNodes), + }, + { + name: 'Clone', + onClick: close(cloneNodes), + }, + // TODO: restore when group and ungroup can be triggered outside of workpad_page + // this._getGroupMenuItem(), + ]; + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: 'Element options', + items, + }, + ]; + + if (showLayerControls) { + const { menuItem, panel } = this._getLayerMenuItems(); + // add Order menu item to first panel + items.push(menuItem); + // add nested panel for layers controls + panels.push(panel); + } + + items.push({ + name: 'Save as new element', + icon: 'indexOpen', + className: topBorderClassName, + onClick: this._showModal, + }); + + return panels; + }; + + private _renderContextMenu = () => ( + + {({ closePopover }: { closePopover: () => void }) => ( + + )} + + ); + + render() { + const { title, showLayerControls, createCustomElement } = this.props; + const { isModalVisible } = this.state; + + return ( + + + + +

{title}

+
+
+ + + {showLayerControls ? this._renderLayoutControls() : null} + + + + + + {this._renderContextMenu()} + + +
+ {isModalVisible ? ( + + ) : null} +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/workpad_header/index.js b/x-pack/plugins/canvas/public/components/workpad_header/index.js index 66b1988c83c8..b3de256155d5 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_header/index.js @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { compose, withState } from 'recompose'; +import { compose } from 'recompose'; import { connect } from 'react-redux'; import { canUserWrite } from '../../state/selectors/app'; import { getSelectedPage, isWriteable } from '../../state/selectors/workpad'; import { setWriteable } from '../../state/actions/workpad'; -import { addElement } from '../../state/actions/elements'; import { WorkpadHeader as Component } from './workpad_header'; const mapStateToProps = state => ({ @@ -20,14 +19,12 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ setWriteable: isWriteable => dispatch(setWriteable(isWriteable)), - addElement: pageId => partialElement => dispatch(addElement(pageId, partialElement)), }); const mergeProps = (stateProps, dispatchProps, ownProps) => ({ ...stateProps, ...dispatchProps, ...ownProps, - addElement: dispatchProps.addElement(stateProps.selectedPage), toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable), }); @@ -36,6 +33,5 @@ export const WorkpadHeader = compose( mapStateToProps, mapDispatchToProps, mergeProps - ), - withState('showElementModal', 'setShowElementModal', false) + ) )(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js index cf13476f71da..eb817c522308 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.js @@ -28,12 +28,11 @@ export class WorkpadHeader extends React.PureComponent { static propTypes = { isWriteable: PropTypes.bool, toggleWriteable: PropTypes.func, - addElement: PropTypes.func.isRequired, - showElementModal: PropTypes.bool, - setShowElementModal: PropTypes.func, }; - fullscreenButton = ({ toggleFullscreen }) => ( + state = { isModalVisible: false }; + + _fullscreenButton = ({ toggleFullscreen }) => ( ); - keyHandler = action => { + _keyHandler = action => { if (action === 'EDITING') { this.props.toggleWriteable(); } }; - elementAdd = () => { - const { addElement, setShowElementModal } = this.props; + _hideElementModal = () => this.setState({ isModalVisible: false }); + _showElementModal = () => this.setState({ isModalVisible: true }); - return ( - - setShowElementModal(false)} - className="canvasModal--fixedSize" - maxWidth="1000px" - initialFocus=".canvasElements__filter" - > - { - addElement(element); - setShowElementModal(false); - }} - /> - - setShowElementModal(false)}> - Close - - - - - ); - }; + _elementAdd = () => ( + + + + + + Close + + + + + ); - getTooltipText = () => { + _getTooltipText = () => { if (!this.props.canUserWrite) { return "You don't have permission to edit this workpad"; } else { @@ -85,17 +78,12 @@ export class WorkpadHeader extends React.PureComponent { }; render() { - const { - isWriteable, - canUserWrite, - toggleWriteable, - setShowElementModal, - showElementModal, - } = this.props; + const { isWriteable, canUserWrite, toggleWriteable } = this.props; + const { isModalVisible } = this.state; return (
- {showElementModal ? this.elementAdd() : null} + {isModalVisible ? this._elementAdd() : null} @@ -106,7 +94,7 @@ export class WorkpadHeader extends React.PureComponent { - {this.fullscreenButton} + {this._fullscreenButton} @@ -115,19 +103,17 @@ export class WorkpadHeader extends React.PureComponent { {canUserWrite && ( )} - + { - toggleWriteable(); - }} + onClick={toggleWriteable} size="s" - aria-label={this.getTooltipText()} + aria-label={this._getTooltipText()} isDisabled={!canUserWrite} /> @@ -146,7 +132,7 @@ export class WorkpadHeader extends React.PureComponent { size="s" iconType="vector" data-test-subj="add-element-button" - onClick={() => setShowElementModal(true)} + onClick={this._showElementModal} > Add element diff --git a/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js b/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js index 7cf7eac48ca5..dc5f76ee82e6 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js @@ -48,4 +48,5 @@ export const interactiveWorkpadPagePropTypes = { sendToBack: PropTypes.func, canvasOrigin: PropTypes.func, saveCanvasOrigin: PropTypes.func.isRequired, + commit: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index b3686d9b1a3f..69feffdf9d71 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -51,11 +51,6 @@ const configuration = { tooltipZ: 1100, }; -const groupHandlerCreators = { - groupNodes: ({ commit }) => () => commit('actionEvent', { event: 'group' }), - ungroupNodes: ({ commit }) => () => commit('actionEvent', { event: 'ungroup' }), -}; - const componentLayoutState = ({ aeroStore, setAeroStore, elements, selectedToplevelNodes }) => { const shapes = shapesForNodes(elements); const selectedShapes = selectedToplevelNodes.filter(e => shapes.find(s => s.id === e)); @@ -111,13 +106,13 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => ({ dispatch, - insertNodes: pageId => selectedNodes => dispatch(insertNodes(selectedNodes, pageId)), - removeNodes: pageId => nodeIds => dispatch(removeElements(nodeIds, pageId)), + insertNodes: (selectedNodes, pageId) => dispatch(insertNodes(selectedNodes, pageId)), + removeNodes: (nodeIds, pageId) => dispatch(removeElements(nodeIds, pageId)), selectToplevelNodes: nodes => dispatch(selectToplevelNodes(nodes.filter(e => !e.position.parent).map(e => e.id))), // TODO: Abstract this out, this is similar to layering code in sidebar/index.js: - elementLayer: (pageId, selectedElement, movement) => { - dispatch(elementLayer({ pageId, elementId: selectedElement.id, movement })); + elementLayer: (pageId, elementId, movement) => { + dispatch(elementLayer({ pageId, elementId, movement })); }, }); @@ -164,7 +159,6 @@ export const InteractivePage = compose( withProps(({ commit, forceRerender }) => ({ commit: (...args) => forceRerender(commit(...args)), })), - withHandlers(groupHandlerCreators), withHandlers(eventHandlers), // Captures user intent, needs to have reconciled state () => InteractiveComponent ); diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interactive_workpad_page.js b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interactive_workpad_page.js index 46c60f383e98..881503132d23 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interactive_workpad_page.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interactive_workpad_page.js @@ -12,8 +12,8 @@ import { TooltipAnnotation } from '../../tooltip_annotation'; import { RotationHandle } from '../../rotation_handle'; import { BorderConnection } from '../../border_connection'; import { BorderResizeHandle } from '../../border_resize_handle'; +import { WorkpadShortcuts } from '../../workpad_shortcuts'; import { interactiveWorkpadPagePropTypes } from '../prop_types'; -import { WorkpadShortcuts } from './workpad_shortcuts'; export class InteractiveWorkpadPage extends PureComponent { static propTypes = interactiveWorkpadPagePropTypes; @@ -44,23 +44,21 @@ export class InteractiveWorkpadPage extends PureComponent { insertNodes, removeNodes, elementLayer, - groupNodes, - ungroupNodes, canvasOrigin, saveCanvasOrigin, + commit, } = this.props; let shortcuts = null; const shortcutProps = { elementLayer, - groupNodes, insertNodes, pageId, removeNodes, selectedNodes, selectToplevelNodes, - ungroupNodes, + commit, }; shortcuts = ; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/workpad_shortcuts.tsx b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/workpad_shortcuts.tsx deleted file mode 100644 index 803b31dc3d76..000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/workpad_shortcuts.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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, { Component } from 'react'; - -import isEqual from 'react-fast-compare'; -// @ts-ignore -import { Shortcuts } from 'react-shortcuts'; -// @ts-ignore -import { getClipboardData, setClipboardData } from '../../../lib/clipboard'; -// @ts-ignore -import { cloneSubgraphs } from '../../../lib/clone_subgraphs'; -// @ts-ignore -import { notify } from '../../../lib/notify'; - -export interface Props { - pageId: string; - selectedNodes: any[]; - selectToplevelNodes: (...nodeIds: string[]) => void; - insertNodes: (pageId: string) => (selectedNodes: any[]) => void; - removeNodes: (pageId: string) => (selectedNodeIds: string[]) => void; - elementLayer: (pageId: string, selectedNode: any, movement: any) => void; - groupNodes: () => void; - ungroupNodes: () => void; -} - -const id = (node: any): string => node.id; - -const keyMap = { - DELETE: function deleteNodes({ pageId, removeNodes, selectedNodes }: any): any { - // currently, handle the removal of one node, exploiting multiselect subsequently - if (selectedNodes.length) { - removeNodes(pageId)(selectedNodes.map(id)); - } - }, - COPY: function copyNodes({ selectedNodes }: any): any { - if (selectedNodes.length) { - setClipboardData({ selectedNodes }); - notify.success('Copied element to clipboard'); - } - }, - CUT: function cutNodes({ pageId, removeNodes, selectedNodes }: any): any { - if (selectedNodes.length) { - setClipboardData({ selectedNodes }); - removeNodes(pageId)(selectedNodes.map(id)); - notify.success('Cut element to clipboard'); - } - }, - CLONE: function duplicateNodes({ - insertNodes, - pageId, - selectToplevelNodes, - selectedNodes, - }: any): any { - // TODO: This is slightly different from the duplicateNodes function in sidebar/index.js. Should they be doing the same thing? - // This should also be abstracted. - const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); - if (clonedNodes) { - insertNodes(pageId)(clonedNodes); - selectToplevelNodes(clonedNodes); - } - }, - PASTE: function pasteNodes({ insertNodes, pageId, selectToplevelNodes }: any): any { - const { selectedNodes } = JSON.parse(getClipboardData()) || { selectedNodes: [] }; - const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); - if (clonedNodes) { - insertNodes(pageId)(clonedNodes); // first clone and persist the new node(s) - selectToplevelNodes(clonedNodes); // then select the cloned node(s) - } - }, - BRING_FORWARD: function bringForward({ elementLayer, pageId, selectedNodes }: any): any { - // TODO: Same as above. Abstract these out. This is the same code as in sidebar/index.js - // Note: these layer actions only work when a single node is selected - if (selectedNodes.length === 1) { - elementLayer(pageId, selectedNodes[0], 1); - } - }, - BRING_TO_FRONT: function bringToFront({ elementLayer, pageId, selectedNodes }: any): any { - if (selectedNodes.length === 1) { - elementLayer(pageId, selectedNodes[0], Infinity); - } - }, - SEND_BACKWARD: function sendBackward({ elementLayer, pageId, selectedNodes }: any): any { - if (selectedNodes.length === 1) { - elementLayer(pageId, selectedNodes[0], -1); - } - }, - SEND_TO_BACK: function sendToBack({ elementLayer, pageId, selectedNodes }: any): any { - if (selectedNodes.length === 1) { - elementLayer(pageId, selectedNodes[0], -Infinity); - } - }, - GROUP: ({ groupNodes }: any): any => groupNodes(), - UNGROUP: ({ ungroupNodes }: any): any => ungroupNodes(), -} as any; - -export class WorkpadShortcuts extends Component { - public render() { - return ( - { - event.preventDefault(); - keyMap[action](this.props); - }} - targetNodeSelector={`#${this.props.pageId}`} - global - /> - ); - } - - public shouldComponentUpdate(nextProps: Props) { - return !isEqual(nextProps, this.props); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_shortcuts/index.tsx b/x-pack/plugins/canvas/public/components/workpad_shortcuts/index.tsx new file mode 100644 index 000000000000..e9e947ae5fd5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_shortcuts/index.tsx @@ -0,0 +1,33 @@ +/* + * 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 PropTypes from 'prop-types'; +import { withHandlers, compose } from 'recompose'; +import { WorkpadShortcuts as Component, Props as WorkpadShortcutsProps } from './workpad_shortcuts'; +import { + groupHandlerCreators, + layerHandlerCreators, + basicHandlerCreators, + clipboardHandlerCreators, + Props as HandlerCreatorProps, +} from '../../lib/element_handler_creators'; + +export const WorkpadShortcuts = compose( + withHandlers(groupHandlerCreators), + withHandlers(layerHandlerCreators), + withHandlers(basicHandlerCreators), + withHandlers(clipboardHandlerCreators) +)(Component); + +WorkpadShortcuts.propTypes = { + pageId: PropTypes.string.isRequired, + selectedNodes: PropTypes.arrayOf(PropTypes.object), + elementLayer: PropTypes.func.isRequired, + insertNodes: PropTypes.func.isRequired, + removeNodes: PropTypes.func.isRequired, + selectToplevelNodes: PropTypes.func.isRequired, + commit: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx b/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx new file mode 100644 index 000000000000..545042eba31a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx @@ -0,0 +1,95 @@ +/* + * 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, { Component, KeyboardEvent } from 'react'; + +import isEqual from 'react-fast-compare'; +// @ts-ignore no @types definition +import { Shortcuts } from 'react-shortcuts'; +import { isTextInput } from '../../lib/is_text_input'; + +export interface Props { + /** + * cuts selected elements + */ + cutNodes: () => void; + /** + * copies selected elements to clipboard + */ + copyNodes: () => void; + /** + * pastes elements stored in clipboard to page + */ + pasteNodes: () => void; + /** + * clones selected elements + */ + cloneNodes: () => void; + /** + * deletes selected elements + */ + deleteNodes: () => void; + /** + * moves selected element to top layer + */ + bringToFront: () => void; + /** + * moves selected element up one layer + */ + bringForward: () => void; + /** + * moves selected element down one layer + */ + sendBackward: () => void; + /** + * moves selected element to bottom layer + */ + sendToBack: () => void; + /** + * groups selected elements + */ + groupNodes: () => void; + /** + * ungroups selected group + */ + ungroupNodes: () => void; +} + +export class WorkpadShortcuts extends Component { + private _keyMap: { [key: string]: () => void } = { + CUT: this.props.cutNodes, + COPY: this.props.copyNodes, + PASTE: this.props.pasteNodes, + CLONE: this.props.cloneNodes, + DELETE: this.props.deleteNodes, + BRING_TO_FRONT: this.props.bringToFront, + BRING_FORWARD: this.props.bringForward, + SEND_BACKWARD: this.props.sendBackward, + SEND_TO_BACK: this.props.sendToBack, + GROUP: this.props.groupNodes, + UNGROUP: this.props.ungroupNodes, + }; + + public render() { + return ( + { + if (!isTextInput(event.target as HTMLInputElement)) { + event.preventDefault(); + this._keyMap[action](); + } + }} + targetNodeSelector={`body`} + global + /> + ); + } + + public shouldComponentUpdate(nextProps: Props) { + return !isEqual(nextProps, this.props); + } +} diff --git a/x-pack/plugins/canvas/public/lib/custom_element_service.ts b/x-pack/plugins/canvas/public/lib/custom_element_service.ts new file mode 100644 index 000000000000..56c3b79ce37f --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/custom_element_service.ts @@ -0,0 +1,69 @@ +/* + * 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. + */ + +// @ts-ignore unconverted Elastic lib +import chrome from 'ui/chrome'; +import { AxiosPromise } from 'axios'; +// @ts-ignore unconverted local file +import { API_ROUTE_CUSTOM_ELEMENT } from '../../common/lib/constants'; +// @ts-ignore unconverted local file +import { fetch } from '../../common/lib/fetch'; + +interface CustomElement { + /** + * unique ID for the custom element + */ + id: string; + /** + * name of the custom element + */ + name: string; + /** + * name to be displayed from element picker + */ + displayName: string; + /** + * description of the custom element + */ + help?: string; + /** + * base 64 data URL string of the preview image + */ + image?: string; + /** + * the element object stringified + */ + content: string; +} + +const basePath = chrome.getBasePath(); +const apiPath = `${basePath}${API_ROUTE_CUSTOM_ELEMENT}`; + +export const create = (customElement: CustomElement): AxiosPromise => + fetch.post(apiPath, customElement); + +export const get = (customElementId: string): Promise => + fetch + .get(`${apiPath}/${customElementId}`) + .then(({ data: element }: { data: CustomElement }) => element); + +export const update = (id: string, element: CustomElement): AxiosPromise => + fetch.put(`${apiPath}/${id}`, element); + +export const remove = (id: string): AxiosPromise => fetch.delete(`${apiPath}/${id}`); + +export const find = async ( + searchTerm: string +): Promise<{ total: number; customElements: CustomElement[] }> => { + const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; + + return fetch + .get(`${apiPath}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) + .then( + ({ data: customElements }: { data: { total: number; customElements: CustomElement[] } }) => + customElements + ); +}; diff --git a/x-pack/plugins/canvas/public/lib/elastic_logo.js b/x-pack/plugins/canvas/public/lib/elastic_logo.js deleted file mode 100644 index 1ade7f1f269c..000000000000 --- a/x-pack/plugins/canvas/public/lib/elastic_logo.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -export const elasticLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+'; diff --git a/x-pack/plugins/canvas/public/lib/elastic_logo.ts b/x-pack/plugins/canvas/public/lib/elastic_logo.ts new file mode 100644 index 000000000000..e73b8615045a --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/elastic_logo.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +/* eslint-disable */ +export const elasticLogo = + 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+'; diff --git a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts new file mode 100644 index 000000000000..ee402a07497f --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts @@ -0,0 +1,156 @@ +/* + * 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 { Http2ServerResponse } from 'http2'; +import { camelCase } from 'lodash'; +// @ts-ignore unconverted local file +import { getClipboardData, setClipboardData } from './clipboard'; +// @ts-ignore unconverted local file +import { cloneSubgraphs } from './clone_subgraphs'; +// @ts-ignore unconverted local file +import { notify } from './notify'; +import * as customElementService from './custom_element_service'; +import { getId } from './get_id'; +import { PositionedElement } from './positioned_element'; + +const extractId = (node: { id: string }): string => node.id; + +export interface Props { + /** + * ID of the active page + */ + pageId: string; + /** + * array of selected elements + */ + selectedNodes: PositionedElement[]; + /** + * adds elements to the page + */ + insertNodes: (elements: PositionedElement[], pageId: string) => void; + /** + * changes the layer position of an element + */ + elementLayer: (pageId: string, elementId: string, movement: number) => void; + /** + * selects elements on the page + */ + selectToplevelNodes: (elements: PositionedElement) => void; + /** + * deletes elements from the page + */ + removeNodes: (nodeIds: string[], pageId: string) => void; + /** + * commits events to layout engine + */ + commit: (eventType: string, config: { event: string }) => void; +} + +// handlers for clone and delete +export const basicHandlerCreators = { + cloneNodes: ({ insertNodes, pageId, selectToplevelNodes, selectedNodes }: Props) => (): void => { + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + if (clonedNodes) { + insertNodes(clonedNodes, pageId); + selectToplevelNodes(clonedNodes); + } + }, + deleteNodes: ({ pageId, removeNodes, selectedNodes }: Props) => (): void => { + if (selectedNodes.length) { + removeNodes(selectedNodes.map(extractId), pageId); + } + }, + createCustomElement: ({ selectedNodes }: Props) => ( + name = '', + description = '', + image = '' + ): void => { + if (selectedNodes.length) { + const content = JSON.stringify({ selectedNodes }); + const customElement = { + id: getId('custom-element'), + name: camelCase(name), + displayName: name, + help: description, + image, + content, + }; + customElementService + .create(customElement) + .then(() => + notify.success( + `Custom element '${customElement.displayName || customElement.id}' was saved` + ) + ) + .catch((result: Http2ServerResponse) => + notify.warning(result, { + title: `Custom element '${customElement.displayName || + customElement.id}' was not saved`, + }) + ); + } + }, +}; + +// handlers for group and ungroup +export const groupHandlerCreators = { + groupNodes: ({ commit }: Props) => (): void => { + commit('actionEvent', { event: 'group' }); + }, + ungroupNodes: ({ commit }: Props) => (): void => { + commit('actionEvent', { event: 'ungroup' }); + }, +}; + +// handlers for cut/copy/paste +export const clipboardHandlerCreators = { + cutNodes: ({ pageId, removeNodes, selectedNodes }: Props) => (): void => { + if (selectedNodes.length) { + setClipboardData({ selectedNodes }); + removeNodes(selectedNodes.map(extractId), pageId); + notify.success('Cut element to clipboard'); + } + }, + copyNodes: ({ selectedNodes }: Props) => (): void => { + if (selectedNodes.length) { + setClipboardData({ selectedNodes }); + notify.success('Copied element to clipboard'); + } + }, + pasteNodes: ({ insertNodes, pageId, selectToplevelNodes }: Props) => (): void => { + const { selectedNodes = [] } = JSON.parse(getClipboardData()) || {}; + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + if (clonedNodes) { + insertNodes(clonedNodes, pageId); // first clone and persist the new node(s) + selectToplevelNodes(clonedNodes); // then select the cloned node(s) + } + }, +}; + +// handlers for changing element layer position +// TODO: support relayering multiple elements +export const layerHandlerCreators = { + bringToFront: ({ elementLayer, pageId, selectedNodes }: Props) => (): void => { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0].id, Infinity); + } + }, + bringForward: ({ elementLayer, pageId, selectedNodes }: Props) => (): void => { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0].id, 1); + } + }, + sendBackward: ({ elementLayer, pageId, selectedNodes }: Props) => (): void => { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0].id, -1); + } + }, + sendToBack: ({ elementLayer, pageId, selectedNodes }: Props) => (): void => { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0].id, -Infinity); + } + }, +}; diff --git a/x-pack/plugins/canvas/public/lib/get_id.js b/x-pack/plugins/canvas/public/lib/get_id.ts similarity index 86% rename from x-pack/plugins/canvas/public/lib/get_id.js rename to x-pack/plugins/canvas/public/lib/get_id.ts index 7518503e5f24..0927a277f7ca 100644 --- a/x-pack/plugins/canvas/public/lib/get_id.js +++ b/x-pack/plugins/canvas/public/lib/get_id.ts @@ -6,6 +6,6 @@ import uuid from 'uuid/v4'; -export function getId(type) { +export function getId(type: string): string { return `${type}-${uuid()}`; } diff --git a/x-pack/plugins/canvas/public/lib/is_text_input.js b/x-pack/plugins/canvas/public/lib/is_text_input.ts similarity index 88% rename from x-pack/plugins/canvas/public/lib/is_text_input.js rename to x-pack/plugins/canvas/public/lib/is_text_input.ts index b61a5df0f4c8..d0ae85844d09 100644 --- a/x-pack/plugins/canvas/public/lib/is_text_input.js +++ b/x-pack/plugins/canvas/public/lib/is_text_input.ts @@ -17,7 +17,7 @@ const nonTextInputs = [ 'submit', ]; -export const isTextInput = ({ tagName, type }) => { +export const isTextInput = ({ tagName, type }: HTMLInputElement): boolean => { switch (tagName.toLowerCase()) { case 'input': return !nonTextInputs.includes(type); diff --git a/x-pack/plugins/canvas/public/lib/keymap.js b/x-pack/plugins/canvas/public/lib/keymap.js index 669fea9ed6c2..138b0696a1ca 100644 --- a/x-pack/plugins/canvas/public/lib/keymap.js +++ b/x-pack/plugins/canvas/public/lib/keymap.js @@ -49,10 +49,10 @@ const fullscreentExitShortcut = ['esc']; export const keymap = { ELEMENT: { displayName: 'Element controls', - COPY: { ...getCtrlShortcuts('c'), help: 'Copy' }, - CLONE: { ...getCtrlShortcuts('d'), help: 'Clone' }, CUT: { ...getCtrlShortcuts('x'), help: 'Cut' }, + COPY: { ...getCtrlShortcuts('c'), help: 'Copy' }, PASTE: { ...getCtrlShortcuts('v'), help: 'Paste' }, + CLONE: { ...getCtrlShortcuts('d'), help: 'Clone' }, DELETE: { osx: deleteElementShortcuts, windows: deleteElementShortcuts, @@ -60,13 +60,13 @@ export const keymap = { other: deleteElementShortcuts, help: 'Delete', }, - BRING_FORWARD: { - ...getCtrlShortcuts('up'), - help: 'Send forward', - }, BRING_TO_FRONT: { ...getCtrlShortcuts('shift+up'), - help: 'Send to front', + help: 'Bring to front', + }, + BRING_FORWARD: { + ...getCtrlShortcuts('up'), + help: 'Bring forward', }, SEND_BACKWARD: { ...getCtrlShortcuts('down'), @@ -102,7 +102,7 @@ export const keymap = { REFRESH: refreshShortcut, }, PRESENTATION: { - displayName: 'Presentation mode', + displayName: 'Presentation controls', FULLSCREEN: { ...getAltShortcuts(['p', 'f']), help: 'Enter presentation mode' }, FULLSCREEN_EXIT: { osx: fullscreentExitShortcut, diff --git a/x-pack/plugins/canvas/public/lib/positioned_element.ts b/x-pack/plugins/canvas/public/lib/positioned_element.ts new file mode 100644 index 000000000000..1ac27a3d9d99 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/positioned_element.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +type ArgumentValue = null | string | number | boolean | Ast; + +interface Function { + type: 'function'; + /** + * name of the function + */ + function: string; + /** + * arguments passed into the function + */ + arguments: { [argument: string]: ArgumentValue } | {}; +} + +interface Ast { + type: 'expression'; + /** + * array of functions in the expression + */ + chain: Function[]; +} + +interface Position { + /** + * distance from the left edge of the page + */ + left: number; + /** + * distance from the top edge of the page + * */ + top: number; + /** + * width of the element + */ + width: number; + /** + * height of the element + */ + height: number; + /** + * angle of rotation + */ + angle: number; + /** + * the id of the parent of this element part of a group + */ + parent: string | null; +} + +export interface PositionedElement { + /** + * a Canvas element used to populate config forms + */ + id: string; + /** + * layout engine settings + */ + position: Position; + /** + * Canvas expression used to generate the element + */ + expression: string; + /** + * AST of the Canvas expression for the element + */ + ast: Ast; +} diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index 6959fb8eeeca..4bfa748e51fd 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -128,8 +128,12 @@ export function getGlobalFilterExpression(state) { } // element getters -function getSelectedElementId(state) { - const toplevelNodes = get(state, 'transient.selectedToplevelNodes') || []; +export function getSelectedToplevelNodes(state) { + return get(state, 'transient.selectedToplevelNodes', []); +} + +export function getSelectedElementId(state) { + const toplevelNodes = getSelectedToplevelNodes(state); return toplevelNodes.length === 1 ? toplevelNodes[0] : null; } diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss index 0690bbdf397e..adb605b53ff4 100644 --- a/x-pack/plugins/canvas/public/style/index.scss +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -45,6 +45,7 @@ @import '../components/shape_preview/shape_preview'; @import '../components/shape_picker_mini/shape_picker_mini'; @import '../components/sidebar/sidebar'; +@import '../components/sidebar_header/sidebar_header'; @import '../components/toolbar/toolbar'; @import '../components/toolbar/tray/tray'; @import '../components/tooltip_annotation/tooltip_annotation'; diff --git a/x-pack/plugins/canvas/public/style/main.scss b/x-pack/plugins/canvas/public/style/main.scss index ca22b3648a4d..ef3057ca539a 100644 --- a/x-pack/plugins/canvas/public/style/main.scss +++ b/x-pack/plugins/canvas/public/style/main.scss @@ -1,3 +1,8 @@ +/* + Canvas global SASS varialbes +*/ +$canvasElementCardWidth: 210px; + .canvas.canvasContainer { display: flex; flex-grow: 1; diff --git a/x-pack/plugins/canvas/server/lib/format_response.js b/x-pack/plugins/canvas/server/lib/format_response.js new file mode 100644 index 000000000000..d52e2819465f --- /dev/null +++ b/x-pack/plugins/canvas/server/lib/format_response.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import boom from 'boom'; + +export const formatResponse = esErrors => resp => { + if (resp.isBoom) { + return resp; + } // can't wrap it if it's already a boom error + + if (resp instanceof esErrors['400']) { + return boom.badRequest(resp); + } + + if (resp instanceof esErrors['401']) { + return boom.unauthorized(); + } + + if (resp instanceof esErrors['403']) { + return boom.forbidden("Sorry, you don't have access to that"); + } + + if (resp instanceof esErrors['404']) { + return boom.boomify(resp, { statusCode: 404 }); + } + + return resp; +}; diff --git a/x-pack/plugins/canvas/server/mappings.js b/x-pack/plugins/canvas/server/mappings.ts similarity index 51% rename from x-pack/plugins/canvas/server/mappings.js rename to x-pack/plugins/canvas/server/mappings.ts index 25ca8670b734..bf2be51882b1 100644 --- a/x-pack/plugins/canvas/server/mappings.js +++ b/x-pack/plugins/canvas/server/mappings.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CANVAS_TYPE } from '../common/lib/constants'; +// @ts-ignore converting /libs/constants to TS breaks CI +import { CANVAS_TYPE, CUSTOM_ELEMENT_TYPE } from '../common/lib/constants'; export const mappings = { [CANVAS_TYPE]: { @@ -22,4 +23,22 @@ export const mappings = { '@created': { type: 'date' }, }, }, + [CUSTOM_ELEMENT_TYPE]: { + dynamic: false, + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + help: { type: 'text' }, + content: { type: 'text' }, + image: { type: 'text' }, + '@timestamp': { type: 'date' }, + '@created': { type: 'date' }, + }, + }, }; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements.js b/x-pack/plugins/canvas/server/routes/custom_elements.js new file mode 100644 index 000000000000..372b01e6fa59 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements.js @@ -0,0 +1,152 @@ +/* + * 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 boom from 'boom'; +import { omit } from 'lodash'; +import { API_ROUTE_CUSTOM_ELEMENT, CUSTOM_ELEMENT_TYPE } from '../../common/lib/constants'; +import { getId } from '../../public/lib/get_id'; +import { formatResponse as formatRes } from '../lib/format_response'; + +export function customElements(server) { + const { errors: esErrors } = server.plugins.elasticsearch.getCluster('data'); + const routePrefix = API_ROUTE_CUSTOM_ELEMENT; + const formatResponse = formatRes(esErrors); + + const createCustomElement = req => { + const savedObjectsClient = req.getSavedObjectsClient(); + + if (!req.payload) { + return Promise.reject(boom.badRequest('A custom element payload is required')); + } + + const now = new Date().toISOString(); + const { id, ...payload } = req.payload; + return savedObjectsClient.create( + CUSTOM_ELEMENT_TYPE, + { + ...payload, + '@timestamp': now, + '@created': now, + }, + { id: id || getId('custom-element') } + ); + }; + + const updateCustomElement = (req, newPayload) => { + const savedObjectsClient = req.getSavedObjectsClient(); + const { id } = req.params; + const payload = newPayload ? newPayload : req.payload; + + const now = new Date().toISOString(); + + return savedObjectsClient.get(CUSTOM_ELEMENT_TYPE, id).then(element => { + // TODO: Using create with force over-write because of version conflict issues with update + return savedObjectsClient.create( + CUSTOM_ELEMENT_TYPE, + { + ...element.attributes, + ...omit(payload, 'id'), // never write the id property + '@timestamp': now, // always update the modified time + '@created': element.attributes['@created'], // ensure created is not modified + }, + { overwrite: true, id } + ); + }); + }; + + const deleteCustomElement = req => { + const savedObjectsClient = req.getSavedObjectsClient(); + const { id } = req.params; + + return savedObjectsClient.delete(CUSTOM_ELEMENT_TYPE, id); + }; + + const findCustomElement = req => { + const savedObjectsClient = req.getSavedObjectsClient(); + const { name, page, perPage } = req.query; + + return savedObjectsClient.find({ + type: CUSTOM_ELEMENT_TYPE, + sortField: '@timestamp', + sortOrder: 'desc', + search: name ? `${name}* | ${name}` : '*', + searchFields: ['name'], + fields: ['id', 'name', 'displayName', 'help', 'image', 'content', '@created', '@timestamp'], + page, + perPage, + }); + }; + + const getCustomElementById = req => { + const savedObjectsClient = req.getSavedObjectsClient(); + const { id } = req.params; + return savedObjectsClient.get(CUSTOM_ELEMENT_TYPE, id); + }; + + // get custom element by id + server.route({ + method: 'GET', + path: `${routePrefix}/{id}`, + handler: req => + getCustomElementById(req) + .then(obj => ({ id: obj.id, ...obj.attributes })) + .then(formatResponse) + .catch(formatResponse), + }); + + // create custom element + server.route({ + method: 'POST', + path: routePrefix, + config: { payload: { allow: 'application/json', maxBytes: 26214400 } }, // 25MB payload limit + handler: req => + createCustomElement(req) + .then(() => ({ ok: true })) + .catch(formatResponse), + }); + + // update custom element + server.route({ + method: 'PUT', + path: `${routePrefix}/{id}`, + config: { payload: { allow: 'application/json', maxBytes: 26214400 } }, // 25MB payload limit + handler: req => + updateCustomElement(req) + .then(() => ({ ok: true })) + .catch(formatResponse), + }); + + // delete custom element + server.route({ + method: 'DELETE', + path: `${routePrefix}/{id}`, + handler: req => + deleteCustomElement(req) + .then(() => ({ ok: true })) + .catch(formatResponse), + }); + + // find custom elements + server.route({ + method: 'GET', + path: `${routePrefix}/find`, + handler: req => + findCustomElement(req) + .then(formatResponse) + .then(resp => { + return { + total: resp.total, + customElements: resp.saved_objects.map(hit => ({ id: hit.id, ...hit.attributes })), + }; + }) + .catch(() => { + return { + total: 0, + customElements: [], + }; + }), + }); +} diff --git a/x-pack/plugins/canvas/server/routes/index.js b/x-pack/plugins/canvas/server/routes/index.js index 45f26a423fc8..209851ee3f20 100644 --- a/x-pack/plugins/canvas/server/routes/index.js +++ b/x-pack/plugins/canvas/server/routes/index.js @@ -6,8 +6,10 @@ import { workpad } from './workpad'; import { esFields } from './es_fields'; +import { customElements } from './custom_elements'; export function routes(server) { - workpad(server); + customElements(server); esFields(server); + workpad(server); } diff --git a/x-pack/plugins/canvas/server/routes/workpad.js b/x-pack/plugins/canvas/server/routes/workpad.js index d45c197d5f80..9a9367cbf224 100644 --- a/x-pack/plugins/canvas/server/routes/workpad.js +++ b/x-pack/plugins/canvas/server/routes/workpad.js @@ -13,37 +13,14 @@ import { API_ROUTE_WORKPAD_STRUCTURES, } from '../../common/lib/constants'; import { getId } from '../../public/lib/get_id'; +import { formatResponse as formatRes } from '../lib/format_response'; export function workpad(server) { - //const config = server.config(); const { errors: esErrors } = server.plugins.elasticsearch.getCluster('data'); const routePrefix = API_ROUTE_WORKPAD; const routePrefixAssets = API_ROUTE_WORKPAD_ASSETS; const routePrefixStructures = API_ROUTE_WORKPAD_STRUCTURES; - - function formatResponse(resp) { - if (resp.isBoom) { - return resp; - } // can't wrap it if it's already a boom error - - if (resp instanceof esErrors['400']) { - return boom.badRequest(resp); - } - - if (resp instanceof esErrors['401']) { - return boom.unauthorized(); - } - - if (resp instanceof esErrors['403']) { - return boom.forbidden("Sorry, you don't have access to that"); - } - - if (resp instanceof esErrors['404']) { - return boom.boomify(resp, { statusCode: 404 }); - } - - return resp; - } + const formatResponse = formatRes(esErrors); function createWorkpad(req) { const savedObjectsClient = req.getSavedObjectsClient(); diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index e521e8176344..d47cc16bc869 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -716,6 +716,13 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `saved_object:${version}:canvas-workpad/bulk_create`, `saved_object:${version}:canvas-workpad/update`, `saved_object:${version}:canvas-workpad/delete`, + `saved_object:${version}:canvas-element/bulk_get`, + `saved_object:${version}:canvas-element/get`, + `saved_object:${version}:canvas-element/find`, + `saved_object:${version}:canvas-element/create`, + `saved_object:${version}:canvas-element/bulk_create`, + `saved_object:${version}:canvas-element/update`, + `saved_object:${version}:canvas-element/delete`, `saved_object:${version}:telemetry/bulk_get`, `saved_object:${version}:telemetry/get`, `saved_object:${version}:telemetry/find`, @@ -732,6 +739,9 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `ui:${version}:savedObjectsManagement/canvas-workpad/delete`, `ui:${version}:savedObjectsManagement/canvas-workpad/edit`, `ui:${version}:savedObjectsManagement/canvas-workpad/read`, + `ui:${version}:savedObjectsManagement/canvas-element/delete`, + `ui:${version}:savedObjectsManagement/canvas-element/edit`, + `ui:${version}:savedObjectsManagement/canvas-element/read`, `ui:${version}:savedObjectsManagement/telemetry/delete`, `ui:${version}:savedObjectsManagement/telemetry/edit`, `ui:${version}:savedObjectsManagement/telemetry/read`, @@ -753,11 +763,15 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `saved_object:${version}:canvas-workpad/bulk_get`, `saved_object:${version}:canvas-workpad/get`, `saved_object:${version}:canvas-workpad/find`, + `saved_object:${version}:canvas-element/bulk_get`, + `saved_object:${version}:canvas-element/get`, + `saved_object:${version}:canvas-element/find`, `saved_object:${version}:config/bulk_get`, `saved_object:${version}:config/get`, `saved_object:${version}:config/find`, `ui:${version}:savedObjectsManagement/index-pattern/read`, `ui:${version}:savedObjectsManagement/canvas-workpad/read`, + `ui:${version}:savedObjectsManagement/canvas-element/read`, `ui:${version}:savedObjectsManagement/config/read`, ], }, @@ -1089,8 +1103,18 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `saved_object:${version}:canvas-workpad/bulk_create`, `saved_object:${version}:canvas-workpad/update`, `saved_object:${version}:canvas-workpad/delete`, + `saved_object:${version}:canvas-element/bulk_get`, + `saved_object:${version}:canvas-element/get`, + `saved_object:${version}:canvas-element/find`, + `saved_object:${version}:canvas-element/create`, + `saved_object:${version}:canvas-element/bulk_create`, + `saved_object:${version}:canvas-element/update`, + `saved_object:${version}:canvas-element/delete`, `ui:${version}:savedObjectsManagement/canvas-workpad/delete`, `ui:${version}:savedObjectsManagement/canvas-workpad/edit`, + `ui:${version}:savedObjectsManagement/canvas-element/delete`, + `ui:${version}:savedObjectsManagement/canvas-element/edit`, + `ui:${version}:savedObjectsManagement/canvas-element/read`, `ui:${version}:canvas/save`, `api:${version}:infra`, `app:${version}:infra`, @@ -1206,6 +1230,10 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `app:${version}:canvas`, `ui:${version}:catalogue/canvas`, `ui:${version}:navLinks/canvas`, + `saved_object:${version}:canvas-element/bulk_get`, + `saved_object:${version}:canvas-element/get`, + `saved_object:${version}:canvas-element/find`, + `ui:${version}:savedObjectsManagement/canvas-element/read`, `api:${version}:infra`, `app:${version}:infra`, `ui:${version}:catalogue/infraops`, @@ -1395,8 +1423,18 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `saved_object:${version}:canvas-workpad/bulk_create`, `saved_object:${version}:canvas-workpad/update`, `saved_object:${version}:canvas-workpad/delete`, + `saved_object:${version}:canvas-element/bulk_get`, + `saved_object:${version}:canvas-element/get`, + `saved_object:${version}:canvas-element/find`, + `saved_object:${version}:canvas-element/create`, + `saved_object:${version}:canvas-element/bulk_create`, + `saved_object:${version}:canvas-element/update`, + `saved_object:${version}:canvas-element/delete`, `ui:${version}:savedObjectsManagement/canvas-workpad/delete`, `ui:${version}:savedObjectsManagement/canvas-workpad/edit`, + `ui:${version}:savedObjectsManagement/canvas-element/delete`, + `ui:${version}:savedObjectsManagement/canvas-element/edit`, + `ui:${version}:savedObjectsManagement/canvas-element/read`, `ui:${version}:canvas/save`, `api:${version}:infra`, `app:${version}:infra`, @@ -1512,6 +1550,10 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) { `app:${version}:canvas`, `ui:${version}:catalogue/canvas`, `ui:${version}:navLinks/canvas`, + `saved_object:${version}:canvas-element/bulk_get`, + `saved_object:${version}:canvas-element/get`, + `saved_object:${version}:canvas-element/find`, + `ui:${version}:savedObjectsManagement/canvas-element/read`, `api:${version}:infra`, `app:${version}:infra`, `ui:${version}:catalogue/infraops`, diff --git a/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts b/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts index c522d68180fd..c2cfb00a9490 100644 --- a/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts +++ b/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts @@ -38,6 +38,7 @@ export class SavedObjectsManagementBuilder { 'map', 'maps-telemetry', 'canvas-workpad', + 'canvas-element', 'infrastructure-ui-source', 'upgrade-assistant-reindex-operation', 'upgrade-assistant-telemetry', diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/saved_objects_management.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/saved_objects_management.ts index 2accc571b628..f3e9937239d0 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/saved_objects_management.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/saved_objects_management.ts @@ -55,6 +55,7 @@ export default function savedObjectsManagementTests({ 'graph-workspace', 'map', 'canvas-workpad', + 'canvas-element', 'index-pattern', 'visualization', 'search', @@ -82,6 +83,7 @@ export default function savedObjectsManagementTests({ 'graph-workspace', 'map', 'canvas-workpad', + 'canvas-element', 'index-pattern', 'visualization', 'search', diff --git a/x-pack/test/ui_capabilities/security_only/tests/saved_objects_management.ts b/x-pack/test/ui_capabilities/security_only/tests/saved_objects_management.ts index 5c7fd0f84790..588b3adb13a3 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/saved_objects_management.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/saved_objects_management.ts @@ -49,6 +49,7 @@ export default function savedObjectsManagementTests({ 'graph-workspace', 'map', 'canvas-workpad', + 'canvas-element', 'index-pattern', 'visualization', 'search', @@ -72,6 +73,7 @@ export default function savedObjectsManagementTests({ 'graph-workspace', 'map', 'canvas-workpad', + 'canvas-element', 'index-pattern', 'visualization', 'search',