mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
parent
1d7c0d4cad
commit
85522c6a12
45 changed files with 3117 additions and 29 deletions
|
@ -50,9 +50,9 @@ import {
|
|||
importLegacyFile,
|
||||
resolveImportErrors,
|
||||
logLegacyImport,
|
||||
processImportResponse,
|
||||
getDefaultTitle,
|
||||
} from '../../../../lib';
|
||||
import { processImportResponse } from '../../../../lib/process_import_response';
|
||||
import {
|
||||
resolveSavedObjects,
|
||||
resolveSavedSearches,
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
*/
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
import { SavedObjectsManagementActionRegistry } from 'ui/management/saved_objects_management';
|
||||
import React, { PureComponent, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
@ -73,6 +74,12 @@ class TableUI extends PureComponent {
|
|||
parseErrorMessage: null,
|
||||
isExportPopoverOpen: false,
|
||||
isIncludeReferencesDeepChecked: true,
|
||||
activeAction: null,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.extraActions = SavedObjectsManagementActionRegistry.get();
|
||||
}
|
||||
|
||||
onChange = ({ query, error }) => {
|
||||
|
@ -238,6 +245,24 @@ class TableUI extends PureComponent {
|
|||
icon: 'kqlSelector',
|
||||
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>
|
||||
);
|
||||
|
||||
const activeActionContents = this.state.activeAction ? this.state.activeAction.render() : null;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{activeActionContents}
|
||||
<EuiSearchBar
|
||||
box={{ 'data-test-subj': 'savedObjectSearchBar' }}
|
||||
filters={filters}
|
||||
|
|
|
@ -17,7 +17,38 @@
|
|||
* 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
|
||||
const failedImports = [];
|
||||
const unmatchedReferences = new Map();
|
||||
|
@ -29,7 +60,9 @@ export function processImportResponse(response) {
|
|||
// Currently only supports resolving references on index patterns
|
||||
const indexPatternRefs = error.references.filter(ref => ref.type === 'index-pattern');
|
||||
for (const missingReference of indexPatternRefs) {
|
||||
const conflict = unmatchedReferences.get(`${missingReference.type}:${missingReference.id}`) || {
|
||||
const conflict = unmatchedReferences.get(
|
||||
`${missingReference.type}:${missingReference.id}`
|
||||
) || {
|
||||
existingIndexPatternId: missingReference.id,
|
||||
list: [],
|
||||
newIndexPatternId: undefined,
|
||||
|
@ -44,9 +77,11 @@ export function processImportResponse(response) {
|
|||
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
|
||||
// returned errors of type missing_references.
|
||||
status: unmatchedReferences.size === 0 && !failedImports.some(issue => issue.error.type === 'conflict')
|
||||
? 'success'
|
||||
: 'idle',
|
||||
status:
|
||||
unmatchedReferences.size === 0 &&
|
||||
!failedImports.some(issue => issue.error.type === 'conflict')
|
||||
? 'success'
|
||||
: 'idle',
|
||||
importCount: response.successCount,
|
||||
conflictedSavedObjectsLinkedToSavedSearches: 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,
|
||||
'\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`,
|
||||
'^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`,
|
||||
'^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`,
|
||||
},
|
||||
setupFiles: [
|
||||
`${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.
|
||||
*/
|
||||
|
||||
import { SpacesManager } from './spaces_manager';
|
||||
|
||||
function createSpacesManagerMock() {
|
||||
return ({
|
||||
return {
|
||||
getSpaces: jest.fn().mockResolvedValue([]),
|
||||
getSpace: jest.fn().mockResolvedValue(undefined),
|
||||
createSpace: jest.fn().mockResolvedValue(undefined),
|
||||
updateSpace: jest.fn().mockResolvedValue(undefined),
|
||||
deleteSpace: jest.fn().mockResolvedValue(undefined),
|
||||
copySavedObjects: jest.fn().mockResolvedValue(undefined),
|
||||
resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined),
|
||||
redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined),
|
||||
requestRefresh: jest.fn(),
|
||||
on: jest.fn(),
|
||||
} as unknown) as SpacesManager;
|
||||
};
|
||||
}
|
||||
|
||||
export const spacesManagerMock = {
|
||||
|
|
|
@ -7,7 +7,10 @@ import { i18n } from '@kbn/i18n';
|
|||
import { toastNotifications } from 'ui/notify';
|
||||
import { EventEmitter } from 'events';
|
||||
import { kfetch } from 'ui/kfetch';
|
||||
import { SavedObjectsManagementRecord } from 'ui/management/saved_objects_management';
|
||||
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 {
|
||||
private spaceSelectorURL: string;
|
||||
|
@ -17,8 +20,8 @@ export class SpacesManager extends EventEmitter {
|
|||
this.spaceSelectorURL = spaceSelectorURL;
|
||||
}
|
||||
|
||||
public async getSpaces(): Promise<Space[]> {
|
||||
return await kfetch({ pathname: '/api/spaces/space' });
|
||||
public async getSpaces(purpose?: GetSpacePurpose): Promise<Space[]> {
|
||||
return await kfetch({ pathname: '/api/spaces/space', query: { purpose } });
|
||||
}
|
||||
|
||||
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) {
|
||||
await kfetch({
|
||||
pathname: `/api/spaces/v1/space/${encodeURIComponent(space.id)}/select`,
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
@import './components/confirm_delete_modal';
|
||||
@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 { ConfirmDeleteModal } from './confirm_delete_modal';
|
||||
import { spacesManagerMock } from '../../../lib/mocks';
|
||||
import { SpacesManager } from '../../../lib';
|
||||
|
||||
describe('ConfirmDeleteModal', () => {
|
||||
it('renders as expected', () => {
|
||||
|
@ -32,7 +33,7 @@ describe('ConfirmDeleteModal', () => {
|
|||
shallowWithIntl(
|
||||
<ConfirmDeleteModal.WrappedComponent
|
||||
space={space}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
|
@ -62,7 +63,7 @@ describe('ConfirmDeleteModal', () => {
|
|||
const wrapper = mountWithIntl(
|
||||
<ConfirmDeleteModal.WrappedComponent
|
||||
space={space}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
onCancel={onCancel}
|
||||
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 { DeleteSpacesButton } from './delete_spaces_button';
|
||||
import { spacesManagerMock } from '../../../lib/mocks';
|
||||
import { SpacesManager } from '../../../lib';
|
||||
|
||||
const space = {
|
||||
id: 'my-space',
|
||||
|
@ -28,7 +29,7 @@ describe('DeleteSpacesButton', () => {
|
|||
const wrapper = shallowWithIntl(
|
||||
<DeleteSpacesButton.WrappedComponent
|
||||
space={space}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
onDelete={jest.fn()}
|
||||
intl={null as any}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal
|
|||
import { ManageSpacePage } from './manage_space_page';
|
||||
import { SectionPanel } from './section_panel';
|
||||
import { spacesManagerMock } from '../../../lib/mocks';
|
||||
import { SpacesManager } from '../../../lib';
|
||||
|
||||
const space = {
|
||||
id: 'my-space',
|
||||
|
@ -35,7 +36,7 @@ describe('ManageSpacePage', () => {
|
|||
|
||||
const wrapper = mountWithIntl(
|
||||
<ManageSpacePage.WrappedComponent
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
intl={null as any}
|
||||
/>
|
||||
|
@ -81,7 +82,7 @@ describe('ManageSpacePage', () => {
|
|||
const wrapper = mountWithIntl(
|
||||
<ManageSpacePage.WrappedComponent
|
||||
spaceId={'existing-space'}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
intl={null as any}
|
||||
/>
|
||||
|
@ -127,7 +128,7 @@ describe('ManageSpacePage', () => {
|
|||
const wrapper = mountWithIntl(
|
||||
<ManageSpacePage.WrappedComponent
|
||||
spaceId={'my-space'}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
intl={null as any}
|
||||
/>
|
||||
|
@ -182,7 +183,7 @@ describe('ManageSpacePage', () => {
|
|||
const wrapper = mountWithIntl(
|
||||
<ManageSpacePage.WrappedComponent
|
||||
spaceId={'my-space'}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
intl={null as any}
|
||||
/>
|
||||
|
|
|
@ -11,18 +11,20 @@ import {
|
|||
PAGE_SUBTITLE_COMPONENT,
|
||||
PAGE_TITLE_COMPONENT,
|
||||
registerSettingsComponent,
|
||||
// @ts-ignore
|
||||
} from 'ui/management';
|
||||
import { SavedObjectsManagementActionRegistry } from 'ui/management/saved_objects_management';
|
||||
// @ts-ignore
|
||||
import routes from 'ui/routes';
|
||||
import { SpacesManager } from '../../lib';
|
||||
import { AdvancedSettingsSubtitle } from './components/advanced_settings_subtitle';
|
||||
import { AdvancedSettingsTitle } from './components/advanced_settings_title';
|
||||
import { CopyToSpaceSavedObjectsManagementAction } from '../../lib/copy_saved_objects_to_space';
|
||||
|
||||
const MANAGE_SPACES_KEY = 'spaces';
|
||||
|
||||
routes.defaults(/\/management/, {
|
||||
resolve: {
|
||||
spacesManagementSection(activeSpace: any) {
|
||||
spacesManagementSection(activeSpace: any, spaceSelectorURL: string) {
|
||||
function getKibanaSection() {
|
||||
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} />;
|
||||
registerSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle, true);
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import React from 'react';
|
|||
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { SpaceAvatar } from '../../../components';
|
||||
import { spacesManagerMock } from '../../../lib/mocks';
|
||||
import { SpacesManager } from '../../../lib';
|
||||
import { SpacesNavState } from '../../nav_control';
|
||||
import { SpacesGridPage } from './spaces_grid_page';
|
||||
|
||||
|
@ -49,7 +50,7 @@ describe('SpacesGridPage', () => {
|
|||
expect(
|
||||
shallowWithIntl(
|
||||
<SpacesGridPage.WrappedComponent
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
intl={null as any}
|
||||
/>
|
||||
|
@ -60,7 +61,7 @@ describe('SpacesGridPage', () => {
|
|||
it('renders the list of spaces', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<SpacesGridPage.WrappedComponent
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
spacesNavState={spacesNavState}
|
||||
intl={null as any}
|
||||
/>
|
||||
|
|
|
@ -8,6 +8,7 @@ import { mount, shallow } from 'enzyme';
|
|||
import React from 'react';
|
||||
import { SpaceAvatar } from '../../components';
|
||||
import { spacesManagerMock } from '../../lib/mocks';
|
||||
import { SpacesManager } from '../../lib';
|
||||
import { SpacesHeaderNavButton } from './components/spaces_header_nav_button';
|
||||
import { NavControlPopover } from './nav_control_popover';
|
||||
|
||||
|
@ -23,7 +24,7 @@ describe('NavControlPopover', () => {
|
|||
const wrapper = shallow(
|
||||
<NavControlPopover
|
||||
activeSpace={activeSpace}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
anchorPosition={'downRight'}
|
||||
buttonClass={SpacesHeaderNavButton}
|
||||
/>
|
||||
|
@ -54,7 +55,7 @@ describe('NavControlPopover', () => {
|
|||
const wrapper = mount<any, any>(
|
||||
<NavControlPopover
|
||||
activeSpace={activeSpace}
|
||||
spacesManager={spacesManager}
|
||||
spacesManager={(spacesManager as unknown) as SpacesManager}
|
||||
anchorPosition={'rightCenter'}
|
||||
buttonClass={SpacesHeaderNavButton}
|
||||
/>
|
||||
|
|
|
@ -4,4 +4,4 @@
|
|||
* 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.
|
||||
*/
|
||||
|
||||
import { SpacesClient, GetSpacePurpose } from './spaces_client';
|
||||
import { SpacesClient } from './spaces_client';
|
||||
import { AuthorizationService } from '../../../../security/server/lib/authorization/service';
|
||||
import { actionsFactory } from '../../../../security/server/lib/authorization/actions';
|
||||
import { SpacesConfigType, config } from '../../new_platform/config';
|
||||
import { GetSpacePurpose } from '../../../common/model/types';
|
||||
|
||||
const createMockAuditLogger = () => {
|
||||
return {
|
||||
|
|
|
@ -12,10 +12,10 @@ import { isReservedSpace } from '../../../common/is_reserved_space';
|
|||
import { Space } from '../../../common/model/space';
|
||||
import { SpacesAuditLogger } from '../audit_logger';
|
||||
import { SpacesConfigType } from '../../new_platform/config';
|
||||
import { GetSpacePurpose } from '../../../common/model/types';
|
||||
|
||||
type SpacesClientRequestFacade = Legacy.Request | KibanaRequest;
|
||||
|
||||
export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace';
|
||||
const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace'];
|
||||
|
||||
const PURPOSE_PRIVILEGE_MAP: Record<
|
||||
|
|
|
@ -7,9 +7,10 @@
|
|||
import Boom from 'boom';
|
||||
import Joi from 'joi';
|
||||
import { RequestQuery } from 'hapi';
|
||||
import { GetSpacePurpose } from '../../../../common/model/types';
|
||||
import { Space } from '../../../../common/model/space';
|
||||
import { wrapError } from '../../../lib/errors';
|
||||
import { SpacesClient, GetSpacePurpose } from '../../../lib/spaces_client';
|
||||
import { SpacesClient } from '../../../lib/spaces_client';
|
||||
import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.';
|
||||
|
||||
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() {
|
||||
this.tags('ciGroup4');
|
||||
|
||||
loadTestFile(require.resolve('./copy_saved_objects'));
|
||||
loadTestFile(require.resolve('./feature_controls/spaces_security'));
|
||||
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 { CrossClusterReplicationPageProvider } from './cross_cluster_replication_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
|
||||
// names to Providers. Merge in Kibana's or pick specific ones
|
||||
|
@ -72,4 +73,5 @@ export const pageObjects = {
|
|||
snapshotRestore: SnapshotRestorePageProvider,
|
||||
crossClusterReplication: CrossClusterReplicationPageProvider,
|
||||
remoteClusters: RemoteClustersPageProvider,
|
||||
copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue