[Canvas] Move away from lib/workpad_service (#104183)

* Move away from lib/workpad_service

* Adds stubs

* Fix types. Swap fetching zip to workpad service

* Fix types

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Corey Robertson 2021-07-06 11:25:34 -04:00 committed by GitHub
parent eb57dd4a7e
commit 694f8caeb3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 645 additions and 455 deletions

View file

@ -30,6 +30,7 @@ interface Params {
const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/;
const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/;
const ZIP_CONTENT = /^(application\/zip)(;.*)?$/;
const removedUndefined = (obj: Record<string, any> | undefined) => {
return omitBy(obj, (v) => v === undefined);
@ -153,7 +154,7 @@ export class Fetch {
const contentType = response.headers.get('Content-Type') || '';
try {
if (NDJSON_CONTENT.test(contentType)) {
if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) {
body = await response.blob();
} else if (JSON_CONTENT.test(contentType)) {
body = await response.json();

View file

@ -17,30 +17,6 @@ export const ErrorStrings = {
},
}),
},
downloadWorkpad: {
getDownloadFailureErrorMessage: () =>
i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', {
defaultMessage: "Couldn't download workpad",
}),
getDownloadRenderedWorkpadFailureErrorMessage: () =>
i18n.translate(
'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage',
{
defaultMessage: "Couldn't download rendered workpad",
}
),
getDownloadRuntimeFailureErrorMessage: () =>
i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', {
defaultMessage: "Couldn't download Shareable Runtime",
}),
getDownloadZippedRuntimeFailureErrorMessage: () =>
i18n.translate(
'xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage',
{
defaultMessage: "Couldn't download ZIP file",
}
),
},
esPersist: {
getSaveFailureTitle: () =>
i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', {

View file

@ -8,7 +8,6 @@
export { useCloneWorkpad } from './use_clone_workpad';
export { useCreateWorkpad } from './use_create_workpad';
export { useDeleteWorkpads } from './use_delete_workpad';
export { useDownloadWorkpad } from './use_download_workpad';
export { useFindTemplates } from './use_find_templates';
export { useFindWorkpads } from './use_find_workpad';
export { useImportWorkpad } from './use_upload_workpad';

View file

@ -11,7 +11,8 @@ import { useSelector } from 'react-redux';
import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app';
import type { State } from '../../../../types';
import { usePlatformService } from '../../../services';
import { useCloneWorkpad, useDownloadWorkpad } from '../hooks';
import { useCloneWorkpad } from '../hooks';
import { useDownloadWorkpad } from '../../hooks';
import { WorkpadTable as Component } from './workpad_table.component';
import { WorkpadsContext } from './my_workpads';

View file

@ -10,7 +10,8 @@ import { useSelector } from 'react-redux';
import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app';
import type { State } from '../../../../types';
import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks';
import { useDeleteWorkpads } from '../hooks';
import { useDownloadWorkpad } from '../../hooks';
import {
WorkpadTableTools as Component,

View file

@ -5,8 +5,4 @@
* 2.0.
*/
import { useCallback } from 'react';
import { downloadWorkpad as downloadWorkpadFn } from '../../../lib/download_workpad';
export const useDownloadWorkpad = () =>
useCallback((workpadId: string) => downloadWorkpadFn(workpadId), []);
export * from './workpad';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { useDownloadWorkpad, useDownloadRenderedWorkpad } from './use_download_workpad';

View file

@ -0,0 +1,71 @@
/*
* 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 { useCallback } from 'react';
import fileSaver from 'file-saver';
import { i18n } from '@kbn/i18n';
import { useNotifyService, useWorkpadService } from '../../../services';
import { CanvasWorkpad } from '../../../../types';
import { CanvasRenderedWorkpad } from '../../../../shareable_runtime/types';
const strings = {
getDownloadFailureErrorMessage: () =>
i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', {
defaultMessage: "Couldn't download workpad",
}),
getDownloadRenderedWorkpadFailureErrorMessage: () =>
i18n.translate(
'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage',
{
defaultMessage: "Couldn't download rendered workpad",
}
),
};
export const useDownloadWorkpad = () => {
const notifyService = useNotifyService();
const workpadService = useWorkpadService();
const download = useDownloadWorkpadBlob();
return useCallback(
async (workpadId: string) => {
try {
const workpad = await workpadService.get(workpadId);
download(workpad, `canvas-workpad-${workpad.name}-${workpad.id}`);
} catch (err) {
notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() });
}
},
[workpadService, notifyService, download]
);
};
export const useDownloadRenderedWorkpad = () => {
const notifyService = useNotifyService();
const download = useDownloadWorkpadBlob();
return useCallback(
async (workpad: CanvasRenderedWorkpad) => {
try {
download(workpad, `canvas-embed-workpad-${workpad.name}-${workpad.id}`);
} catch (err) {
notifyService.error(err, {
title: strings.getDownloadRenderedWorkpadFailureErrorMessage(),
});
}
},
[notifyService, download]
);
};
const useDownloadWorkpadBlob = () => {
return useCallback((workpad: CanvasWorkpad | CanvasRenderedWorkpad, filename: string) => {
const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' });
fileSaver.saveAs(jsonBlob, `${filename}.json`);
}, []);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import {
EuiText,
EuiSpacer,
@ -24,35 +24,21 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { arrayBufferFetch } from '../../../../../common/lib/fetch';
import { API_ROUTE_SHAREABLE_ZIP } from '../../../../../common/lib/constants';
import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types';
import {
downloadRenderedWorkpad,
downloadRuntime,
downloadZippedRuntime,
} from '../../../../lib/download_workpad';
import { useDownloadRenderedWorkpad } from '../../../hooks';
import { useDownloadRuntime, useDownloadZippedRuntime } from './hooks';
import { ZIP, CANVAS, HTML } from '../../../../../i18n/constants';
import { OnCloseFn } from '../share_menu.component';
import { WorkpadStep } from './workpad_step';
import { RuntimeStep } from './runtime_step';
import { SnippetsStep } from './snippets_step';
import { useNotifyService, usePlatformService } from '../../../../services';
import { useNotifyService } from '../../../../services';
const strings = {
getCopyShareConfigMessage: () =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.copyShareConfigMessage', {
defaultMessage: 'Copied share markup to clipboard',
}),
getShareableZipErrorTitle: (workpadName: string) =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', {
defaultMessage:
"Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.",
values: {
ZIP,
workpadName,
},
}),
getUnknownExportErrorMessage: (type: string) =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', {
defaultMessage: 'Unknown export type: {type}',
@ -121,33 +107,33 @@ export const ShareWebsiteFlyout: FC<Props> = ({
renderedWorkpad,
}) => {
const notifyService = useNotifyService();
const platformService = usePlatformService();
const onCopy = () => {
notifyService.info(strings.getCopyShareConfigMessage());
};
const onDownload = (type: 'share' | 'shareRuntime' | 'shareZip') => {
switch (type) {
case 'share':
downloadRenderedWorkpad(renderedWorkpad);
return;
case 'shareRuntime':
downloadRuntime(platformService.getBasePath());
case 'shareZip':
const basePath = platformService.getBasePath();
arrayBufferFetch
.post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad))
.then((blob) => downloadZippedRuntime(blob.data))
.catch((err: Error) => {
notifyService.error(err, {
title: strings.getShareableZipErrorTitle(renderedWorkpad.name),
});
});
return;
default:
throw new Error(strings.getUnknownExportErrorMessage(type));
}
};
const onCopy = useCallback(() => notifyService.info(strings.getCopyShareConfigMessage()), [
notifyService,
]);
const downloadRenderedWorkpad = useDownloadRenderedWorkpad();
const downloadRuntime = useDownloadRuntime();
const downloadZippedRuntime = useDownloadZippedRuntime();
const onDownload = useCallback(
(type: 'share' | 'shareRuntime' | 'shareZip') => {
switch (type) {
case 'share':
downloadRenderedWorkpad(renderedWorkpad);
return;
case 'shareRuntime':
downloadRuntime();
return;
case 'shareZip':
downloadZippedRuntime(renderedWorkpad);
return;
default:
throw new Error(strings.getUnknownExportErrorMessage(type));
}
},
[downloadRenderedWorkpad, downloadRuntime, downloadZippedRuntime, renderedWorkpad]
);
const link = (
<EuiLink

View file

@ -5,22 +5,21 @@
* 2.0.
*/
import { connect } from 'react-redux';
import { compose, withProps } from 'recompose';
import React, { FC } from 'react';
import { useSelector } from 'react-redux';
import {
getWorkpad,
getRenderedWorkpad,
getRenderedWorkpadExpressions,
} from '../../../../state/selectors/workpad';
import { ShareWebsiteFlyout as Component, Props as ComponentProps } from './flyout.component';
import { ShareWebsiteFlyout as FlyoutComponent } from './flyout.component';
import { State, CanvasWorkpad } from '../../../../../types';
import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types';
import { renderFunctionNames } from '../../../../../shareable_runtime/supported_renderers';
import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public/';
import { OnCloseFn } from '../share_menu.component';
export { OnDownloadFn, OnCopyFn } from './flyout.component';
const getUnsupportedRenderers = (state: State) => {
@ -35,12 +34,6 @@ const getUnsupportedRenderers = (state: State) => {
return renderers;
};
const mapStateToProps = (state: State) => ({
renderedWorkpad: getRenderedWorkpad(state),
unsupportedRenderers: getUnsupportedRenderers(state),
workpad: getWorkpad(state),
});
interface Props {
onClose: OnCloseFn;
renderedWorkpad: CanvasRenderedWorkpad;
@ -48,14 +41,18 @@ interface Props {
workpad: CanvasWorkpad;
}
export const ShareWebsiteFlyout = compose<ComponentProps, Pick<Props, 'onClose'>>(
connect(mapStateToProps),
withKibana,
withProps(
({ unsupportedRenderers, renderedWorkpad, onClose, workpad }: Props): ComponentProps => ({
renderedWorkpad,
unsupportedRenderers,
onClose,
})
)
)(Component);
export const ShareWebsiteFlyout: FC<Pick<Props, 'onClose'>> = ({ onClose }) => {
const { renderedWorkpad, unsupportedRenderers } = useSelector((state: State) => ({
renderedWorkpad: getRenderedWorkpad(state),
unsupportedRenderers: getUnsupportedRenderers(state),
workpad: getWorkpad(state),
}));
return (
<FlyoutComponent
onClose={onClose}
unsupportedRenderers={unsupportedRenderers}
renderedWorkpad={renderedWorkpad}
/>
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './use_download_runtime';

View file

@ -0,0 +1,86 @@
/*
* 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 { useCallback } from 'react';
import fileSaver from 'file-saver';
import { i18n } from '@kbn/i18n';
import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../../../../../common/lib/constants';
import { ZIP } from '../../../../../../i18n/constants';
import { usePlatformService, useNotifyService, useWorkpadService } from '../../../../../services';
import { CanvasRenderedWorkpad } from '../../../../../../shareable_runtime/types';
const strings = {
getDownloadRuntimeFailureErrorMessage: () =>
i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', {
defaultMessage: "Couldn't download Shareable Runtime",
}),
getDownloadZippedRuntimeFailureErrorMessage: () =>
i18n.translate('xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', {
defaultMessage: "Couldn't download ZIP file",
}),
getShareableZipErrorTitle: (workpadName: string) =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', {
defaultMessage:
"Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.",
values: {
ZIP,
workpadName,
},
}),
};
export const useDownloadRuntime = () => {
const platformService = usePlatformService();
const notifyService = useNotifyService();
const downloadRuntime = useCallback(() => {
try {
const path = `${platformService.getBasePath()}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`;
window.open(path);
return;
} catch (err) {
notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() });
}
}, [platformService, notifyService]);
return downloadRuntime;
};
export const useDownloadZippedRuntime = () => {
const workpadService = useWorkpadService();
const notifyService = useNotifyService();
const downloadZippedRuntime = useCallback(
(workpad: CanvasRenderedWorkpad) => {
const downloadZip = async () => {
try {
let runtimeZipBlob: Blob | undefined;
try {
runtimeZipBlob = await workpadService.getRuntimeZip(workpad);
} catch (err) {
notifyService.error(err, {
title: strings.getShareableZipErrorTitle(workpad.name),
});
}
if (runtimeZipBlob) {
fileSaver.saveAs(runtimeZipBlob, 'canvas-workpad-embed.zip');
}
} catch (err) {
notifyService.error(err, {
title: strings.getDownloadZippedRuntimeFailureErrorMessage(),
});
}
};
downloadZip();
},
[notifyService, workpadService]
);
return downloadZippedRuntime;
};

View file

@ -1,65 +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 { compose, withProps } from 'recompose';
import { i18n } from '@kbn/i18n';
import { CanvasWorkpad, State } from '../../../../types';
import { downloadWorkpad } from '../../../lib/download_workpad';
import { withServices, WithServicesProps } from '../../../services';
import { getPages, getWorkpad } from '../../../state/selectors/workpad';
import { Props as ComponentProps, ShareMenu as Component } from './share_menu.component';
const strings = {
getUnknownExportErrorMessage: (type: string) =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', {
defaultMessage: 'Unknown export type: {type}',
values: {
type,
},
}),
};
const mapStateToProps = (state: State) => ({
workpad: getWorkpad(state),
pageCount: getPages(state).length,
});
interface Props {
workpad: CanvasWorkpad;
pageCount: number;
}
export const ShareMenu = compose<ComponentProps, {}>(
connect(mapStateToProps),
withServices,
withProps(
({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => {
const {
reporting: { start: reporting },
} = services;
return {
sharingServices: { reporting },
sharingData: { workpad, pageCount },
onExport: (type) => {
switch (type) {
case 'pdf':
// notifications are automatically handled by the Reporting plugin
break;
case 'json':
downloadWorkpad(workpad.id);
return;
default:
throw new Error(strings.getUnknownExportErrorMessage(type));
}
},
};
}
)
)(Component);

View file

@ -0,0 +1,68 @@
/*
* 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, { FC, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { State } from '../../../../types';
import { useReportingService } from '../../../services';
import { getPages, getWorkpad } from '../../../state/selectors/workpad';
import { useDownloadWorkpad } from '../../hooks';
import { ShareMenu as ShareMenuComponent } from './share_menu.component';
const strings = {
getUnknownExportErrorMessage: (type: string) =>
i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', {
defaultMessage: 'Unknown export type: {type}',
values: {
type,
},
}),
};
export const ShareMenu: FC = () => {
const { workpad, pageCount } = useSelector((state: State) => ({
workpad: getWorkpad(state),
pageCount: getPages(state).length,
}));
const reportingService = useReportingService();
const downloadWorkpad = useDownloadWorkpad();
const sharingServices = {
reporting: reportingService.start,
};
const sharingData = {
workpad,
pageCount,
};
const onExport = useCallback(
(type: string) => {
switch (type) {
case 'pdf':
// notifications are automatically handled by the Reporting plugin
break;
case 'json':
downloadWorkpad(workpad.id);
return;
default:
throw new Error(strings.getUnknownExportErrorMessage(type));
}
},
[downloadWorkpad, workpad]
);
return (
<ShareMenuComponent
sharingServices={sharingServices}
sharingData={sharingData}
onExport={onExport}
/>
);
};

View file

@ -1,64 +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 fileSaver from 'file-saver';
import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants';
import { ErrorStrings } from '../../i18n';
// TODO: clint - convert this whole file to hooks
import { pluginServices } from '../services';
// @ts-expect-error untyped local
import * as workpadService from './workpad_service';
import { CanvasRenderedWorkpad } from '../../shareable_runtime/types';
const { downloadWorkpad: strings } = ErrorStrings;
export const downloadWorkpad = async (workpadId: string) => {
try {
const workpad = await workpadService.get(workpadId);
const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' });
fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`);
} catch (err) {
const notifyService = pluginServices.getServices().notify;
notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() });
}
};
export const downloadRenderedWorkpad = async (renderedWorkpad: CanvasRenderedWorkpad) => {
try {
const jsonBlob = new Blob([JSON.stringify(renderedWorkpad)], { type: 'application/json' });
fileSaver.saveAs(
jsonBlob,
`canvas-embed-workpad-${renderedWorkpad.name}-${renderedWorkpad.id}.json`
);
} catch (err) {
const notifyService = pluginServices.getServices().notify;
notifyService.error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() });
}
};
export const downloadRuntime = async (basePath: string) => {
try {
const path = `${basePath}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`;
window.open(path);
return;
} catch (err) {
const notifyService = pluginServices.getServices().notify;
notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() });
}
};
export const downloadZippedRuntime = async (data: any) => {
try {
const zip = new Blob([data], { type: 'octet/stream' });
fileSaver.saveAs(zip, 'canvas-workpad-embed.zip');
} catch (err) {
const notifyService = pluginServices.getServices().notify;
notifyService.error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() });
}
};

View file

@ -1,111 +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.
*/
// TODO: clint - move to workpad service.
import {
API_ROUTE_WORKPAD,
API_ROUTE_WORKPAD_ASSETS,
API_ROUTE_WORKPAD_STRUCTURES,
DEFAULT_WORKPAD_CSS,
} from '../../common/lib/constants';
import { fetch } from '../../common/lib/fetch';
import { pluginServices } from '../services';
/*
Remove any top level keys from the workpad which will be rejected by validation
*/
const validKeys = [
'@created',
'@timestamp',
'assets',
'colors',
'css',
'variables',
'height',
'id',
'isWriteable',
'name',
'page',
'pages',
'width',
];
const sanitizeWorkpad = function (workpad) {
const workpadKeys = Object.keys(workpad);
for (const key of workpadKeys) {
if (!validKeys.includes(key)) {
delete workpad[key];
}
}
return workpad;
};
const getApiPath = function () {
const platformService = pluginServices.getServices().platform;
const basePath = platformService.getBasePath();
return `${basePath}${API_ROUTE_WORKPAD}`;
};
const getApiPathStructures = function () {
const platformService = pluginServices.getServices().platform;
const basePath = platformService.getBasePath();
return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`;
};
const getApiPathAssets = function () {
const platformService = pluginServices.getServices().platform;
const basePath = platformService.getBasePath();
return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`;
};
export function create(workpad) {
return fetch.post(getApiPath(), {
...sanitizeWorkpad({ ...workpad }),
assets: workpad.assets || {},
variables: workpad.variables || [],
});
}
export async function createFromTemplate(templateId) {
return fetch.post(getApiPath(), {
templateId,
});
}
export function get(workpadId) {
return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => {
// shim old workpads with new properties
return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad };
});
}
// TODO: I think this function is never used. Look into and remove the corresponding route as well
export function update(id, workpad) {
return fetch.put(`${getApiPath()}/${id}`, sanitizeWorkpad({ ...workpad }));
}
export function updateWorkpad(id, workpad) {
return fetch.put(`${getApiPathStructures()}/${id}`, sanitizeWorkpad({ ...workpad }));
}
export function updateAssets(id, workpadAssets) {
return fetch.put(`${getApiPathAssets()}/${id}`, workpadAssets);
}
export function remove(id) {
return fetch.delete(`${getApiPath()}/${id}`);
}
export function find(searchTerm) {
const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0;
return fetch
.get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`)
.then(({ data: workpads }) => workpads);
}

View file

@ -0,0 +1,200 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useWorkpadPersist } from './use_workpad_persist';
const mockGetState = jest.fn();
const mockUpdateWorkpad = jest.fn();
const mockUpdateAssets = jest.fn();
const mockUpdate = jest.fn();
const mockNotifyError = jest.fn();
// Mock the hooks and actions used by the UseWorkpad hook
jest.mock('react-redux', () => ({
useSelector: (selector: any) => selector(mockGetState()),
}));
jest.mock('../../../services', () => ({
useWorkpadService: () => ({
updateWorkpad: mockUpdateWorkpad,
updateAssets: mockUpdateAssets,
update: mockUpdate,
}),
useNotifyService: () => ({
error: mockNotifyError,
}),
}));
describe('useWorkpadPersist', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('initial render does not persist state', () => {
const state = {
persistent: {
workpad: { some: 'workpad' },
},
assets: {
asset1: 'some asset',
asset2: 'other asset',
},
};
mockGetState.mockReturnValue(state);
renderHook(useWorkpadPersist);
expect(mockUpdateWorkpad).not.toBeCalled();
expect(mockUpdateAssets).not.toBeCalled();
expect(mockUpdate).not.toBeCalled();
});
test('changes to workpad cause a workpad update', () => {
const state = {
persistent: {
workpad: { some: 'workpad' },
},
assets: {
asset1: 'some asset',
asset2: 'other asset',
},
};
mockGetState.mockReturnValue(state);
const { rerender } = renderHook(useWorkpadPersist);
const newState = {
...state,
persistent: {
workpad: { new: 'workpad' },
},
};
mockGetState.mockReturnValue(newState);
rerender();
expect(mockUpdateWorkpad).toHaveBeenCalled();
});
test('changes to assets cause an asset update', () => {
const state = {
persistent: {
workpad: { some: 'workpad' },
},
assets: {
asset1: 'some asset',
asset2: 'other asset',
},
};
mockGetState.mockReturnValue(state);
const { rerender } = renderHook(useWorkpadPersist);
const newState = {
...state,
assets: {
asset1: 'some asset',
},
};
mockGetState.mockReturnValue(newState);
rerender();
expect(mockUpdateAssets).toHaveBeenCalled();
});
test('changes to both assets and workpad causes a full update', () => {
const state = {
persistent: {
workpad: { some: 'workpad' },
},
assets: {
asset1: 'some asset',
asset2: 'other asset',
},
};
mockGetState.mockReturnValue(state);
const { rerender } = renderHook(useWorkpadPersist);
const newState = {
persistent: {
workpad: { new: 'workpad' },
},
assets: {
asset1: 'some asset',
},
};
mockGetState.mockReturnValue(newState);
rerender();
expect(mockUpdate).toHaveBeenCalled();
});
test('non changes causes no updated', () => {
const state = {
persistent: {
workpad: { some: 'workpad' },
},
assets: {
asset1: 'some asset',
asset2: 'other asset',
},
};
mockGetState.mockReturnValue(state);
const { rerender } = renderHook(useWorkpadPersist);
rerender();
expect(mockUpdate).not.toHaveBeenCalled();
expect(mockUpdateWorkpad).not.toHaveBeenCalled();
expect(mockUpdateAssets).not.toHaveBeenCalled();
});
test('non write permissions causes no updates', () => {
const state = {
persistent: {
workpad: { some: 'workpad' },
},
assets: {
asset1: 'some asset',
asset2: 'other asset',
},
transient: {
canUserWrite: false,
},
};
mockGetState.mockReturnValue(state);
const { rerender } = renderHook(useWorkpadPersist);
const newState = {
persistent: {
workpad: { new: 'workpad value' },
},
assets: {
asset3: 'something',
},
transient: {
canUserWrite: false,
},
};
mockGetState.mockReturnValue(newState);
rerender();
expect(mockUpdate).not.toHaveBeenCalled();
expect(mockUpdateWorkpad).not.toHaveBeenCalled();
expect(mockUpdateAssets).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,89 @@
/*
* 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 { useEffect, useCallback } from 'react';
import { isEqual } from 'lodash';
import usePrevious from 'react-use/lib/usePrevious';
import { useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { CanvasWorkpad, State } from '../../../../types';
import { getWorkpad, getFullWorkpadPersisted } from '../../../state/selectors/workpad';
import { canUserWrite } from '../../../state/selectors/app';
import { getAssetIds } from '../../../state/selectors/assets';
import { useWorkpadService, useNotifyService } from '../../../services';
const strings = {
getSaveFailureTitle: () =>
i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', {
defaultMessage: "Couldn't save your changes to Elasticsearch",
}),
getTooLargeErrorMessage: () =>
i18n.translate('xpack.canvas.error.esPersist.tooLargeErrorMessage', {
defaultMessage:
'The server gave a response that the workpad data was too large. This usually means uploaded image assets that are too large for Kibana or a proxy. Try removing some assets in the asset manager.',
}),
getUpdateFailureTitle: () =>
i18n.translate('xpack.canvas.error.esPersist.updateFailureTitle', {
defaultMessage: "Couldn't update workpad",
}),
};
export const useWorkpadPersist = () => {
const service = useWorkpadService();
const notifyService = useNotifyService();
const notifyError = useCallback(
(err: any) => {
const statusCode = err.response && err.response.status;
switch (statusCode) {
case 400:
return notifyService.error(err.response, {
title: strings.getSaveFailureTitle(),
});
case 413:
return notifyService.error(strings.getTooLargeErrorMessage(), {
title: strings.getSaveFailureTitle(),
});
default:
return notifyService.error(err, {
title: strings.getUpdateFailureTitle(),
});
}
},
[notifyService]
);
// Watch for workpad state or workpad assets to change and then persist those changes
const [workpad, assetIds, fullWorkpad, canWrite]: [
CanvasWorkpad,
Array<string | number>,
CanvasWorkpad,
boolean
] = useSelector((state: State) => [
getWorkpad(state),
getAssetIds(state),
getFullWorkpadPersisted(state),
canUserWrite(state),
]);
const previousWorkpad = usePrevious(workpad);
const previousAssetIds = usePrevious(assetIds);
const workpadChanged = previousWorkpad && workpad !== previousWorkpad;
const assetsChanged = previousAssetIds && !isEqual(assetIds, previousAssetIds);
useEffect(() => {
if (canWrite) {
if (workpadChanged && assetsChanged) {
service.update(workpad.id, fullWorkpad).catch(notifyError);
}
if (workpadChanged) {
service.updateWorkpad(workpad.id, workpad).catch(notifyError);
} else if (assetsChanged) {
service.updateAssets(workpad.id, fullWorkpad.assets).catch(notifyError);
}
}
}, [service, workpad, fullWorkpad, workpadChanged, assetsChanged, canWrite, notifyError]);
};

View file

@ -20,6 +20,7 @@ import { useWorkpad } from './hooks/use_workpad';
import { useRestoreHistory } from './hooks/use_restore_history';
import { useWorkpadHistory } from './hooks/use_workpad_history';
import { usePageSync } from './hooks/use_page_sync';
import { useWorkpadPersist } from './hooks/use_workpad_persist';
import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.';
import { WorkpadRoutingContextComponent } from './workpad_routing_context';
import { WorkpadPresentationHelper } from './workpad_presentation_helper';
@ -88,6 +89,7 @@ export const WorkpadHistoryManager: FC = ({ children }) => {
useRestoreHistory();
useWorkpadHistory();
usePageSync();
useWorkpadPersist();
return <>{children}</>;
};

View file

@ -14,6 +14,9 @@ import {
API_ROUTE_WORKPAD,
DEFAULT_WORKPAD_CSS,
API_ROUTE_TEMPLATES,
API_ROUTE_WORKPAD_ASSETS,
API_ROUTE_WORKPAD_STRUCTURES,
API_ROUTE_SHAREABLE_ZIP,
} from '../../../common/lib/constants';
import { CanvasWorkpad } from '../../../types';
@ -93,5 +96,25 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart,
remove: (id: string) => {
return coreStart.http.delete(`${getApiPath()}/${id}`);
},
update: (id, workpad) => {
return coreStart.http.put(`${getApiPath()}/${id}`, {
body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }),
});
},
updateWorkpad: (id, workpad) => {
return coreStart.http.put(`${API_ROUTE_WORKPAD_STRUCTURES}/${id}`, {
body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }),
});
},
updateAssets: (id, assets) => {
return coreStart.http.put(`${API_ROUTE_WORKPAD_ASSETS}/${id}`, {
body: JSON.stringify(assets),
});
},
getRuntimeZip: (workpad) => {
return coreStart.http.post<Blob>(API_ROUTE_SHAREABLE_ZIP, {
body: JSON.stringify(workpad),
});
},
};
};

View file

@ -26,13 +26,14 @@ const defaultContextValue = {
search: {},
};
const context = createContext<CanvasServices>(defaultContextValue as CanvasServices);
export const ServicesContext = createContext<CanvasServices>(defaultContextValue as CanvasServices);
export const useServices = () => useContext(context);
export const useServices = () => useContext(ServicesContext);
export const useEmbeddablesService = () => useServices().embeddables;
export const useExpressionsService = () => useServices().expressions;
export const useNavLinkService = () => useServices().navLink;
export const useLabsService = () => useServices().labs;
export const useReportingService = () => useServices().reporting;
export const withServices = <Props extends WithServicesProps>(type: ComponentType<Props>) => {
const EnhancedType: FC<Props> = (props) =>
@ -53,5 +54,5 @@ export const LegacyServicesProvider: FC<{
reporting: specifiedProviders.reporting.getService(),
labs: specifiedProviders.labs.getService(),
};
return <context.Provider value={value}>{children}</context.Provider>;
return <ServicesContext.Provider value={value}>{children}</ServicesContext.Provider>;
};

View file

@ -97,4 +97,18 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({
action('workpadService.remove')(id);
return Promise.resolve();
},
update: (id, workpad) => {
action('worpadService.update')(workpad, id);
return Promise.resolve();
},
updateWorkpad: (id, workpad) => {
action('workpadService.updateWorkpad')(workpad, id);
return Promise.resolve();
},
updateAssets: (id, assets) => {
action('workpadService.updateAssets')(assets, id);
return Promise.resolve();
},
getRuntimeZip: (workpad) =>
Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })),
});

View file

@ -96,4 +96,9 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({
createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()),
find: findNoWorkpads(),
remove: (_id: string) => Promise.resolve(),
update: (id, workpad) => Promise.resolve(),
updateWorkpad: (id, workpad) => Promise.resolve(),
updateAssets: (id, assets) => Promise.resolve(),
getRuntimeZip: (workpad) =>
Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })),
});

View file

@ -6,6 +6,7 @@
*/
import { CanvasWorkpad, CanvasTemplate } from '../../types';
import { CanvasRenderedWorkpad } from '../../shareable_runtime/types';
export type FoundWorkpads = Array<Pick<CanvasWorkpad, 'name' | 'id' | '@timestamp' | '@created'>>;
export type FoundWorkpad = FoundWorkpads[number];
@ -24,4 +25,8 @@ export interface CanvasWorkpadService {
find: (term: string) => Promise<WorkpadFindResponse>;
remove: (id: string) => Promise<void>;
findTemplates: () => Promise<TemplateFindResponse>;
update: (id: string, workpad: CanvasWorkpad) => Promise<void>;
updateWorkpad: (id: string, workpad: CanvasWorkpad) => Promise<void>;
updateAssets: (id: string, assets: CanvasWorkpad['assets']) => Promise<void>;
getRuntimeZip: (workpad: CanvasRenderedWorkpad) => Promise<Blob>;
}

View file

@ -1,99 +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 { isEqual } from 'lodash';
import { ErrorStrings } from '../../../i18n';
import { getWorkpad, getFullWorkpadPersisted, getWorkpadPersisted } from '../selectors/workpad';
import { getAssetIds } from '../selectors/assets';
import { appReady } from '../actions/app';
import { setWorkpad, setRefreshInterval, resetWorkpad } from '../actions/workpad';
import { setAssets, resetAssets } from '../actions/assets';
import * as transientActions from '../actions/transient';
import * as resolvedArgsActions from '../actions/resolved_args';
import { update, updateAssets, updateWorkpad } from '../../lib/workpad_service';
import { pluginServices } from '../../services';
import { canUserWrite } from '../selectors/app';
const { esPersist: strings } = ErrorStrings;
const workpadChanged = (before, after) => {
const workpad = getWorkpad(before);
return getWorkpad(after) !== workpad;
};
const assetsChanged = (before, after) => {
const assets = getAssetIds(before);
return !isEqual(assets, getAssetIds(after));
};
export const esPersistMiddleware = ({ getState }) => {
// these are the actions we don't want to trigger a persist call
const skippedActions = [
appReady, // there's no need to resave the workpad once we've loaded it.
resetWorkpad, // used for resetting the workpad in state
setWorkpad, // used for loading and creating workpads
setAssets, // used when loading assets
resetAssets, // used when creating new workpads
setRefreshInterval, // used to set refresh time interval which is a transient value
...Object.values(resolvedArgsActions), // no resolved args affect persisted values
...Object.values(transientActions), // no transient actions cause persisted state changes
].map((a) => a.toString());
return (next) => (action) => {
// if the action is in the skipped list, do not persist
if (skippedActions.indexOf(action.type) >= 0) {
return next(action);
}
// capture state before and after the action
const curState = getState();
next(action);
const newState = getState();
// skips the update request if user doesn't have write permissions
if (!canUserWrite(newState)) {
return;
}
const notifyError = (err) => {
const statusCode = err.response && err.response.status;
const notifyService = pluginServices.getServices().notify;
switch (statusCode) {
case 400:
return notifyService.error(err.response, {
title: strings.getSaveFailureTitle(),
});
case 413:
return notifyService.error(strings.getTooLargeErrorMessage(), {
title: strings.getSaveFailureTitle(),
});
default:
return notifyService.error(err, {
title: strings.getUpdateFailureTitle(),
});
}
};
const changedWorkpad = workpadChanged(curState, newState);
const changedAssets = assetsChanged(curState, newState);
if (changedWorkpad && changedAssets) {
// if both the workpad and the assets changed, save it in its entirety to elasticsearch
const persistedWorkpad = getFullWorkpadPersisted(getState());
return update(persistedWorkpad.id, persistedWorkpad).catch(notifyError);
} else if (changedWorkpad) {
// if the workpad changed, save it to elasticsearch
const persistedWorkpad = getWorkpadPersisted(getState());
return updateWorkpad(persistedWorkpad.id, persistedWorkpad).catch(notifyError);
} else if (changedAssets) {
// if the assets changed, save it to elasticsearch
const persistedWorkpad = getFullWorkpadPersisted(getState());
return updateAssets(persistedWorkpad.id, persistedWorkpad.assets).catch(notifyError);
}
};
};

View file

@ -8,21 +8,13 @@
import { applyMiddleware, compose as reduxCompose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { getWindow } from '../../lib/get_window';
import { esPersistMiddleware } from './es_persist';
import { inFlight } from './in_flight';
import { workpadUpdate } from './workpad_update';
import { elementStats } from './element_stats';
import { resolvedArgs } from './resolved_args';
const middlewares = [
applyMiddleware(
thunkMiddleware,
elementStats,
resolvedArgs,
esPersistMiddleware,
inFlight,
workpadUpdate
),
applyMiddleware(thunkMiddleware, elementStats, resolvedArgs, inFlight, workpadUpdate),
];
// compose with redux devtools, if extension is installed

View file

@ -7,6 +7,7 @@
import { get, omit } from 'lodash';
import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common';
import { CanvasRenderedWorkpad } from '../../../shareable_runtime/types';
import { append } from '../../lib/modify_path';
import { getAssets } from './assets';
import {
@ -500,7 +501,7 @@ export function getRenderedWorkpad(state: State) {
return {
pages: renderedPages,
...rest,
};
} as CanvasRenderedWorkpad;
}
export function getRenderedWorkpadExpressions(state: State) {

View file

@ -24,15 +24,14 @@ export interface CanvasRenderedElement {
* Represents a Page within a Canvas Workpad that is made up of ready-to-
* render Elements.
*/
export interface CanvasRenderedPage extends Omit<Omit<CanvasPage, 'elements'>, 'groups'> {
export interface CanvasRenderedPage extends Omit<CanvasPage, 'elements'> {
elements: CanvasRenderedElement[];
groups: CanvasRenderedElement[][];
}
/**
* A Canvas Workpad made up of ready-to-render Elements.
*/
export interface CanvasRenderedWorkpad extends Omit<CanvasWorkpad, 'pages'> {
export interface CanvasRenderedWorkpad extends Omit<CanvasWorkpad, 'pages' | 'variables'> {
pages: CanvasRenderedPage[];
}

View file

@ -94,7 +94,7 @@ interface PersistentState {
export interface State {
app: StoreAppState;
assets: { [assetKey: string]: AssetType | undefined };
assets: { [assetKey: string]: AssetType };
transient: TransientState;
persistent: PersistentState;
}