[canvas] Create Custom Elements Service (#107356)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Clint Andrew Hall 2021-08-13 12:08:50 -05:00 committed by GitHub
parent f3e094c836
commit 44014c78b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 247 additions and 243 deletions

View file

@ -17,13 +17,11 @@ storiesOf('components/SavedElementsModal', module)
.add('no custom elements', () => (
<SavedElementsModal
customElements={[] as CustomElement[]}
search=""
setSearch={action('setSearch')}
onAddCustomElement={action('onAddCustomElement')}
onSearch={action('onSearch')}
onUpdateCustomElement={action('onUpdateCustomElement')}
onRemoveCustomElement={action('onRemoveCustomElement')}
onClose={action('onClose')}
addCustomElement={action('addCustomElement')}
findCustomElements={action('findCustomElements')}
updateCustomElement={action('updateCustomElement')}
removeCustomElement={action('removeCustomElement')}
/>
))
.add(
@ -31,13 +29,11 @@ storiesOf('components/SavedElementsModal', module)
(_, props) => (
<SavedElementsModal
customElements={props?.testCustomElements}
search=""
setSearch={action('setSearch')}
onAddCustomElement={action('onAddCustomElement')}
onSearch={action('onSearch')}
onUpdateCustomElement={action('onUpdateCustomElement')}
onRemoveCustomElement={action('onRemoveCustomElement')}
onClose={action('onClose')}
addCustomElement={action('addCustomElement')}
findCustomElements={action('findCustomElements')}
updateCustomElement={action('updateCustomElement')}
removeCustomElement={action('removeCustomElement')}
/>
),
{ decorators: [waitFor(getTestCustomElements())] }
@ -47,13 +43,12 @@ storiesOf('components/SavedElementsModal', module)
(_, props) => (
<SavedElementsModal
customElements={props?.testCustomElements}
search="Element 2"
initialSearch="Element 2"
onAddCustomElement={action('onAddCustomElement')}
onSearch={action('onSearch')}
onUpdateCustomElement={action('onUpdateCustomElement')}
onRemoveCustomElement={action('onRemoveCustomElement')}
onClose={action('onClose')}
setSearch={action('setSearch')}
addCustomElement={action('addCustomElement')}
findCustomElements={action('findCustomElements')}
updateCustomElement={action('updateCustomElement')}
removeCustomElement={action('removeCustomElement')}
/>
),
{ decorators: [waitFor(getTestCustomElements())] }

View file

@ -13,7 +13,6 @@ import React, {
useEffect,
useRef,
} from 'react';
import PropTypes from 'prop-types';
import {
EuiModal,
EuiModalBody,
@ -81,66 +80,62 @@ const strings = {
export interface Props {
/**
* Adds the custom element to the workpad
* Element add handler
*/
addCustomElement: (customElement: CustomElement) => void;
/**
* Queries ES for custom element saved objects
*/
findCustomElements: () => void;
onAddCustomElement: (customElement: CustomElement) => void;
/**
* Handler invoked when the modal closes
*/
onClose: () => void;
/**
* Deletes the custom element
* Element delete handler
*/
removeCustomElement: (id: string) => void;
onRemoveCustomElement: (id: string) => void;
/**
* Saved edits to the custom element
* Element update handler
*/
updateCustomElement: (id: string, name: string, description: string, image: string) => void;
onUpdateCustomElement: (id: string, name: string, description: string, image: string) => void;
/**
* Array of custom elements to display
*/
customElements: CustomElement[];
/**
* Text used to filter custom elements list
* Element search handler
*/
search: string;
onSearch: (search: string) => void;
/**
* Setter for search text
* Initial search term
*/
setSearch: (search: string) => void;
initialSearch?: string;
}
export const SavedElementsModal: FunctionComponent<Props> = ({
search,
setSearch,
customElements,
addCustomElement,
findCustomElements,
onAddCustomElement,
onClose,
removeCustomElement,
updateCustomElement,
onRemoveCustomElement,
onUpdateCustomElement,
onSearch,
initialSearch = '',
}) => {
const hasLoadedElements = useRef<boolean>(false);
const [elementToDelete, setElementToDelete] = useState<CustomElement | null>(null);
const [elementToEdit, setElementToEdit] = useState<CustomElement | null>(null);
const [search, setSearch] = useState<string>(initialSearch);
useEffect(() => {
if (!hasLoadedElements.current) {
hasLoadedElements.current = true;
findCustomElements();
onSearch('');
}
}, [findCustomElements, hasLoadedElements]);
}, [onSearch, hasLoadedElements]);
const showEditModal = (element: CustomElement) => setElementToEdit(element);
const hideEditModal = () => setElementToEdit(null);
const handleEdit = async (name: string, description: string, image: string) => {
if (elementToEdit) {
updateCustomElement(elementToEdit.id, name, description, image);
onUpdateCustomElement(elementToEdit.id, name, description, image);
}
hideEditModal();
};
@ -150,7 +145,7 @@ export const SavedElementsModal: FunctionComponent<Props> = ({
const handleDelete = async () => {
if (elementToDelete) {
removeCustomElement(elementToDelete.id);
onRemoveCustomElement(elementToDelete.id);
}
hideDeleteModal();
};
@ -193,7 +188,7 @@ export const SavedElementsModal: FunctionComponent<Props> = ({
const sortElements = (elements: CustomElement[]): CustomElement[] =>
sortBy(elements, 'displayName');
const onSearch = (e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value);
const onFieldSearch = (e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value);
let customElementContent = (
<EuiEmptyPrompt
@ -209,7 +204,7 @@ export const SavedElementsModal: FunctionComponent<Props> = ({
<ElementGrid
elements={sortElements(customElements)}
filterText={search}
onClick={addCustomElement}
onClick={onAddCustomElement}
onEdit={showEditModal}
onDelete={showDeleteModal}
/>
@ -235,7 +230,7 @@ export const SavedElementsModal: FunctionComponent<Props> = ({
fullWidth
value={search}
placeholder={strings.getFindElementPlaceholder()}
onChange={onSearch}
onChange={onFieldSearch}
/>
<EuiSpacer />
{customElementContent}
@ -252,11 +247,3 @@ export const SavedElementsModal: FunctionComponent<Props> = ({
</Fragment>
);
};
SavedElementsModal.propTypes = {
addCustomElement: PropTypes.func.isRequired,
findCustomElements: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
removeCustomElement: PropTypes.func.isRequired,
updateCustomElement: PropTypes.func.isRequired,
};

View file

@ -1,138 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { compose, withState } from 'recompose';
import { camelCase } from 'lodash';
import { cloneSubgraphs } from '../../lib/clone_subgraphs';
import * as customElementService from '../../lib/custom_element_service';
import { withServices, WithServicesProps, pluginServices } from '../../services';
// @ts-expect-error untyped local
import { selectToplevelNodes } from '../../state/actions/transient';
// @ts-expect-error untyped local
import { insertNodes } from '../../state/actions/elements';
import { getSelectedPage } from '../../state/selectors/workpad';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric';
import {
SavedElementsModal as Component,
Props as ComponentProps,
} from './saved_elements_modal.component';
import { State, PositionedElement, CustomElement } from '../../../types';
const customElementAdded = 'elements-custom-added';
interface OwnProps {
onClose: () => void;
}
interface OwnPropsWithState extends OwnProps {
customElements: CustomElement[];
setCustomElements: (customElements: CustomElement[]) => void;
search: string;
setSearch: (search: string) => void;
}
interface DispatchProps {
selectToplevelNodes: (nodes: PositionedElement[]) => void;
insertNodes: (selectedNodes: PositionedElement[], pageId: string) => void;
}
interface StateProps {
pageId: string;
}
const mapStateToProps = (state: State): StateProps => ({
pageId: getSelectedPage(state),
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
selectToplevelNodes: (nodes: PositionedElement[]) =>
dispatch(
selectToplevelNodes(
nodes
.filter((e: PositionedElement): boolean => !e.position.parent)
.map((e: PositionedElement): string => e.id)
)
),
insertNodes: (selectedNodes: PositionedElement[], pageId: string) =>
dispatch(insertNodes(selectedNodes, pageId)),
});
const mergeProps = (
stateProps: StateProps,
dispatchProps: DispatchProps,
ownProps: OwnPropsWithState & WithServicesProps
): ComponentProps => {
const notifyService = pluginServices.getServices().notify;
const { pageId } = stateProps;
const { onClose, search, setCustomElements } = ownProps;
const findCustomElements = async () => {
const { customElements } = await customElementService.find(search);
setCustomElements(customElements);
};
return {
...ownProps,
// add custom element to the page
addCustomElement: (customElement: CustomElement) => {
const { selectedNodes = [] } = JSON.parse(customElement.content) || {};
const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes);
if (clonedNodes) {
dispatchProps.insertNodes(clonedNodes, pageId); // first clone and persist the new node(s)
dispatchProps.selectToplevelNodes(clonedNodes); // then select the cloned node(s)
}
onClose();
trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded);
},
// custom element search
findCustomElements: async (text?: string) => {
try {
await findCustomElements();
} catch (err) {
notifyService.error(err, {
title: `Couldn't find custom elements`,
});
}
},
// remove custom element
removeCustomElement: async (id: string) => {
try {
await customElementService.remove(id);
await findCustomElements();
} catch (err) {
notifyService.error(err, {
title: `Couldn't delete custom elements`,
});
}
},
// update custom element
updateCustomElement: async (id: string, name: string, description: string, image: string) => {
try {
await customElementService.update(id, {
name: camelCase(name),
displayName: name,
image,
help: description,
});
await findCustomElements();
} catch (err) {
notifyService.error(err, {
title: `Couldn't update custom elements`,
});
}
},
};
};
export const SavedElementsModal = compose<ComponentProps, OwnProps>(
withServices,
withState('search', 'setSearch', ''),
withState('customElements', 'setCustomElements', []),
connect(mapStateToProps, mapDispatchToProps, mergeProps)
)(Component);

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { camelCase } from 'lodash';
import { cloneSubgraphs } from '../../lib/clone_subgraphs';
import { useNotifyService, useCustomElementService } from '../../services';
// @ts-expect-error untyped local
import { selectToplevelNodes } from '../../state/actions/transient';
// @ts-expect-error untyped local
import { insertNodes } from '../../state/actions/elements';
import { getSelectedPage } from '../../state/selectors/workpad';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric';
import {
SavedElementsModal as Component,
Props as ComponentProps,
} from './saved_elements_modal.component';
import { PositionedElement, CustomElement } from '../../../types';
const customElementAdded = 'elements-custom-added';
export type Props = Pick<ComponentProps, 'onClose'>;
export const SavedElementsModal = ({ onClose }: Props) => {
const notifyService = useNotifyService();
const customElementService = useCustomElementService();
const dispatch = useDispatch();
const pageId = useSelector(getSelectedPage);
const [customElements, setCustomElements] = useState<CustomElement[]>([]);
const onSearch = async (search = '') => {
try {
const { customElements: foundElements } = await customElementService.find(search);
setCustomElements(foundElements);
} catch (err) {
notifyService.error(err, {
title: `Couldn't find custom elements`,
});
}
};
const onAddCustomElement = (customElement: CustomElement) => {
const { selectedNodes = [] } = JSON.parse(customElement.content) || {};
const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes);
if (clonedNodes) {
dispatch(insertNodes(clonedNodes, pageId)); // first clone and persist the new node(s)
dispatch(
selectToplevelNodes(
clonedNodes
.filter((e: PositionedElement): boolean => !e.position.parent)
.map((e: PositionedElement): string => e.id)
)
); // then select the cloned node(s)
}
onClose();
trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded);
};
const onRemoveCustomElement = async (id: string) => {
try {
await customElementService.remove(id);
await onSearch();
} catch (err) {
notifyService.error(err, {
title: `Couldn't delete custom elements`,
});
}
};
const onUpdateCustomElement = async (
id: string,
name: string,
description: string,
image: string
) => {
try {
await customElementService.update(id, {
name: camelCase(name),
displayName: name,
image,
help: description,
});
await onSearch();
} catch (err) {
notifyService.error(err, {
title: `Couldn't update custom elements`,
});
}
};
return (
<Component
{...{
onAddCustomElement,
onClose,
onRemoveCustomElement,
onSearch,
onUpdateCustomElement,
customElements,
}}
/>
);
};

View file

@ -1,43 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AxiosPromise } from 'axios';
import { API_ROUTE_CUSTOM_ELEMENT } from '../../common/lib/constants';
import { fetch } from '../../common/lib/fetch';
import { CustomElement } from '../../types';
import { pluginServices } from '../services';
const getApiPath = function () {
const basePath = pluginServices.getServices().platform.getBasePath();
return `${basePath}${API_ROUTE_CUSTOM_ELEMENT}`;
};
export const create = (customElement: CustomElement): AxiosPromise =>
fetch.post(getApiPath(), customElement);
export const get = (customElementId: string): Promise<CustomElement> =>
fetch
.get(`${getApiPath()}/${customElementId}`)
.then(({ data: element }: { data: CustomElement }) => element);
export const update = (id: string, element: Partial<CustomElement>): AxiosPromise =>
fetch.put(`${getApiPath()}/${id}`, element);
export const remove = (id: string): AxiosPromise => fetch.delete(`${getApiPath()}/${id}`);
export const find = async (
searchTerm: string
): Promise<{ total: number; customElements: CustomElement[] }> => {
const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0;
return fetch
.get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`)
.then(
({ data: customElements }: { data: { total: number; customElements: CustomElement[] } }) =>
customElements
);
};

View file

@ -9,7 +9,6 @@ import { camelCase } from 'lodash';
import { getClipboardData, setClipboardData } from './clipboard';
import { cloneSubgraphs } from './clone_subgraphs';
import { pluginServices } from '../services';
import * as customElementService from './custom_element_service';
import { getId } from './get_id';
import { PositionedElement } from '../../types';
import { ELEMENT_NUDGE_OFFSET, ELEMENT_SHIFT_OFFSET } from '../../common/lib/constants';
@ -71,6 +70,7 @@ export const basicHandlerCreators = {
image = ''
): void => {
const notifyService = pluginServices.getServices().notify;
const customElementService = pluginServices.getServices().customElement;
if (selectedNodes.length) {
const content = JSON.stringify({ selectedNodes });

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CustomElement } from '../../types';
export interface CustomElementFindResponse {
total: number;
customElements: CustomElement[];
}
export interface CanvasCustomElementService {
create: (customElement: CustomElement) => Promise<void>;
get: (customElementId: string) => Promise<CustomElement>;
update: (id: string, element: Partial<CustomElement>) => Promise<void>;
remove: (id: string) => Promise<void>;
find: (searchTerm: string) => Promise<CustomElementFindResponse>;
}

View file

@ -8,18 +8,20 @@
export * from './legacy';
import { PluginServices } from '../../../../../src/plugins/presentation_util/public';
import { CanvasExpressionsService } from './expressions';
import { CanvasNavLinkService } from './nav_link';
import { CanvasEmbeddablesService } from './embeddables';
import { CanvasExpressionsService } from './expressions';
import { CanvasCustomElementService } from './custom_element';
import { CanvasNavLinkService } from './nav_link';
import { CanvasNotifyService } from './notify';
import { CanvasPlatformService } from './platform';
import { CanvasReportingService } from './reporting';
import { CanvasWorkpadService } from './workpad';
export interface CanvasPluginServices {
customElement: CanvasCustomElementService;
embeddables: CanvasEmbeddablesService;
expressions: CanvasExpressionsService;
navLink: CanvasNavLinkService;
embeddables: CanvasEmbeddablesService;
notify: CanvasNotifyService;
platform: CanvasPlatformService;
reporting: CanvasReportingService;
@ -28,11 +30,13 @@ export interface CanvasPluginServices {
export const pluginServices = new PluginServices<CanvasPluginServices>();
export const useEmbeddablesService = () =>
(() => pluginServices.getHooks().embeddables.useService())();
export const useCustomElementService = () =>
(() => pluginServices.getHooks().customElement.useService())();
export const useExpressionsService = () =>
(() => pluginServices.getHooks().expressions.useService())();
export const useNavLinkService = () => (() => pluginServices.getHooks().navLink.useService())();
export const useEmbeddablesService = () =>
(() => pluginServices.getHooks().embeddables.useService())();
export const useNotifyService = () => (() => pluginServices.getHooks().notify.useService())();
export const usePlatformService = () => (() => pluginServices.getHooks().platform.useService())();
export const useReportingService = () => (() => pluginServices.getHooks().reporting.useService())();

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { KibanaPluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public';
import { API_ROUTE_CUSTOM_ELEMENT } from '../../../common/lib/constants';
import { CustomElement } from '../../../types';
import { CanvasStartDeps } from '../../plugin';
import { CanvasCustomElementService } from '../custom_element';
export type CanvasCustomElementServiceFactory = KibanaPluginServiceFactory<
CanvasCustomElementService,
CanvasStartDeps
>;
export const customElementServiceFactory: CanvasCustomElementServiceFactory = ({ coreStart }) => {
const { http } = coreStart;
const apiPath = `${API_ROUTE_CUSTOM_ELEMENT}`;
return {
create: (customElement) => http.post(apiPath, { body: JSON.stringify(customElement) }),
get: (customElementId) =>
http
.get(`${apiPath}/${customElementId}`)
.then(({ data: element }: { data: CustomElement }) => element),
update: (id, element) => http.put(`${apiPath}/${id}`, { body: JSON.stringify(element) }),
remove: (id) => http.delete(`${apiPath}/${id}`),
find: async (name) => {
return http.get(`${apiPath}/find`, {
query: {
name,
perPage: 10000,
},
});
},
};
};

View file

@ -14,6 +14,7 @@ import {
import { CanvasPluginServices } from '..';
import { CanvasStartDeps } from '../../plugin';
import { customElementServiceFactory } from './custom_element';
import { embeddablesServiceFactory } from './embeddables';
import { expressionsServiceFactory } from './expressions';
import { navLinkServiceFactory } from './nav_link';
@ -22,8 +23,9 @@ import { platformServiceFactory } from './platform';
import { reportingServiceFactory } from './reporting';
import { workpadServiceFactory } from './workpad';
export { expressionsServiceFactory } from './expressions';
export { customElementServiceFactory } from './custom_element';
export { embeddablesServiceFactory } from './embeddables';
export { expressionsServiceFactory } from './expressions';
export { notifyServiceFactory } from './notify';
export { platformServiceFactory } from './platform';
export { reportingServiceFactory } from './reporting';
@ -33,9 +35,10 @@ export const pluginServiceProviders: PluginServiceProviders<
CanvasPluginServices,
KibanaPluginServiceParams<CanvasStartDeps>
> = {
customElement: new PluginServiceProvider(customElementServiceFactory),
embeddables: new PluginServiceProvider(embeddablesServiceFactory),
expressions: new PluginServiceProvider(expressionsServiceFactory),
navLink: new PluginServiceProvider(navLinkServiceFactory),
embeddables: new PluginServiceProvider(embeddablesServiceFactory),
notify: new PluginServiceProvider(notifyServiceFactory),
platform: new PluginServiceProvider(platformServiceFactory),
reporting: new PluginServiceProvider(reportingServiceFactory),

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginServiceFactory } from '../../../../../../src/plugins/presentation_util/public';
import { CanvasCustomElementService } from '../custom_element';
type CanvasCustomElementServiceFactory = PluginServiceFactory<CanvasCustomElementService>;
const noop = (..._args: any[]): any => {};
export const customElementServiceFactory: CanvasCustomElementServiceFactory = () => ({
create: noop,
find: noop,
get: noop,
remove: noop,
update: noop,
});

View file

@ -14,6 +14,7 @@ import {
} from '../../../../../../src/plugins/presentation_util/public';
import { CanvasPluginServices } from '..';
import { customElementServiceFactory } from './custom_element';
import { embeddablesServiceFactory } from './embeddables';
import { expressionsServiceFactory } from './expressions';
import { navLinkServiceFactory } from './nav_link';
@ -22,6 +23,7 @@ import { platformServiceFactory } from './platform';
import { reportingServiceFactory } from './reporting';
import { workpadServiceFactory } from './workpad';
export { customElementServiceFactory } from './custom_element';
export { expressionsServiceFactory } from './expressions';
export { navLinkServiceFactory } from './nav_link';
export { notifyServiceFactory } from './notify';
@ -30,6 +32,7 @@ export { reportingServiceFactory } from './reporting';
export { workpadServiceFactory } from './workpad';
export const pluginServiceProviders: PluginServiceProviders<CanvasPluginServices> = {
customElement: new PluginServiceProvider(customElementServiceFactory),
embeddables: new PluginServiceProvider(embeddablesServiceFactory),
expressions: new PluginServiceProvider(expressionsServiceFactory),
navLink: new PluginServiceProvider(navLinkServiceFactory),