[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:
parent
045c8388ca
commit
53ad55cf6f
|
@ -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`;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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')} />
|
||||
));
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
15
x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx
Normal file
15
x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx
Normal 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>
|
||||
);
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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} />
|
||||
));
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -48,4 +48,5 @@ export const interactiveWorkpadPagePropTypes = {
|
|||
sendToBack: PropTypes.func,
|
||||
canvasOrigin: PropTypes.func,
|
||||
saveCanvasOrigin: PropTypes.func.isRequired,
|
||||
commit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
69
x-pack/plugins/canvas/public/lib/custom_element_service.ts
Normal file
69
x-pack/plugins/canvas/public/lib/custom_element_service.ts
Normal 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
|
||||
);
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
/* eslint-disable */
|
||||
export const elasticLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmlld0JveD0iMCAwIDI3MC42MDAwMSAyNjkuNTQ2NjYiCiAgIGhlaWdodD0iMjY5LjU0NjY2IgogICB3aWR0aD0iMjcwLjYwMDAxIgogICB4bWw6c3BhY2U9InByZXNlcnZlIgogICBpZD0ic3ZnMiIKICAgdmVyc2lvbj0iMS4xIj48bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE4Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNiIgLz48ZwogICAgIHRyYW5zZm9ybT0ibWF0cml4KDEuMzMzMzMzMywwLDAsLTEuMzMzMzMzMywwLDI2OS41NDY2NykiCiAgICAgaWQ9ImcxMCI+PGcKICAgICAgIHRyYW5zZm9ybT0ic2NhbGUoMC4xKSIKICAgICAgIGlkPSJnMTIiPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMTQiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMjAyOS40OCw5NjIuNDQxIGMgMCwxNzAuMDk5IC0xMDUuNDYsMzE4Ljc5OSAtMjY0LjE3LDM3Ni42NTkgNi45OCwzNS44NiAxMC42Miw3MS43MSAxMC42MiwxMDkuMDUgMCwzMTYuMTkgLTI1Ny4yNCw1NzMuNDMgLTU3My40Nyw1NzMuNDMgLTE4NC43MiwwIC0zNTYuNTU4LC04OC41OSAtNDY0LjUzLC0yMzcuODUgLTUzLjA5LDQxLjE4IC0xMTguMjg1LDYzLjc1IC0xODYuMzA1LDYzLjc1IC0xNjcuODM2LDAgLTMwNC4zODMsLTEzNi41NCAtMzA0LjM4MywtMzA0LjM4IDAsLTM3LjA4IDYuNjE3LC03Mi41OCAxOS4wMzEsLTEwNi4wOCBDIDEwOC40ODgsMTM4MC4wOSAwLDEyMjcuODkgMCwxMDU4Ljg4IDAsODg3LjkxIDEwNS45NzcsNzM4LjUzOSAyNjUuMzk4LDY4MS4wOSBjIC02Ljc2OSwtMzUuNDQyIC0xMC40NiwtNzIuMDIgLTEwLjQ2LC0xMDkgQyAyNTQuOTM4LDI1Ni42MjEgNTExLjU2NiwwIDgyNy4wMjcsMCAxMDEyLjIsMCAxMTgzLjk0LDg4Ljk0MTQgMTI5MS4zLDIzOC44MzIgYyA1My40NSwtNDEuOTYxIDExOC44LC02NC45OTIgMTg2LjU2LC02NC45OTIgMTY3LjgzLDAgMzA0LjM4LDEzNi40OTIgMzA0LjM4LDMwNC4zMzIgMCwzNy4wNzggLTYuNjIsNzIuNjI5IC0xOS4wMywxMDYuMTI5IDE1Ny43OCw1Ni44NzkgMjY2LjI3LDIwOS4xMjkgMjY2LjI3LDM3OC4xNCIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDE2IgogICAgICAgICBzdHlsZT0iZmlsbDojZmFjZjA5O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDc5Ny44OTgsMTE1MC45MyA0NDQuMDcyLC0yMDIuNDUgNDQ4LjA1LDM5Mi41OCBjIDYuNDksMzIuMzkgOS42Niw2NC42NyA5LjY2LDk4LjQ2IDAsMjc2LjIzIC0yMjQuNjgsNTAwLjk1IC01MDAuOSw1MDAuOTUgLTE2NS4yNCwwIC0zMTkuMzcsLTgxLjM2IC00MTMuMDUzLC0yMTcuNzkgbCAtNzQuNTI0LC0zODYuNjQgODYuNjk1LC0xODUuMTEiIC8+PHBhdGgKICAgICAgICAgaWQ9InBhdGgxOCIKICAgICAgICAgc3R5bGU9ImZpbGw6IzQ5YzFhZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6bm9uZSIKICAgICAgICAgZD0ibSAzMzguMjIzLDY4MC42NzIgYyAtNi40ODksLTMyLjM4MyAtOS44MDksLTY1Ljk4MSAtOS44MDksLTk5Ljk3MyAwLC0yNzYuOTI5IDIyNS4zMzYsLTUwMi4yNTc2IDUwMi4zMTMsLTUwMi4yNTc2IDE2Ni41OTMsMCAzMjEuNDczLDgyLjExNzYgNDE1LjAxMywyMTkuOTQ5NiBsIDczLjk3LDM4NS4zNDcgLTk4LjcyLDE4OC42MjEgTCA3NzUuMTU2LDEwNzUuNTcgMzM4LjIyMyw2ODAuNjcyIiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjAiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNlZjI5OWI7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMzM1LjQxLDE0NDkuMTggMzA0LjMzMiwtNzEuODYgNjYuNjgsMzQ2LjAyIGMgLTQxLjU4NiwzMS43OCAtOTIuOTMsNDkuMTggLTE0NS43MzEsNDkuMTggLTEzMi4yNSwwIC0yMzkuODEyLC0xMDcuNjEgLTIzOS44MTIsLTIzOS44NyAwLC0yOS4yMSA0Ljg3OSwtNTcuMjIgMTQuNTMxLC04My40NyIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDIyIgogICAgICAgICBzdHlsZT0iZmlsbDojNGNhYmU0O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJNIDMwOC45OTIsMTM3Ni43IEMgMTczLjAyLDEzMzEuNjQgNzguNDgwNSwxMjAxLjMgNzguNDgwNSwxMDU3LjkzIDc4LjQ4MDUsOTE4LjM0IDE2NC44Miw3OTMuNjggMjk0LjQwNiw3NDQuMzUyIGwgNDI2Ljk4MSwzODUuOTM4IC03OC4zOTUsMTY3LjUxIC0zMzQsNzguOSIgLz48cGF0aAogICAgICAgICBpZD0icGF0aDI0IgogICAgICAgICBzdHlsZT0iZmlsbDojODVjZTI2O2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lIgogICAgICAgICBkPSJtIDEzMjMuOCwyOTguNDEgYyA0MS43NCwtMzIuMDkgOTIuODMsLTQ5LjU5IDE0NC45OCwtNDkuNTkgMTMyLjI1LDAgMjM5LjgxLDEwNy41NTkgMjM5LjgxLDIzOS44MjEgMCwyOS4xNiAtNC44OCw1Ny4xNjggLTE0LjUzLDgzLjQxOCBsIC0zMDQuMDgsNzEuMTYgLTY2LjE4LC0zNDQuODA5IiAvPjxwYXRoCiAgICAgICAgIGlkPSJwYXRoMjYiCiAgICAgICAgIHN0eWxlPSJmaWxsOiMzMTc3YTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOm5vbmUiCiAgICAgICAgIGQ9Im0gMTM4NS42Nyw3MjIuOTMgMzM0Ljc2LC03OC4zMDEgYyAxMzYuMDIsNDQuOTYxIDIzMC41NiwxNzUuMzUxIDIzMC41NiwzMTguNzYyIDAsMTM5LjMzOSAtODYuNTQsMjYzLjg1OSAtMjE2LjM4LDMxMy4wMzkgbCAtNDM3Ljg0LC0zODMuNTkgODguOSwtMTY5LjkxIiAvPjwvZz48L2c+PC9zdmc+';
|
9
x-pack/plugins/canvas/public/lib/elastic_logo.ts
Normal file
9
x-pack/plugins/canvas/public/lib/elastic_logo.ts
Normal 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+';
|
156
x-pack/plugins/canvas/public/lib/element_handler_creators.ts
Normal file
156
x-pack/plugins/canvas/public/lib/element_handler_creators.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
|
@ -6,6 +6,6 @@
|
|||
|
||||
import uuid from 'uuid/v4';
|
||||
|
||||
export function getId(type) {
|
||||
export function getId(type: string): string {
|
||||
return `${type}-${uuid()}`;
|
||||
}
|
|
@ -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);
|
|
@ -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,
|
||||
|
|
73
x-pack/plugins/canvas/public/lib/positioned_element.ts
Normal file
73
x-pack/plugins/canvas/public/lib/positioned_element.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/*
|
||||
Canvas global SASS varialbes
|
||||
*/
|
||||
$canvasElementCardWidth: 210px;
|
||||
|
||||
.canvas.canvasContainer {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
|
30
x-pack/plugins/canvas/server/lib/format_response.js
Normal file
30
x-pack/plugins/canvas/server/lib/format_response.js
Normal 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;
|
||||
};
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
};
|
152
x-pack/plugins/canvas/server/routes/custom_elements.js
Normal file
152
x-pack/plugins/canvas/server/routes/custom_elements.js
Normal 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: [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -38,6 +38,7 @@ export class SavedObjectsManagementBuilder {
|
|||
'map',
|
||||
'maps-telemetry',
|
||||
'canvas-workpad',
|
||||
'canvas-element',
|
||||
'infrastructure-ui-source',
|
||||
'upgrade-assistant-reindex-operation',
|
||||
'upgrade-assistant-telemetry',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in a new issue