Spaces - Copy Saved Objects to Spaces UI (#39002)
This commit is contained in:
parent
c145a33b95
commit
6a9844c223
|
@ -50,9 +50,9 @@ import {
|
||||||
importLegacyFile,
|
importLegacyFile,
|
||||||
resolveImportErrors,
|
resolveImportErrors,
|
||||||
logLegacyImport,
|
logLegacyImport,
|
||||||
processImportResponse,
|
|
||||||
getDefaultTitle,
|
getDefaultTitle,
|
||||||
} from '../../../../lib';
|
} from '../../../../lib';
|
||||||
|
import { processImportResponse } from '../../../../lib/process_import_response';
|
||||||
import {
|
import {
|
||||||
resolveSavedObjects,
|
resolveSavedObjects,
|
||||||
resolveSavedSearches,
|
resolveSavedSearches,
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import chrome from 'ui/chrome';
|
import chrome from 'ui/chrome';
|
||||||
|
import { SavedObjectsManagementActionRegistry } from 'ui/management/saved_objects_management';
|
||||||
import React, { PureComponent, Fragment } from 'react';
|
import React, { PureComponent, Fragment } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
@ -73,6 +74,12 @@ class TableUI extends PureComponent {
|
||||||
parseErrorMessage: null,
|
parseErrorMessage: null,
|
||||||
isExportPopoverOpen: false,
|
isExportPopoverOpen: false,
|
||||||
isIncludeReferencesDeepChecked: true,
|
isIncludeReferencesDeepChecked: true,
|
||||||
|
activeAction: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.extraActions = SavedObjectsManagementActionRegistry.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange = ({ query, error }) => {
|
onChange = ({ query, error }) => {
|
||||||
|
@ -238,6 +245,24 @@ class TableUI extends PureComponent {
|
||||||
icon: 'kqlSelector',
|
icon: 'kqlSelector',
|
||||||
onClick: object => onShowRelationships(object),
|
onClick: object => onShowRelationships(object),
|
||||||
},
|
},
|
||||||
|
...this.extraActions.map(action => {
|
||||||
|
return {
|
||||||
|
...action.euiAction,
|
||||||
|
onClick: (object) => {
|
||||||
|
this.setState({
|
||||||
|
activeAction: action
|
||||||
|
});
|
||||||
|
|
||||||
|
action.registerOnFinishCallback(() => {
|
||||||
|
this.setState({
|
||||||
|
activeAction: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
action.euiAction.onClick(object);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -269,8 +294,11 @@ class TableUI extends PureComponent {
|
||||||
</EuiButton>
|
</EuiButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeActionContents = this.state.activeAction ? this.state.activeAction.render() : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
{activeActionContents}
|
||||||
<EuiSearchBar
|
<EuiSearchBar
|
||||||
box={{ 'data-test-subj': 'savedObjectSearchBar' }}
|
box={{ 'data-test-subj': 'savedObjectSearchBar' }}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
|
@ -17,7 +17,38 @@
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function processImportResponse(response) {
|
import {
|
||||||
|
SavedObjectsImportResponse,
|
||||||
|
SavedObjectsImportConflictError,
|
||||||
|
SavedObjectsImportUnsupportedTypeError,
|
||||||
|
SavedObjectsImportMissingReferencesError,
|
||||||
|
SavedObjectsImportUnknownError,
|
||||||
|
SavedObjectsImportError,
|
||||||
|
} from 'src/core/server';
|
||||||
|
|
||||||
|
export interface ProcessedImportResponse {
|
||||||
|
failedImports: Array<{
|
||||||
|
obj: Pick<SavedObjectsImportError, 'id' | 'type' | 'title'>;
|
||||||
|
error:
|
||||||
|
| SavedObjectsImportConflictError
|
||||||
|
| SavedObjectsImportUnsupportedTypeError
|
||||||
|
| SavedObjectsImportMissingReferencesError
|
||||||
|
| SavedObjectsImportUnknownError;
|
||||||
|
}>;
|
||||||
|
unmatchedReferences: Array<{
|
||||||
|
existingIndexPatternId: string;
|
||||||
|
list: Array<Record<string, any>>;
|
||||||
|
newIndexPatternId: string | undefined;
|
||||||
|
}>;
|
||||||
|
status: 'success' | 'idle';
|
||||||
|
importCount: number;
|
||||||
|
conflictedSavedObjectsLinkedToSavedSearches: undefined;
|
||||||
|
conflictedSearchDocs: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processImportResponse(
|
||||||
|
response: SavedObjectsImportResponse
|
||||||
|
): ProcessedImportResponse {
|
||||||
// Go through the failures and split between unmatchedReferences and failedImports
|
// Go through the failures and split between unmatchedReferences and failedImports
|
||||||
const failedImports = [];
|
const failedImports = [];
|
||||||
const unmatchedReferences = new Map();
|
const unmatchedReferences = new Map();
|
||||||
|
@ -29,7 +60,9 @@ export function processImportResponse(response) {
|
||||||
// Currently only supports resolving references on index patterns
|
// Currently only supports resolving references on index patterns
|
||||||
const indexPatternRefs = error.references.filter(ref => ref.type === 'index-pattern');
|
const indexPatternRefs = error.references.filter(ref => ref.type === 'index-pattern');
|
||||||
for (const missingReference of indexPatternRefs) {
|
for (const missingReference of indexPatternRefs) {
|
||||||
const conflict = unmatchedReferences.get(`${missingReference.type}:${missingReference.id}`) || {
|
const conflict = unmatchedReferences.get(
|
||||||
|
`${missingReference.type}:${missingReference.id}`
|
||||||
|
) || {
|
||||||
existingIndexPatternId: missingReference.id,
|
existingIndexPatternId: missingReference.id,
|
||||||
list: [],
|
list: [],
|
||||||
newIndexPatternId: undefined,
|
newIndexPatternId: undefined,
|
||||||
|
@ -44,9 +77,11 @@ export function processImportResponse(response) {
|
||||||
unmatchedReferences: Array.from(unmatchedReferences.values()),
|
unmatchedReferences: Array.from(unmatchedReferences.values()),
|
||||||
// Import won't be successful in the scenario unmatched references exist, import API returned errors of type unknown or import API
|
// Import won't be successful in the scenario unmatched references exist, import API returned errors of type unknown or import API
|
||||||
// returned errors of type missing_references.
|
// returned errors of type missing_references.
|
||||||
status: unmatchedReferences.size === 0 && !failedImports.some(issue => issue.error.type === 'conflict')
|
status:
|
||||||
? 'success'
|
unmatchedReferences.size === 0 &&
|
||||||
: 'idle',
|
!failedImports.some(issue => issue.error.type === 'conflict')
|
||||||
|
? 'success'
|
||||||
|
: 'idle',
|
||||||
importCount: response.successCount,
|
importCount: response.successCount,
|
||||||
conflictedSavedObjectsLinkedToSavedSearches: undefined,
|
conflictedSavedObjectsLinkedToSavedSearches: undefined,
|
||||||
conflictedSearchDocs: undefined,
|
conflictedSearchDocs: undefined,
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||||
|
* license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright
|
||||||
|
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||||
|
* the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
* not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SavedObjectsManagementActionRegistry } from './saved_objects_management_action_registry';
|
||||||
|
export {
|
||||||
|
SavedObjectsManagementAction,
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
SavedObjectsManagementRecordReference,
|
||||||
|
} from './saved_objects_management_action';
|
||||||
|
export {
|
||||||
|
processImportResponse,
|
||||||
|
ProcessedImportResponse,
|
||||||
|
} from '../../../../core_plugins/kibana/public/management/sections/objects/lib/process_import_response';
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||||
|
* license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright
|
||||||
|
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||||
|
* the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
* not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactNode } from '@elastic/eui/node_modules/@types/react';
|
||||||
|
|
||||||
|
export interface SavedObjectsManagementRecordReference {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export interface SavedObjectsManagementRecord {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
meta: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
references: SavedObjectsManagementRecordReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class SavedObjectsManagementAction {
|
||||||
|
public abstract render: () => ReactNode;
|
||||||
|
public abstract id: string;
|
||||||
|
public abstract euiAction: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
type: string;
|
||||||
|
available?: (item: SavedObjectsManagementRecord) => boolean;
|
||||||
|
enabled?: (item: SavedObjectsManagementRecord) => boolean;
|
||||||
|
onClick?: (item: SavedObjectsManagementRecord) => void;
|
||||||
|
render?: (item: SavedObjectsManagementRecord) => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
private callbacks: Function[] = [];
|
||||||
|
|
||||||
|
protected record: SavedObjectsManagementRecord | null = null;
|
||||||
|
|
||||||
|
public registerOnFinishCallback(callback: Function) {
|
||||||
|
this.callbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected start(record: SavedObjectsManagementRecord) {
|
||||||
|
this.record = record;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected finish() {
|
||||||
|
this.record = null;
|
||||||
|
this.callbacks.forEach(callback => callback());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||||
|
* license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright
|
||||||
|
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||||
|
* the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
* not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObjectsManagementActionRegistry } from './saved_objects_management_action_registry';
|
||||||
|
import { SavedObjectsManagementAction } from './saved_objects_management_action';
|
||||||
|
|
||||||
|
describe('SavedObjectsManagementActionRegistry', () => {
|
||||||
|
it('allows actions to be registered and retrieved', () => {
|
||||||
|
const action = { id: 'foo' } as SavedObjectsManagementAction;
|
||||||
|
SavedObjectsManagementActionRegistry.register(action);
|
||||||
|
expect(SavedObjectsManagementActionRegistry.get()).toContain(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires an "id" property', () => {
|
||||||
|
expect(() =>
|
||||||
|
SavedObjectsManagementActionRegistry.register({} as SavedObjectsManagementAction)
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(`"Saved Objects Management Actions must have an id"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow actions with duplicate ids to be registered', () => {
|
||||||
|
const action = { id: 'my-action' } as SavedObjectsManagementAction;
|
||||||
|
SavedObjectsManagementActionRegistry.register(action);
|
||||||
|
expect(() =>
|
||||||
|
SavedObjectsManagementActionRegistry.register(action)
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"Saved Objects Management Action with id 'my-action' already exists"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#has returns true when an action with a matching ID exists', () => {
|
||||||
|
const action = { id: 'existing-action' } as SavedObjectsManagementAction;
|
||||||
|
SavedObjectsManagementActionRegistry.register(action);
|
||||||
|
expect(SavedObjectsManagementActionRegistry.has('existing-action')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`#has returns false when an action with doesn't exist`, () => {
|
||||||
|
expect(SavedObjectsManagementActionRegistry.has('missing-action')).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||||
|
* license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright
|
||||||
|
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||||
|
* the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
* not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { SavedObjectsManagementAction } from './saved_objects_management_action';
|
||||||
|
|
||||||
|
const actions: Map<string, SavedObjectsManagementAction> = new Map();
|
||||||
|
|
||||||
|
export const SavedObjectsManagementActionRegistry = {
|
||||||
|
register: (action: SavedObjectsManagementAction) => {
|
||||||
|
if (!action.id) {
|
||||||
|
throw new TypeError('Saved Objects Management Actions must have an id');
|
||||||
|
}
|
||||||
|
if (actions.has(action.id)) {
|
||||||
|
throw new Error(`Saved Objects Management Action with id '${action.id}' already exists`);
|
||||||
|
}
|
||||||
|
actions.set(action.id, action);
|
||||||
|
},
|
||||||
|
|
||||||
|
has: (actionId: string) => actions.has(actionId),
|
||||||
|
|
||||||
|
get: () => Array.from(actions.values()),
|
||||||
|
};
|
|
@ -26,6 +26,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) {
|
||||||
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': fileMockPath,
|
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': fileMockPath,
|
||||||
'\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`,
|
'\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`,
|
||||||
'^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`,
|
'^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`,
|
||||||
|
'^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`,
|
||||||
},
|
},
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
`${kibanaDirectory}/src/dev/jest/setup/babel_polyfill.js`,
|
`${kibanaDirectory}/src/dev/jest/setup/babel_polyfill.js`,
|
||||||
|
|
7
x-pack/legacy/plugins/spaces/common/model/types.ts
Normal file
7
x-pack/legacy/plugins/spaces/common/model/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace';
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
SavedObjectsManagementAction,
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
} from 'ui/management/saved_objects_management';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import { toastNotifications } from 'ui/notify';
|
||||||
|
import { CopySavedObjectsToSpaceFlyout } from '../../views/management/components/copy_saved_objects_to_space';
|
||||||
|
import { Space } from '../../../common/model/space';
|
||||||
|
import { SpacesManager } from '../spaces_manager';
|
||||||
|
|
||||||
|
export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction {
|
||||||
|
public id: string = 'copy_saved_objects_to_space';
|
||||||
|
|
||||||
|
public euiAction = {
|
||||||
|
name: i18n.translate('xpack.spaces.management.copyToSpace.actionTitle', {
|
||||||
|
defaultMessage: 'Copy to space',
|
||||||
|
}),
|
||||||
|
description: i18n.translate('xpack.spaces.management.copyToSpace.actionDescription', {
|
||||||
|
defaultMessage: 'Copy this saved object to one or more spaces',
|
||||||
|
}),
|
||||||
|
icon: 'spacesApp',
|
||||||
|
type: 'icon',
|
||||||
|
onClick: (object: SavedObjectsManagementRecord) => {
|
||||||
|
this.start(object);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly spacesManager: SpacesManager, private readonly activeSpace: Space) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render = () => {
|
||||||
|
if (!this.record) {
|
||||||
|
throw new Error('No record available! `render()` was likely called before `start()`.');
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CopySavedObjectsToSpaceFlyout
|
||||||
|
onClose={this.onClose}
|
||||||
|
savedObject={this.record}
|
||||||
|
spacesManager={this.spacesManager}
|
||||||
|
activeSpace={this.activeSpace}
|
||||||
|
toastNotifications={toastNotifications}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onClose = () => {
|
||||||
|
this.finish();
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './summarize_copy_result';
|
||||||
|
export { CopyToSpaceSavedObjectsManagementAction } from './copy_saved_objects_to_space_action';
|
|
@ -0,0 +1,284 @@
|
||||||
|
/*
|
||||||
|
* 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 { summarizeCopyResult } from './summarize_copy_result';
|
||||||
|
import { ProcessedImportResponse } from 'ui/management/saved_objects_management';
|
||||||
|
|
||||||
|
const createSavedObjectsManagementRecord = () => ({
|
||||||
|
type: 'dashboard',
|
||||||
|
id: 'foo',
|
||||||
|
meta: { icon: 'foo-icon', title: 'my-dashboard' },
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: 'visualization',
|
||||||
|
id: 'foo-viz',
|
||||||
|
name: 'Foo Viz',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'visualization',
|
||||||
|
id: 'bar-viz',
|
||||||
|
name: 'Bar Viz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCopyResult = (
|
||||||
|
opts: { withConflicts?: boolean; withUnresolvableError?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const failedImports: ProcessedImportResponse['failedImports'] = [];
|
||||||
|
if (opts.withConflicts) {
|
||||||
|
failedImports.push(
|
||||||
|
{
|
||||||
|
obj: { type: 'visualization', id: 'foo-viz' },
|
||||||
|
error: { type: 'conflict' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
obj: { type: 'index-pattern', id: 'transient-index-pattern-conflict' },
|
||||||
|
error: { type: 'conflict' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (opts.withUnresolvableError) {
|
||||||
|
failedImports.push({
|
||||||
|
obj: { type: 'visualization', id: 'bar-viz' },
|
||||||
|
error: { type: 'missing_references', blocking: [], references: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyResult: ProcessedImportResponse = {
|
||||||
|
failedImports,
|
||||||
|
} as ProcessedImportResponse;
|
||||||
|
|
||||||
|
return copyResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('summarizeCopyResult', () => {
|
||||||
|
it('indicates the result is processing when not provided', () => {
|
||||||
|
const SavedObjectsManagementRecord = createSavedObjectsManagementRecord();
|
||||||
|
const copyResult = undefined;
|
||||||
|
const includeRelated = true;
|
||||||
|
|
||||||
|
const summarizedResult = summarizeCopyResult(
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
copyResult,
|
||||||
|
includeRelated
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summarizedResult).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"objects": Array [
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo",
|
||||||
|
"name": "my-dashboard",
|
||||||
|
"type": "dashboard",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo-viz",
|
||||||
|
"name": "Foo Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "bar-viz",
|
||||||
|
"name": "Bar Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"processing": true,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes failedImports to extract conflicts, including transient conflicts', () => {
|
||||||
|
const SavedObjectsManagementRecord = createSavedObjectsManagementRecord();
|
||||||
|
const copyResult = createCopyResult({ withConflicts: true });
|
||||||
|
const includeRelated = true;
|
||||||
|
|
||||||
|
const summarizedResult = summarizeCopyResult(
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
copyResult,
|
||||||
|
includeRelated
|
||||||
|
);
|
||||||
|
expect(summarizedResult).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"hasConflicts": true,
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"objects": Array [
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo",
|
||||||
|
"name": "my-dashboard",
|
||||||
|
"type": "dashboard",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [
|
||||||
|
Object {
|
||||||
|
"error": Object {
|
||||||
|
"type": "conflict",
|
||||||
|
},
|
||||||
|
"obj": Object {
|
||||||
|
"id": "foo-viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo-viz",
|
||||||
|
"name": "Foo Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "bar-viz",
|
||||||
|
"name": "Bar Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [
|
||||||
|
Object {
|
||||||
|
"error": Object {
|
||||||
|
"type": "conflict",
|
||||||
|
},
|
||||||
|
"obj": Object {
|
||||||
|
"id": "transient-index-pattern-conflict",
|
||||||
|
"type": "index-pattern",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "transient-index-pattern-conflict",
|
||||||
|
"name": "transient-index-pattern-conflict",
|
||||||
|
"type": "index-pattern",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"processing": false,
|
||||||
|
"successful": false,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes failedImports to extract unresolvable errors', () => {
|
||||||
|
const SavedObjectsManagementRecord = createSavedObjectsManagementRecord();
|
||||||
|
const copyResult = createCopyResult({ withUnresolvableError: true });
|
||||||
|
const includeRelated = true;
|
||||||
|
|
||||||
|
const summarizedResult = summarizeCopyResult(
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
copyResult,
|
||||||
|
includeRelated
|
||||||
|
);
|
||||||
|
expect(summarizedResult).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"hasConflicts": false,
|
||||||
|
"hasUnresolvableErrors": true,
|
||||||
|
"objects": Array [
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo",
|
||||||
|
"name": "my-dashboard",
|
||||||
|
"type": "dashboard",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo-viz",
|
||||||
|
"name": "Foo Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": true,
|
||||||
|
"id": "bar-viz",
|
||||||
|
"name": "Bar Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"processing": false,
|
||||||
|
"successful": false,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes a result without errors', () => {
|
||||||
|
const SavedObjectsManagementRecord = createSavedObjectsManagementRecord();
|
||||||
|
const copyResult = createCopyResult();
|
||||||
|
const includeRelated = true;
|
||||||
|
|
||||||
|
const summarizedResult = summarizeCopyResult(
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
copyResult,
|
||||||
|
includeRelated
|
||||||
|
);
|
||||||
|
expect(summarizedResult).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"hasConflicts": false,
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"objects": Array [
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo",
|
||||||
|
"name": "my-dashboard",
|
||||||
|
"type": "dashboard",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo-viz",
|
||||||
|
"name": "Foo Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "bar-viz",
|
||||||
|
"name": "Bar Viz",
|
||||||
|
"type": "visualization",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"processing": false,
|
||||||
|
"successful": true,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include references unless requested', () => {
|
||||||
|
const SavedObjectsManagementRecord = createSavedObjectsManagementRecord();
|
||||||
|
const copyResult = createCopyResult();
|
||||||
|
const includeRelated = false;
|
||||||
|
|
||||||
|
const summarizedResult = summarizeCopyResult(
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
copyResult,
|
||||||
|
includeRelated
|
||||||
|
);
|
||||||
|
expect(summarizedResult).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"hasConflicts": false,
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"objects": Array [
|
||||||
|
Object {
|
||||||
|
"conflicts": Array [],
|
||||||
|
"hasUnresolvableErrors": false,
|
||||||
|
"id": "foo",
|
||||||
|
"name": "my-dashboard",
|
||||||
|
"type": "dashboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"processing": false,
|
||||||
|
"successful": true,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,133 @@
|
||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
ProcessedImportResponse,
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
} from 'ui/management/saved_objects_management';
|
||||||
|
|
||||||
|
export interface SummarizedSavedObjectResult {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
conflicts: ProcessedImportResponse['failedImports'];
|
||||||
|
hasUnresolvableErrors: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessfulResponse {
|
||||||
|
successful: true;
|
||||||
|
hasConflicts: false;
|
||||||
|
hasUnresolvableErrors: false;
|
||||||
|
objects: SummarizedSavedObjectResult[];
|
||||||
|
processing: false;
|
||||||
|
}
|
||||||
|
interface UnsuccessfulResponse {
|
||||||
|
successful: false;
|
||||||
|
hasConflicts: boolean;
|
||||||
|
hasUnresolvableErrors: boolean;
|
||||||
|
objects: SummarizedSavedObjectResult[];
|
||||||
|
processing: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessingResponse {
|
||||||
|
objects: SummarizedSavedObjectResult[];
|
||||||
|
processing: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SummarizedCopyToSpaceResult =
|
||||||
|
| SuccessfulResponse
|
||||||
|
| UnsuccessfulResponse
|
||||||
|
| ProcessingResponse;
|
||||||
|
|
||||||
|
export function summarizeCopyResult(
|
||||||
|
savedObject: SavedObjectsManagementRecord,
|
||||||
|
copyResult: ProcessedImportResponse | undefined,
|
||||||
|
includeRelated: boolean
|
||||||
|
): SummarizedCopyToSpaceResult {
|
||||||
|
const successful = Boolean(copyResult && copyResult.failedImports.length === 0);
|
||||||
|
|
||||||
|
const conflicts = copyResult
|
||||||
|
? copyResult.failedImports.filter(failed => failed.error.type === 'conflict')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const unresolvableErrors = copyResult
|
||||||
|
? copyResult.failedImports.filter(failed => failed.error.type !== 'conflict')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const hasConflicts = conflicts.length > 0;
|
||||||
|
|
||||||
|
const hasUnresolvableErrors = Boolean(
|
||||||
|
copyResult && copyResult.failedImports.some(failed => failed.error.type !== 'conflict')
|
||||||
|
);
|
||||||
|
|
||||||
|
const objectMap = new Map();
|
||||||
|
objectMap.set(`${savedObject.type}:${savedObject.id}`, {
|
||||||
|
type: savedObject.type,
|
||||||
|
id: savedObject.id,
|
||||||
|
name: savedObject.meta.title,
|
||||||
|
conflicts: conflicts.filter(
|
||||||
|
c => c.obj.type === savedObject.type && c.obj.id === savedObject.id
|
||||||
|
),
|
||||||
|
hasUnresolvableErrors: unresolvableErrors.some(
|
||||||
|
e => e.obj.type === savedObject.type && e.obj.id === savedObject.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (includeRelated) {
|
||||||
|
savedObject.references.forEach(ref => {
|
||||||
|
objectMap.set(`${ref.type}:${ref.id}`, {
|
||||||
|
type: ref.type,
|
||||||
|
id: ref.id,
|
||||||
|
name: ref.name,
|
||||||
|
conflicts: conflicts.filter(c => c.obj.type === ref.type && c.obj.id === ref.id),
|
||||||
|
hasUnresolvableErrors: unresolvableErrors.some(
|
||||||
|
e => e.obj.type === ref.type && e.obj.id === ref.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The `savedObject.references` array only includes the direct references. It does not include any references of references.
|
||||||
|
// Therefore, if there are conflicts detected in these transitive references, we need to include them here so that they are visible
|
||||||
|
// in the UI as resolvable conflicts.
|
||||||
|
const transitiveConflicts = conflicts.filter(c => !objectMap.has(`${c.obj.type}:${c.obj.id}`));
|
||||||
|
transitiveConflicts.forEach(conflict => {
|
||||||
|
objectMap.set(`${conflict.obj.type}:${conflict.obj.id}`, {
|
||||||
|
type: conflict.obj.type,
|
||||||
|
id: conflict.obj.id,
|
||||||
|
name: conflict.obj.title || conflict.obj.id,
|
||||||
|
conflicts: conflicts.filter(c => c.obj.type === conflict.obj.type && conflict.obj.id),
|
||||||
|
hasUnresolvableErrors: unresolvableErrors.some(
|
||||||
|
e => e.obj.type === conflict.obj.type && e.obj.id === conflict.obj.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof copyResult === 'undefined') {
|
||||||
|
return {
|
||||||
|
processing: true,
|
||||||
|
objects: Array.from(objectMap.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
return {
|
||||||
|
successful,
|
||||||
|
hasConflicts: false,
|
||||||
|
objects: Array.from(objectMap.values()),
|
||||||
|
hasUnresolvableErrors: false,
|
||||||
|
processing: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
successful,
|
||||||
|
hasConflicts,
|
||||||
|
objects: Array.from(objectMap.values()),
|
||||||
|
hasUnresolvableErrors,
|
||||||
|
processing: false,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* 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 { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/server';
|
||||||
|
|
||||||
|
export interface CopyOptions {
|
||||||
|
includeRelated: boolean;
|
||||||
|
overwrite: boolean;
|
||||||
|
selectedSpaceIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportRetry = Omit<SavedObjectsImportRetry, 'replaceReferences'>;
|
||||||
|
|
||||||
|
export interface CopySavedObjectsToSpaceResponse {
|
||||||
|
[spaceId: string]: SavedObjectsImportResponse;
|
||||||
|
}
|
|
@ -4,19 +4,19 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SpacesManager } from './spaces_manager';
|
|
||||||
|
|
||||||
function createSpacesManagerMock() {
|
function createSpacesManagerMock() {
|
||||||
return ({
|
return {
|
||||||
getSpaces: jest.fn().mockResolvedValue([]),
|
getSpaces: jest.fn().mockResolvedValue([]),
|
||||||
getSpace: jest.fn().mockResolvedValue(undefined),
|
getSpace: jest.fn().mockResolvedValue(undefined),
|
||||||
createSpace: jest.fn().mockResolvedValue(undefined),
|
createSpace: jest.fn().mockResolvedValue(undefined),
|
||||||
updateSpace: jest.fn().mockResolvedValue(undefined),
|
updateSpace: jest.fn().mockResolvedValue(undefined),
|
||||||
deleteSpace: jest.fn().mockResolvedValue(undefined),
|
deleteSpace: jest.fn().mockResolvedValue(undefined),
|
||||||
|
copySavedObjects: jest.fn().mockResolvedValue(undefined),
|
||||||
|
resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined),
|
||||||
redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined),
|
redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined),
|
||||||
requestRefresh: jest.fn(),
|
requestRefresh: jest.fn(),
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
} as unknown) as SpacesManager;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const spacesManagerMock = {
|
export const spacesManagerMock = {
|
||||||
|
|
|
@ -7,7 +7,10 @@ import { i18n } from '@kbn/i18n';
|
||||||
import { toastNotifications } from 'ui/notify';
|
import { toastNotifications } from 'ui/notify';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { kfetch } from 'ui/kfetch';
|
import { kfetch } from 'ui/kfetch';
|
||||||
|
import { SavedObjectsManagementRecord } from 'ui/management/saved_objects_management';
|
||||||
import { Space } from '../../common/model/space';
|
import { Space } from '../../common/model/space';
|
||||||
|
import { GetSpacePurpose } from '../../common/model/types';
|
||||||
|
import { CopySavedObjectsToSpaceResponse } from './copy_saved_objects_to_space/types';
|
||||||
|
|
||||||
export class SpacesManager extends EventEmitter {
|
export class SpacesManager extends EventEmitter {
|
||||||
private spaceSelectorURL: string;
|
private spaceSelectorURL: string;
|
||||||
|
@ -17,8 +20,8 @@ export class SpacesManager extends EventEmitter {
|
||||||
this.spaceSelectorURL = spaceSelectorURL;
|
this.spaceSelectorURL = spaceSelectorURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSpaces(): Promise<Space[]> {
|
public async getSpaces(purpose?: GetSpacePurpose): Promise<Space[]> {
|
||||||
return await kfetch({ pathname: '/api/spaces/space' });
|
return await kfetch({ pathname: '/api/spaces/space', query: { purpose } });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSpace(id: string): Promise<Space> {
|
public async getSpace(id: string): Promise<Space> {
|
||||||
|
@ -51,6 +54,40 @@ export class SpacesManager extends EventEmitter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async copySavedObjects(
|
||||||
|
objects: Array<Pick<SavedObjectsManagementRecord, 'type' | 'id'>>,
|
||||||
|
spaces: string[],
|
||||||
|
includeReferences: boolean,
|
||||||
|
overwrite: boolean
|
||||||
|
): Promise<CopySavedObjectsToSpaceResponse> {
|
||||||
|
return await kfetch({
|
||||||
|
pathname: `/api/spaces/_copy_saved_objects`,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
objects,
|
||||||
|
spaces,
|
||||||
|
includeReferences,
|
||||||
|
overwrite,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveCopySavedObjectsErrors(
|
||||||
|
objects: Array<Pick<SavedObjectsManagementRecord, 'type' | 'id'>>,
|
||||||
|
retries: unknown,
|
||||||
|
includeReferences: boolean
|
||||||
|
): Promise<CopySavedObjectsToSpaceResponse> {
|
||||||
|
return await kfetch({
|
||||||
|
pathname: `/api/spaces/_resolve_copy_saved_objects_errors`,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
objects,
|
||||||
|
includeReferences,
|
||||||
|
retries,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async changeSelectedSpace(space: Space) {
|
public async changeSelectedSpace(space: Space) {
|
||||||
await kfetch({
|
await kfetch({
|
||||||
pathname: `/api/spaces/v1/space/${encodeURIComponent(space.id)}/select`,
|
pathname: `/api/spaces/v1/space/${encodeURIComponent(space.id)}/select`,
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
@import './components/confirm_delete_modal';
|
@import './components/confirm_delete_modal';
|
||||||
@import './edit_space/enabled_features/index';
|
@import './edit_space/enabled_features/index';
|
||||||
|
@import './components/copy_saved_objects_to_space/index';
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||||
import { SpacesNavState } from '../../nav_control';
|
import { SpacesNavState } from '../../nav_control';
|
||||||
import { ConfirmDeleteModal } from './confirm_delete_modal';
|
import { ConfirmDeleteModal } from './confirm_delete_modal';
|
||||||
import { spacesManagerMock } from '../../../lib/mocks';
|
import { spacesManagerMock } from '../../../lib/mocks';
|
||||||
|
import { SpacesManager } from '../../../lib';
|
||||||
|
|
||||||
describe('ConfirmDeleteModal', () => {
|
describe('ConfirmDeleteModal', () => {
|
||||||
it('renders as expected', () => {
|
it('renders as expected', () => {
|
||||||
|
@ -32,7 +33,7 @@ describe('ConfirmDeleteModal', () => {
|
||||||
shallowWithIntl(
|
shallowWithIntl(
|
||||||
<ConfirmDeleteModal.WrappedComponent
|
<ConfirmDeleteModal.WrappedComponent
|
||||||
space={space}
|
space={space}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
|
@ -62,7 +63,7 @@ describe('ConfirmDeleteModal', () => {
|
||||||
const wrapper = mountWithIntl(
|
const wrapper = mountWithIntl(
|
||||||
<ConfirmDeleteModal.WrappedComponent
|
<ConfirmDeleteModal.WrappedComponent
|
||||||
space={space}
|
space={space}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
|
||||||
|
.spcCopyToSpaceResult {
|
||||||
|
padding-bottom: $euiSizeS;
|
||||||
|
border-bottom: $euiBorderThin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spcCopyToSpaceResultDetails {
|
||||||
|
margin-top: $euiSizeS;
|
||||||
|
padding-left: $euiSizeL;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spcCopyToSpaceResultDetails__row {
|
||||||
|
margin-bottom: $euiSizeXS;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spcCopyToSpaceResultDetails__savedObjectName {
|
||||||
|
// Constrains name to the flex item, and allows for truncation when necessary
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spcCopyToSpace__spacesList {
|
||||||
|
margin-top: $euiSizeXS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make icon occupy the same space as an EuiSwitch
|
||||||
|
// icon is size m, which is the native $euiSize value
|
||||||
|
// see @elastic/eui/src/components/icon/_variables.scss
|
||||||
|
.spcCopyToSpaceIncludeRelated .euiIcon {
|
||||||
|
margin-right: $euiSwitchWidth - $euiSize;
|
||||||
|
}
|
||||||
|
.spcCopyToSpaceIncludeRelated__label {
|
||||||
|
font-size: $euiFontSizeS;
|
||||||
|
}
|
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
* 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 { EuiLoadingSpinner, EuiText, EuiIconTip } from '@elastic/eui';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import {
|
||||||
|
SummarizedCopyToSpaceResult,
|
||||||
|
SummarizedSavedObjectResult,
|
||||||
|
} from '../../../../lib/copy_saved_objects_to_space';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
summarizedCopyResult: SummarizedCopyToSpaceResult;
|
||||||
|
object: { type: string; id: string };
|
||||||
|
overwritePending: boolean;
|
||||||
|
conflictResolutionInProgress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyStatusIndicator = (props: Props) => {
|
||||||
|
const { summarizedCopyResult, conflictResolutionInProgress } = props;
|
||||||
|
if (summarizedCopyResult.processing || conflictResolutionInProgress) {
|
||||||
|
return <EuiLoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectResult = summarizedCopyResult.objects.find(
|
||||||
|
o => o.type === props.object!.type && o.id === props.object!.id
|
||||||
|
) as SummarizedSavedObjectResult;
|
||||||
|
|
||||||
|
const successful =
|
||||||
|
!objectResult.hasUnresolvableErrors &&
|
||||||
|
(objectResult.conflicts.length === 0 || props.overwritePending === true);
|
||||||
|
const successColor = props.overwritePending ? 'warning' : 'success';
|
||||||
|
const hasConflicts = objectResult.conflicts.length > 0;
|
||||||
|
const hasUnresolvableErrors = objectResult.hasUnresolvableErrors;
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
const message = props.overwritePending ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage"
|
||||||
|
defaultMessage="Saved object will be overwritten. Click 'Skip' to cancel this operation."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatus.successMessage"
|
||||||
|
defaultMessage="Saved object copied successfully."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return <EuiIconTip type={'check'} color={successColor} content={message} />;
|
||||||
|
}
|
||||||
|
if (hasUnresolvableErrors) {
|
||||||
|
return (
|
||||||
|
<EuiIconTip
|
||||||
|
type={'cross'}
|
||||||
|
color={'danger'}
|
||||||
|
data-test-subj={`cts-object-result-error-${objectResult.id}`}
|
||||||
|
content={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage"
|
||||||
|
defaultMessage="There was an error copying this saved object."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasConflicts) {
|
||||||
|
return (
|
||||||
|
<EuiIconTip
|
||||||
|
type={'alert'}
|
||||||
|
color={'warning'}
|
||||||
|
content={
|
||||||
|
<EuiText>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage"
|
||||||
|
defaultMessage="A saved object with a matching id ({id}) already exists in this space."
|
||||||
|
values={{
|
||||||
|
id: objectResult.conflicts[0].obj.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage"
|
||||||
|
defaultMessage="Click 'Overwrite' to replace this version with the copied one."
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</EuiText>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
* 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 { EuiLoadingSpinner, EuiIconTip } from '@elastic/eui';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { SummarizedCopyToSpaceResult } from '../../../../lib/copy_saved_objects_to_space';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
space: Space;
|
||||||
|
summarizedCopyResult: SummarizedCopyToSpaceResult;
|
||||||
|
conflictResolutionInProgress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyStatusSummaryIndicator = (props: Props) => {
|
||||||
|
const { summarizedCopyResult } = props;
|
||||||
|
const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${props.space.id}`;
|
||||||
|
|
||||||
|
if (summarizedCopyResult.processing || props.conflictResolutionInProgress) {
|
||||||
|
return <EuiLoadingSpinner data-test-subj={getDataTestSubj('loading')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summarizedCopyResult.successful) {
|
||||||
|
return (
|
||||||
|
<EuiIconTip
|
||||||
|
type={'check'}
|
||||||
|
color={'success'}
|
||||||
|
iconProps={{
|
||||||
|
'data-test-subj': getDataTestSubj('success'),
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage"
|
||||||
|
defaultMessage="Copied successfully to the {space} space."
|
||||||
|
values={{ space: props.space.name }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (summarizedCopyResult.hasUnresolvableErrors) {
|
||||||
|
return (
|
||||||
|
<EuiIconTip
|
||||||
|
type={'cross'}
|
||||||
|
color={'danger'}
|
||||||
|
iconProps={{
|
||||||
|
'data-test-subj': getDataTestSubj('failed'),
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatusSummary.failedMessage"
|
||||||
|
defaultMessage="Copy to the {space} space failed. Expand this section for details."
|
||||||
|
values={{ space: props.space.name }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (summarizedCopyResult.hasConflicts) {
|
||||||
|
return (
|
||||||
|
<EuiIconTip
|
||||||
|
type={'alert'}
|
||||||
|
color={'warning'}
|
||||||
|
iconProps={{
|
||||||
|
'data-test-subj': getDataTestSubj('conflicts'),
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage"
|
||||||
|
defaultMessage="One or more conflicts detected in the {space} space. Expand this section to resolve."
|
||||||
|
values={{ space: props.space.name }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
|
@ -0,0 +1,455 @@
|
||||||
|
/*
|
||||||
|
* 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 Boom from 'boom';
|
||||||
|
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||||
|
import { CopySavedObjectsToSpaceFlyout } from './copy_to_space_flyout';
|
||||||
|
import { CopyToSpaceForm } from './copy_to_space_form';
|
||||||
|
import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { findTestSubject } from 'test_utils/find_test_subject';
|
||||||
|
import { SelectableSpacesControl } from './selectable_spaces_control';
|
||||||
|
import { act } from 'react-testing-library';
|
||||||
|
import { ProcessingCopyToSpace } from './processing_copy_to_space';
|
||||||
|
import { spacesManagerMock } from '../../../../lib/mocks';
|
||||||
|
import { SpacesManager } from '../../../../lib';
|
||||||
|
import { ToastNotifications } from 'ui/notify/toasts/toast_notifications';
|
||||||
|
|
||||||
|
interface SetupOpts {
|
||||||
|
mockSpaces?: Space[];
|
||||||
|
returnBeforeSpacesLoad?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setup = async (opts: SetupOpts = {}) => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
|
||||||
|
const mockSpacesManager = spacesManagerMock.create();
|
||||||
|
mockSpacesManager.getSpaces.mockResolvedValue(
|
||||||
|
opts.mockSpaces || [
|
||||||
|
{
|
||||||
|
id: 'space-1',
|
||||||
|
name: 'Space 1',
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'space-2',
|
||||||
|
name: 'Space 2',
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'space-3',
|
||||||
|
name: 'Space 3',
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'my-active-space',
|
||||||
|
name: 'my active space',
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockToastNotifications = {
|
||||||
|
addError: jest.fn(),
|
||||||
|
addSuccess: jest.fn(),
|
||||||
|
};
|
||||||
|
const savedObjectToCopy = {
|
||||||
|
type: 'dashboard',
|
||||||
|
id: 'my-dash',
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: 'visualization',
|
||||||
|
id: 'my-viz',
|
||||||
|
name: 'My Viz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { icon: 'dashboard', title: 'foo' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithIntl(
|
||||||
|
<CopySavedObjectsToSpaceFlyout
|
||||||
|
savedObject={savedObjectToCopy}
|
||||||
|
spacesManager={(mockSpacesManager as unknown) as SpacesManager}
|
||||||
|
activeSpace={{
|
||||||
|
id: 'my-active-space',
|
||||||
|
name: 'my active space',
|
||||||
|
disabledFeatures: [],
|
||||||
|
}}
|
||||||
|
toastNotifications={(mockToastNotifications as unknown) as ToastNotifications}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!opts.returnBeforeSpacesLoad) {
|
||||||
|
// Wait for spaces manager to complete and flyout to rerender
|
||||||
|
await Promise.resolve();
|
||||||
|
wrapper.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToCopy };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CopyToSpaceFlyout', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for spaces to load', async () => {
|
||||||
|
const { wrapper } = await setup({ returnBeforeSpacesLoad: true });
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a message within an EuiEmptyPrompt when no spaces are available', async () => {
|
||||||
|
const { wrapper, onClose } = await setup({ mockSpaces: [] });
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a message within an EuiEmptyPrompt when only the active space is available', async () => {
|
||||||
|
const { wrapper, onClose } = await setup({
|
||||||
|
mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles errors thrown from copySavedObjects API call', async () => {
|
||||||
|
const { wrapper, mockSpacesManager, mockToastNotifications } = await setup();
|
||||||
|
|
||||||
|
mockSpacesManager.copySavedObjects.mockImplementation(() => {
|
||||||
|
return Promise.reject(Boom.serverUnavailable('Something bad happened'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0);
|
||||||
|
|
||||||
|
// Using props callback instead of simulating clicks,
|
||||||
|
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||||
|
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||||
|
act(() => {
|
||||||
|
spaceSelector.props().onChange(['space-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
|
||||||
|
act(() => {
|
||||||
|
startButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(mockSpacesManager.copySavedObjects).toHaveBeenCalled();
|
||||||
|
expect(mockToastNotifications.addError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles errors thrown from resolveCopySavedObjectsErrors API call', async () => {
|
||||||
|
const { wrapper, mockSpacesManager, mockToastNotifications } = await setup();
|
||||||
|
|
||||||
|
mockSpacesManager.copySavedObjects.mockResolvedValue({
|
||||||
|
'space-1': {
|
||||||
|
success: true,
|
||||||
|
successCount: 3,
|
||||||
|
},
|
||||||
|
'space-2': {
|
||||||
|
success: false,
|
||||||
|
successCount: 1,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'index-pattern',
|
||||||
|
id: 'conflicting-ip',
|
||||||
|
error: { type: 'conflict' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'visualization',
|
||||||
|
id: 'my-viz',
|
||||||
|
error: { type: 'conflict' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSpacesManager.resolveCopySavedObjectsErrors.mockImplementation(() => {
|
||||||
|
return Promise.reject(Boom.serverUnavailable('Something bad happened'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0);
|
||||||
|
|
||||||
|
// Using props callback instead of simulating clicks,
|
||||||
|
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||||
|
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||||
|
act(() => {
|
||||||
|
spaceSelector.props().onChange(['space-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
|
||||||
|
act(() => {
|
||||||
|
startButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(mockSpacesManager.copySavedObjects).toHaveBeenCalled();
|
||||||
|
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`);
|
||||||
|
spaceResult.simulate('click');
|
||||||
|
|
||||||
|
const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`);
|
||||||
|
overwriteButton.simulate('click');
|
||||||
|
|
||||||
|
const finishButton = findTestSubject(wrapper, 'cts-finish-button');
|
||||||
|
act(() => {
|
||||||
|
finishButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalled();
|
||||||
|
expect(mockToastNotifications.addError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the form to be filled out', async () => {
|
||||||
|
const {
|
||||||
|
wrapper,
|
||||||
|
onClose,
|
||||||
|
mockSpacesManager,
|
||||||
|
mockToastNotifications,
|
||||||
|
savedObjectToCopy,
|
||||||
|
} = await setup();
|
||||||
|
|
||||||
|
mockSpacesManager.copySavedObjects.mockResolvedValue({
|
||||||
|
'space-1': {
|
||||||
|
success: true,
|
||||||
|
successCount: 3,
|
||||||
|
},
|
||||||
|
'space-2': {
|
||||||
|
success: true,
|
||||||
|
successCount: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0);
|
||||||
|
|
||||||
|
// Using props callback instead of simulating clicks,
|
||||||
|
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||||
|
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
spaceSelector.props().onChange(['space-1', 'space-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
|
||||||
|
act(() => {
|
||||||
|
startButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(mockSpacesManager.copySavedObjects).toHaveBeenCalledWith(
|
||||||
|
[{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }],
|
||||||
|
['space-1', 'space-2'],
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1);
|
||||||
|
|
||||||
|
const finishButton = findTestSubject(wrapper, 'cts-finish-button');
|
||||||
|
act(() => {
|
||||||
|
finishButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows conflicts to be resolved', async () => {
|
||||||
|
const {
|
||||||
|
wrapper,
|
||||||
|
onClose,
|
||||||
|
mockSpacesManager,
|
||||||
|
mockToastNotifications,
|
||||||
|
savedObjectToCopy,
|
||||||
|
} = await setup();
|
||||||
|
|
||||||
|
mockSpacesManager.copySavedObjects.mockResolvedValue({
|
||||||
|
'space-1': {
|
||||||
|
success: true,
|
||||||
|
successCount: 3,
|
||||||
|
},
|
||||||
|
'space-2': {
|
||||||
|
success: false,
|
||||||
|
successCount: 1,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'index-pattern',
|
||||||
|
id: 'conflicting-ip',
|
||||||
|
error: { type: 'conflict' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'visualization',
|
||||||
|
id: 'my-viz',
|
||||||
|
error: { type: 'conflict' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSpacesManager.resolveCopySavedObjectsErrors.mockResolvedValue({
|
||||||
|
'space-2': {
|
||||||
|
success: true,
|
||||||
|
successCount: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Using props callback instead of simulating clicks,
|
||||||
|
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||||
|
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
spaceSelector.props().onChange(['space-1', 'space-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
|
||||||
|
act(() => {
|
||||||
|
startButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1);
|
||||||
|
|
||||||
|
const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`);
|
||||||
|
spaceResult.simulate('click');
|
||||||
|
|
||||||
|
const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`);
|
||||||
|
overwriteButton.simulate('click');
|
||||||
|
|
||||||
|
const finishButton = findTestSubject(wrapper, 'cts-finish-button');
|
||||||
|
act(() => {
|
||||||
|
finishButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith(
|
||||||
|
[{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }],
|
||||||
|
{
|
||||||
|
'space-2': [{ type: 'index-pattern', id: 'conflicting-ip', overwrite: true }],
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays an error when missing references are encountered', async () => {
|
||||||
|
const { wrapper, onClose, mockSpacesManager, mockToastNotifications } = await setup();
|
||||||
|
|
||||||
|
mockSpacesManager.copySavedObjects.mockResolvedValue({
|
||||||
|
'space-1': {
|
||||||
|
success: true,
|
||||||
|
successCount: 3,
|
||||||
|
},
|
||||||
|
'space-2': {
|
||||||
|
success: false,
|
||||||
|
successCount: 1,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'visualization',
|
||||||
|
id: 'my-viz',
|
||||||
|
error: {
|
||||||
|
type: 'missing_references',
|
||||||
|
references: [{ type: 'index-pattern', id: 'missing-index-pattern' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Using props callback instead of simulating clicks,
|
||||||
|
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||||
|
const spaceSelector = wrapper.find(SelectableSpacesControl);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
spaceSelector.props().onChange(['space-1', 'space-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = findTestSubject(wrapper, 'cts-initiate-button');
|
||||||
|
act(() => {
|
||||||
|
startButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0);
|
||||||
|
expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1);
|
||||||
|
|
||||||
|
const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`);
|
||||||
|
spaceResult.simulate('click');
|
||||||
|
|
||||||
|
const errorIconTip = spaceResult.find(
|
||||||
|
'EuiIconTip[data-test-subj="cts-object-result-error-my-viz"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorIconTip.props()).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"color": "danger",
|
||||||
|
"content": <FormattedMessage
|
||||||
|
defaultMessage="There was an error copying this saved object."
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage"
|
||||||
|
values={Object {}}
|
||||||
|
/>,
|
||||||
|
"data-test-subj": "cts-object-result-error-my-viz",
|
||||||
|
"type": "cross",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const finishButton = findTestSubject(wrapper, 'cts-finish-button');
|
||||||
|
act(() => {
|
||||||
|
finishButton.simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSpacesManager.resolveCopySavedObjectsErrors).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockToastNotifications.addError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,261 @@
|
||||||
|
/*
|
||||||
|
* 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, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
EuiFlyout,
|
||||||
|
EuiIcon,
|
||||||
|
EuiFlyoutHeader,
|
||||||
|
EuiTitle,
|
||||||
|
EuiText,
|
||||||
|
EuiFlyoutBody,
|
||||||
|
EuiFlyoutFooter,
|
||||||
|
EuiLoadingSpinner,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiHorizontalRule,
|
||||||
|
EuiEmptyPrompt,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { mapValues } from 'lodash';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import {
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
processImportResponse,
|
||||||
|
ProcessedImportResponse,
|
||||||
|
} from 'ui/management/saved_objects_management';
|
||||||
|
import { ToastNotifications } from 'ui/notify/toasts/toast_notifications';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { SpacesManager } from '../../../../lib';
|
||||||
|
import { ProcessingCopyToSpace } from './processing_copy_to_space';
|
||||||
|
import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer';
|
||||||
|
import { CopyToSpaceForm } from './copy_to_space_form';
|
||||||
|
import { CopyOptions, ImportRetry } from '../../../../lib/copy_saved_objects_to_space/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
savedObject: SavedObjectsManagementRecord;
|
||||||
|
spacesManager: SpacesManager;
|
||||||
|
activeSpace: Space;
|
||||||
|
toastNotifications: ToastNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
|
||||||
|
const { onClose, savedObject, spacesManager, toastNotifications } = props;
|
||||||
|
const [copyOptions, setCopyOptions] = useState<CopyOptions>({
|
||||||
|
includeRelated: true,
|
||||||
|
overwrite: true,
|
||||||
|
selectedSpaceIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ isLoading, spaces }, setSpacesState] = useState<{ isLoading: boolean; spaces: Space[] }>(
|
||||||
|
{
|
||||||
|
isLoading: true,
|
||||||
|
spaces: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
spacesManager
|
||||||
|
.getSpaces('copySavedObjectsIntoSpace')
|
||||||
|
.then(response => {
|
||||||
|
setSpacesState({
|
||||||
|
isLoading: false,
|
||||||
|
spaces: response,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
toastNotifications.addError(e, {
|
||||||
|
title: i18n.translate('xpack.spaces.management.copyToSpace.spacesLoadErrorTitle', {
|
||||||
|
defaultMessage: 'Error loading available spaces',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const eligibleSpaces = spaces.filter(space => space.id !== props.activeSpace.id);
|
||||||
|
|
||||||
|
const [copyInProgress, setCopyInProgress] = useState(false);
|
||||||
|
const [conflictResolutionInProgress, setConflictResolutionInProgress] = useState(false);
|
||||||
|
const [copyResult, setCopyResult] = useState<Record<string, ProcessedImportResponse>>({});
|
||||||
|
const [retries, setRetries] = useState<Record<string, ImportRetry[]>>({});
|
||||||
|
|
||||||
|
const initialCopyFinished = Object.values(copyResult).length > 0;
|
||||||
|
|
||||||
|
const onRetriesChange = (updatedRetries: Record<string, ImportRetry[]>) => {
|
||||||
|
setRetries(updatedRetries);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function startCopy() {
|
||||||
|
setCopyInProgress(true);
|
||||||
|
setCopyResult({});
|
||||||
|
try {
|
||||||
|
const copySavedObjectsResult = await spacesManager.copySavedObjects(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: savedObject.type,
|
||||||
|
id: savedObject.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
copyOptions.selectedSpaceIds,
|
||||||
|
copyOptions.includeRelated,
|
||||||
|
copyOptions.overwrite
|
||||||
|
);
|
||||||
|
const processedResult = mapValues(copySavedObjectsResult, processImportResponse);
|
||||||
|
setCopyResult(processedResult);
|
||||||
|
} catch (e) {
|
||||||
|
setCopyInProgress(false);
|
||||||
|
toastNotifications.addError(e, {
|
||||||
|
title: i18n.translate('xpack.spaces.management.copyToSpace.copyErrorTitle', {
|
||||||
|
defaultMessage: 'Error copying saved object',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishCopy() {
|
||||||
|
const needsConflictResolution = Object.values(retries).some(spaceRetry =>
|
||||||
|
spaceRetry.some(retry => retry.overwrite)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needsConflictResolution) {
|
||||||
|
setConflictResolutionInProgress(true);
|
||||||
|
try {
|
||||||
|
await spacesManager.resolveCopySavedObjectsErrors(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: savedObject.type,
|
||||||
|
id: savedObject.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
retries,
|
||||||
|
copyOptions.includeRelated
|
||||||
|
);
|
||||||
|
|
||||||
|
toastNotifications.addSuccess(
|
||||||
|
i18n.translate('xpack.spaces.management.copyToSpace.resolveCopySuccessTitle', {
|
||||||
|
defaultMessage: 'Overwrite successful',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setCopyInProgress(false);
|
||||||
|
toastNotifications.addError(e, {
|
||||||
|
title: i18n.translate('xpack.spaces.management.copyToSpace.resolveCopyErrorTitle', {
|
||||||
|
defaultMessage: 'Error resolving saved object conflicts',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFlyoutBody = () => {
|
||||||
|
// Step 1: loading assets for main form
|
||||||
|
if (isLoading) {
|
||||||
|
return <EuiLoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1a: assets loaded, but no spaces are available for copy.
|
||||||
|
if (eligibleSpaces.length === 0) {
|
||||||
|
return (
|
||||||
|
<EuiEmptyPrompt
|
||||||
|
body={
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.noSpacesBody"
|
||||||
|
defaultMessage="There are no eligible spaces to copy into."
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.noSpacesTitle"
|
||||||
|
defaultMessage="No spaces available"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Copy has not been initiated yet; User must fill out form to continue.
|
||||||
|
if (!copyInProgress) {
|
||||||
|
return (
|
||||||
|
<CopyToSpaceForm
|
||||||
|
spaces={eligibleSpaces}
|
||||||
|
copyOptions={copyOptions}
|
||||||
|
onUpdate={setCopyOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step3: Copy operation is in progress
|
||||||
|
return (
|
||||||
|
<ProcessingCopyToSpace
|
||||||
|
savedObject={savedObject}
|
||||||
|
copyInProgress={copyInProgress}
|
||||||
|
conflictResolutionInProgress={conflictResolutionInProgress}
|
||||||
|
copyResult={copyResult}
|
||||||
|
spaces={eligibleSpaces}
|
||||||
|
copyOptions={copyOptions}
|
||||||
|
retries={retries}
|
||||||
|
onRetriesChange={onRetriesChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiFlyout onClose={onClose} maxWidth={600} data-test-subj="copy-to-space-flyout">
|
||||||
|
<EuiFlyoutHeader hasBorder>
|
||||||
|
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiIcon size="m" type="spacesApp" />
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiTitle size="m">
|
||||||
|
<h2>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpaceFlyoutHeader"
|
||||||
|
defaultMessage="Copy saved object to space"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
</EuiTitle>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
</EuiFlyoutHeader>
|
||||||
|
<EuiFlyoutBody>
|
||||||
|
<EuiFlexGroup alignItems="center" gutterSize="m">
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiIcon type={savedObject.meta.icon || 'apps'} />
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiText>
|
||||||
|
<p>{savedObject.meta.title}</p>
|
||||||
|
</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
|
||||||
|
<EuiHorizontalRule margin="m" />
|
||||||
|
|
||||||
|
{getFlyoutBody()}
|
||||||
|
</EuiFlyoutBody>
|
||||||
|
|
||||||
|
<EuiFlyoutFooter>
|
||||||
|
<CopyToSpaceFlyoutFooter
|
||||||
|
copyInProgress={copyInProgress}
|
||||||
|
conflictResolutionInProgress={conflictResolutionInProgress}
|
||||||
|
initialCopyFinished={initialCopyFinished}
|
||||||
|
copyResult={copyResult}
|
||||||
|
numberOfSelectedSpaces={copyOptions.selectedSpaceIds.length}
|
||||||
|
retries={retries}
|
||||||
|
onCopyStart={startCopy}
|
||||||
|
onCopyFinish={finishCopy}
|
||||||
|
/>
|
||||||
|
</EuiFlyoutFooter>
|
||||||
|
</EuiFlyout>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,199 @@
|
||||||
|
/*
|
||||||
|
* 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 } from 'react';
|
||||||
|
import { ProcessedImportResponse } from 'ui/management/saved_objects_management';
|
||||||
|
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiStat, EuiHorizontalRule } from '@elastic/eui';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import { ImportRetry } from '../../../../lib/copy_saved_objects_to_space/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
copyInProgress: boolean;
|
||||||
|
conflictResolutionInProgress: boolean;
|
||||||
|
initialCopyFinished: boolean;
|
||||||
|
copyResult: Record<string, ProcessedImportResponse>;
|
||||||
|
retries: Record<string, ImportRetry[]>;
|
||||||
|
numberOfSelectedSpaces: number;
|
||||||
|
onCopyStart: () => void;
|
||||||
|
onCopyFinish: () => void;
|
||||||
|
}
|
||||||
|
export const CopyToSpaceFlyoutFooter = (props: Props) => {
|
||||||
|
const { copyInProgress, initialCopyFinished, copyResult, retries } = props;
|
||||||
|
|
||||||
|
let summarizedResults = {
|
||||||
|
successCount: 0,
|
||||||
|
overwriteConflictCount: 0,
|
||||||
|
conflictCount: 0,
|
||||||
|
unresolvableErrorCount: 0,
|
||||||
|
};
|
||||||
|
if (copyResult) {
|
||||||
|
summarizedResults = Object.entries(copyResult).reduce((acc, result) => {
|
||||||
|
const [spaceId, spaceResult] = result;
|
||||||
|
const overwriteCount = (retries[spaceId] || []).filter(c => c.overwrite).length;
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
successCount: acc.successCount + spaceResult.importCount,
|
||||||
|
overwriteConflictCount: acc.overwriteConflictCount + overwriteCount,
|
||||||
|
conflictCount:
|
||||||
|
acc.conflictCount +
|
||||||
|
spaceResult.failedImports.filter(i => i.error.type === 'conflict').length -
|
||||||
|
overwriteCount,
|
||||||
|
unresolvableErrorCount:
|
||||||
|
acc.unresolvableErrorCount +
|
||||||
|
spaceResult.failedImports.filter(i => i.error.type !== 'conflict').length,
|
||||||
|
};
|
||||||
|
}, summarizedResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getButton = () => {
|
||||||
|
let actionButton;
|
||||||
|
if (initialCopyFinished) {
|
||||||
|
const hasPendingOverwrites = summarizedResults.overwriteConflictCount > 0;
|
||||||
|
|
||||||
|
const buttonText = hasPendingOverwrites ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton"
|
||||||
|
defaultMessage="Overwrite {overwriteCount} objects"
|
||||||
|
values={{ overwriteCount: summarizedResults.overwriteConflictCount }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.finishCopyToSpacesButton"
|
||||||
|
defaultMessage="Finish"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
actionButton = (
|
||||||
|
<EuiButton
|
||||||
|
fill
|
||||||
|
isLoading={props.conflictResolutionInProgress}
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-label={
|
||||||
|
props.conflictResolutionInProgress
|
||||||
|
? i18n.translate('xpack.spaces.management.copyToSpace.inProgressButtonLabel', {
|
||||||
|
defaultMessage: 'Copy is in progress. Please wait.',
|
||||||
|
})
|
||||||
|
: i18n.translate('xpack.spaces.management.copyToSpace.finishedButtonLabel', {
|
||||||
|
defaultMessage: 'Copy finished.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClick={() => props.onCopyFinish()}
|
||||||
|
data-test-subj="cts-finish-button"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</EuiButton>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
actionButton = (
|
||||||
|
<EuiButton
|
||||||
|
fill
|
||||||
|
isLoading={copyInProgress}
|
||||||
|
onClick={() => props.onCopyStart()}
|
||||||
|
data-test-subj="cts-initiate-button"
|
||||||
|
disabled={props.numberOfSelectedSpaces === 0 || copyInProgress}
|
||||||
|
>
|
||||||
|
{props.numberOfSelectedSpaces > 0 ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyToSpacesButton"
|
||||||
|
defaultMessage="Copy to {spaceCount} {spaceCount, plural, one {space} other {spaces}}"
|
||||||
|
values={{ spaceCount: props.numberOfSelectedSpaces }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton"
|
||||||
|
defaultMessage="Copy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EuiButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiFlexGroup justifyContent="flexEnd">
|
||||||
|
<EuiFlexItem grow={false}>{actionButton}</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!copyInProgress) {
|
||||||
|
return getButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<EuiFlexGroup>
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiStat
|
||||||
|
data-test-subj={`cts-summary-success-count`}
|
||||||
|
title={summarizedResults.successCount}
|
||||||
|
titleSize="s"
|
||||||
|
titleColor={initialCopyFinished ? 'secondary' : 'subdued'}
|
||||||
|
isLoading={!initialCopyFinished}
|
||||||
|
textAlign="center"
|
||||||
|
description={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpaceFlyoutFooter.successCount"
|
||||||
|
defaultMessage="Copied"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
{summarizedResults.overwriteConflictCount > 0 && (
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiStat
|
||||||
|
data-test-subj={`cts-summary-overwrite-count`}
|
||||||
|
title={summarizedResults.overwriteConflictCount}
|
||||||
|
titleSize="s"
|
||||||
|
titleColor={summarizedResults.overwriteConflictCount > 0 ? 'primary' : 'subdued'}
|
||||||
|
isLoading={!initialCopyFinished}
|
||||||
|
textAlign="center"
|
||||||
|
description={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount"
|
||||||
|
defaultMessage="Pending"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiStat
|
||||||
|
data-test-subj={`cts-summary-conflict-count`}
|
||||||
|
title={summarizedResults.conflictCount}
|
||||||
|
titleSize="s"
|
||||||
|
titleColor={summarizedResults.conflictCount > 0 ? 'primary' : 'subdued'}
|
||||||
|
isLoading={!initialCopyFinished}
|
||||||
|
textAlign="center"
|
||||||
|
description={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount"
|
||||||
|
defaultMessage="Skipped"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiStat
|
||||||
|
data-test-subj={`cts-summary-error-count`}
|
||||||
|
title={summarizedResults.unresolvableErrorCount}
|
||||||
|
titleSize="s"
|
||||||
|
titleColor={summarizedResults.unresolvableErrorCount > 0 ? 'danger' : 'subdued'}
|
||||||
|
isLoading={!initialCopyFinished}
|
||||||
|
textAlign="center"
|
||||||
|
description={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount"
|
||||||
|
defaultMessage="Errors"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
<EuiHorizontalRule />
|
||||||
|
{getButton()}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
EuiSwitch,
|
||||||
|
EuiSpacer,
|
||||||
|
EuiHorizontalRule,
|
||||||
|
EuiFormRow,
|
||||||
|
EuiListGroup,
|
||||||
|
EuiListGroupItem,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import { CopyOptions } from '../../../../lib/copy_saved_objects_to_space/types';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { SelectableSpacesControl } from './selectable_spaces_control';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
spaces: Space[];
|
||||||
|
onUpdate: (copyOptions: CopyOptions) => void;
|
||||||
|
copyOptions: CopyOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyToSpaceForm = (props: Props) => {
|
||||||
|
const setOverwrite = (overwrite: boolean) => props.onUpdate({ ...props.copyOptions, overwrite });
|
||||||
|
|
||||||
|
const setSelectedSpaceIds = (selectedSpaceIds: string[]) =>
|
||||||
|
props.onUpdate({ ...props.copyOptions, selectedSpaceIds });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-test-subj="copy-to-space-form">
|
||||||
|
<EuiListGroup className="spcCopyToSpaceOptionsView" flush>
|
||||||
|
<EuiListGroupItem
|
||||||
|
className="spcCopyToSpaceIncludeRelated"
|
||||||
|
iconType={'check'}
|
||||||
|
label={
|
||||||
|
<span className="spcCopyToSpaceIncludeRelated__label">
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.includeRelatedFormLabel"
|
||||||
|
defaultMessage="Including related saved objects"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EuiListGroup>
|
||||||
|
|
||||||
|
<EuiSpacer />
|
||||||
|
|
||||||
|
<EuiSwitch
|
||||||
|
data-test-subj="cts-form-overwrite"
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.automaticallyOverwrite"
|
||||||
|
defaultMessage="Automatically overwrite all saved objects"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
checked={props.copyOptions.overwrite}
|
||||||
|
onChange={e => setOverwrite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EuiHorizontalRule margin="m" />
|
||||||
|
|
||||||
|
<EuiFormRow
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.selectSpacesLabel"
|
||||||
|
defaultMessage="Select spaces to copy into"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<SelectableSpacesControl
|
||||||
|
spaces={props.spaces}
|
||||||
|
selectedSpaceIds={props.copyOptions.selectedSpaceIds}
|
||||||
|
onChange={selection => setSelectedSpaceIds(selection)}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 { CopySavedObjectsToSpaceFlyout } from './copy_to_space_flyout';
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* 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 } from 'react';
|
||||||
|
import {
|
||||||
|
ProcessedImportResponse,
|
||||||
|
SavedObjectsManagementRecord,
|
||||||
|
} from 'ui/management/saved_objects_management';
|
||||||
|
import {
|
||||||
|
EuiSpacer,
|
||||||
|
EuiText,
|
||||||
|
EuiListGroup,
|
||||||
|
EuiListGroupItem,
|
||||||
|
EuiHorizontalRule,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import { summarizeCopyResult } from '../../../../lib/copy_saved_objects_to_space';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { CopyOptions, ImportRetry } from '../../../../lib/copy_saved_objects_to_space/types';
|
||||||
|
import { SpaceResult } from './space_result';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
savedObject: SavedObjectsManagementRecord;
|
||||||
|
copyInProgress: boolean;
|
||||||
|
conflictResolutionInProgress: boolean;
|
||||||
|
copyResult: Record<string, ProcessedImportResponse>;
|
||||||
|
retries: Record<string, ImportRetry[]>;
|
||||||
|
onRetriesChange: (retries: Record<string, ImportRetry[]>) => void;
|
||||||
|
spaces: Space[];
|
||||||
|
copyOptions: CopyOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProcessingCopyToSpace = (props: Props) => {
|
||||||
|
function updateRetries(spaceId: string, updatedRetries: ImportRetry[]) {
|
||||||
|
props.onRetriesChange({
|
||||||
|
...props.retries,
|
||||||
|
[spaceId]: updatedRetries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-test-subj="copy-to-space-processing">
|
||||||
|
<EuiListGroup className="spcCopyToSpaceOptionsView" flush>
|
||||||
|
<EuiListGroupItem
|
||||||
|
iconType={props.copyOptions.includeRelated ? 'check' : 'cross'}
|
||||||
|
label={
|
||||||
|
props.copyOptions.includeRelated ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.includeRelatedLabel"
|
||||||
|
defaultMessage="Including related saved objects"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel"
|
||||||
|
defaultMessage="Not including related saved objects"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<EuiListGroupItem
|
||||||
|
iconType={props.copyOptions.overwrite ? 'check' : 'cross'}
|
||||||
|
label={
|
||||||
|
props.copyOptions.overwrite ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.overwriteLabel"
|
||||||
|
defaultMessage="Automatically overwriting saved objects"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.dontOverwriteLabel"
|
||||||
|
defaultMessage="Not overwriting saved objects"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EuiListGroup>
|
||||||
|
<EuiHorizontalRule margin="m" />
|
||||||
|
<EuiText size="s">
|
||||||
|
<h5>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyResultsLabel"
|
||||||
|
defaultMessage="Copy results"
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</EuiText>
|
||||||
|
<EuiSpacer size="m" />
|
||||||
|
{props.copyOptions.selectedSpaceIds.map(id => {
|
||||||
|
const space = props.spaces.find(s => s.id === id) as Space;
|
||||||
|
const spaceCopyResult = props.copyResult[space.id];
|
||||||
|
const summarizedSpaceCopyResult = summarizeCopyResult(
|
||||||
|
props.savedObject,
|
||||||
|
spaceCopyResult,
|
||||||
|
props.copyOptions.includeRelated
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={id}>
|
||||||
|
<SpaceResult
|
||||||
|
savedObject={props.savedObject}
|
||||||
|
space={space}
|
||||||
|
summarizedCopyResult={summarizedSpaceCopyResult}
|
||||||
|
retries={props.retries[space.id] || []}
|
||||||
|
onRetriesChange={retries => updateRetries(space.id, retries)}
|
||||||
|
conflictResolutionInProgress={props.conflictResolutionInProgress}
|
||||||
|
/>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
* 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, useState } from 'react';
|
||||||
|
import { EuiSelectable, EuiLoadingSpinner } from '@elastic/eui';
|
||||||
|
import { SpaceAvatar } from '../../../../components';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
spaces: Space[];
|
||||||
|
selectedSpaceIds: string[];
|
||||||
|
onChange: (selectedSpaceIds: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpaceOption {
|
||||||
|
label: string;
|
||||||
|
prepend?: any;
|
||||||
|
checked: 'on' | 'off' | null;
|
||||||
|
['data-space-id']: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectableSpacesControl = (props: Props) => {
|
||||||
|
const [options, setOptions] = useState<SpaceOption[]>([]);
|
||||||
|
|
||||||
|
// TODO: update once https://github.com/elastic/eui/issues/2071 is fixed
|
||||||
|
if (options.length === 0) {
|
||||||
|
setOptions(
|
||||||
|
props.spaces.map(space => ({
|
||||||
|
label: space.name,
|
||||||
|
prepend: <SpaceAvatar space={space} size={'s'} />,
|
||||||
|
checked: props.selectedSpaceIds.includes(space.id) ? 'on' : null,
|
||||||
|
['data-space-id']: space.id,
|
||||||
|
['data-test-subj']: `cts-space-selector-row-${space.id}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedSpaces(selectedOptions: SpaceOption[]) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
const selectedSpaceIds = selectedOptions
|
||||||
|
.filter(opt => opt.checked)
|
||||||
|
.map(opt => opt['data-space-id']);
|
||||||
|
|
||||||
|
props.onChange(selectedSpaceIds);
|
||||||
|
// TODO: remove once https://github.com/elastic/eui/issues/2071 is fixed
|
||||||
|
setOptions(selectedOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return <EuiLoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiSelectable
|
||||||
|
options={options as any[]}
|
||||||
|
onChange={newOptions => updateSelectedSpaces(newOptions as SpaceOption[])}
|
||||||
|
listProps={{
|
||||||
|
bordered: true,
|
||||||
|
rowHeight: 40,
|
||||||
|
className: 'spcCopyToSpace__spacesList',
|
||||||
|
'data-test-subj': 'cts-form-space-selector',
|
||||||
|
}}
|
||||||
|
searchable
|
||||||
|
>
|
||||||
|
{(list, search) => {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{search}
|
||||||
|
{list}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</EuiSelectable>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui';
|
||||||
|
import { SavedObjectsManagementRecord } from 'ui/management/saved_objects_management';
|
||||||
|
import { SummarizedCopyToSpaceResult } from '../../../../lib/copy_saved_objects_to_space';
|
||||||
|
import { SpaceAvatar } from '../../../../components';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { CopyStatusSummaryIndicator } from './copy_status_summary_indicator';
|
||||||
|
import { SpaceCopyResultDetails } from './space_result_details';
|
||||||
|
import { ImportRetry } from '../../../../lib/copy_saved_objects_to_space/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
savedObject: SavedObjectsManagementRecord;
|
||||||
|
space: Space;
|
||||||
|
summarizedCopyResult: SummarizedCopyToSpaceResult;
|
||||||
|
retries: ImportRetry[];
|
||||||
|
onRetriesChange: (retries: ImportRetry[]) => void;
|
||||||
|
conflictResolutionInProgress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpaceResult = (props: Props) => {
|
||||||
|
const {
|
||||||
|
space,
|
||||||
|
summarizedCopyResult,
|
||||||
|
retries,
|
||||||
|
onRetriesChange,
|
||||||
|
savedObject,
|
||||||
|
conflictResolutionInProgress,
|
||||||
|
} = props;
|
||||||
|
const spaceHasPendingOverwrites = retries.some(r => r.overwrite);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiAccordion
|
||||||
|
id={`copyToSpace-${space.id}`}
|
||||||
|
data-test-subj={`cts-space-result-${space.id}`}
|
||||||
|
className="spcCopyToSpaceResult"
|
||||||
|
buttonContent={
|
||||||
|
<EuiFlexGroup responsive={false}>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<SpaceAvatar space={space} size="s" />
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem>
|
||||||
|
<EuiText>{space.name}</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
}
|
||||||
|
extraAction={
|
||||||
|
<CopyStatusSummaryIndicator
|
||||||
|
space={space}
|
||||||
|
summarizedCopyResult={summarizedCopyResult}
|
||||||
|
conflictResolutionInProgress={conflictResolutionInProgress && spaceHasPendingOverwrites}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<SpaceCopyResultDetails
|
||||||
|
savedObject={savedObject}
|
||||||
|
summarizedCopyResult={summarizedCopyResult}
|
||||||
|
space={space}
|
||||||
|
retries={retries}
|
||||||
|
onRetriesChange={onRetriesChange}
|
||||||
|
conflictResolutionInProgress={conflictResolutionInProgress && spaceHasPendingOverwrites}
|
||||||
|
/>
|
||||||
|
</EuiAccordion>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
* 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 { SavedObjectsManagementRecord } from 'ui/management/saved_objects_management';
|
||||||
|
import { SummarizedCopyToSpaceResult } from 'plugins/spaces/lib/copy_saved_objects_to_space';
|
||||||
|
import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
import { Space } from '../../../../../common/model/space';
|
||||||
|
import { CopyStatusIndicator } from './copy_status_indicator';
|
||||||
|
import { ImportRetry } from '../../../../lib/copy_saved_objects_to_space/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
savedObject: SavedObjectsManagementRecord;
|
||||||
|
summarizedCopyResult: SummarizedCopyToSpaceResult;
|
||||||
|
space: Space;
|
||||||
|
retries: ImportRetry[];
|
||||||
|
onRetriesChange: (retries: ImportRetry[]) => void;
|
||||||
|
conflictResolutionInProgress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpaceCopyResultDetails = (props: Props) => {
|
||||||
|
const onOverwriteClick = (object: { type: string; id: string }) => {
|
||||||
|
const retry = props.retries.find(r => r.type === object.type && r.id === object.id);
|
||||||
|
|
||||||
|
props.onRetriesChange([
|
||||||
|
...props.retries.filter(r => r !== retry),
|
||||||
|
{
|
||||||
|
type: object.type,
|
||||||
|
id: object.id,
|
||||||
|
overwrite: retry ? !retry.overwrite : true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPendingOverwrite = (object: { type: string; id: string }) => {
|
||||||
|
const retry = props.retries.find(r => r.type === object.type && r.id === object.id);
|
||||||
|
|
||||||
|
return Boolean(retry && retry.overwrite);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { objects } = props.summarizedCopyResult;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="spcCopyToSpaceResultDetails">
|
||||||
|
{objects.map((object, index) => {
|
||||||
|
const objectOverwritePending = hasPendingOverwrite(object);
|
||||||
|
|
||||||
|
const showOverwriteButton =
|
||||||
|
object.conflicts.length > 0 &&
|
||||||
|
!objectOverwritePending &&
|
||||||
|
!props.conflictResolutionInProgress;
|
||||||
|
|
||||||
|
const showSkipButton =
|
||||||
|
!showOverwriteButton && objectOverwritePending && !props.conflictResolutionInProgress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiFlexGroup
|
||||||
|
responsive={false}
|
||||||
|
key={index}
|
||||||
|
alignItems="center"
|
||||||
|
gutterSize="s"
|
||||||
|
className="spcCopyToSpaceResultDetails__row"
|
||||||
|
>
|
||||||
|
<EuiFlexItem grow={5} className="spcCopyToSpaceResultDetails__savedObjectName">
|
||||||
|
<EuiText size="s">
|
||||||
|
<p className="eui-textTruncate" title={object.name || object.id}>
|
||||||
|
{object.type}: {object.name || object.id}
|
||||||
|
</p>
|
||||||
|
</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
{showOverwriteButton && (
|
||||||
|
<EuiFlexItem grow={1}>
|
||||||
|
<EuiText size="s">
|
||||||
|
<EuiButtonEmpty
|
||||||
|
onClick={() => onOverwriteClick(object)}
|
||||||
|
size="xs"
|
||||||
|
data-test-subj={`cts-overwrite-conflict-${object.id}`}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyDetail.overwriteButton"
|
||||||
|
defaultMessage="Overwrite"
|
||||||
|
/>
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
{showSkipButton && (
|
||||||
|
<EuiFlexItem grow={1}>
|
||||||
|
<EuiText size="s">
|
||||||
|
<EuiButtonEmpty
|
||||||
|
onClick={() => onOverwriteClick(object)}
|
||||||
|
size="xs"
|
||||||
|
data-test-subj={`cts-skip-conflict-${object.id}`}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton"
|
||||||
|
defaultMessage="Skip"
|
||||||
|
/>
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
<EuiFlexItem className="spcCopyToSpaceResultDetails__statusIndicator" grow={1}>
|
||||||
|
<div className="eui-textRight">
|
||||||
|
<CopyStatusIndicator
|
||||||
|
summarizedCopyResult={props.summarizedCopyResult}
|
||||||
|
object={object}
|
||||||
|
overwritePending={hasPendingOverwrite(object)}
|
||||||
|
conflictResolutionInProgress={
|
||||||
|
props.conflictResolutionInProgress && objectOverwritePending
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -9,6 +9,7 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||||
import { SpacesNavState } from '../../nav_control';
|
import { SpacesNavState } from '../../nav_control';
|
||||||
import { DeleteSpacesButton } from './delete_spaces_button';
|
import { DeleteSpacesButton } from './delete_spaces_button';
|
||||||
import { spacesManagerMock } from '../../../lib/mocks';
|
import { spacesManagerMock } from '../../../lib/mocks';
|
||||||
|
import { SpacesManager } from '../../../lib';
|
||||||
|
|
||||||
const space = {
|
const space = {
|
||||||
id: 'my-space',
|
id: 'my-space',
|
||||||
|
@ -28,7 +29,7 @@ describe('DeleteSpacesButton', () => {
|
||||||
const wrapper = shallowWithIntl(
|
const wrapper = shallowWithIntl(
|
||||||
<DeleteSpacesButton.WrappedComponent
|
<DeleteSpacesButton.WrappedComponent
|
||||||
space={space}
|
space={space}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
onDelete={jest.fn()}
|
onDelete={jest.fn()}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal
|
||||||
import { ManageSpacePage } from './manage_space_page';
|
import { ManageSpacePage } from './manage_space_page';
|
||||||
import { SectionPanel } from './section_panel';
|
import { SectionPanel } from './section_panel';
|
||||||
import { spacesManagerMock } from '../../../lib/mocks';
|
import { spacesManagerMock } from '../../../lib/mocks';
|
||||||
|
import { SpacesManager } from '../../../lib';
|
||||||
|
|
||||||
const space = {
|
const space = {
|
||||||
id: 'my-space',
|
id: 'my-space',
|
||||||
|
@ -35,7 +36,7 @@ describe('ManageSpacePage', () => {
|
||||||
|
|
||||||
const wrapper = mountWithIntl(
|
const wrapper = mountWithIntl(
|
||||||
<ManageSpacePage.WrappedComponent
|
<ManageSpacePage.WrappedComponent
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
/>
|
/>
|
||||||
|
@ -81,7 +82,7 @@ describe('ManageSpacePage', () => {
|
||||||
const wrapper = mountWithIntl(
|
const wrapper = mountWithIntl(
|
||||||
<ManageSpacePage.WrappedComponent
|
<ManageSpacePage.WrappedComponent
|
||||||
spaceId={'existing-space'}
|
spaceId={'existing-space'}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
/>
|
/>
|
||||||
|
@ -127,7 +128,7 @@ describe('ManageSpacePage', () => {
|
||||||
const wrapper = mountWithIntl(
|
const wrapper = mountWithIntl(
|
||||||
<ManageSpacePage.WrappedComponent
|
<ManageSpacePage.WrappedComponent
|
||||||
spaceId={'my-space'}
|
spaceId={'my-space'}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
/>
|
/>
|
||||||
|
@ -182,7 +183,7 @@ describe('ManageSpacePage', () => {
|
||||||
const wrapper = mountWithIntl(
|
const wrapper = mountWithIntl(
|
||||||
<ManageSpacePage.WrappedComponent
|
<ManageSpacePage.WrappedComponent
|
||||||
spaceId={'my-space'}
|
spaceId={'my-space'}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -11,18 +11,20 @@ import {
|
||||||
PAGE_SUBTITLE_COMPONENT,
|
PAGE_SUBTITLE_COMPONENT,
|
||||||
PAGE_TITLE_COMPONENT,
|
PAGE_TITLE_COMPONENT,
|
||||||
registerSettingsComponent,
|
registerSettingsComponent,
|
||||||
// @ts-ignore
|
|
||||||
} from 'ui/management';
|
} from 'ui/management';
|
||||||
|
import { SavedObjectsManagementActionRegistry } from 'ui/management/saved_objects_management';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import routes from 'ui/routes';
|
import routes from 'ui/routes';
|
||||||
|
import { SpacesManager } from '../../lib';
|
||||||
import { AdvancedSettingsSubtitle } from './components/advanced_settings_subtitle';
|
import { AdvancedSettingsSubtitle } from './components/advanced_settings_subtitle';
|
||||||
import { AdvancedSettingsTitle } from './components/advanced_settings_title';
|
import { AdvancedSettingsTitle } from './components/advanced_settings_title';
|
||||||
|
import { CopyToSpaceSavedObjectsManagementAction } from '../../lib/copy_saved_objects_to_space';
|
||||||
|
|
||||||
const MANAGE_SPACES_KEY = 'spaces';
|
const MANAGE_SPACES_KEY = 'spaces';
|
||||||
|
|
||||||
routes.defaults(/\/management/, {
|
routes.defaults(/\/management/, {
|
||||||
resolve: {
|
resolve: {
|
||||||
spacesManagementSection(activeSpace: any) {
|
spacesManagementSection(activeSpace: any, spaceSelectorURL: string) {
|
||||||
function getKibanaSection() {
|
function getKibanaSection() {
|
||||||
return management.getSection('kibana');
|
return management.getSection('kibana');
|
||||||
}
|
}
|
||||||
|
@ -45,6 +47,18 @@ routes.defaults(/\/management/, {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customize Saved Objects Management
|
||||||
|
const action = new CopyToSpaceSavedObjectsManagementAction(
|
||||||
|
new SpacesManager(spaceSelectorURL),
|
||||||
|
activeSpace.space
|
||||||
|
);
|
||||||
|
// This route resolve function executes any time the management screen is loaded, and we want to ensure
|
||||||
|
// that this action is only registered once.
|
||||||
|
if (!SavedObjectsManagementActionRegistry.has(action.id)) {
|
||||||
|
SavedObjectsManagementActionRegistry.register(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customize Advanced Settings
|
||||||
const PageTitle = () => <AdvancedSettingsTitle space={activeSpace.space} />;
|
const PageTitle = () => <AdvancedSettingsTitle space={activeSpace.space} />;
|
||||||
registerSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle, true);
|
registerSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle, true);
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import React from 'react';
|
||||||
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
|
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||||
import { SpaceAvatar } from '../../../components';
|
import { SpaceAvatar } from '../../../components';
|
||||||
import { spacesManagerMock } from '../../../lib/mocks';
|
import { spacesManagerMock } from '../../../lib/mocks';
|
||||||
|
import { SpacesManager } from '../../../lib';
|
||||||
import { SpacesNavState } from '../../nav_control';
|
import { SpacesNavState } from '../../nav_control';
|
||||||
import { SpacesGridPage } from './spaces_grid_page';
|
import { SpacesGridPage } from './spaces_grid_page';
|
||||||
|
|
||||||
|
@ -49,7 +50,7 @@ describe('SpacesGridPage', () => {
|
||||||
expect(
|
expect(
|
||||||
shallowWithIntl(
|
shallowWithIntl(
|
||||||
<SpacesGridPage.WrappedComponent
|
<SpacesGridPage.WrappedComponent
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
/>
|
/>
|
||||||
|
@ -60,7 +61,7 @@ describe('SpacesGridPage', () => {
|
||||||
it('renders the list of spaces', async () => {
|
it('renders the list of spaces', async () => {
|
||||||
const wrapper = mountWithIntl(
|
const wrapper = mountWithIntl(
|
||||||
<SpacesGridPage.WrappedComponent
|
<SpacesGridPage.WrappedComponent
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
spacesNavState={spacesNavState}
|
spacesNavState={spacesNavState}
|
||||||
intl={null as any}
|
intl={null as any}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { mount, shallow } from 'enzyme';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SpaceAvatar } from '../../components';
|
import { SpaceAvatar } from '../../components';
|
||||||
import { spacesManagerMock } from '../../lib/mocks';
|
import { spacesManagerMock } from '../../lib/mocks';
|
||||||
|
import { SpacesManager } from '../../lib';
|
||||||
import { SpacesHeaderNavButton } from './components/spaces_header_nav_button';
|
import { SpacesHeaderNavButton } from './components/spaces_header_nav_button';
|
||||||
import { NavControlPopover } from './nav_control_popover';
|
import { NavControlPopover } from './nav_control_popover';
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ describe('NavControlPopover', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = shallow(
|
||||||
<NavControlPopover
|
<NavControlPopover
|
||||||
activeSpace={activeSpace}
|
activeSpace={activeSpace}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
anchorPosition={'downRight'}
|
anchorPosition={'downRight'}
|
||||||
buttonClass={SpacesHeaderNavButton}
|
buttonClass={SpacesHeaderNavButton}
|
||||||
/>
|
/>
|
||||||
|
@ -54,7 +55,7 @@ describe('NavControlPopover', () => {
|
||||||
const wrapper = mount<any, any>(
|
const wrapper = mount<any, any>(
|
||||||
<NavControlPopover
|
<NavControlPopover
|
||||||
activeSpace={activeSpace}
|
activeSpace={activeSpace}
|
||||||
spacesManager={spacesManager}
|
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||||
anchorPosition={'rightCenter'}
|
anchorPosition={'rightCenter'}
|
||||||
buttonClass={SpacesHeaderNavButton}
|
buttonClass={SpacesHeaderNavButton}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -4,4 +4,4 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { SpacesClient, GetSpacePurpose } from './spaces_client';
|
export { SpacesClient } from './spaces_client';
|
||||||
|
|
|
@ -4,10 +4,11 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SpacesClient, GetSpacePurpose } from './spaces_client';
|
import { SpacesClient } from './spaces_client';
|
||||||
import { AuthorizationService } from '../../../../security/server/lib/authorization/service';
|
import { AuthorizationService } from '../../../../security/server/lib/authorization/service';
|
||||||
import { actionsFactory } from '../../../../security/server/lib/authorization/actions';
|
import { actionsFactory } from '../../../../security/server/lib/authorization/actions';
|
||||||
import { SpacesConfigType, config } from '../../new_platform/config';
|
import { SpacesConfigType, config } from '../../new_platform/config';
|
||||||
|
import { GetSpacePurpose } from '../../../common/model/types';
|
||||||
|
|
||||||
const createMockAuditLogger = () => {
|
const createMockAuditLogger = () => {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -12,10 +12,10 @@ import { isReservedSpace } from '../../../common/is_reserved_space';
|
||||||
import { Space } from '../../../common/model/space';
|
import { Space } from '../../../common/model/space';
|
||||||
import { SpacesAuditLogger } from '../audit_logger';
|
import { SpacesAuditLogger } from '../audit_logger';
|
||||||
import { SpacesConfigType } from '../../new_platform/config';
|
import { SpacesConfigType } from '../../new_platform/config';
|
||||||
|
import { GetSpacePurpose } from '../../../common/model/types';
|
||||||
|
|
||||||
type SpacesClientRequestFacade = Legacy.Request | KibanaRequest;
|
type SpacesClientRequestFacade = Legacy.Request | KibanaRequest;
|
||||||
|
|
||||||
export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace';
|
|
||||||
const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace'];
|
const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace'];
|
||||||
|
|
||||||
const PURPOSE_PRIVILEGE_MAP: Record<
|
const PURPOSE_PRIVILEGE_MAP: Record<
|
||||||
|
|
|
@ -7,9 +7,10 @@
|
||||||
import Boom from 'boom';
|
import Boom from 'boom';
|
||||||
import Joi from 'joi';
|
import Joi from 'joi';
|
||||||
import { RequestQuery } from 'hapi';
|
import { RequestQuery } from 'hapi';
|
||||||
|
import { GetSpacePurpose } from '../../../../common/model/types';
|
||||||
import { Space } from '../../../../common/model/space';
|
import { Space } from '../../../../common/model/space';
|
||||||
import { wrapError } from '../../../lib/errors';
|
import { wrapError } from '../../../lib/errors';
|
||||||
import { SpacesClient, GetSpacePurpose } from '../../../lib/spaces_client';
|
import { SpacesClient } from '../../../lib/spaces_client';
|
||||||
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
|
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
|
||||||
|
|
||||||
export function initGetSpacesApi(deps: ExternalRouteDeps) {
|
export function initGetSpacesApi(deps: ExternalRouteDeps) {
|
||||||
|
|
119
x-pack/test/functional/apps/spaces/copy_saved_objects.ts
Normal file
119
x-pack/test/functional/apps/spaces/copy_saved_objects.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
/*
|
||||||
|
* 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 expect from '@kbn/expect';
|
||||||
|
import { SpacesService } from '../../../common/services';
|
||||||
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||||
|
|
||||||
|
export default function spaceSelectorFunctonalTests({
|
||||||
|
getService,
|
||||||
|
getPageObjects,
|
||||||
|
}: FtrProviderContext) {
|
||||||
|
const esArchiver = getService('esArchiver');
|
||||||
|
const spaces: SpacesService = getService('spaces');
|
||||||
|
const testSubjects = getService('testSubjects');
|
||||||
|
const PageObjects = getPageObjects(['security', 'settings', 'copySavedObjectsToSpace']);
|
||||||
|
|
||||||
|
describe('Copy Saved Objects to Space', function() {
|
||||||
|
before(async () => {
|
||||||
|
await esArchiver.load('spaces/copy_saved_objects');
|
||||||
|
|
||||||
|
await spaces.create({
|
||||||
|
id: 'marketing',
|
||||||
|
name: 'Marketing',
|
||||||
|
disabledFeatures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await spaces.create({
|
||||||
|
id: 'sales',
|
||||||
|
name: 'Sales',
|
||||||
|
disabledFeatures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await PageObjects.security.login(null, null, {
|
||||||
|
expectSpaceSelector: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PageObjects.settings.navigateTo();
|
||||||
|
await PageObjects.settings.clickKibanaSavedObjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await spaces.delete('sales');
|
||||||
|
await spaces.delete('marketing');
|
||||||
|
await esArchiver.unload('spaces/copy_saved_objects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a dashboard to be copied to the marketing space, with all references', async () => {
|
||||||
|
const destinationSpaceId = 'marketing';
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard');
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.setupForm({
|
||||||
|
overwrite: true,
|
||||||
|
destinationSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.startCopy();
|
||||||
|
|
||||||
|
// Wait for successful copy
|
||||||
|
await testSubjects.waitForDeleted(`cts-summary-indicator-loading-${destinationSpaceId}`);
|
||||||
|
await testSubjects.existOrFail(`cts-summary-indicator-success-${destinationSpaceId}`);
|
||||||
|
|
||||||
|
const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts();
|
||||||
|
|
||||||
|
expect(summaryCounts).to.eql({
|
||||||
|
copied: 3,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
overwrite: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.finishCopy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows conflicts to be resolved', async () => {
|
||||||
|
const destinationSpaceId = 'sales';
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject('A Dashboard');
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.setupForm({
|
||||||
|
overwrite: false,
|
||||||
|
destinationSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.startCopy();
|
||||||
|
|
||||||
|
// Wait for successful copy with conflict warning
|
||||||
|
await testSubjects.waitForDeleted(`cts-summary-indicator-loading-${destinationSpaceId}`);
|
||||||
|
await testSubjects.existOrFail(`cts-summary-indicator-conflicts-${destinationSpaceId}`);
|
||||||
|
|
||||||
|
const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts();
|
||||||
|
|
||||||
|
expect(summaryCounts).to.eql({
|
||||||
|
copied: 2,
|
||||||
|
skipped: 1,
|
||||||
|
errors: 0,
|
||||||
|
overwrite: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark conflict for overwrite
|
||||||
|
await testSubjects.click(`cts-space-result-${destinationSpaceId}`);
|
||||||
|
await testSubjects.click(`cts-overwrite-conflict-logstash-*`);
|
||||||
|
|
||||||
|
// Verify summary changed
|
||||||
|
const updatedSummaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts(true);
|
||||||
|
|
||||||
|
expect(updatedSummaryCounts).to.eql({
|
||||||
|
copied: 2,
|
||||||
|
skipped: 0,
|
||||||
|
overwrite: 1,
|
||||||
|
errors: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PageObjects.copySavedObjectsToSpace.finishCopy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ export default function spacesApp({ loadTestFile }: FtrProviderContext) {
|
||||||
describe('Spaces app', function spacesAppTestSuite() {
|
describe('Spaces app', function spacesAppTestSuite() {
|
||||||
this.tags('ciGroup4');
|
this.tags('ciGroup4');
|
||||||
|
|
||||||
|
loadTestFile(require.resolve('./copy_saved_objects'));
|
||||||
loadTestFile(require.resolve('./feature_controls/spaces_security'));
|
loadTestFile(require.resolve('./feature_controls/spaces_security'));
|
||||||
loadTestFile(require.resolve('./spaces_selection'));
|
loadTestFile(require.resolve('./spaces_selection'));
|
||||||
});
|
});
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,333 @@
|
||||||
|
{
|
||||||
|
"type": "index",
|
||||||
|
"value": {
|
||||||
|
"index": ".kibana",
|
||||||
|
"mappings": {
|
||||||
|
"doc": {
|
||||||
|
"dynamic": "strict",
|
||||||
|
"properties": {
|
||||||
|
"migrationVersion": {
|
||||||
|
"dynamic": "true",
|
||||||
|
"properties": {
|
||||||
|
"index-pattern": {
|
||||||
|
"type": "text",
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"type": "keyword",
|
||||||
|
"ignore_above": 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"dynamic": "true",
|
||||||
|
"properties": {
|
||||||
|
"buildNum": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"dateFormat:tz": {
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"ignore_above": 256,
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"defaultIndex": {
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"ignore_above": 256,
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"notifications:lifetime:banner": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"notifications:lifetime:error": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"notifications:lifetime:info": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"notifications:lifetime:warning": {
|
||||||
|
"type": "long"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"optionsJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"panelsJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"refreshInterval": {
|
||||||
|
"properties": {
|
||||||
|
"display": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"pause": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeFrom": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"timeRestore": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"timeTo": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"uiStateJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"index-pattern": {
|
||||||
|
"properties": {
|
||||||
|
"fieldFormatMap": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"intervalName": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"notExpandable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sourceFilters": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"timeFieldName": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"properties": {
|
||||||
|
"columns": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timelion-sheet": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timelion_chart_height": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timelion_columns": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timelion_interval": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"timelion_other_interval": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"timelion_rows": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"timelion_sheet": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"properties": {
|
||||||
|
"accessCount": {
|
||||||
|
"type": "long"
|
||||||
|
},
|
||||||
|
"accessDate": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"createDate": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"ignore_above": 2048,
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualization": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"kibanaSavedObjectMeta": {
|
||||||
|
"properties": {
|
||||||
|
"searchSourceJSON": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"savedSearchId": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"uiStateJSON": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"visState": {
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"namespace": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"space": {
|
||||||
|
"properties": {
|
||||||
|
"_reserved": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"disabledFeatures": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"initials": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"ignore_above": 2048,
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"type": "nested",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"index": {
|
||||||
|
"auto_expand_replicas": "0-1",
|
||||||
|
"number_of_replicas": "0",
|
||||||
|
"number_of_shards": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* 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 { FtrProviderContext } from '../ftr_provider_context';
|
||||||
|
|
||||||
|
function extractCountFromSummary(str: string) {
|
||||||
|
return parseInt(str.split('\n')[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopySavedObjectsToSpacePageProvider({ getService }: FtrProviderContext) {
|
||||||
|
const testSubjects = getService('testSubjects');
|
||||||
|
const browser = getService('browser');
|
||||||
|
const find = getService('find');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async searchForObject(objectName: string) {
|
||||||
|
const searchBox = await testSubjects.find('savedObjectSearchBar');
|
||||||
|
await searchBox.clearValue();
|
||||||
|
await searchBox.type(objectName);
|
||||||
|
await searchBox.pressKeys(browser.keys.ENTER);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openCopyToSpaceFlyoutForObject(objectName: string) {
|
||||||
|
await this.searchForObject(objectName);
|
||||||
|
|
||||||
|
// Click action button to show context menu
|
||||||
|
await find.clickByCssSelector(
|
||||||
|
'table.euiTable tbody tr.euiTableRow td.euiTableRowCell:last-child .euiButtonIcon'
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions = await find.allByCssSelector('.euiContextMenuItem');
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
const actionText = await action.getVisibleText();
|
||||||
|
if (actionText === 'Copy to space') {
|
||||||
|
await action.click();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await testSubjects.existOrFail('copy-to-space-flyout');
|
||||||
|
},
|
||||||
|
|
||||||
|
async setupForm({
|
||||||
|
overwrite,
|
||||||
|
destinationSpaceId,
|
||||||
|
}: {
|
||||||
|
overwrite?: boolean;
|
||||||
|
destinationSpaceId: string;
|
||||||
|
}) {
|
||||||
|
if (!overwrite) {
|
||||||
|
await testSubjects.click('cts-form-overwrite');
|
||||||
|
}
|
||||||
|
await testSubjects.click(`cts-space-selector-row-${destinationSpaceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCopy() {
|
||||||
|
await testSubjects.click('cts-initiate-button');
|
||||||
|
},
|
||||||
|
|
||||||
|
async finishCopy() {
|
||||||
|
await testSubjects.click('cts-finish-button');
|
||||||
|
await testSubjects.waitForDeleted('copy-to-space-flyout');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSummaryCounts(includeOverwrite: boolean = false) {
|
||||||
|
const copied = extractCountFromSummary(
|
||||||
|
await testSubjects.getVisibleText('cts-summary-success-count')
|
||||||
|
);
|
||||||
|
const skipped = extractCountFromSummary(
|
||||||
|
await testSubjects.getVisibleText('cts-summary-conflict-count')
|
||||||
|
);
|
||||||
|
const errors = extractCountFromSummary(
|
||||||
|
await testSubjects.getVisibleText('cts-summary-error-count')
|
||||||
|
);
|
||||||
|
|
||||||
|
let overwrite;
|
||||||
|
if (includeOverwrite) {
|
||||||
|
overwrite = extractCountFromSummary(
|
||||||
|
await testSubjects.getVisibleText('cts-summary-overwrite-count')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await testSubjects.missingOrFail('cts-summary-overwrite-count', { timeout: 250 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
copied,
|
||||||
|
skipped,
|
||||||
|
errors,
|
||||||
|
overwrite,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -43,6 +43,7 @@ import { IndexLifecycleManagementPageProvider } from './index_lifecycle_manageme
|
||||||
import { SnapshotRestorePageProvider } from './snapshot_restore_page';
|
import { SnapshotRestorePageProvider } from './snapshot_restore_page';
|
||||||
import { CrossClusterReplicationPageProvider } from './cross_cluster_replication_page';
|
import { CrossClusterReplicationPageProvider } from './cross_cluster_replication_page';
|
||||||
import { RemoteClustersPageProvider } from './remote_clusters_page';
|
import { RemoteClustersPageProvider } from './remote_clusters_page';
|
||||||
|
import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page';
|
||||||
|
|
||||||
// just like services, PageObjects are defined as a map of
|
// just like services, PageObjects are defined as a map of
|
||||||
// names to Providers. Merge in Kibana's or pick specific ones
|
// names to Providers. Merge in Kibana's or pick specific ones
|
||||||
|
@ -72,4 +73,5 @@ export const pageObjects = {
|
||||||
snapshotRestore: SnapshotRestorePageProvider,
|
snapshotRestore: SnapshotRestorePageProvider,
|
||||||
crossClusterReplication: CrossClusterReplicationPageProvider,
|
crossClusterReplication: CrossClusterReplicationPageProvider,
|
||||||
remoteClusters: RemoteClustersPageProvider,
|
remoteClusters: RemoteClustersPageProvider,
|
||||||
|
copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider,
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue