[Canvas]Feat: Custom Elements (#34140)

* Added custom element routes

    Added context menu to sidebar header

    Added custom element modal

    Added tabs to element_types

    Added handlers for retrieving custom elements

    sidebar header tweaks

    Added edit and delete element controls

    Added a selector for transient.selectedToplevelNodes

    Refactored element event handlers

    Added additional sidebar views

    Fixed adding custom element to workpad

    Converted element_controls to tsx

    Disabled group/ungroup buttons sidebar

    Disabled layer controls in multi_element_settings

    Cleaned up props for element_types

    Added story for element_controls

    Added stories for global_config, group_settings, and multi_element_config

    TSified element_handler_creators

    fixed ts errors

    more tsifying

    Added decorator to element_controls

    Updated stories for custom_element_modal

    Disabled global_config, group_settings, and multi_element_settings stories

    TSified sidebar

    disable layer controls in group_settings

    Removed save element shortcut

    added public/private keywords

    Converted sidebar_header to ts and added stories

Refactored sidebar_header

fix file extension

design cleanup

fix image in edit modal

Fixed ts errors

Update x-pack/plugins/canvas/server/routes/custom_elements.js

Co-Authored-By: cqliu1 <catherineqliu@outlook.com>

Update x-pack/plugins/canvas/server/routes/workpad.js

Co-Authored-By: cqliu1 <catherineqliu@outlook.com>

Reordered args for insertNodes and removeNodes to match corresponding redux actions

Extracted PositionedElement interface

Fixed TS issues

Adjust title and desc lengths

Added comments to props interface

Refactored onClick handlers

more ts

Added types for ast

Switched common/lib/constants back to JS

Added comments

Updated more comments

Removed unused import

Added snapshots

Typed custom_element_service

Fixed ts errors

* Added comments to @ts-ignore's

* Removed custom_element_modal stories

* Added a few more comments

* Update security tests with new canvas-element saved object

* Updated privileges test

* Updated ui_capabilities security_only saved_objects_management test

* Added state interface for CustomElementTypes

* Added state interfaces

* fixed comment

* Removed unnecessary exports
This commit is contained in:
Catherine Liu 2019-05-02 16:38:46 -05:00 committed by GitHub
parent 045c8388ca
commit 53ad55cf6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 2581 additions and 517 deletions

View file

@ -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`;

View file

@ -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<string | ArrayBuffer | null>((resolve, reject) => {
return new Promise<string>((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);
});

View file

@ -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: [],
},

View file

@ -0,0 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots components/ElementTypes/ElementControls has two buttons 1`] = `
<div
style={
Object {
"width": "50px",
}
}
>
<div
className="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive canvasElementCard__controls"
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Edit element"
className="euiButtonIcon euiButtonIcon--primary"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M12.148 3.148L11 2l-9 9v3h3l9-9-1.144-1.144-8.002 7.998a.502.502 0 0 1-.708 0 .502.502 0 0 1 0-.708l8.002-7.998zM11 1c.256 0 .512.098.707.293l3 3a.999.999 0 0 1 0 1.414l-9 9A.997.997 0 0 1 5 15H2a1 1 0 0 1-1-1v-3c0-.265.105-.52.293-.707l9-9A.997.997 0 0 1 11 1zM5 14H2v-3l3 3z"
id="pencil-a"
/>
</defs>
<use
xlinkHref="#pencil-a"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Delete element"
className="euiButtonIcon euiButtonIcon--danger"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M11 3h5v1H0V3h5V1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2zm-7.056 8H7v1H4.1l.392 2.519c.042.269.254.458.493.458h6.03c.239 0 .451-.189.493-.458l1.498-9.576H14l-1.504 9.73c-.116.747-.74 1.304-1.481 1.304h-6.03c-.741 0-1.365-.557-1.481-1.304l-1.511-9.73H9V5.95H3.157L3.476 8H8v1H3.632l.312 2zM6 3h4V1H6v2z"
id="trash-a"
/>
</defs>
<use
xlinkHref="#trash-a"
/>
</svg>
</button>
</span>
</div>
</div>
</div>
`;

View file

@ -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 => (
<div
style={{
width: '50px',
}}
>
{story()}
</div>
))
.add('has two buttons', () => (
<ElementControls onDelete={action('onDelete')} onEdit={action('onEdit')} />
));

View file

@ -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<Props> = ({ onDelete, onEdit }) => (
<EuiFlexGroup
className="canvasElementCard__controls"
gutterSize="xs"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiToolTip content="Edit">
<EuiButtonIcon iconType="pencil" aria-label="Edit element" onClick={onEdit} />
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content="Delete">
<EuiButtonIcon
color="danger"
iconType="trash"
aria-label="Delete element"
onClick={onDelete}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
ElementControls.propTypes = {
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
};

View file

@ -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 = (
<EuiFlexItem key={name}>
<EuiCard
textAlign="left"
image={image}
title={displayName}
description={help}
onClick={whenClicked}
className="canvasCard"
/>
</EuiFlexItem>
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 = (
<EuiFlexItem key={name} className="canvasElementCard">
<EuiCard
textAlign="left"
image={image}
icon={image ? null : <EuiIcon type="canvasApp" size="xxl" />}
title={displayName}
description={help}
onClick={whenClicked}
className={image ? 'canvasCard' : 'canvasCard canvasCard--hasIcon'}
/>
{showControls && (
<ElementControls
onEdit={() => this._showEditModal(element)}
onDelete={() => this._showDeleteModal(element)}
/>
)}
</EuiFlexItem>
);
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 (
<CustomElementModal
title="Edit element"
name={elementToEdit.displayName}
description={elementToEdit.help}
image={elementToEdit.image}
onSave={updateCustomElement(elementToEdit.id)}
onCancel={this._hideEditModal}
/>
);
};
_renderDeleteModal = () => {
const { removeCustomElement } = this.props;
const { elementToDelete } = this.state;
return (
<ConfirmModal
isOpen
title={`Delete element '${elementToDelete.displayName}'?`}
message="Are you sure you want to delete this element?"
confirmButtonText="Delete"
onConfirm={() => {
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
<EuiEmptyPrompt
iconType="vector"
title={<h2>Add new elements</h2>}
body={<p>Group and save workpad elements to create new elements</p>}
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 (
<Fragment>
<EuiModalHeader>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFieldSearch
className="canvasElements__filter"
placeholder="Filter elements"
onChange={e => setSearch(e.target.value)}
value={search}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGrid gutterSize="l" columns={4}>
{elementList}
</EuiFlexGrid>
</EuiModalBody>
</Fragment>
);
};
const tabs = [
{
id: 'elements',
name: 'Elements',
content: (
<Fragment>
<EuiSpacer />
<EuiFlexGrid gutterSize="l" columns={4}>
{elementList}
</EuiFlexGrid>
</Fragment>
),
},
{
id: 'customElements',
name: 'My elements',
content: (
<Fragment>
<EuiSpacer />
<EuiFlexGrid gutterSize="l" columns={4}>
{customElementContent}
</EuiFlexGrid>
</Fragment>
),
},
];
ElementTypes.propTypes = {
elements: PropTypes.object,
onClick: PropTypes.func,
search: PropTypes.string,
setSearch: PropTypes.func,
};
return (
<Fragment>
<EuiModalHeader>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFieldSearch
className="canvasElements__filter"
placeholder="Filter elements"
onChange={e => setSearch(e.target.value)}
value={search}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalHeader>
<EuiModalBody>
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} />
</EuiModalBody>
{isEditModalVisible && elementToEdit && this._renderEditModal()}
{isDeleteModalVisible && elementToDelete && this._renderDeleteModal()}
</Fragment>
);
}
}

View file

@ -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;
}
}
}
.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;
}
}

View file

@ -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,
};

View file

@ -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<Props> = ({ element }) => {
const tabs = [
{
id: 'edit',
name: 'Display',
content: (
<div className="canvasSidebar__pop">
<EuiSpacer size="s" />
<div className="canvasSidebar--args">
<FunctionFormList element={element} />
</div>
</div>
),
},
{
id: 'data',
name: 'Data',
content: (
<div className="canvasSidebar__pop">
<EuiSpacer size="s" />
<Datasource />
</div>
),
},
];
return (
<Fragment>
<SidebarHeader title="Selected element" />
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} size="s" />
</Fragment>
);
};
ElementSettings.propTypes = {
element: PropTypes.object,
};

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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,
};

View file

@ -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 = () => (
<div className="canvasSidebar">
export const GlobalConfig: FunctionComponent = () => (
<Fragment>
<SidebarSection>
<WorkpadConfig />
</SidebarSection>
@ -21,5 +25,5 @@ export const GlobalConfig = () => (
<SidebarSection>
<ElementConfig />
</SidebarSection>
</div>
</Fragment>
);

View file

@ -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 = () => (
<Fragment>
<SidebarHeader title="Grouped element" groupIsSelected showLayerControls={false} />
<EuiSpacer />
<EuiText size="s">
<p>Ungroup (U) to edit individual element settings.</p>
<p>Save this group as a new element to re-use it throughout your workpad.</p>
</EuiText>
</Fragment>
);

View file

@ -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);

View file

@ -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';

View file

@ -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 = () => (
<Fragment>
<SidebarHeader title="Multiple elements" showLayerControls={false} />
<EuiSpacer />
<EuiText size="s">
<p>Multiple elements are currently selected.</p>
<p>
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.
</p>
</EuiText>
</Fragment>
);

View file

@ -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);

View file

@ -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 = () => (
<div className="canvasSidebar">
<SidebarContent />
</div>
);

View file

@ -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: (
<div className="canvasSidebar__pop">
<EuiSpacer size="s" />
<div className="canvasSidebar--args">
<FunctionFormList element={selectedElement} />
</div>
</div>
),
},
{
id: 'data',
name: 'Data',
content: (
<div className="canvasSidebar__pop">
<EuiSpacer size="s" />
<Datasource />
</div>
),
},
];
return (
<div className="canvasSidebar">
{elementIsSelected && (
<div>
<EuiFlexGroup gutterSize="none" alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h3>Selected layer</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element to top layer">
<EuiButtonIcon
color="text"
iconType="sortUp"
onClick={() => elementLayer(Infinity)}
aria-label="Move element to top layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element up one layer">
<EuiButtonIcon
color="text"
iconType="arrowUp"
onClick={() => elementLayer(1)}
aria-label="Move element up one layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element down one layer">
<EuiButtonIcon
color="text"
iconType="arrowDown"
onClick={() => elementLayer(-1)}
aria-label="Move element down one layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element to bottom layer">
<EuiButtonIcon
color="text"
iconType="sortDown"
onClick={() => elementLayer(-Infinity)}
aria-label="Move element to bottom layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Clone the selected element">
<EuiButtonIcon
color="text"
iconType="copy"
onClick={() => duplicateElement()}
aria-label="Clone the selected element"
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} size="s" />
</div>
)}
</div>
);
};
SidebarComponent.propTypes = {
selectedElement: PropTypes.object,
duplicateElement: PropTypes.func.isRequired,
elementLayer: PropTypes.func,
elementIsSelected: PropTypes.bool.isRequired,
};

View file

@ -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);

View file

@ -10,7 +10,7 @@ import { EuiTitle, EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui';
export const SidebarSectionTitle = ({ title, tip, children }) => {
const formattedTitle = (
<EuiTitle size="xs">
<EuiTitle size="xxs">
<h4>{title}</h4>
</EuiTitle>
);
@ -27,7 +27,12 @@ export const SidebarSectionTitle = ({ title, tip, children }) => {
};
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexGroup
className="canvasSidebar__panelTitle"
gutterSize="xs"
alignItems="center"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>{renderTitle(tip)}</EuiFlexItem>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,398 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots components/SidebarHeader/ default 1`] = `
<div
style={
Object {
"width": "300px",
}
}
>
<div
className="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive canvasLayout__sidebarHeader"
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<h3
className="euiTitle euiTitle--xsmall"
>
Selected layer
</h3>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
className="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Move element to top layer"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 4.207v8.237c0 .307-.224.556-.5.556s-.5-.249-.5-.556V4.207L2.904 8.303a.5.5 0 0 1-.707-.707l4.242-4.242a1.5 1.5 0 0 1 2.122 0l4.242 4.242a.5.5 0 1 1-.707.707L8 4.207z"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Move element up one layer"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M13.069 5.157L8.384 9.768a.546.546 0 0 1-.768 0L2.93 5.158a.552.552 0 0 0-.771 0 .53.53 0 0 0 0 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 0 0 0-.76.552.552 0 0 0-.771 0z"
id="arrow_up-a"
/>
</defs>
<use
fillRule="nonzero"
transform="rotate(180 8 8)"
xlinkHref="#arrow_up-a"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Move element down one layer"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M13.069 5.157L8.384 9.768a.546.546 0 0 1-.768 0L2.93 5.158a.552.552 0 0 0-.771 0 .53.53 0 0 0 0 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 0 0 0-.76.552.552 0 0 0-.771 0z"
id="arrow_down-a"
/>
</defs>
<use
fillRule="nonzero"
xlinkHref="#arrow_down-a"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Move element to bottom layer"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 11.692V3.556C7 3.249 7.224 3 7.5 3s.5.249.5.556v8.136l4.096-4.096a.5.5 0 0 1 .707.707l-4.242 4.243a1.494 1.494 0 0 1-.925.433.454.454 0 0 1-.272 0 1.494 1.494 0 0 1-.925-.433L2.197 8.303a.5.5 0 1 1 .707-.707L7 11.692z"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Save as new element"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2H2v11h6v1H1V1h12v6h-1V2zM5 5h5.999V4H5v1zM3 5V4h1v1H3zm2 3V7h3v1H5zM3 8V7h1v1H3zm2 3v-1h2v1H5zm5-1H8v1h2v2h1v-2h2v-1h-2V8h-1v2zm-7 1v-1h1v1H3zm7.5-5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9z"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
className="euiPopover euiPopover--anchorDownCenter canvasContextMenu"
container={null}
id="sidebar-context-menu-popover"
onKeyDown={[Function]}
onMouseDown={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className="euiPopover__anchor"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Element options"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M7 1v2h2V1H7zM6 0h4v4H6V0zm0 6h4v4H6V6zm1 1v2h2V7H7zm-1 5h4v4H6v-4zm1 1v2h2v-2H7z"
id="boxes_vertical-a"
/>
</defs>
<use
xlinkHref="#boxes_vertical-a"
/>
</svg>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`Storyshots components/SidebarHeader/ without layer controls 1`] = `
<div
style={
Object {
"width": "300px",
}
}
>
<div
className="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive canvasLayout__sidebarHeader"
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<h3
className="euiTitle euiTitle--xsmall"
>
Grouped element
</h3>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
className="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Save as new element"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2H2v11h6v1H1V1h12v6h-1V2zM5 5h5.999V4H5v1zM3 5V4h1v1H3zm2 3V7h3v1H5zM3 8V7h1v1H3zm2 3v-1h2v1H5zm5-1H8v1h2v2h1v-2h2v-1h-2V8h-1v2zm-7 1v-1h1v1H3zm7.5-5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9z"
/>
</svg>
</button>
</span>
</div>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
className="euiPopover euiPopover--anchorDownCenter canvasContextMenu"
container={null}
id="sidebar-context-menu-popover"
onKeyDown={[Function]}
onMouseDown={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className="euiPopover__anchor"
>
<span
className="euiToolTipAnchor"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<button
aria-describedby="generated-id"
aria-label="Element options"
className="euiButtonIcon euiButtonIcon--text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonIcon__icon"
focusable="false"
height="16"
style={null}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M7 1v2h2V1H7zM6 0h4v4H6V0zm0 6h4v4H6V6zm1 1v2h2V7H7zm-1 5h4v4H6v-4zm1 1v2h2v-2H7z"
id="boxes_vertical-a"
/>
</defs>
<use
xlinkHref="#boxes_vertical-a"
/>
</svg>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -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 => <div style={{ width: '300px' }}>{story()}</div>)
.add('default', () => <SidebarHeader title="Selected layer" {...handlers} />)
.add('without layer controls', () => (
<SidebarHeader title="Grouped element" showLayerControls={false} {...handlers} />
));

View file

@ -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<Props, State> {
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 (
<EuiOverlayMask>
<EuiModal
{...rest}
className={`canvasCustomElementModal`}
maxWidth={700}
onClose={onCancel}
initialFocus=".canvasCustomElementForm__name"
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h3>{title}</h3>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem className="canvasCustomElementForm" grow={2}>
<EuiFormRow
label="Name"
helpText={`${MAX_NAME_LENGTH - name.length} characters remaining`}
compressed
>
<EuiFieldText
value={name}
className="canvasCustomElementForm__name"
onChange={e =>
e.target.value.length <= MAX_NAME_LENGTH &&
this._handleChange('name', e.target.value)
}
required
/>
</EuiFormRow>
<EuiFormRow
label="Description"
helpText={`${MAX_DESCRIPTION_LENGTH - description.length} characters remaining`}
>
<EuiTextArea
value={description}
rows={2}
onChange={e =>
e.target.value.length <= MAX_DESCRIPTION_LENGTH &&
this._handleChange('description', e.target.value)
}
/>
</EuiFormRow>
<EuiFormRow
className="canvasCustomElementForm__thumbnail"
label="Thumbnail image"
compressed
>
<EuiFilePicker
initialPromptText="Select or drag and drop an image"
onChange={this._handleUpload}
className="canvasImageUpload"
accept="image/*"
/>
</EuiFormRow>
<EuiText className="canvasCustomElementForm__thumbnailHelp" size="xs">
<p>
Take a screenshot of your element and upload it here. This can also be done
after saving.
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem className="canvasElementCard canvasCustomElementForm__preview" grow={1}>
<EuiTitle size="xxxs">
<h4>Element preview</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiCard
textAlign="left"
image={image}
icon={image ? null : <EuiIcon type="canvasApp" size="xxl" />}
title={name}
description={description}
className={image ? 'canvasCard' : 'canvasCard canvasCard--hasIcon'}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>Cancel</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={() => {
onSave(name, description, image);
onCancel();
}}
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
}
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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) => (
<EuiButtonIcon
color="text"
iconType="boxesVertical"
onClick={handleClick}
aria-label="Element options"
/>
);
export class SidebarHeader extends Component<Props, State> {
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 (
<Fragment>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element to top layer">
<EuiButtonIcon
color="text"
iconType="sortUp"
onClick={bringToFront}
aria-label="Move element to top layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element up one layer">
<EuiButtonIcon
color="text"
iconType="arrowUp"
onClick={bringForward}
aria-label="Move element up one layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element down one layer">
<EuiButtonIcon
color="text"
iconType="arrowDown"
onClick={sendBackward}
aria-label="Move element down one layer"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Move element to bottom layer">
<EuiButtonIcon
color="text"
iconType="sortDown"
onClick={sendToBack}
aria-label="Move element to bottom layer"
/>
</EuiToolTip>
</EuiFlexItem>
</Fragment>
);
};
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 = () => (
<Popover
id="sidebar-context-menu-popover"
className="canvasContextMenu"
button={contextMenuButton}
panelPaddingSize="none"
tooltip="Element options"
tooltipPosition="bottom"
>
{({ closePopover }: { closePopover: () => void }) => (
<EuiContextMenu initialPanelId={0} panels={this._getPanels(closePopover)} />
)}
</Popover>
);
render() {
const { title, showLayerControls, createCustomElement } = this.props;
const { isModalVisible } = this.state;
return (
<Fragment>
<EuiFlexGroup
className="canvasLayout__sidebarHeader"
gutterSize="none"
alignItems="center"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h3>{title}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="none">
{showLayerControls ? this._renderLayoutControls() : null}
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Save as new element">
<EuiButtonIcon
color="text"
iconType="indexOpen"
onClick={this._showModal}
aria-label="Save as new element"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>{this._renderContextMenu()}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{isModalVisible ? (
<CustomElementModal
title="Create new element"
onSave={createCustomElement}
onCancel={this._hideModal}
/>
) : null}
</Fragment>
);
}
}

View file

@ -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);

View file

@ -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 }) => (
<EuiToolTip position="bottom" content="Enter fullscreen mode">
<EuiButtonIcon
iconType="fullScreen"
@ -43,40 +42,34 @@ export class WorkpadHeader extends React.PureComponent {
</EuiToolTip>
);
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 (
<EuiOverlayMask>
<EuiModal
onClose={() => setShowElementModal(false)}
className="canvasModal--fixedSize"
maxWidth="1000px"
initialFocus=".canvasElements__filter"
>
<ElementTypes
onClick={element => {
addElement(element);
setShowElementModal(false);
}}
/>
<EuiModalFooter>
<EuiButton size="s" onClick={() => setShowElementModal(false)}>
Close
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
};
_elementAdd = () => (
<EuiOverlayMask>
<EuiModal
onClose={this._hideElementModal}
className="canvasModal--fixedSize"
maxWidth="1000px"
initialFocus=".canvasElements__filter"
>
<ElementTypes onClose={this._hideElementModal} />
<EuiModalFooter>
<EuiButton size="s" onClick={this._hideElementModal}>
Close
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
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 (
<div>
{showElementModal ? this.elementAdd() : null}
{isModalVisible ? this._elementAdd() : null}
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="xs">
@ -106,7 +94,7 @@ export class WorkpadHeader extends React.PureComponent {
<RefreshControl />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FullscreenControl>{this.fullscreenButton}</FullscreenControl>
<FullscreenControl>{this._fullscreenButton}</FullscreenControl>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<WorkpadExport />
@ -115,19 +103,17 @@ export class WorkpadHeader extends React.PureComponent {
{canUserWrite && (
<Shortcuts
name="EDITOR"
handler={this.keyHandler}
handler={this._keyHandler}
targetNodeSelector="body"
global
/>
)}
<EuiToolTip position="bottom" content={this.getTooltipText()}>
<EuiToolTip position="bottom" content={this._getTooltipText()}>
<EuiButtonIcon
iconType={isWriteable ? 'lockOpen' : 'lock'}
onClick={() => {
toggleWriteable();
}}
onClick={toggleWriteable}
size="s"
aria-label={this.getTooltipText()}
aria-label={this._getTooltipText()}
isDisabled={!canUserWrite}
/>
</EuiToolTip>
@ -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
</EuiButton>

View file

@ -48,4 +48,5 @@ export const interactiveWorkpadPagePropTypes = {
sendToBack: PropTypes.func,
canvasOrigin: PropTypes.func,
saveCanvasOrigin: PropTypes.func.isRequired,
commit: PropTypes.func.isRequired,
};

View file

@ -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
);

View file

@ -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 = <WorkpadShortcuts {...shortcutProps} />;

View file

@ -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<Props> {
public render() {
return (
<Shortcuts
name="ELEMENT"
handler={(action: string, event: Event) => {
event.preventDefault();
keyMap[action](this.props);
}}
targetNodeSelector={`#${this.props.pageId}`}
global
/>
);
}
public shouldComponentUpdate(nextProps: Props) {
return !isEqual(nextProps, this.props);
}
}

View file

@ -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<WorkpadShortcutsProps, HandlerCreatorProps>(
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,
};

View file

@ -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<Props> {
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 (
<Shortcuts
name="ELEMENT"
handler={(action: string, event: KeyboardEvent) => {
if (!isTextInput(event.target as HTMLInputElement)) {
event.preventDefault();
this._keyMap[action]();
}
}}
targetNodeSelector={`body`}
global
/>
);
}
public shouldComponentUpdate(nextProps: Props) {
return !isEqual(nextProps, this.props);
}
}

View file

@ -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<CustomElement> =>
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
);
};

View file

@ -1,2 +0,0 @@
/* eslint-disable */
export const elasticLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+';

View file

@ -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+';

View file

@ -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);
}
},
};

View file

@ -6,6 +6,6 @@
import uuid from 'uuid/v4';
export function getId(type) {
export function getId(type: string): string {
return `${type}-${uuid()}`;
}

View file

@ -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);

View file

@ -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,

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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';

View file

@ -1,3 +1,8 @@
/*
Canvas global SASS varialbes
*/
$canvasElementCardWidth: 210px;
.canvas.canvasContainer {
display: flex;
flex-grow: 1;

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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;
};

View file

@ -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' },
},
},
};

View file

@ -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: [],
};
}),
});
}

View file

@ -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);
}

View file

@ -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();

View file

@ -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`,

View file

@ -38,6 +38,7 @@ export class SavedObjectsManagementBuilder {
'map',
'maps-telemetry',
'canvas-workpad',
'canvas-element',
'infrastructure-ui-source',
'upgrade-assistant-reindex-operation',
'upgrade-assistant-telemetry',

View file

@ -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',

View file

@ -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',