Spaces - Copy Saved Objects to Spaces UI (#39002)

This commit is contained in:
Larry Gregory 2019-08-23 14:24:53 -04:00 committed by GitHub
parent c145a33b95
commit 6a9844c223
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 3117 additions and 29 deletions

View file

@ -50,9 +50,9 @@ import {
importLegacyFile,
resolveImportErrors,
logLegacyImport,
processImportResponse,
getDefaultTitle,
} from '../../../../lib';
import { processImportResponse } from '../../../../lib/process_import_response';
import {
resolveSavedObjects,
resolveSavedSearches,

View file

@ -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}

View file

@ -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,

View file

@ -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';

View file

@ -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());
}
}

View file

@ -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);
});
});

View file

@ -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()),
};

View file

@ -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`,

View 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';

View file

@ -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();
};
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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';

View file

@ -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,
}
`);
});
});

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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 = {

View file

@ -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`,

View file

@ -1,2 +1,3 @@
@import './components/confirm_delete_modal';
@import './edit_space/enabled_features/index';
@import './components/copy_saved_objects_to_space/index';

View file

@ -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}

View file

@ -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;
}

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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 { CopySavedObjectsToSpaceFlyout } from './copy_to_space_flyout';

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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>
);
};

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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}
/>

View file

@ -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);

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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';

View file

@ -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 {

View file

@ -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<

View file

@ -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) {

View 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();
});
});
}

View file

@ -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

View file

@ -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"
}
}
}
}

View file

@ -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,
};
},
};
}

View file

@ -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,
};