Allow any type of saved object to import / export (#34896) (#36147)

* Modify the relationships API and UI

* Remove type validation on export

* Update relationship test snapshots

* Change relationships table titles

* Change relationships UI to share one table

* Add server side logic to inject meta data into saved objects from plugins

* Manually enable each type of saved object to support

* Use injected vars to determine what types are import / exportable

* Fix some broken tests

* Remove unused translations

* Fix relationships mocha tests

* Remove tests that ensured types are restricted, functionality removed

* Move kfetch logic into separate file

* Add inAppUrl to missing types

* Add tooltip to management table titles that aren't links

* Make relationships screen support filtering by type

* Fix failing tests

* Add refresh support for inAppUrls

* Add error notifications when export API call fails

* Add relationship direction

* Fix broken tests

* Remove graph workspace from import / export

* Use parent / child terminology for relationships

* Use direct relationship terminology

* Flip view / edit logic in saved object management app

* Make config saved object redirect to advanced settings

* Fix broken tests

* Remove unused translations

* Code cleanup

* Add tests

* Add fallback overwrite confirmation object title

* Enforce supported types on import, export and resolve import errors

* Fix broken tests

* Fix broken tests pt2

* Fix broken tests pt3

* Test cleanup

* Use server.decorate to access savedobjectschemas

* Fix some broken tests

* Fix broken tests, add new title to relationships screen

* Fix some broken tests

* Handle dynamic versions

* Fix inAppUrl structure in tests

* Re-use generic canGoInApp

* Fix broken tests

* Apply maps PR feedback

* Apply PR feedback pt1

* Apply PR feedback pt2

* Add savedObjectsManagement to uiExports

* Fix broken tests

* Fix encodeURIComponent implementation

* Merge 403 and unsupported type errors into single error

* Apply suggestion

* Remove import / exportable by default, opt-in instead

* Fix type config to show up properly in the table

* Change config type title and fix tests

* Remove isImportableAndExportable where set to false (new default)

* Remove comments referencing to authorization

* Add unit tests for spaces

* Add unit tests for security plugin

* Change can* signature to be the same as their equivalent function, apply PR feedback

* Cleanup git diff

* Revert "Change can* signature to be the same as their equivalent function, apply PR feedback"

This reverts commit b657ac8fc1.

* Revert "Add unit tests for security plugin"

This reverts commit 6287a8cecf.

* Revert "Add unit tests for spaces"

This reverts commit 2674a9d78f.

* Revert "Remove comments referencing to authorization"

This reverts commit 9618c2cc3a.

* Revert "Merge 403 and unsupported type errors into single error"

This reverts commit 99aea10c0f.

* Add CUSTOM_ELEMENT_TYPE for import / export

* Fix broken tests

* Fix broken tests pt2

* Prevent crashing app when inAppUrl is undefined
This commit is contained in:
Mike Côté 2019-05-06 20:43:01 -04:00 committed by GitHub
parent ee084c73e2
commit 5a5e0f170a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 3414 additions and 1170 deletions

View file

@ -128,11 +128,101 @@ export default function (kibana) {
},
],
savedObjectsManagement: {
'index-pattern': {
icon: 'indexPatternApp',
defaultSearchField: 'title',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/index_patterns/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/management/kibana/index_patterns/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'management.kibana.index_patterns',
};
},
},
visualization: {
icon: 'visualizeApp',
defaultSearchField: 'title',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/objects/savedVisualizations/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/visualize/edit/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'visualize.show',
};
},
},
search: {
icon: 'search',
defaultSearchField: 'title',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/objects/savedSearches/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/discover/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'discover.show',
};
},
},
dashboard: {
icon: 'dashboardApp',
defaultSearchField: 'title',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/objects/savedDashboards/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/dashboard/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'dashboard.show',
};
},
},
url: {
defaultSearchField: 'url',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.url;
},
},
config: {
isImportableAndExportable: true,
getInAppUrl() {
return {
path: `/app/kibana#/management/kibana/settings`,
uiCapabilitiesPath: 'advancedSettings.show',
};
},
getTitle(obj) {
return `Advanced Settings [${obj.id}]`;
},
},
},
savedObjectSchemas: {
'kql-telemetry': {
'sample-data-telemetry': {
isNamespaceAgnostic: true,
},
'sample-data-telemetry': {
'kql-telemetry': {
isNamespaceAgnostic: true,
},
},
@ -169,6 +259,7 @@ export default function (kibana) {
index_patterns: true,
},
advancedSettings: {
show: true,
save: true
},
indexPatterns: {

View file

@ -26,11 +26,17 @@ export function injectVars(server) {
// If url is set, old settings must be used for backward compatibility
const isOverridden = typeof tilemap.url === 'string' && tilemap.url !== '';
// Get types that are import and exportable, by default yes unless isImportableAndExportable is set to false
const { types: allTypes } = server.savedObjects;
const savedObjectsManagement = server.getSavedObjectsManagement();
const importAndExportableTypes = allTypes.filter(type => savedObjectsManagement.isImportAndExportable(type));
return {
kbnDefaultAppId: serverConfig.get('kibana.defaultAppId'),
disableWelcomeScreen: serverConfig.get('kibana.disableWelcomeScreen'),
regionmapsConfig: regionmap,
mapConfig: mapConfig,
importAndExportableTypes,
tilemapsConfig: {
deprecated: {
isOverridden: isOverridden,

View file

@ -21,20 +21,19 @@ import { savedObjectManagementRegistry } from '../../saved_object_registry';
import objectIndexHTML from './_objects.html';
import uiRoutes from 'ui/routes';
import chrome from 'ui/chrome';
import { toastNotifications } from 'ui/notify';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
import { uiModules } from 'ui/modules';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { ObjectsTable } from './components/objects_table';
import { canViewInApp, getInAppUrl } from './lib/in_app_url';
import { I18nContext } from 'ui/i18n';
import { get } from 'lodash';
import { getIndexBreadcrumbs } from './breadcrumbs';
const REACT_OBJECTS_TABLE_DOM_ELEMENT_ID = 'reactSavedObjectsTable';
function updateObjectsTable($scope, $injector, i18n) {
function updateObjectsTable($scope, $injector) {
const Private = $injector.get('Private');
const indexPatterns = $injector.get('indexPatterns');
const $http = $injector.get('$http');
@ -44,10 +43,6 @@ function updateObjectsTable($scope, $injector, i18n) {
const savedObjectsClient = Private(SavedObjectsClientProvider);
const services = savedObjectManagementRegistry.all().map(obj => $injector.get(obj.service));
const allServices = savedObjectManagementRegistry.all();
const typeToServiceName = type => allServices.reduce((serviceName, service) => {
return service.title.includes(type) ? service.service : serviceName;
}, null);
$scope.$$postDigest(() => {
const node = document.getElementById(REACT_OBJECTS_TABLE_DOM_ELEMENT_ID);
@ -66,27 +61,15 @@ function updateObjectsTable($scope, $injector, i18n) {
basePath={chrome.getBasePath()}
newIndexPatternUrl={kbnUrl.eval('#/management/kibana/index_pattern')}
uiCapabilities={uiCapabilites}
getEditUrl={(id, type) => {
if (type === 'index-pattern' || type === 'indexPatterns') {
return kbnUrl.eval(`#/management/kibana/index_patterns/${id}`);
goInspectObject={object => {
if (object.meta.editUrl) {
kbnUrl.change(object.meta.editUrl);
$scope.$apply();
}
const serviceName = typeToServiceName(type);
if (!serviceName) {
toastNotifications.addWarning(i18n('kbn.management.objects.unknownSavedObjectTypeNotificationMessage', {
defaultMessage: 'Unknown saved object type: {type}',
values: { type }
}));
return null;
}
return kbnUrl.eval(`#/management/kibana/objects/${serviceName}/${id}`);
}}
canGoInApp={(type) => {
return canViewInApp(uiCapabilites, type);
}}
goInApp={(id, type) => {
kbnUrl.change(getInAppUrl(id, type));
$scope.$apply();
canGoInApp={object => {
const { inAppUrl } = object.meta;
return inAppUrl && get(uiCapabilites, inAppUrl.uiCapabilitiesPath);
}}
/>
</I18nContext>,
@ -114,8 +97,8 @@ uiModules.get('apps/management')
return {
restrict: 'E',
controllerAs: 'managementObjectsController',
controller: function ($scope, $injector, i18n) {
updateObjectsTable($scope, $injector, i18n);
controller: function ($scope, $injector) {
updateObjectsTable($scope, $injector);
$scope.$on('$destroy', destroyObjectsTable);
}
};

View file

@ -49,7 +49,7 @@ exports[`ObjectsTable delete should show a confirm modal 1`] = `
"name": "Id",
},
Object {
"field": "title",
"field": "meta.title",
"name": "Title",
},
]
@ -220,14 +220,24 @@ exports[`ObjectsTable import should show the flyout 1`] = `
exports[`ObjectsTable relationships should show the flyout 1`] = `
<InjectIntl(RelationshipsUI)
canGoInApp={[Function]}
close={[Function]}
getEditUrl={[Function]}
getRelationships={[Function]}
goInApp={[Function]}
id="1"
title="MySearch"
type="search"
goInspectObject={[Function]}
savedObject={
Object {
"id": "2",
"meta": Object {
"editUrl": "#/management/kibana/objects/savedSearches/2",
"icon": "search",
"inAppUrl": Object {
"path": "/discover/2",
"uiCapabilitiesPath": "discover.show",
},
"title": "MySearch",
},
"type": "search",
}
}
/>
`;
@ -247,7 +257,6 @@ exports[`ObjectsTable should render normally 1`] = `
/>
<InjectIntl(TableUI)
canDeleteSavedObjectTypes={Array []}
canGoInApp={[Function]}
filterOptions={
Array [
Object {
@ -272,34 +281,61 @@ exports[`ObjectsTable should render normally 1`] = `
},
]
}
getEditUrl={[Function]}
goInApp={[Function]}
goInspectObject={[Function]}
isSearching={false}
itemId="id"
items={
Array [
Object {
"icon": "indexPatternApp",
"id": "1",
"title": "MyIndexPattern*",
"meta": Object {
"editUrl": "#/management/kibana/index_patterns/1",
"icon": "indexPatternApp",
"inAppUrl": Object {
"path": "/management/kibana/index_patterns/1",
"uiCapabilitiesPath": "management.kibana.index_patterns",
},
"title": "MyIndexPattern*",
},
"type": "index-pattern",
},
Object {
"icon": "search",
"id": "2",
"title": "MySearch",
"meta": Object {
"editUrl": "#/management/kibana/objects/savedSearches/2",
"icon": "search",
"inAppUrl": Object {
"path": "/discover/2",
"uiCapabilitiesPath": "discover.show",
},
"title": "MySearch",
},
"type": "search",
},
Object {
"icon": "dashboardApp",
"id": "3",
"title": "MyDashboard",
"meta": Object {
"editUrl": "#/management/kibana/objects/savedDashboards/3",
"icon": "dashboardApp",
"inAppUrl": Object {
"path": "/dashboard/3",
"uiCapabilitiesPath": "dashboard.show",
},
"title": "MyDashboard",
},
"type": "dashboard",
},
Object {
"icon": "visualizeApp",
"id": "4",
"title": "MyViz",
"meta": Object {
"editUrl": "#/management/kibana/objects/savedVisualizations/4",
"icon": "visualizeApp",
"inAppUrl": Object {
"path": "/visualize/edit/4",
"uiCapabilitiesPath": "visualize.show",
},
"title": "MyViz",
},
"type": "visualization",
},
]

View file

@ -23,9 +23,14 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { ObjectsTable, POSSIBLE_TYPES } from '../objects_table';
import { Flyout } from '../components/flyout/';
import { Relationships } from '../components/relationships/';
import { findObjects } from '../../../lib';
jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() }));
jest.mock('../../../lib/find_objects', () => ({
findObjects: jest.fn(),
}));
jest.mock('../components/header', () => ({
Header: () => 'Header',
}));
@ -44,7 +49,8 @@ jest.mock('ui/errors', () => ({
}));
jest.mock('ui/chrome', () => ({
addBasePath: () => ''
addBasePath: () => '',
getInjected: () => ['index-pattern', 'visualization', 'dashboard', 'search'],
}));
jest.mock('../../../lib/fetch_export_objects', () => ({
@ -110,29 +116,10 @@ const allSavedObjects = [
const $http = () => {};
$http.post = jest.fn().mockImplementation(() => ([]));
const defaultProps = {
goInspectObject: () => {},
savedObjectsClient: {
find: jest.fn().mockImplementation(({ type }) => {
// We pass in a single type when fetching counts
if (type && !Array.isArray(type)) {
return {
total: 1,
savedObjects: [
{
id: '1',
type,
attributes: {
title: `Title${type}`
}
},
]
};
}
return {
total: allSavedObjects.length,
savedObjects: allSavedObjects,
};
}),
find: jest.fn(),
bulkGet: jest.fn(),
},
indexPatterns: {
cache: {
@ -144,9 +131,6 @@ const defaultProps = {
newIndexPatternUrl: '',
kbnIndex: '',
services: [],
getEditUrl: () => {},
canGoInApp: () => {},
goInApp: () => {},
uiCapabilities: {
savedObjectsManagement: {
'index-pattern': {
@ -171,6 +155,66 @@ const defaultProps = {
]
};
beforeEach(() => {
findObjects.mockImplementation(() => ({
total: 4,
savedObjects: [
{
id: '1',
type: 'index-pattern',
meta: {
title: `MyIndexPattern*`,
icon: 'indexPatternApp',
editUrl: '#/management/kibana/index_patterns/1',
inAppUrl: {
path: '/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
},
{
id: '2',
type: 'search',
meta: {
title: `MySearch`,
icon: 'search',
editUrl: '#/management/kibana/objects/savedSearches/2',
inAppUrl: {
path: '/discover/2',
uiCapabilitiesPath: 'discover.show',
},
},
},
{
id: '3',
type: 'dashboard',
meta: {
title: `MyDashboard`,
icon: 'dashboardApp',
editUrl: '#/management/kibana/objects/savedDashboards/3',
inAppUrl: {
path: '/dashboard/3',
uiCapabilitiesPath: 'dashboard.show',
},
},
},
{
id: '4',
type: 'visualization',
meta: {
title: `MyViz`,
icon: 'visualizeApp',
editUrl: '#/management/kibana/objects/savedVisualizations/4',
inAppUrl: {
path: '/visualize/edit/4',
uiCapabilitiesPath: 'visualize.show',
},
},
},
],
}));
});
let addDangerMock;
let addSuccessMock;
@ -209,15 +253,12 @@ describe('ObjectsTable', () => {
});
it('should add danger toast when find fails', async () => {
const savedObjectsClientWithFindError = {
find: () => {
throw new Error('Simulated find error');
}
};
const customizedProps = { ...defaultProps, savedObjectsClient: savedObjectsClientWithFindError };
findObjects.mockImplementation(() => {
throw new Error('Simulated find error');
});
const component = shallowWithIntl(
<ObjectsTable.WrappedComponent
{...customizedProps}
{...defaultProps}
perPageConfig={15}
/>
);
@ -260,7 +301,7 @@ describe('ObjectsTable', () => {
// Ensure the state changes are reflected
component.update();
expect(defaultProps.savedObjectsClient.find).toHaveBeenCalledWith(expect.objectContaining({
expect(findObjects).toHaveBeenCalledWith(expect.objectContaining({
type: ['search']
}));
});
@ -458,13 +499,35 @@ describe('ObjectsTable', () => {
// Ensure the state changes are reflected
component.update();
component.instance().onShowRelationships('1', 'search', 'MySearch');
component.instance().onShowRelationships({
id: '2',
type: 'search',
meta: {
title: `MySearch`,
icon: 'search',
editUrl: '#/management/kibana/objects/savedSearches/2',
inAppUrl: {
path: '/discover/2',
uiCapabilitiesPath: 'discover.show',
},
},
});
component.update();
expect(component.find(Relationships)).toMatchSnapshot();
expect(component.state('relationshipId')).toBe('1');
expect(component.state('relationshipType')).toBe('search');
expect(component.state('relationshipTitle')).toBe('MySearch');
expect(component.state('relationshipObject')).toEqual({
id: '2',
type: 'search',
meta: {
title: 'MySearch',
editUrl: '#/management/kibana/objects/savedSearches/2',
icon: 'search',
inAppUrl: {
path: '/discover/2',
uiCapabilitiesPath: 'discover.show',
},
},
});
});
it('should hide the flyout', async () => {

View file

@ -273,6 +273,37 @@ exports[`Flyout conflicts should handle errors 1`] = `
</EuiCallOut>
`;
exports[`Flyout errors should display unsupported type errors properly 1`] = `
<EuiCallOut
color="warning"
iconType="help"
size="m"
title={
<FormattedMessage
defaultMessage="Import failed"
id="kbn.management.objects.objectsTable.flyout.importFailedTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Failed to import {failedImportCount} of {totalImportCount} objects. Import failed"
id="kbn.management.objects.objectsTable.flyout.importFailedDescription"
values={
Object {
"failedImportCount": 1,
"totalImportCount": 1,
}
}
/>
</p>
<p>
wigwags [id=1] unsupported type
</p>
</EuiCallOut>
`;
exports[`Flyout legacy conflicts should allow conflict resolution 1`] = `
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"

View file

@ -47,6 +47,7 @@ jest.mock('../../../../../lib/resolve_import_errors', () => ({
jest.mock('ui/chrome', () => ({
addBasePath: () => {},
getInjected: () => ['index-pattern', 'visualization', 'dashboard', 'search'],
}));
jest.mock('../../../../../lib/import_legacy_file', () => ({
@ -312,6 +313,75 @@ describe('Flyout', () => {
});
});
describe('errors', () => {
const { importFile } = require('../../../../../lib/import_file');
const { resolveImportErrors } = require('../../../../../lib/resolve_import_errors');
it('should display unsupported type errors properly', async () => {
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
await Promise.resolve();
// Ensure the state changes are reflected
component.update();
importFile.mockImplementation(() => ({
success: false,
successCount: 0,
errors: [
{
id: '1',
type: 'wigwags',
title: 'My Title',
error: {
type: 'unsupported_type',
}
},
],
}));
resolveImportErrors.mockImplementation(() => ({
status: 'success',
importCount: 0,
failedImports: [
{
error: {
type: 'unsupported_type',
},
obj: {
id: '1',
type: 'wigwags',
title: 'My Title',
},
},
],
}));
component.setState({ file: mockFile, isLegacyFile: false });
// Go through the import flow
await component.instance().import();
component.update();
// Ensure all promises resolve
await Promise.resolve();
expect(component.state('status')).toBe('success');
expect(component.state('failedImports')).toEqual([
{
error: {
type: 'unsupported_type',
},
obj: {
id: '1',
type: 'wigwags',
title: 'My Title',
},
},
]);
expect(component.find('EuiFlyout EuiCallOut')).toMatchSnapshot();
});
});
describe('legacy conflicts', () => {
const { importLegacyFile } = require('../../../../../lib/import_legacy_file');
const {

View file

@ -51,6 +51,7 @@ import {
resolveImportErrors,
logLegacyImport,
processImportResponse,
getDefaultTitle,
} from '../../../../lib';
import {
resolveSavedObjects,
@ -600,6 +601,17 @@ class FlyoutUI extends Component {
}
);
});
} else if (error.type === 'unsupported_type') {
return intl.formatMessage(
{
id: 'kbn.management.objects.objectsTable.flyout.importFailedUnsupportedType',
defaultMessage: '{type} [id={id}] unsupported type',
},
{
id: obj.id,
type: obj.type,
},
);
}
return getField(error, 'body.message', error.message || '');
}).join(' ')}
@ -887,7 +899,9 @@ class FlyoutUI extends Component {
<FormattedMessage
id="kbn.management.objects.objectsTable.flyout.confirmOverwriteBody"
defaultMessage="Are you sure you want to overwrite {title}?"
values={{ title: this.state.conflictingRecord.title }}
values={{
title: this.state.conflictingRecord.title || getDefaultTitle(this.state.conflictingRecord)
}}
/>
</p>
</EuiConfirmModal>

View file

@ -33,55 +33,53 @@ exports[`Relationships should render dashboards normally 1`] = `
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiDescriptionList>
<EuiDescriptionListTitle
style={
Object {
"marginBottom": "1rem",
}
}
<div>
<EuiCallOut
color="primary"
size="m"
>
<EuiCallOut
color="success"
size="m"
title={
<FormattedMessage
defaultMessage="Dashboard"
id="kbn.management.objects.objectsTable.relationships.dashboard.calloutTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Here are some visualizations used on this dashboard. You can safely delete this dashboard and the visualizations will still work properly."
id="kbn.management.objects.objectsTable.relationships.dashboard.calloutText"
values={Object {}}
/>
</p>
</EuiCallOut>
</EuiDescriptionListTitle>
<p>
Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children.
</p>
</EuiCallOut>
<EuiSpacer />
<EuiInMemoryTable
columns={
Array [
Object {
"align": "center",
"description": "Type of the saved object",
"field": "type",
"name": "Type",
"render": [Function],
"width": "24px",
"sortable": false,
"width": "50px",
},
Object {
"field": "title",
"dataType": "string",
"field": "relationship",
"name": "Direct relationship",
"render": [Function],
"sortable": false,
"width": "125px",
},
Object {
"dataType": "string",
"description": "Title of the saved object",
"field": "meta.title",
"name": "Title",
"render": [Function],
"sortable": false,
},
Object {
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"description": "Inspect this saved object",
"icon": "inspect",
"name": "Inspect",
"onClick": [Function],
"testId": "savedObjectsManagementRelationshipsViewInApp",
"type": "icon",
},
],
"name": "Actions",
@ -93,17 +91,76 @@ exports[`Relationships should render dashboards normally 1`] = `
Array [
Object {
"id": "1",
"meta": Object {
"editUrl": "/management/kibana/objects/savedVisualizations/1",
"icon": "visualizeApp",
"inAppUrl": Object {
"path": "/app/kibana#/visualize/edit/1",
"uiCapabilitiesPath": "visualize.show",
},
"title": "My Visualization Title 1",
},
"relationship": "child",
"type": "visualization",
},
Object {
"id": "2",
"meta": Object {
"editUrl": "/management/kibana/objects/savedVisualizations/2",
"icon": "visualizeApp",
"inAppUrl": Object {
"path": "/app/kibana#/visualize/edit/2",
"uiCapabilitiesPath": "visualize.show",
},
"title": "My Visualization Title 2",
},
"relationship": "child",
"type": "visualization",
},
]
}
pagination={true}
responsive={true}
search={
Object {
"filters": Array [
Object {
"field": "relationship",
"multiSelect": "or",
"name": "Direct relationship",
"options": Array [
Object {
"name": "parent",
"value": "parent",
"view": "Parent",
},
Object {
"name": "child",
"value": "child",
"view": "Child",
},
],
"type": "field_value_selection",
},
Object {
"field": "type",
"multiSelect": "or",
"name": "Type",
"options": Array [
Object {
"name": "visualization",
"value": "visualization",
"view": "visualization",
},
],
"type": "field_value_selection",
},
],
}
}
sorting={false}
/>
</EuiDescriptionList>
</div>
</EuiFlyoutBody>
</EuiFlyout>
`;
@ -191,49 +248,53 @@ exports[`Relationships should render index patterns normally 1`] = `
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiDescriptionList>
<EuiDescriptionListTitle
style={
Object {
"marginBottom": "1rem",
}
}
<div>
<EuiCallOut
color="primary"
size="m"
>
<EuiCallOut
color="warning"
size="m"
title={
<FormattedMessage
defaultMessage="Warning"
id="kbn.management.objects.objectsTable.relationships.warningTitle"
values={Object {}}
/>
}
>
<p />
</EuiCallOut>
</EuiDescriptionListTitle>
<p>
Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children.
</p>
</EuiCallOut>
<EuiSpacer />
<EuiInMemoryTable
columns={
Array [
Object {
"align": "center",
"description": "Type of the saved object",
"field": "type",
"name": "Type",
"render": [Function],
"width": "24px",
"sortable": false,
"width": "50px",
},
Object {
"field": "title",
"dataType": "string",
"field": "relationship",
"name": "Direct relationship",
"render": [Function],
"sortable": false,
"width": "125px",
},
Object {
"dataType": "string",
"description": "Title of the saved object",
"field": "meta.title",
"name": "Title",
"render": [Function],
"sortable": false,
},
Object {
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"description": "Inspect this saved object",
"icon": "inspect",
"name": "Inspect",
"onClick": [Function],
"testId": "savedObjectsManagementRelationshipsViewInApp",
"type": "icon",
},
],
"name": "Actions",
@ -245,74 +306,81 @@ exports[`Relationships should render index patterns normally 1`] = `
Array [
Object {
"id": "1",
},
]
}
pagination={true}
responsive={true}
sorting={false}
/>
<EuiDescriptionListTitle
style={
Object {
"marginBottom": "1rem",
}
}
>
<EuiCallOut
color="warning"
size="m"
title={
<FormattedMessage
defaultMessage="Warning"
id="kbn.management.objects.objectsTable.relationships.warningTitle"
values={Object {}}
/>
}
>
<p />
</EuiCallOut>
</EuiDescriptionListTitle>
<EuiInMemoryTable
columns={
Array [
Object {
"render": [Function],
"width": "24px",
},
Object {
"field": "title",
"name": "Title",
"render": [Function],
},
Object {
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"onClick": [Function],
"testId": "savedObjectsManagementRelationshipsViewInApp",
"meta": Object {
"editUrl": "/management/kibana/objects/savedSearches/1",
"icon": "search",
"inAppUrl": Object {
"path": "/app/kibana#/discover/1",
"uiCapabilitiesPath": "discover.show",
},
],
"name": "Actions",
"title": "My Search Title",
},
"relationship": "parent",
"type": "search",
},
]
}
executeQueryOptions={Object {}}
items={
Array [
Object {
"id": "2",
"meta": Object {
"editUrl": "/management/kibana/objects/savedVisualizations/2",
"icon": "visualizeApp",
"inAppUrl": Object {
"path": "/app/kibana#/visualize/edit/2",
"uiCapabilitiesPath": "visualize.show",
},
"title": "My Visualization Title",
},
"relationship": "parent",
"type": "visualization",
},
]
}
pagination={true}
responsive={true}
search={
Object {
"filters": Array [
Object {
"field": "relationship",
"multiSelect": "or",
"name": "Direct relationship",
"options": Array [
Object {
"name": "parent",
"value": "parent",
"view": "Parent",
},
Object {
"name": "child",
"value": "child",
"view": "Child",
},
],
"type": "field_value_selection",
},
Object {
"field": "type",
"multiSelect": "or",
"name": "Type",
"options": Array [
Object {
"name": "search",
"value": "search",
"view": "search",
},
Object {
"name": "visualization",
"value": "visualization",
"view": "visualization",
},
],
"type": "field_value_selection",
},
],
}
}
sorting={false}
/>
</EuiDescriptionList>
</div>
</EuiFlyoutBody>
</EuiFlyout>
`;
@ -350,55 +418,53 @@ exports[`Relationships should render searches normally 1`] = `
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiDescriptionList>
<EuiDescriptionListTitle
style={
Object {
"marginBottom": "1rem",
}
}
<div>
<EuiCallOut
color="primary"
size="m"
>
<EuiCallOut
color="success"
size="m"
title={
<FormattedMessage
defaultMessage="Saved Search"
id="kbn.management.objects.objectsTable.relationships.search.calloutTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Here is the index pattern tied to this saved search."
id="kbn.management.objects.objectsTable.relationships.search.calloutText"
values={Object {}}
/>
</p>
</EuiCallOut>
</EuiDescriptionListTitle>
<p>
Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children.
</p>
</EuiCallOut>
<EuiSpacer />
<EuiInMemoryTable
columns={
Array [
Object {
"align": "center",
"description": "Type of the saved object",
"field": "type",
"name": "Type",
"render": [Function],
"width": "24px",
"sortable": false,
"width": "50px",
},
Object {
"field": "title",
"dataType": "string",
"field": "relationship",
"name": "Direct relationship",
"render": [Function],
"sortable": false,
"width": "125px",
},
Object {
"dataType": "string",
"description": "Title of the saved object",
"field": "meta.title",
"name": "Title",
"render": [Function],
"sortable": false,
},
Object {
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"description": "Inspect this saved object",
"icon": "inspect",
"name": "Inspect",
"onClick": [Function],
"testId": "savedObjectsManagementRelationshipsViewInApp",
"type": "icon",
},
],
"name": "Actions",
@ -410,80 +476,81 @@ exports[`Relationships should render searches normally 1`] = `
Array [
Object {
"id": "1",
},
]
}
pagination={true}
responsive={true}
sorting={false}
/>
<EuiDescriptionListTitle
style={
Object {
"marginBottom": "1rem",
}
}
>
<EuiCallOut
color="warning"
size="m"
title={
<FormattedMessage
defaultMessage="Warning"
id="kbn.management.objects.objectsTable.relationships.warningTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Here are some visualizations that use this saved search. If you delete this saved search, these visualizations will not longer work properly."
id="kbn.management.objects.objectsTable.relationships.search.visualizations.calloutText"
values={Object {}}
/>
</p>
</EuiCallOut>
</EuiDescriptionListTitle>
<EuiInMemoryTable
columns={
Array [
Object {
"render": [Function],
"width": "24px",
},
Object {
"field": "title",
"name": "Title",
"render": [Function],
},
Object {
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"onClick": [Function],
"testId": "savedObjectsManagementRelationshipsViewInApp",
"meta": Object {
"editUrl": "/management/kibana/index_patterns/1",
"icon": "indexPatternApp",
"inAppUrl": Object {
"path": "/app/kibana#/management/kibana/index_patterns/1",
"uiCapabilitiesPath": "management.kibana.index_patterns",
},
],
"name": "Actions",
"title": "My Index Pattern",
},
"relationship": "child",
"type": "index-pattern",
},
]
}
executeQueryOptions={Object {}}
items={
Array [
Object {
"id": "2",
"meta": Object {
"editUrl": "/management/kibana/objects/savedVisualizations/2",
"icon": "visualizeApp",
"inAppUrl": Object {
"path": "/app/kibana#/visualize/edit/2",
"uiCapabilitiesPath": "visualize.show",
},
"title": "My Visualization Title",
},
"relationship": "parent",
"type": "visualization",
},
]
}
pagination={true}
responsive={true}
search={
Object {
"filters": Array [
Object {
"field": "relationship",
"multiSelect": "or",
"name": "Direct relationship",
"options": Array [
Object {
"name": "parent",
"value": "parent",
"view": "Parent",
},
Object {
"name": "child",
"value": "child",
"view": "Child",
},
],
"type": "field_value_selection",
},
Object {
"field": "type",
"multiSelect": "or",
"name": "Type",
"options": Array [
Object {
"name": "index-pattern",
"value": "index-pattern",
"view": "index-pattern",
},
Object {
"name": "visualization",
"value": "visualization",
"view": "visualization",
},
],
"type": "field_value_selection",
},
],
}
}
sorting={false}
/>
</EuiDescriptionList>
</div>
</EuiFlyoutBody>
</EuiFlyout>
`;
@ -521,55 +588,53 @@ exports[`Relationships should render visualizations normally 1`] = `
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiDescriptionList>
<EuiDescriptionListTitle
style={
Object {
"marginBottom": "1rem",
}
}
<div>
<EuiCallOut
color="primary"
size="m"
>
<EuiCallOut
color="warning"
size="m"
title={
<FormattedMessage
defaultMessage="Warning"
id="kbn.management.objects.objectsTable.relationships.warningTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Here are some dashboards which contain this visualization. If you delete this visualization, these dashboards will no longer show them."
id="kbn.management.objects.objectsTable.relationships.visualization.calloutText"
values={Object {}}
/>
</p>
</EuiCallOut>
</EuiDescriptionListTitle>
<p>
Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children.
</p>
</EuiCallOut>
<EuiSpacer />
<EuiInMemoryTable
columns={
Array [
Object {
"align": "center",
"description": "Type of the saved object",
"field": "type",
"name": "Type",
"render": [Function],
"width": "24px",
"sortable": false,
"width": "50px",
},
Object {
"field": "title",
"dataType": "string",
"field": "relationship",
"name": "Direct relationship",
"render": [Function],
"sortable": false,
"width": "125px",
},
Object {
"dataType": "string",
"description": "Title of the saved object",
"field": "meta.title",
"name": "Title",
"render": [Function],
"sortable": false,
},
Object {
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"description": "Inspect this saved object",
"icon": "inspect",
"name": "Inspect",
"onClick": [Function],
"testId": "savedObjectsManagementRelationshipsViewInApp",
"type": "icon",
},
],
"name": "Actions",
@ -581,17 +646,76 @@ exports[`Relationships should render visualizations normally 1`] = `
Array [
Object {
"id": "1",
"meta": Object {
"editUrl": "/management/kibana/objects/savedDashboards/1",
"icon": "dashboardApp",
"inAppUrl": Object {
"path": "/app/kibana#/dashboard/1",
"uiCapabilitiesPath": "dashboard.show",
},
"title": "My Dashboard 1",
},
"relationship": "parent",
"type": "dashboard",
},
Object {
"id": "2",
"meta": Object {
"editUrl": "/management/kibana/objects/savedDashboards/2",
"icon": "dashboardApp",
"inAppUrl": Object {
"path": "/app/kibana#/dashboard/2",
"uiCapabilitiesPath": "dashboard.show",
},
"title": "My Dashboard 2",
},
"relationship": "parent",
"type": "dashboard",
},
]
}
pagination={true}
responsive={true}
search={
Object {
"filters": Array [
Object {
"field": "relationship",
"multiSelect": "or",
"name": "Direct relationship",
"options": Array [
Object {
"name": "parent",
"value": "parent",
"view": "Parent",
},
Object {
"name": "child",
"value": "child",
"view": "Child",
},
],
"type": "field_value_selection",
},
Object {
"field": "type",
"multiSelect": "or",
"name": "Type",
"options": Array [
Object {
"name": "dashboard",
"value": "dashboard",
"view": "dashboard",
},
],
"type": "field_value_selection",
},
],
}
}
sorting={false}
/>
</EuiDescriptionList>
</div>
</EuiFlyoutBody>
</EuiFlyout>
`;

View file

@ -53,24 +53,50 @@ describe('Relationships', () => {
it('should render index patterns normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
searches: [
{
id: '1',
}
],
visualizations: [
{
id: '2',
}
],
})),
getEditUrl: () => '',
canGoInApp: () => true,
goInApp: jest.fn(),
id: '1',
type: 'index-pattern',
title: 'MyIndexPattern*',
goInspectObject: () => {},
getRelationships: jest.fn().mockImplementation(() => ([
{
type: 'search',
id: '1',
relationship: 'parent',
meta: {
editUrl: '/management/kibana/objects/savedSearches/1',
icon: 'search',
inAppUrl: {
path: '/app/kibana#/discover/1',
uiCapabilitiesPath: 'discover.show',
},
title: 'My Search Title',
},
},
{
type: 'visualization',
id: '2',
relationship: 'parent',
meta: {
editUrl: '/management/kibana/objects/savedVisualizations/2',
icon: 'visualizeApp',
inAppUrl: {
path: '/app/kibana#/visualize/edit/2',
uiCapabilitiesPath: 'visualize.show',
},
title: 'My Visualization Title',
},
},
])),
savedObject: {
id: '1',
type: 'index-pattern',
meta: {
title: 'MyIndexPattern*',
icon: 'indexPatternApp',
editUrl: '#/management/kibana/index_patterns/1',
inAppUrl: {
path: '/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
},
close: jest.fn(),
};
@ -94,24 +120,50 @@ describe('Relationships', () => {
it('should render searches normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
'index-pattern': [
{
id: '1',
}
],
visualization: [
{
id: '2',
}
],
})),
getEditUrl: () => '',
canGoInApp: () => true,
goInApp: jest.fn(),
id: '1',
type: 'search',
title: 'MySearch',
goInspectObject: () => {},
getRelationships: jest.fn().mockImplementation(() => ([
{
type: 'index-pattern',
id: '1',
relationship: 'child',
meta: {
editUrl: '/management/kibana/index_patterns/1',
icon: 'indexPatternApp',
inAppUrl: {
path: '/app/kibana#/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
title: 'My Index Pattern',
},
},
{
type: 'visualization',
id: '2',
relationship: 'parent',
meta: {
editUrl: '/management/kibana/objects/savedVisualizations/2',
icon: 'visualizeApp',
inAppUrl: {
path: '/app/kibana#/visualize/edit/2',
uiCapabilitiesPath: 'visualize.show',
},
title: 'My Visualization Title',
},
},
])),
savedObject: {
id: '1',
type: 'search',
meta: {
title: 'MySearch',
icon: 'search',
editUrl: '#/management/kibana/objects/savedSearches/1',
inAppUrl: {
path: '/discover/1',
uiCapabilitiesPath: 'discover.show',
},
},
},
close: jest.fn(),
};
@ -135,22 +187,50 @@ describe('Relationships', () => {
it('should render visualizations normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
dashboard: [
{
id: '1',
goInspectObject: () => {},
getRelationships: jest.fn().mockImplementation(() => ([
{
type: 'dashboard',
id: '1',
relationship: 'parent',
meta: {
editUrl: '/management/kibana/objects/savedDashboards/1',
icon: 'dashboardApp',
inAppUrl: {
path: '/app/kibana#/dashboard/1',
uiCapabilitiesPath: 'dashboard.show',
},
title: 'My Dashboard 1',
},
{
id: '2',
}
],
})),
getEditUrl: () => '',
canGoInApp: () => true,
goInApp: jest.fn(),
id: '1',
type: 'visualization',
title: 'MyViz',
},
{
type: 'dashboard',
id: '2',
relationship: 'parent',
meta: {
editUrl: '/management/kibana/objects/savedDashboards/2',
icon: 'dashboardApp',
inAppUrl: {
path: '/app/kibana#/dashboard/2',
uiCapabilitiesPath: 'dashboard.show',
},
title: 'My Dashboard 2',
},
},
])),
savedObject: {
id: '1',
type: 'visualization',
meta: {
title: 'MyViz',
icon: 'visualizeApp',
editUrl: '#/management/kibana/objects/savedVisualizations/1',
inAppUrl: {
path: '/visualize/edit/1',
uiCapabilitiesPath: 'visualize.show',
},
},
},
close: jest.fn(),
};
@ -174,22 +254,50 @@ describe('Relationships', () => {
it('should render dashboards normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
visualization: [
{
id: '1',
goInspectObject: () => {},
getRelationships: jest.fn().mockImplementation(() => ([
{
type: 'visualization',
id: '1',
relationship: 'child',
meta: {
editUrl: '/management/kibana/objects/savedVisualizations/1',
icon: 'visualizeApp',
inAppUrl: {
path: '/app/kibana#/visualize/edit/1',
uiCapabilitiesPath: 'visualize.show',
},
title: 'My Visualization Title 1',
},
{
id: '2',
}
],
})),
getEditUrl: () => '',
canGoInApp: () => true,
goInApp: jest.fn(),
id: '1',
type: 'dashboard',
title: 'MyDashboard',
},
{
type: 'visualization',
id: '2',
relationship: 'child',
meta: {
editUrl: '/management/kibana/objects/savedVisualizations/2',
icon: 'visualizeApp',
inAppUrl: {
path: '/app/kibana#/visualize/edit/2',
uiCapabilitiesPath: 'visualize.show',
},
title: 'My Visualization Title 2',
},
},
])),
savedObject: {
id: '1',
type: 'dashboard',
meta: {
title: 'MyDashboard',
icon: 'dashboardApp',
editUrl: '#/management/kibana/objects/savedDashboards/1',
inAppUrl: {
path: '/dashboard/1',
uiCapabilitiesPath: 'dashboard.show',
},
},
},
close: jest.fn(),
};
@ -213,15 +321,23 @@ describe('Relationships', () => {
it('should render errors', async () => {
const props = {
goInspectObject: () => {},
getRelationships: jest.fn().mockImplementation(() => {
throw new Error('foo');
}),
getEditUrl: () => '',
canGoInApp: () => true,
goInApp: jest.fn(),
id: '1',
type: 'dashboard',
title: 'MyDashboard',
savedObject: {
id: '1',
type: 'dashboard',
meta: {
title: 'MyDashboard',
icon: 'dashboardApp',
editUrl: '#/management/kibana/objects/savedDashboards/1',
inAppUrl: {
path: '/dashboard/1',
uiCapabilitiesPath: 'dashboard.show',
},
},
},
close: jest.fn(),
};

View file

@ -17,7 +17,7 @@
* under the License.
*/
import React, { Component, Fragment } from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
@ -25,28 +25,26 @@ import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiLink,
EuiIcon,
EuiCallOut,
EuiLoadingKibana,
EuiInMemoryTable,
EuiToolTip
EuiToolTip,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import chrome from 'ui/chrome';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import { getSavedObjectIcon, getSavedObjectLabel } from '../../../../lib';
import { getDefaultTitle, getSavedObjectLabel } from '../../../../lib';
class RelationshipsUI extends Component {
static propTypes = {
getRelationships: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
savedObject: PropTypes.object.isRequired,
close: PropTypes.func.isRequired,
getEditUrl: PropTypes.func.isRequired,
goInspectObject: PropTypes.func.isRequired,
canGoInApp: PropTypes.func.isRequired,
goInApp: PropTypes.func.isRequired,
};
constructor(props) {
@ -64,18 +62,18 @@ class RelationshipsUI extends Component {
}
componentWillReceiveProps(nextProps) {
if (nextProps.id !== this.props.id) {
if (nextProps.savedObject.id !== this.props.savedObject.id) {
this.getRelationshipData();
}
}
async getRelationshipData() {
const { id, type, getRelationships } = this.props;
const { savedObject, getRelationships } = this.props;
this.setState({ isLoading: true });
try {
const relationships = await getRelationships(type, id);
const relationships = await getRelationships(savedObject.type, savedObject.id);
this.setState({ relationships, isLoading: false, error: undefined });
} catch (err) {
this.setState({ error: err.message, isLoading: false });
@ -102,7 +100,7 @@ class RelationshipsUI extends Component {
}
renderRelationships() {
const { getEditUrl, canGoInApp, goInApp, intl } = this.props;
const { intl, goInspectObject, savedObject } = this.props;
const { relationships, isLoading, error } = this.state;
if (error) {
@ -113,195 +111,214 @@ class RelationshipsUI extends Component {
return <EuiLoadingKibana size="xl" />;
}
const items = [];
const columns = [
{
field: 'type',
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnTypeName',
defaultMessage: 'Type',
}),
width: '50px',
align: 'center',
description:
intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnTypeDescription',
defaultMessage: 'Type of the saved object',
}),
sortable: false,
render: (type, object) => {
return (
<EuiToolTip
position="top"
content={getSavedObjectLabel(type)}
>
<EuiIcon
aria-label={getSavedObjectLabel(type)}
type={object.meta.icon || 'apps'}
size="s"
/>
</EuiToolTip>
);
},
},
{
field: 'relationship',
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnRelationshipName',
defaultMessage: 'Direct relationship',
}),
dataType: 'string',
sortable: false,
width: '125px',
render: relationship => {
if (relationship === 'parent') {
return (
<EuiText size="s">
<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.columnRelationship.parentAsValue"
defaultMessage="Parent"
/>
</EuiText>
);
}
if (relationship === 'child') {
return (
<EuiText size="s">
<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.columnRelationship.childAsValue"
defaultMessage="Child"
/>
</EuiText>
);
}
},
},
{
field: 'meta.title',
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnTitleName',
defaultMessage: 'Title',
}),
description:
intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnTitleDescription',
defaultMessage: 'Title of the saved object',
}),
dataType: 'string',
sortable: false,
render: (title, object) => {
const { path } = object.meta.inAppUrl || {};
const canGoInApp = this.props.canGoInApp(object);
if (!canGoInApp) {
return (
<EuiText size="s">{title || getDefaultTitle(object)}</EuiText>
);
}
return (
<EuiLink href={chrome.addBasePath(path)}>{title || getDefaultTitle(object)}</EuiLink>
);
},
},
{
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnActionsName',
defaultMessage: 'Actions',
}),
actions: [
{
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnActions.inspectActionName',
defaultMessage: 'Inspect',
}),
description:
intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnActions.inspectActionDescription',
defaultMessage: 'Inspect this saved object',
}),
type: 'icon',
icon: 'inspect',
onClick: object => goInspectObject(object),
available: object => !!object.meta.editUrl,
},
],
},
];
for (const [type, list] of Object.entries(relationships)) {
if (list.length === 0) {
items.push(
<EuiDescriptionListTitle key={`${type}_not_found`}>
<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.itemNotFoundText"
defaultMessage="No {type} found."
values={{ type }}
/>
</EuiDescriptionListTitle>
);
} else {
// let node;
let calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.warningTitle"
defaultMessage="Warning"
/>);
let calloutColor = 'warning';
let calloutText;
const filterTypesMap = new Map(
relationships.map(relationship => [
relationship.type,
{
value: relationship.type,
name: relationship.type,
view: relationship.type,
},
])
);
switch (this.props.type) {
case 'dashboard':
calloutColor = 'success';
calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.dashboard.calloutTitle"
defaultMessage="Dashboard"
/>);
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.dashboard.calloutText"
defaultMessage="Here are some visualizations used on this dashboard.
You can safely delete this dashboard and the visualizations will still work properly."
/>);
break;
case 'search':
if (type === 'visualization') {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.search.visualizations.calloutText"
defaultMessage="Here are some visualizations that use this saved search. If
you delete this saved search, these visualizations will not
longer work properly."
/>);
} else {
calloutColor = 'success';
calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.search.calloutTitle"
defaultMessage="Saved Search"
/>);
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.search.calloutText"
defaultMessage="Here is the index pattern tied to this saved search."
/>);
}
break;
case 'visualization':
if (type === 'index-pattern') {
calloutColor = 'success';
calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.indexPattern.calloutTitle"
defaultMessage="Index Pattern"
/>);
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.indexPattern.calloutText"
defaultMessage="Here is the index pattern tied to this visualization."
/>);
} else if (type === 'search') {
calloutColor = 'success';
calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.search.calloutTitle"
defaultMessage="Saved Search"
/>);
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.search.calloutText"
defaultMessage="Here is the saved search tied to this visualization."
/>);
} else {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.calloutText"
defaultMessage="Here are some dashboards which contain this visualization. If
you delete this visualization, these dashboards will no longer
show them."
/>);
}
break;
case 'index-pattern':
if (type === 'visualization') {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.indexPattern.visualizations.calloutText"
defaultMessage="Here are some visualizations that use this index pattern. If
you delete this index pattern, these visualizations will not
longer work properly."
/>);
} else if (type === 'search') {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.indexPattern.searches.calloutText"
defaultMessage="Here are some saved searches that use this index pattern. If
you delete this index pattern, these saved searches will not
longer work properly."
/>);
}
break;
}
const search = {
filters: [
{
type: 'field_value_selection',
field: 'relationship',
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.name',
defaultMessage: 'Direct relationship',
}),
multiSelect: 'or',
options: [
{
value: 'parent',
name: 'parent',
view: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.parentAsValue.view',
defaultMessage: 'Parent',
}),
},
{
value: 'child',
name: 'child',
view: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.childAsValue.view',
defaultMessage: 'Child',
}),
},
],
},
{
type: 'field_value_selection',
field: 'type',
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.search.filters.type.name',
defaultMessage: 'Type',
}),
multiSelect: 'or',
options: [...filterTypesMap.values()],
},
],
};
items.push(
<Fragment key={type}>
<EuiDescriptionListTitle style={{ marginBottom: '1rem' }}>
<EuiCallOut color={calloutColor} title={calloutTitle}>
<p>{calloutText}</p>
</EuiCallOut>
</EuiDescriptionListTitle>
<EuiInMemoryTable
items={list}
columns={[
{
width: '24px',
render: () => (
<EuiToolTip
position="top"
content={getSavedObjectLabel(type)}
>
<EuiIcon
aria-label={getSavedObjectLabel(type)}
size="s"
type={getSavedObjectIcon(type)}
/>
</EuiToolTip>
),
},
{
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnTitleName', defaultMessage: 'Title'
}),
field: 'title',
render: (title, item) => (
<EuiLink href={`${getEditUrl(item.id, type)}`}>
{title}
</EuiLink>
),
},
{
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnActionsName', defaultMessage: 'Actions'
}),
actions: [
{
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnActions.inAppName',
defaultMessage: 'In app'
}),
description: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.columnActions.inAppDescription',
defaultMessage: 'View this saved object within Kibana'
}),
icon: 'eye',
available: () => canGoInApp(type),
onClick: object => goInApp(object.id, type),
testId: 'savedObjectsManagementRelationshipsViewInApp'
},
],
},
]}
pagination={true}
/>
</Fragment>
);
}
}
return <EuiDescriptionList>{items}</EuiDescriptionList>;
return (
<div>
<EuiCallOut>
<p>
{intl.formatMessage({
id: 'kbn.management.objects.objectsTable.relationships.relationshipsTitle',
defaultMessage: 'Here are the saved objects related to {title}. ' +
'Deleting this {type} affects its parent objects, but not its children.',
}, {
type: savedObject.type,
title: savedObject.meta.title || getDefaultTitle(savedObject)
})}
</p>
</EuiCallOut>
<EuiSpacer />
<EuiInMemoryTable
items={relationships}
columns={columns}
pagination={true}
search={search}
/>
</div>
);
}
render() {
const { close, title, type } = this.props;
const { close, savedObject } = this.props;
return (
<EuiFlyout onClose={close}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<EuiToolTip position="top" content={getSavedObjectLabel(type)}>
<EuiToolTip position="top" content={getSavedObjectLabel(savedObject.type)}>
<EuiIcon
aria-label={getSavedObjectLabel(type)}
aria-label={getSavedObjectLabel(savedObject.type)}
size="m"
type={getSavedObjectIcon(type)}
type={savedObject.meta.icon || 'apps'}
/>
</EuiToolTip>
&nbsp;&nbsp;
{title}
{savedObject.meta.title || getDefaultTitle(savedObject)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>

View file

@ -143,7 +143,7 @@ exports[`Table restricts which saved objects can be deleted based on type 1`] =
Object {
"dataType": "string",
"description": "Title of the saved object",
"field": "title",
"field": "meta.title",
"name": "Title",
"render": [Function],
"sortable": false,
@ -152,9 +152,9 @@ exports[`Table restricts which saved objects can be deleted based on type 1`] =
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"description": "Inspect this saved object",
"icon": "inspect",
"name": "Inspect",
"onClick": [Function],
"type": "icon",
},
@ -173,7 +173,19 @@ exports[`Table restricts which saved objects can be deleted based on type 1`] =
itemId="id"
items={
Array [
3,
Object {
"id": "1",
"meta": Object {
"editUrl": "#/management/kibana/index_patterns/1",
"icon": "indexPatternApp",
"inAppUrl": Object {
"path": "/management/kibana/index_patterns/1",
"uiCapabilitiesPath": "management.kibana.index_patterns",
},
"title": "MyIndexPattern*",
},
"type": "index-pattern",
},
]
}
loading={false}
@ -235,9 +247,10 @@ exports[`Table should render normally 1`] = `
fill={false}
iconSide="left"
iconType="trash"
isDisabled={false}
isDisabled={true}
onClick={[Function]}
size="m"
title="Unable to delete index-pattern"
type="button"
>
<FormattedMessage
@ -345,7 +358,7 @@ exports[`Table should render normally 1`] = `
Object {
"dataType": "string",
"description": "Title of the saved object",
"field": "title",
"field": "meta.title",
"name": "Title",
"render": [Function],
"sortable": false,
@ -354,9 +367,9 @@ exports[`Table should render normally 1`] = `
"actions": Array [
Object {
"available": [Function],
"description": "View this saved object within Kibana",
"icon": "eye",
"name": "In app",
"description": "Inspect this saved object",
"icon": "inspect",
"name": "Inspect",
"onClick": [Function],
"type": "icon",
},
@ -375,7 +388,19 @@ exports[`Table should render normally 1`] = `
itemId="id"
items={
Array [
3,
Object {
"id": "1",
"meta": Object {
"editUrl": "#/management/kibana/index_patterns/1",
"icon": "indexPatternApp",
"inAppUrl": Object {
"path": "/management/kibana/index_patterns/1",
"uiCapabilitiesPath": "management.kibana.index_patterns",
},
"title": "MyIndexPattern*",
},
"type": "index-pattern",
},
]
}
loading={false}

View file

@ -44,19 +44,42 @@ jest.mock('ui/chrome', () => ({
import { Table } from '../table';
const defaultProps = {
selectedSavedObjects: [{ type: 'visualization' }],
selectedSavedObjects: [{
id: '1',
type: 'index-pattern',
meta: {
title: `MyIndexPattern*`,
icon: 'indexPatternApp',
editUrl: '#/management/kibana/index_patterns/1',
inAppUrl: {
path: '/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
}],
selectionConfig: {
onSelectionChange: () => {},
},
filterOptions: [{ value: 2 }],
onDelete: () => {},
onExport: () => {},
getEditUrl: () => {},
goInspectObject: () => {},
canGoInApp: () => {},
goInApp: () => {},
pageIndex: 1,
pageSize: 2,
items: [3],
items: [{
id: '1',
type: 'index-pattern',
meta: {
title: `MyIndexPattern*`,
icon: 'indexPatternApp',
editUrl: '#/management/kibana/index_patterns/1',
inAppUrl: {
path: '/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
}],
itemId: 'id',
totalItemCount: 3,
onQueryChange: () => {},

View file

@ -17,6 +17,7 @@
* under the License.
*/
import chrome from 'ui/chrome';
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
@ -31,9 +32,10 @@ import {
EuiFormErrorText,
EuiPopover,
EuiSwitch,
EuiFormRow
EuiFormRow,
EuiText
} from '@elastic/eui';
import { getSavedObjectLabel, getSavedObjectIcon } from '../../../../lib';
import { getDefaultTitle, getSavedObjectLabel } from '../../../../lib';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
class TableUI extends PureComponent {
@ -48,9 +50,7 @@ class TableUI extends PureComponent {
canDeleteSavedObjectTypes: PropTypes.array.isRequired,
onDelete: PropTypes.func.isRequired,
onExport: PropTypes.func.isRequired,
getEditUrl: PropTypes.func.isRequired,
canGoInApp: PropTypes.func.isRequired,
goInApp: PropTypes.func.isRequired,
goInspectObject: PropTypes.func.isRequired,
pageIndex: PropTypes.number.isRequired,
pageSize: PropTypes.number.isRequired,
@ -126,9 +126,7 @@ class TableUI extends PureComponent {
onDelete,
selectedSavedObjects,
onTableChange,
canGoInApp,
goInApp,
getEditUrl,
goInspectObject,
onShowRelationships,
intl,
} = this.props;
@ -169,7 +167,7 @@ class TableUI extends PureComponent {
id: 'kbn.management.objects.objectsTable.table.columnTypeDescription', defaultMessage: 'Type of the saved object'
}),
sortable: false,
render: type => {
render: (type, object) => {
return (
<EuiToolTip
position="top"
@ -177,7 +175,7 @@ class TableUI extends PureComponent {
>
<EuiIcon
aria-label={getSavedObjectLabel(type)}
type={getSavedObjectIcon(type)}
type={object.meta.icon || 'apps'}
size="s"
/>
</EuiToolTip>
@ -185,7 +183,7 @@ class TableUI extends PureComponent {
},
},
{
field: 'title',
field: 'meta.title',
name: intl.formatMessage({ id: 'kbn.management.objects.objectsTable.table.columnTitleName', defaultMessage: 'Title' }),
description:
intl.formatMessage({
@ -193,26 +191,36 @@ class TableUI extends PureComponent {
}),
dataType: 'string',
sortable: false,
render: (title, object) => (
<EuiLink href={getEditUrl(object.id, object.type)}>{title}</EuiLink>
),
render: (title, object) => {
const { path } = object.meta.inAppUrl || {};
const canGoInApp = this.props.canGoInApp(object);
if (!canGoInApp) {
return (
<EuiText size="s">{title || getDefaultTitle(object)}</EuiText>
);
}
return (
<EuiLink href={chrome.addBasePath(path)}>{title || getDefaultTitle(object)}</EuiLink>
);
},
},
{
name: intl.formatMessage({ id: 'kbn.management.objects.objectsTable.table.columnActionsName', defaultMessage: 'Actions' }),
actions: [
{
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.table.columnActions.viewInAppActionName', defaultMessage: 'In app'
id: 'kbn.management.objects.objectsTable.table.columnActions.inspectActionName',
defaultMessage: 'Inspect'
}),
description:
intl.formatMessage({
id: 'kbn.management.objects.objectsTable.table.columnActions.viewInAppActionDescription',
defaultMessage: 'View this saved object within Kibana'
id: 'kbn.management.objects.objectsTable.table.columnActions.inspectActionDescription',
defaultMessage: 'Inspect this saved object'
}),
type: 'icon',
icon: 'eye',
available: object => canGoInApp(object.type),
onClick: object => goInApp(object.id, object.type),
icon: 'inspect',
onClick: object => goInspectObject(object),
available: object => !!object.meta.editUrl,
},
{
name:
@ -227,8 +235,7 @@ class TableUI extends PureComponent {
}),
type: 'icon',
icon: 'kqlSelector',
onClick: object =>
onShowRelationships(object.id, object.type, object.title),
onClick: object => onShowRelationships(object),
},
],
},

View file

@ -17,6 +17,7 @@
* under the License.
*/
import chrome from 'ui/chrome';
import { saveAs } from '@elastic/filesaver';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
@ -53,21 +54,16 @@ import {
} from '@elastic/eui';
import {
parseQuery,
getSavedObjectIcon,
getSavedObjectCounts,
getRelationships,
getSavedObjectLabel,
fetchExportObjects,
fetchExportByType,
findObjects,
} from '../../lib';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
export const POSSIBLE_TYPES = [
'index-pattern',
'visualization',
'dashboard',
'search',
];
export const POSSIBLE_TYPES = chrome.getInjected('importAndExportableTypes');
class ObjectsTableUI extends Component {
static propTypes = {
@ -78,10 +74,9 @@ class ObjectsTableUI extends Component {
perPageConfig: PropTypes.number,
newIndexPatternUrl: PropTypes.string.isRequired,
services: PropTypes.array.isRequired,
getEditUrl: PropTypes.func.isRequired,
canGoInApp: PropTypes.func.isRequired,
goInApp: PropTypes.func.isRequired,
uiCapabilities: PropTypes.object.isRequired,
goInspectObject: PropTypes.func.isRequired,
canGoInApp: PropTypes.func.isRequired,
};
constructor(props) {
@ -105,9 +100,7 @@ class ObjectsTableUI extends Component {
isSearching: false,
filteredItemCount: 0,
isShowingRelationships: false,
relationshipId: undefined,
relationshipType: undefined,
relationshipTitle: undefined,
relationshipObject: undefined,
isShowingDeleteConfirmModal: false,
isShowingExportAllOptionsModal: false,
isDeleting: false,
@ -179,15 +172,16 @@ class ObjectsTableUI extends Component {
}
debouncedFetch = debounce(async () => {
const { intl, savedObjectsClient } = this.props;
const { intl } = this.props;
const { activeQuery: query, page, perPage } = this.state;
const { queryText, visibleTypes } = parseQuery(query);
// "searchFields" is missing from the "findOptions" but gets injected via the API.
// The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute
const findOptions = {
search: queryText ? `${queryText}*` : undefined,
perPage,
page: page + 1,
fields: ['title', 'id'],
searchFields: ['title'],
fields: ['id'],
type: this.savedObjectTypes.filter(
type => !visibleTypes || visibleTypes.includes(type)
),
@ -198,7 +192,7 @@ class ObjectsTableUI extends Component {
let resp;
try {
resp = await savedObjectsClient.find(findOptions);
resp = await findObjects(findOptions);
} catch (error) {
if (this._isMounted) {
this.setState({
@ -226,12 +220,7 @@ class ObjectsTableUI extends Component {
}
return {
savedObjects: resp.savedObjects.map(savedObject => ({
title: savedObject.attributes.title,
type: savedObject.type,
id: savedObject.id,
icon: getSavedObjectIcon(savedObject.type),
})),
savedObjects: resp.savedObjects,
filteredItemCount: resp.total,
isSearching: false,
};
@ -243,12 +232,7 @@ class ObjectsTableUI extends Component {
};
onSelectionChanged = selection => {
const selectedSavedObjects = selection.map(item => ({
id: item.id,
type: item.type,
title: item.title,
}));
this.setState({ selectedSavedObjects });
this.setState({ selectedSavedObjects: selection });
};
onQueryChange = ({ query }) => {
@ -277,21 +261,17 @@ class ObjectsTableUI extends Component {
}, this.fetchSavedObjects);
};
onShowRelationships = (id, type, title) => {
onShowRelationships = (object) => {
this.setState({
isShowingRelationships: true,
relationshipId: id,
relationshipType: type,
relationshipTitle: title,
relationshipObject: object,
});
};
onHideRelationships = () => {
this.setState({
isShowingRelationships: false,
relationshipId: undefined,
relationshipType: undefined,
relationshipTitle: undefined,
relationshipObject: undefined,
});
};
@ -299,7 +279,20 @@ class ObjectsTableUI extends Component {
const { intl } = this.props;
const { selectedSavedObjects } = this.state;
const objectsToExport = selectedSavedObjects.map(obj => ({ id: obj.id, type: obj.type }));
const blob = await fetchExportObjects(objectsToExport, includeReferencesDeep);
let blob;
try {
blob = await fetchExportObjects(objectsToExport, includeReferencesDeep);
} catch (e) {
toastNotifications.addDanger({
title: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.export.dangerNotification',
defaultMessage: 'Unable to generate export',
}),
});
throw e;
}
saveAs(blob, 'export.ndjson');
toastNotifications.addSuccess({
title: intl.formatMessage({
@ -321,7 +314,20 @@ class ObjectsTableUI extends Component {
},
[]
);
const blob = await fetchExportByType(exportTypes, isIncludeReferencesDeepChecked);
let blob;
try {
blob = await fetchExportByType(exportTypes, isIncludeReferencesDeepChecked);
} catch (e) {
toastNotifications.addDanger({
title: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.exportAll.dangerNotification',
defaultMessage: 'Unable to generate export',
}),
});
throw e;
}
saveAs(blob, 'export.ndjson');
toastNotifications.addSuccess({
title: intl.formatMessage({
@ -423,15 +429,12 @@ class ObjectsTableUI extends Component {
return (
<Relationships
id={this.state.relationshipId}
type={this.state.relationshipType}
title={this.state.relationshipTitle}
savedObject={this.state.relationshipObject}
getRelationships={this.getRelationships}
close={this.onHideRelationships}
getDashboardUrl={this.props.getDashboardUrl}
getEditUrl={this.props.getEditUrl}
goInspectObject={this.props.goInspectObject}
canGoInApp={this.props.canGoInApp}
goInApp={this.props.goInApp}
/>
);
}
@ -509,12 +512,12 @@ class ObjectsTableUI extends Component {
id: 'kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName', defaultMessage: 'Type'
}),
width: '50px',
render: type => (
render: (type, object) => (
<EuiToolTip
position="top"
content={getSavedObjectLabel(type)}
>
<EuiIcon type={getSavedObjectIcon(type)} />
<EuiIcon type={object.meta.icon || 'apps'} />
</EuiToolTip>
),
},
@ -525,7 +528,7 @@ class ObjectsTableUI extends Component {
}),
},
{
field: 'title',
field: 'meta.title',
name: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName',
defaultMessage: 'Title',
@ -703,15 +706,14 @@ class ObjectsTableUI extends Component {
onExport={this.onExport}
canDeleteSavedObjectTypes={canDeleteSavedObjectTypes}
onDelete={this.onDelete}
getEditUrl={this.props.getEditUrl}
canGoInApp={this.props.canGoInApp}
goInApp={this.props.goInApp}
goInspectObject={this.props.goInspectObject}
pageIndex={page}
pageSize={perPage}
items={savedObjects}
totalItemCount={filteredItemCount}
isSearching={isSearching}
onShowRelationships={this.onShowRelationships}
canGoInApp={this.props.canGoInApp}
/>
</EuiPageContent>
);

View file

@ -1,47 +0,0 @@
/*
* 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 { getSavedObjectIcon } from '../get_saved_object_icon';
describe('getSavedObjectIcon', () => {
it('should handle saved searches', () => {
expect(getSavedObjectIcon('search')).toEqual('search');
expect(getSavedObjectIcon('searches')).toEqual('search');
});
it('should handle visualizations', () => {
expect(getSavedObjectIcon('visualization')).toEqual('visualizeApp');
expect(getSavedObjectIcon('visualizations')).toEqual('visualizeApp');
});
it('should handle index patterns', () => {
expect(getSavedObjectIcon('index-pattern')).toEqual('indexPatternApp');
expect(getSavedObjectIcon('index-patterns')).toEqual('indexPatternApp');
expect(getSavedObjectIcon('indexPatterns')).toEqual('indexPatternApp');
});
it('should handle dashboards', () => {
expect(getSavedObjectIcon('dashboard')).toEqual('dashboardApp');
expect(getSavedObjectIcon('dashboards')).toEqual('dashboardApp');
});
it('should have a default case', () => {
expect(getSavedObjectIcon('foo')).toEqual('apps');
});
});

View file

@ -17,40 +17,7 @@
* under the License.
*/
import { getInAppUrl, canViewInApp } from '../in_app_url';
describe('getInAppUrl', () => {
it('should handle saved searches', () => {
expect(getInAppUrl(1, 'search')).toEqual('/discover/1');
expect(getInAppUrl(1, 'searches')).toEqual('/discover/1');
});
it('should handle visualizations', () => {
expect(getInAppUrl(1, 'visualization')).toEqual('/visualize/edit/1');
expect(getInAppUrl(1, 'visualizations')).toEqual('/visualize/edit/1');
});
it('should handle index patterns', () => {
expect(getInAppUrl(1, 'index-pattern')).toEqual(
'/management/kibana/index_patterns/1'
);
expect(getInAppUrl(1, 'index-patterns')).toEqual(
'/management/kibana/index_patterns/1'
);
expect(getInAppUrl(1, 'indexPatterns')).toEqual(
'/management/kibana/index_patterns/1'
);
});
it('should handle dashboards', () => {
expect(getInAppUrl(1, 'dashboard')).toEqual('/dashboard/1');
expect(getInAppUrl(1, 'dashboards')).toEqual('/dashboard/1');
});
it('should have a default case', () => {
expect(getInAppUrl(1, 'foo')).toEqual('/foo/1');
});
});
import { canViewInApp } from '../in_app_url';
describe('canViewInApp', () => {
it('should handle saved searches', () => {

View file

@ -17,22 +17,14 @@
* under the License.
*/
export function getSavedObjectIcon(type) {
switch (type) {
case 'search':
case 'searches':
return 'search';
case 'visualization':
case 'visualizations':
return 'visualizeApp';
case 'dashboard':
case 'dashboards':
return 'dashboardApp';
case 'index-pattern':
case 'index-patterns':
case 'indexPatterns':
return 'indexPatternApp';
default:
return 'apps';
}
import { kfetch } from 'ui/kfetch';
import { keysToCamelCaseShallow } from 'ui/utils/case_conversion';
export async function findObjects(findOptions) {
const response = await kfetch({
method: 'GET',
pathname: '/api/kibana/management/saved_objects/_find',
query: findOptions,
});
return keysToCamelCaseShallow(response);
}

View file

@ -0,0 +1,22 @@
/*
* 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 function getDefaultTitle(object) {
return `${object.type} [id=${object.id}]`;
}

View file

@ -17,26 +17,6 @@
* under the License.
*/
export function getInAppUrl(id, type) {
switch (type) {
case 'search':
case 'searches':
return `/discover/${id}`;
case 'visualization':
case 'visualizations':
return `/visualize/edit/${id}`;
case 'index-pattern':
case 'index-patterns':
case 'indexPatterns':
return `/management/kibana/index_patterns/${id}`;
case 'dashboard':
case 'dashboards':
return `/dashboard/${id}`;
default:
return `/${type.toLowerCase()}/${id}`;
}
}
export function canViewInApp(uiCapabilities, type) {
switch (type) {
case 'search':

View file

@ -22,7 +22,6 @@ export * from './fetch_export_objects';
export * from './in_app_url';
export * from './get_relationships';
export * from './get_saved_object_counts';
export * from './get_saved_object_icon';
export * from './get_saved_object_label';
export * from './import_file';
export * from './import_legacy_file';
@ -31,3 +30,5 @@ export * from './resolve_import_errors';
export * from './resolve_saved_objects';
export * from './log_legacy_import';
export * from './process_import_response';
export * from './get_default_title';
export * from './find_objects';

View file

@ -20,6 +20,108 @@
import expect from '@kbn/expect';
import { findRelationships } from '../management/saved_objects/relationships';
function getManagementaMock(savedObjectSchemas) {
return {
isImportAndExportable(type) {
return !savedObjectSchemas[type] || savedObjectSchemas[type].isImportableAndExportable !== false;
},
getDefaultSearchField(type) {
return savedObjectSchemas[type] && savedObjectSchemas[type].defaultSearchField;
},
getIcon(type) {
return savedObjectSchemas[type] && savedObjectSchemas[type].icon;
},
getTitle(savedObject) {
const { type } = savedObject;
const getTitle = savedObjectSchemas[type] && savedObjectSchemas[type].getTitle;
if (getTitle) {
return getTitle(savedObject);
}
},
getEditUrl(savedObject) {
const { type } = savedObject;
const getEditUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getEditUrl;
if (getEditUrl) {
return getEditUrl(savedObject);
}
},
getInAppUrl(savedObject) {
const { type } = savedObject;
const getInAppUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getInAppUrl;
if (getInAppUrl) {
return getInAppUrl(savedObject);
}
},
};
}
const savedObjectsManagement = getManagementaMock({
'index-pattern': {
icon: 'indexPatternApp',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/index_patterns/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/management/kibana/index_patterns/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'management.kibana.index_patterns',
};
},
},
visualization: {
icon: 'visualizeApp',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/objects/savedVisualizations/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/visualize/edit/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'visualize.show',
};
},
},
search: {
icon: 'search',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/objects/savedSearches/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/discover/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'discover.show',
};
},
},
dashboard: {
icon: 'dashboardApp',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title;
},
getEditUrl(obj) {
return `/management/kibana/objects/savedDashboards/${encodeURIComponent(obj.id)}`;
},
getInAppUrl(obj) {
return {
path: `/app/kibana#/dashboard/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'dashboard.show',
};
},
},
});
describe('findRelationships', () => {
it('should find relationships for dashboards', async () => {
const type = 'dashboard';
@ -78,16 +180,54 @@ describe('findRelationships', () => {
{
size,
savedObjectsClient,
savedObjectsManagement,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
visualization: [
{ id: '1', title: 'Foo' },
{ id: '2', title: 'Bar' },
{ id: '3', title: 'FooBar' },
],
});
expect(result).to.eql([
{
id: '1',
type: 'visualization',
relationship: 'parent',
meta: {
icon: 'visualizeApp',
title: 'Foo',
editUrl: '/management/kibana/objects/savedVisualizations/1',
inAppUrl: {
path: '/app/kibana#/visualize/edit/1',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '2',
type: 'visualization',
relationship: 'parent',
meta: {
icon: 'visualizeApp',
title: 'Bar',
editUrl: '/management/kibana/objects/savedVisualizations/2',
inAppUrl: {
path: '/app/kibana#/visualize/edit/2',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '3',
type: 'visualization',
relationship: 'parent',
meta: {
icon: 'visualizeApp',
title: 'FooBar',
editUrl: '/management/kibana/objects/savedVisualizations/3',
inAppUrl: {
path: '/app/kibana#/visualize/edit/3',
uiCapabilitiesPath: 'visualize.show',
},
},
},
]);
});
it('should find relationships for visualizations', async () => {
@ -167,18 +307,57 @@ describe('findRelationships', () => {
{
size,
savedObjectsClient,
savedObjectsManagement,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
'index-pattern': [
{ id: '1', title: 'My Index Pattern' },
],
dashboard: [
{ id: '1', title: 'My Dashboard' },
{ id: '2', title: 'Your Dashboard' },
],
});
expect(result).to.eql([
{
id: '1',
type: 'index-pattern',
relationship: 'child',
meta:
{
icon: 'indexPatternApp',
title: 'My Index Pattern',
editUrl: '/management/kibana/index_patterns/1',
inAppUrl: {
path: '/app/kibana#/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
},
{
id: '1',
type: 'dashboard',
relationship: 'parent',
meta:
{
icon: 'dashboardApp',
title: 'My Dashboard',
editUrl: '/management/kibana/objects/savedDashboards/1',
inAppUrl: {
path: '/app/kibana#/dashboard/1',
uiCapabilitiesPath: 'dashboard.show',
},
},
},
{
id: '2',
type: 'dashboard',
relationship: 'parent',
meta:
{
icon: 'dashboardApp',
title: 'Your Dashboard',
editUrl: '/management/kibana/objects/savedDashboards/2',
inAppUrl: {
path: '/app/kibana#/dashboard/2',
uiCapabilitiesPath: 'dashboard.show',
},
},
},
]);
});
it('should find relationships for saved searches', async () => {
@ -247,17 +426,72 @@ describe('findRelationships', () => {
{
size,
savedObjectsClient,
savedObjectsManagement,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
visualization: [
{ id: '1', title: 'Foo' },
{ id: '2', title: 'Bar' },
{ id: '3', title: 'FooBar' },
],
'index-pattern': [{ id: '1', title: 'My Index Pattern' }],
});
expect(result).to.eql([
{
id: '1',
type: 'index-pattern',
relationship: 'child',
meta:
{
icon: 'indexPatternApp',
title: 'My Index Pattern',
editUrl: '/management/kibana/index_patterns/1',
inAppUrl: {
path: '/app/kibana#/management/kibana/index_patterns/1',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
},
{
id: '1',
type: 'visualization',
relationship: 'parent',
meta:
{
icon: 'visualizeApp',
title: 'Foo',
editUrl: '/management/kibana/objects/savedVisualizations/1',
inAppUrl: {
path: '/app/kibana#/visualize/edit/1',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '2',
type: 'visualization',
relationship: 'parent',
meta:
{
icon: 'visualizeApp',
title: 'Bar',
editUrl: '/management/kibana/objects/savedVisualizations/2',
inAppUrl: {
path: '/app/kibana#/visualize/edit/2',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '3',
type: 'visualization',
relationship: 'parent',
meta:
{
icon: 'visualizeApp',
title: 'FooBar',
editUrl: '/management/kibana/objects/savedVisualizations/3',
inAppUrl: {
path: '/app/kibana#/visualize/edit/3',
uiCapabilitiesPath: 'visualize.show',
},
},
},
]);
});
it('should find relationships for index patterns', async () => {
@ -328,13 +562,72 @@ describe('findRelationships', () => {
{
size,
savedObjectsClient,
savedObjectsManagement,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
visualization: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }, { id: '3', title: 'FooBar' }],
search: [{ id: '1', title: 'My Saved Search' }],
});
expect(result).to.eql([
{
id: '1',
type: 'visualization',
relationship: 'parent',
meta:
{
icon: 'visualizeApp',
title: 'Foo',
editUrl: '/management/kibana/objects/savedVisualizations/1',
inAppUrl: {
path: '/app/kibana#/visualize/edit/1',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '2',
type: 'visualization',
relationship: 'parent',
meta:
{
icon: 'visualizeApp',
title: 'Bar',
editUrl: '/management/kibana/objects/savedVisualizations/2',
inAppUrl: {
path: '/app/kibana#/visualize/edit/2',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '3',
type: 'visualization',
relationship: 'parent',
meta:
{
icon: 'visualizeApp',
title: 'FooBar',
editUrl: '/management/kibana/objects/savedVisualizations/3',
inAppUrl: {
path: '/app/kibana#/visualize/edit/3',
uiCapabilitiesPath: 'visualize.show',
},
},
},
{
id: '1',
type: 'search',
relationship: 'parent',
meta:
{
icon: 'search',
title: 'My Saved Search',
editUrl: '/management/kibana/objects/savedSearches/1',
inAppUrl: {
path: '/app/kibana#/discover/1',
uiCapabilitiesPath: 'discover.show',
},
},
},
]);
});
it('should return an empty object for non related objects', async () => {
@ -360,6 +653,7 @@ describe('findRelationships', () => {
{
size,
savedObjectsClient,
savedObjectsManagement,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);

View file

@ -0,0 +1,33 @@
/*
* 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 function injectMetaAttributes(savedObject, savedObjectsManagement) {
const result = {
...savedObject,
meta: savedObject.meta || {},
};
// Add extra meta information
result.meta.icon = savedObjectsManagement.getIcon(savedObject.type);
result.meta.title = savedObjectsManagement.getTitle(savedObject);
result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject);
result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject);
return result;
}

View file

@ -0,0 +1,146 @@
/*
* 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 { injectMetaAttributes } from './inject_meta_attributes';
function getManagementMock(savedObjectSchemas) {
return {
isImportAndExportable(type) {
return !savedObjectSchemas[type] || savedObjectSchemas[type].isImportableAndExportable !== false;
},
getDefaultSearchField(type) {
return savedObjectSchemas[type] && savedObjectSchemas[type].defaultSearchField;
},
getIcon(type) {
return savedObjectSchemas[type] && savedObjectSchemas[type].icon;
},
getTitle(savedObject) {
const { type } = savedObject;
const getTitle = savedObjectSchemas[type] && savedObjectSchemas[type].getTitle;
if (getTitle) {
return getTitle(savedObject);
}
},
getEditUrl(savedObject) {
const { type } = savedObject;
const getEditUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getEditUrl;
if (getEditUrl) {
return getEditUrl(savedObject);
}
},
getInAppUrl(savedObject) {
const { type } = savedObject;
const getInAppUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getInAppUrl;
if (getInAppUrl) {
return getInAppUrl(savedObject);
}
},
};
}
test('works when no schema is defined for the type', () => {
const savedObject = { type: 'a' };
const savedObjectsManagement = getManagementMock({});
const result = injectMetaAttributes(savedObject, savedObjectsManagement);
expect(result).toEqual({ type: 'a', meta: {} });
});
test('inject icon into meta attribute', () => {
const savedObject = {
type: 'a',
};
const savedObjectsManagement = getManagementMock({
a: {
icon: 'my-icon',
},
});
const result = injectMetaAttributes(savedObject, savedObjectsManagement);
expect(result).toEqual({
type: 'a',
meta: {
icon: 'my-icon',
},
});
});
test('injects title into meta attribute', () => {
const savedObject = {
type: 'a',
};
const savedObjectsManagement = getManagementMock({
a: {
getTitle() {
return 'my-title';
},
},
});
const result = injectMetaAttributes(savedObject, savedObjectsManagement);
expect(result).toEqual({
type: 'a',
meta: {
title: 'my-title',
},
});
});
test('injects editUrl into meta attribute', () => {
const savedObject = {
type: 'a',
};
const savedObjectsManagement = getManagementMock({
a: {
getEditUrl() {
return 'my-edit-url';
},
},
});
const result = injectMetaAttributes(savedObject, savedObjectsManagement);
expect(result).toEqual({
type: 'a',
meta: {
editUrl: 'my-edit-url',
},
});
});
test('injects inAppUrl meta attribute', () => {
const savedObject = {
type: 'a',
};
const savedObjectsManagement = getManagementMock({
a: {
getInAppUrl() {
return {
path: 'my-in-app-url',
uiCapabilitiesPath: 'ui.path',
};
},
},
});
const result = injectMetaAttributes(savedObject, savedObjectsManagement);
expect(result).toEqual({
type: 'a',
meta: {
inAppUrl: {
path: 'my-in-app-url',
uiCapabilitiesPath: 'ui.path',
},
},
});
});

View file

@ -17,50 +17,53 @@
* under the License.
*/
import { pick } from 'lodash';
import { injectMetaAttributes } from './inject_meta_attributes';
export async function findRelationships(type, id, options = {}) {
const {
size,
savedObjectsClient,
savedObjectTypes,
savedObjectsManagement,
} = options;
const { references = [] } = await savedObjectsClient.get(type, id);
// we filter the objects which we execute bulk requests for based on the saved
// object types as well, these are the only types we should be concerned with
const bulkGetOpts = references
.filter(({ type }) => savedObjectTypes.includes(type))
.map(ref => ({ id: ref.id, type: ref.type }));
// Use a map to avoid duplicates, it does happen but have a different "name" in the reference
const referencedToBulkGetOpts = new Map(
references.map(({ type, id }) => [`${type}:${id}`, { id, type }])
);
const [referencedObjects, referencedResponse] = await Promise.all([
bulkGetOpts.length > 0
? savedObjectsClient.bulkGet(bulkGetOpts)
referencedToBulkGetOpts.size > 0
? savedObjectsClient.bulkGet([...referencedToBulkGetOpts.values()])
: Promise.resolve({ saved_objects: [] }),
savedObjectsClient.find({
hasReference: { type, id },
perPage: size,
fields: ['title'],
type: savedObjectTypes,
}),
]);
const relationshipObjects = [].concat(
referencedObjects.saved_objects.map(extractCommonProperties),
referencedResponse.saved_objects.map(extractCommonProperties),
return [].concat(
referencedObjects.saved_objects
.map(obj => injectMetaAttributes(obj, savedObjectsManagement))
.map(extractCommonProperties)
.map(obj => ({
...obj,
relationship: 'child',
})),
referencedResponse.saved_objects
.map(obj => injectMetaAttributes(obj, savedObjectsManagement))
.map(extractCommonProperties)
.map(obj => ({
...obj,
relationship: 'parent',
})),
);
return relationshipObjects.reduce((result, relationshipObject) => {
const objectsForType = (result[relationshipObject.type] || []);
const { type, ...relationshipObjectWithoutType } = relationshipObject;
result[type] = objectsForType.concat(relationshipObjectWithoutType);
return result;
}, {});
}
function extractCommonProperties(savedObject) {
return {
id: savedObject.id,
type: savedObject.type,
title: savedObject.attributes.title,
};
return pick(savedObject, ['id', 'type', 'meta']);
}

View file

@ -17,11 +17,13 @@
* under the License.
*/
import { registerFind } from './saved_objects/find';
import { registerRelationships } from './saved_objects/relationships';
import { registerScrollForExportRoute, registerScrollForCountRoute } from './saved_objects/scroll';
export function managementApi(server) {
registerRelationships(server);
registerFind(server);
registerScrollForExportRoute(server);
registerScrollForCountRoute(server);
}

View file

@ -0,0 +1,105 @@
/*
* 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.
*/
/**
* This file wraps the saved object `_find` API and is designed specifically for the saved object
* management UI. The main difference is this will inject a root `meta` attribute on each saved object
* that the UI depends on. The meta fields come from functions within uiExports which can't be
* injected into the front end when defined within uiExports. There are alternatives to this but have
* decided to go with this approach at the time of development.
*/
import Joi from 'joi';
import { injectMetaAttributes } from '../../../../lib/management/saved_objects/inject_meta_attributes';
export function registerFind(server) {
server.route({
path: '/api/kibana/management/saved_objects/_find',
method: 'GET',
config: {
validate: {
query: Joi.object()
.keys({
perPage: Joi.number()
.min(0)
.default(20),
page: Joi.number()
.min(0)
.default(1),
type: Joi.array()
.items(Joi.string())
.single()
.required(),
search: Joi.string()
.allow('')
.optional(),
defaultSearchOperator: Joi.string()
.valid('OR', 'AND')
.default('OR'),
sortField: Joi.string(),
hasReference: Joi.object()
.keys({
type: Joi.string().required(),
id: Joi.string().required(),
})
.optional(),
fields: Joi.array()
.items(Joi.string())
.single(),
})
.default(),
},
},
async handler(request) {
const searchFields = new Set();
const searchTypes = request.query.type;
const savedObjectsClient = request.getSavedObjectsClient();
const savedObjectsManagement = server.getSavedObjectsManagement();
const importAndExportableTypes = searchTypes.filter(type => savedObjectsManagement.isImportAndExportable(type));
// Accumulate "defaultSearchField" attributes from savedObjectsManagement. Unfortunately
// search fields apply to all types of saved objects, the sum of these fields will
// be searched on for each object.
for (const type of importAndExportableTypes) {
const searchField = savedObjectsManagement.getDefaultSearchField(type);
if (searchField) {
searchFields.add(searchField);
}
}
const findResponse = await savedObjectsClient.find({
...request.query,
fields: undefined,
searchFields: [...searchFields],
});
return {
...findResponse,
saved_objects: findResponse.saved_objects
.map(obj => injectMetaAttributes(obj, savedObjectsManagement))
.map(obj => {
const result = { ...obj, attributes: {} };
for (const field of request.query.fields || []) {
result.attributes[field] = obj.attributes[field];
}
return result;
})
};
},
});
}

View file

@ -43,10 +43,12 @@ export function registerRelationships(server) {
const size = req.query.size;
const savedObjectTypes = req.query.savedObjectTypes;
const savedObjectsClient = req.getSavedObjectsClient();
const savedObjectsManagement = req.server.getSavedObjectsManagement();
return await findRelationships(type, id, {
size,
savedObjectsClient,
savedObjectsManagement,
savedObjectTypes,
});
},

View file

@ -31,7 +31,12 @@ import { ApmOssPlugin } from '../core_plugins/apm_oss';
import { CallClusterWithRequest, ElasticsearchPlugin } from '../core_plugins/elasticsearch';
import { IndexPatternsServiceFactory } from './index_patterns';
import { SavedObjectsClient, SavedObjectsService } from './saved_objects';
import {
SavedObjectsClient,
SavedObjectsService,
SavedObjectsSchema,
SavedObjectsManagement,
} from './saved_objects';
export interface KibanaConfig {
get<T>(key: string): T;
@ -58,6 +63,7 @@ declare module 'hapi' {
savedObjects: SavedObjectsService;
injectUiAppVars: (pluginName: string, getAppVars: () => { [key: string]: any }) => void;
getHiddenUiAppById(appId: string): UiApp;
savedObjectsManagement(): SavedObjectsManagement;
}
interface Request {

View file

@ -27,56 +27,132 @@ describe('collectSavedObjects()', () => {
this.push(null);
},
});
const objects = await collectSavedObjects(readStream, 10);
expect(objects).toMatchInlineSnapshot(`Array []`);
const result = await collectSavedObjects({ readStream, objectLimit: 10, supportedTypes: [] });
expect(result).toMatchInlineSnapshot(`
Object {
"collectedObjects": Array [],
"errors": Array [],
}
`);
});
test('collects objects from stream', async () => {
const readStream = new Readable({
read() {
this.push('{"foo":true}');
this.push('{"foo":true,"type":"a"}');
this.push(null);
},
});
const objects = await collectSavedObjects(readStream, 1);
expect(objects).toMatchInlineSnapshot(`
Array [
Object {
"foo": true,
"migrationVersion": Object {},
},
]
const result = await collectSavedObjects({
readStream,
objectLimit: 1,
supportedTypes: ['a'],
});
expect(result).toMatchInlineSnapshot(`
Object {
"collectedObjects": Array [
Object {
"foo": true,
"migrationVersion": Object {},
"type": "a",
},
],
"errors": Array [],
}
`);
});
test('filters out empty lines', async () => {
const readStream = new Readable({
read() {
this.push('{"foo":true}\n\n');
this.push('{"foo":true,"type":"a"}\n\n');
this.push(null);
},
});
const objects = await collectSavedObjects(readStream, 1);
expect(objects).toMatchInlineSnapshot(`
Array [
Object {
"foo": true,
"migrationVersion": Object {},
},
]
const result = await collectSavedObjects({
readStream,
objectLimit: 1,
supportedTypes: ['a'],
});
expect(result).toMatchInlineSnapshot(`
Object {
"collectedObjects": Array [
Object {
"foo": true,
"migrationVersion": Object {},
"type": "a",
},
],
"errors": Array [],
}
`);
});
test('throws error when object limit is reached', async () => {
const readStream = new Readable({
read() {
this.push('{"foo":true}\n');
this.push('{"bar":true}\n');
this.push('{"foo":true,"type":"a"}\n');
this.push('{"bar":true,"type":"a"}\n');
this.push(null);
},
});
await expect(collectSavedObjects(readStream, 1)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Can't import more than 1 objects"`
);
await expect(
collectSavedObjects({
readStream,
objectLimit: 1,
supportedTypes: ['a'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 objects"`);
});
test('unsupported types return as import errors', async () => {
const readStream = new Readable({
read() {
this.push('{"id":"1","type":"a","attributes":{"title":"my title"}}\n');
this.push('{"id":"2","type":"b","attributes":{"title":"my title 2"}}\n');
this.push(null);
},
});
const result = await collectSavedObjects({ readStream, objectLimit: 2, supportedTypes: ['1'] });
expect(result).toMatchInlineSnapshot(`
Object {
"collectedObjects": Array [],
"errors": Array [
Object {
"error": Object {
"type": "unsupported_type",
},
"id": "1",
"title": "my title",
"type": "a",
},
Object {
"error": Object {
"type": "unsupported_type",
},
"id": "2",
"title": "my title 2",
"type": "b",
},
],
}
`);
});
test('unsupported types still count towards object limit', async () => {
const readStream = new Readable({
read() {
this.push('{"foo":true,"type":"a"}\n');
this.push('{"bar":true,"type":"b"}\n');
this.push(null);
},
});
await expect(
collectSavedObjects({
readStream,
objectLimit: 1,
supportedTypes: ['a'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't import more than 1 objects"`);
});
});

View file

@ -27,13 +27,23 @@ import {
} from '../../../utils/streams';
import { SavedObject } from '../service';
import { createLimitStream } from './create_limit_stream';
import { ImportError } from './types';
export async function collectSavedObjects(
readStream: Readable,
objectLimit: number,
filter?: (obj: SavedObject) => boolean
): Promise<SavedObject[]> {
return (await createPromiseFromStreams([
interface CollectSavedObjectsOptions {
readStream: Readable;
objectLimit: number;
filter?: (obj: SavedObject) => boolean;
supportedTypes: string[];
}
export async function collectSavedObjects({
readStream,
objectLimit,
filter,
supportedTypes,
}: CollectSavedObjectsOptions) {
const errors: ImportError[] = [];
const collectedObjects: SavedObject[] = await createPromiseFromStreams([
readStream,
createSplitStream('\n'),
createMapStream((str: string) => {
@ -43,11 +53,29 @@ export async function collectSavedObjects(
}),
createFilterStream<SavedObject>(obj => !!obj),
createLimitStream(objectLimit),
createFilterStream<SavedObject>(obj => {
if (supportedTypes.includes(obj.type)) {
return true;
}
errors.push({
id: obj.id,
type: obj.type,
title: obj.attributes.title,
error: {
type: 'unsupported_type',
},
});
return false;
}),
createFilterStream<SavedObject>(obj => (filter ? filter(obj) : true)),
createMapStream((obj: SavedObject) => {
// Ensure migrations execute on every saved object
return Object.assign({ migrationVersion: {} }, obj);
}),
createConcatStream([]),
])) as SavedObject[];
]);
return {
errors,
collectedObjects,
};
}

View file

@ -82,6 +82,7 @@ describe('importSavedObjects()', () => {
objectLimit: 1,
overwrite: false,
savedObjectsClient,
supportedTypes: [],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -107,6 +108,7 @@ Object {
objectLimit: 4,
overwrite: false,
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -187,6 +189,7 @@ Object {
objectLimit: 4,
overwrite: true,
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -274,6 +277,7 @@ Object {
objectLimit: 4,
overwrite: false,
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -372,6 +376,7 @@ Object {
objectLimit: 4,
overwrite: false,
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -423,6 +428,98 @@ Object {
},
],
}
`);
});
test('validates supported types', async () => {
const readStream = new Readable({
read() {
savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n'));
this.push('{"id":"1","type":"wigwags","attributes":{"title":"my title"},"references":[]}');
this.push(null);
},
});
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: savedObjects,
});
const result = await importSavedObjects({
readStream,
objectLimit: 5,
overwrite: false,
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
"errors": Array [
Object {
"error": Object {
"type": "unsupported_type",
},
"id": "1",
"title": "my title",
"type": "wigwags",
},
],
"success": false,
"successCount": 4,
}
`);
expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Array [
Object {
"attributes": Object {
"title": "My Index Pattern",
},
"id": "1",
"migrationVersion": Object {},
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {
"title": "My Search",
},
"id": "2",
"migrationVersion": Object {},
"references": Array [],
"type": "search",
},
Object {
"attributes": Object {
"title": "My Visualization",
},
"id": "3",
"migrationVersion": Object {},
"references": Array [],
"type": "visualization",
},
Object {
"attributes": Object {
"title": "My Dashboard",
},
"id": "4",
"migrationVersion": Object {},
"references": Array [],
"type": "dashboard",
},
],
Object {
"overwrite": false,
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
});

View file

@ -29,6 +29,7 @@ interface ImportSavedObjectsOptions {
objectLimit: number;
overwrite: boolean;
savedObjectsClient: SavedObjectsClient;
supportedTypes: string[];
}
interface ImportResponse {
@ -42,34 +43,45 @@ export async function importSavedObjects({
objectLimit,
overwrite,
savedObjectsClient,
supportedTypes,
}: ImportSavedObjectsOptions): Promise<ImportResponse> {
let errorAccumulator: ImportError[] = [];
// Get the objects to import
const objectsFromStream = await collectSavedObjects(readStream, objectLimit);
const {
errors: collectorErrors,
collectedObjects: objectsFromStream,
} = await collectSavedObjects({ readStream, objectLimit, supportedTypes });
errorAccumulator = [...errorAccumulator, ...collectorErrors];
// Validate references
const { filteredObjects, errors: validationErrors } = await validateReferences(
objectsFromStream,
savedObjectsClient
);
errorAccumulator = [...errorAccumulator, ...validationErrors];
// Exit early if no objects to import
if (filteredObjects.length === 0) {
return {
success: validationErrors.length === 0,
success: errorAccumulator.length === 0,
successCount: 0,
...(validationErrors.length ? { errors: validationErrors } : {}),
...(errorAccumulator.length ? { errors: errorAccumulator } : {}),
};
}
// Create objects in bulk
const bulkCreateResult = await savedObjectsClient.bulkCreate(filteredObjects, {
overwrite,
});
const errors = [
...validationErrors,
errorAccumulator = [
...errorAccumulator,
...extractErrors(bulkCreateResult.saved_objects, filteredObjects),
];
return {
success: errors.length === 0,
success: errorAccumulator.length === 0,
successCount: bulkCreateResult.saved_objects.filter(obj => !obj.error).length,
...(errors.length ? { errors } : {}),
...(errorAccumulator.length ? { errors: errorAccumulator } : {}),
};
}

View file

@ -92,6 +92,7 @@ describe('resolveImportErrors()', () => {
objectLimit: 4,
retries: [],
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -124,6 +125,7 @@ Object {
},
],
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -180,6 +182,7 @@ Object {
},
],
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -245,6 +248,7 @@ Object {
},
],
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -312,6 +316,7 @@ Object {
replaceReferences: [],
})),
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -423,6 +428,7 @@ Object {
},
],
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
@ -476,4 +482,48 @@ Object {
}
`);
});
test('validates object types', async () => {
const readStream = new Readable({
read() {
savedObjects.forEach(obj => this.push(JSON.stringify(obj) + '\n'));
this.push('{"id":"1","type":"wigwags","attributes":{"title":"my title"},"references":[]}');
this.push(null);
},
});
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [],
});
const result = await resolveImportErrors({
readStream,
objectLimit: 5,
retries: [
{
id: 'i',
type: 'wigwags',
overwrite: false,
replaceReferences: [],
},
],
savedObjectsClient,
supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'],
});
expect(result).toMatchInlineSnapshot(`
Object {
"errors": Array [
Object {
"error": Object {
"type": "unsupported_type",
},
"id": "1",
"title": "my title",
"type": "wigwags",
},
],
"success": false,
"successCount": 0,
}
`);
expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`);
});
});

View file

@ -31,6 +31,7 @@ interface ResolveImportErrorsOptions {
objectLimit: number;
savedObjectsClient: SavedObjectsClient;
retries: Retry[];
supportedTypes: string[];
}
interface ImportResponse {
@ -44,13 +45,22 @@ export async function resolveImportErrors({
objectLimit,
retries,
savedObjectsClient,
supportedTypes,
}: ResolveImportErrorsOptions): Promise<ImportResponse> {
let successCount = 0;
let errors: ImportError[] = [];
let errorAccumulator: ImportError[] = [];
const filter = createObjectsFilter(retries);
// Get the objects to resolve errors
const objectsToResolve = await collectSavedObjects(readStream, objectLimit, filter);
const { errors: collectorErrors, collectedObjects: objectsToResolve } = await collectSavedObjects(
{
readStream,
objectLimit,
filter,
supportedTypes,
}
);
errorAccumulator = [...errorAccumulator, ...collectorErrors];
// Create a map of references to replace for each object to avoid iterating through
// retries for every object to resolve
@ -81,7 +91,7 @@ export async function resolveImportErrors({
objectsToResolve,
savedObjectsClient
);
errors = errors.concat(validationErrors);
errorAccumulator = [...errorAccumulator, ...validationErrors];
// Bulk create in two batches, overwrites and non-overwrites
const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(filteredObjects, retries);
@ -89,18 +99,24 @@ export async function resolveImportErrors({
const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToOverwrite, {
overwrite: true,
});
errors = errors.concat(extractErrors(bulkCreateResult.saved_objects, objectsToOverwrite));
errorAccumulator = [
...errorAccumulator,
...extractErrors(bulkCreateResult.saved_objects, objectsToOverwrite),
];
successCount += bulkCreateResult.saved_objects.filter(obj => !obj.error).length;
}
if (objectsToNotOverwrite.length) {
const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToNotOverwrite);
errors = errors.concat(extractErrors(bulkCreateResult.saved_objects, objectsToNotOverwrite));
errorAccumulator = [
...errorAccumulator,
...extractErrors(bulkCreateResult.saved_objects, objectsToNotOverwrite),
];
successCount += bulkCreateResult.saved_objects.filter(obj => !obj.error).length;
}
return {
successCount,
success: errors.length === 0,
...(errors.length ? { errors } : {}),
success: errorAccumulator.length === 0,
...(errorAccumulator.length ? { errors: errorAccumulator } : {}),
};
}

View file

@ -32,6 +32,10 @@ export interface ConflictError {
type: 'conflict';
}
export interface UnsupportedTypeError {
type: 'unsupported_type';
}
export interface UnknownError {
type: 'unknown';
message: string;
@ -54,5 +58,5 @@ export interface ImportError {
id: string;
type: string;
title?: string;
error: ConflictError | MissingReferencesError | UnknownError;
error: ConflictError | UnsupportedTypeError | MissingReferencesError | UnknownError;
}

View file

@ -26,3 +26,7 @@ export {
SavedObjectReference,
SavedObjectsService,
} from './service';
export { SavedObjectsSchema } from './schema';
export { SavedObjectsManagement } from './management';

View file

@ -0,0 +1,20 @@
/*
* 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 { SavedObjectsManagement, SavedObjectsManagementDefinition } from './management';

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 { SavedObjectsManagement } from './management';
type Management = PublicMethodsOf<SavedObjectsManagement>;
const createManagementMock = () => {
const mocked: jest.Mocked<Management> = {
isImportAndExportable: jest.fn().mockReturnValue(true),
getDefaultSearchField: jest.fn(),
getIcon: jest.fn(),
getTitle: jest.fn(),
getEditUrl: jest.fn(),
getInAppUrl: jest.fn(),
};
return mocked;
};
export const managementMock = {
create: createManagementMock,
};

View file

@ -0,0 +1,174 @@
/*
* 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 { SavedObjectsManagement } from './management';
describe('isImportAndExportable()', () => {
it('returns false for unknown types', () => {
const management = new SavedObjectsManagement();
const result = management.isImportAndExportable('bar');
expect(result).toBe(false);
});
it('returns true for explicitly importable and exportable type', () => {
const management = new SavedObjectsManagement({
foo: {
isImportableAndExportable: true,
},
});
const result = management.isImportAndExportable('foo');
expect(result).toBe(true);
});
it('returns false for explicitly importable and exportable type', () => {
const management = new SavedObjectsManagement({
foo: {
isImportableAndExportable: false,
},
});
const result = management.isImportAndExportable('foo');
expect(result).toBe(false);
});
});
describe('getDefaultSearchField()', () => {
it('returns empty for unknown types', () => {
const management = new SavedObjectsManagement();
const result = management.getDefaultSearchField('bar');
expect(result).toEqual(undefined);
});
it('returns explicit value', () => {
const management = new SavedObjectsManagement({
foo: {
defaultSearchField: 'value',
},
});
const result = management.getDefaultSearchField('foo');
expect(result).toEqual('value');
});
});
describe('getIcon', () => {
it('returns empty for unknown types', () => {
const management = new SavedObjectsManagement();
const result = management.getIcon('bar');
expect(result).toEqual(undefined);
});
it('returns explicit value', () => {
const management = new SavedObjectsManagement({
foo: {
icon: 'value',
},
});
const result = management.getIcon('foo');
expect(result).toEqual('value');
});
});
describe('getTitle', () => {
it('returns empty for unknown type', () => {
const management = new SavedObjectsManagement();
const result = management.getTitle({
id: '1',
type: 'foo',
attributes: {},
references: [],
});
expect(result).toEqual(undefined);
});
it('returns explicit value', () => {
const management = new SavedObjectsManagement({
foo: {
getTitle() {
return 'called';
},
},
});
const result = management.getTitle({
id: '1',
type: 'foo',
attributes: {},
references: [],
});
expect(result).toEqual('called');
});
});
describe('getEditUrl()', () => {
it('returns empty for unknown type', () => {
const management = new SavedObjectsManagement();
const result = management.getEditUrl({
id: '1',
type: 'foo',
attributes: {},
references: [],
});
expect(result).toEqual(undefined);
});
it('returns explicit value', () => {
const management = new SavedObjectsManagement({
foo: {
getEditUrl() {
return 'called';
},
},
});
const result = management.getEditUrl({
id: '1',
type: 'foo',
attributes: {},
references: [],
});
expect(result).toEqual('called');
});
});
describe('getInAppUrl()', () => {
it('returns empty array for unknown type', () => {
const management = new SavedObjectsManagement();
const result = management.getInAppUrl({
id: '1',
type: 'foo',
attributes: {},
references: [],
});
expect(result).toEqual(undefined);
});
it('returns explicit value', () => {
const management = new SavedObjectsManagement({
foo: {
getInAppUrl() {
return { path: 'called', uiCapabilitiesPath: 'my.path' };
},
},
});
const result = management.getInAppUrl({
id: '1',
type: 'foo',
attributes: {},
references: [],
});
expect(result).toEqual({ path: 'called', uiCapabilitiesPath: 'my.path' });
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 { SavedObject } from '../service';
interface SavedObjectsManagementTypeDefinition {
isImportableAndExportable?: boolean;
defaultSearchField?: string;
icon?: string;
getTitle?: (savedObject: SavedObject) => string;
getEditUrl?: (savedObject: SavedObject) => string;
getInAppUrl?: (savedObject: SavedObject) => { path: string; uiCapabilitiesPath: string };
}
export interface SavedObjectsManagementDefinition {
[key: string]: SavedObjectsManagementTypeDefinition;
}
export class SavedObjectsManagement {
private readonly definition?: SavedObjectsManagementDefinition;
constructor(managementDefinition?: SavedObjectsManagementDefinition) {
this.definition = managementDefinition;
}
public isImportAndExportable(type: string) {
if (this.definition && this.definition.hasOwnProperty(type)) {
return this.definition[type].isImportableAndExportable === true;
}
return false;
}
public getDefaultSearchField(type: string) {
if (this.definition && this.definition.hasOwnProperty(type)) {
return this.definition[type].defaultSearchField;
}
}
public getIcon(type: string) {
if (this.definition && this.definition.hasOwnProperty(type)) {
return this.definition[type].icon;
}
}
public getTitle(savedObject: SavedObject) {
const { type } = savedObject;
if (this.definition && this.definition.hasOwnProperty(type) && this.definition[type].getTitle) {
const { getTitle } = this.definition[type];
if (getTitle) {
return getTitle(savedObject);
}
}
}
public getEditUrl(savedObject: SavedObject) {
const { type } = savedObject;
if (this.definition && this.definition.hasOwnProperty(type)) {
const { getEditUrl } = this.definition[type];
if (getEditUrl) {
return getEditUrl(savedObject);
}
}
}
public getInAppUrl(savedObject: SavedObject) {
const { type } = savedObject;
if (this.definition && this.definition.hasOwnProperty(type)) {
const { getInAppUrl } = this.definition[type];
if (getInAppUrl) {
return getInAppUrl(savedObject);
}
}
}
}

View file

@ -98,6 +98,7 @@ function mockKbnServer({ configValues }: { configValues?: any } = {}) {
savedObjectMigrations: {},
savedObjectMappings: [],
savedObjectSchemas: {},
savedObjectsManagement: {},
},
server: {
config: () => ({

View file

@ -25,6 +25,7 @@
import { once } from 'lodash';
import { MappingProperties } from '../../../mappings';
import { SavedObjectsSchema, SavedObjectsSchemaDefinition } from '../../schema';
import { SavedObjectsManagementDefinition } from '../../management';
import { RawSavedObjectDoc, SavedObjectsSerializer } from '../../serialization';
import { docValidator } from '../../validation';
import { buildActiveMappings, CallCluster, IndexMigrator, LogFn } from '../core';
@ -39,6 +40,7 @@ export interface KbnServer {
savedObjectMigrations: any;
savedObjectValidations: any;
savedObjectSchemas: SavedObjectsSchemaDefinition;
savedObjectsManagement: SavedObjectsManagementDefinition;
};
}

View file

@ -52,7 +52,7 @@ describe('POST /api/saved_objects/_export', () => {
},
};
server.route(createExportRoute(prereqs, server));
server.route(createExportRoute(prereqs, server, ['index-pattern', 'search']));
});
afterEach(() => {

View file

@ -24,8 +24,6 @@ import { SavedObjectsClient } from '../';
import { getSortedObjectsForExport } from '../export';
import { Prerequisites } from './types';
const ALLOWED_TYPES = ['index-pattern', 'search', 'visualization', 'dashboard'];
interface ExportRequest extends Hapi.Request {
pre: {
savedObjectsClient: SavedObjectsClient;
@ -40,7 +38,11 @@ interface ExportRequest extends Hapi.Request {
};
}
export const createExportRoute = (prereqs: Prerequisites, server: Hapi.Server) => ({
export const createExportRoute = (
prereqs: Prerequisites,
server: Hapi.Server,
supportedTypes: string[]
) => ({
path: '/api/saved_objects/_export',
method: 'POST',
config: {
@ -49,13 +51,13 @@ export const createExportRoute = (prereqs: Prerequisites, server: Hapi.Server) =
payload: Joi.object()
.keys({
type: Joi.array()
.items(Joi.string().valid(ALLOWED_TYPES))
.items(Joi.string().valid(supportedTypes))
.single()
.optional(),
objects: Joi.array()
.items({
type: Joi.string()
.valid(ALLOWED_TYPES)
.valid(supportedTypes)
.required(),
id: Joi.string().required(),
})

View file

@ -47,7 +47,9 @@ describe('POST /api/saved_objects/_import', () => {
},
};
server.route(createImportRoute(prereqs, server));
server.route(
createImportRoute(prereqs, server, ['index-pattern', 'visualization', 'dashboard'])
);
});
test('formats successful response', async () => {

View file

@ -44,7 +44,11 @@ interface ImportRequest extends WithoutQueryAndParams<Hapi.Request> {
};
}
export const createImportRoute = (prereqs: Prerequisites, server: Hapi.Server) => ({
export const createImportRoute = (
prereqs: Prerequisites,
server: Hapi.Server,
supportedTypes: string[]
) => ({
path: '/api/saved_objects/_import',
method: 'POST',
config: {
@ -73,6 +77,7 @@ export const createImportRoute = (prereqs: Prerequisites, server: Hapi.Server) =
return Boom.badRequest(`Invalid file extension ${fileExtension}`);
}
return await importSavedObjects({
supportedTypes,
savedObjectsClient,
readStream: request.payload.file,
objectLimit: request.server.config().get('savedObjects.maxImportExportSize'),

View file

@ -47,7 +47,13 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
},
};
server.route(createResolveImportErrorsRoute(prereqs, server));
server.route(
createResolveImportErrorsRoute(prereqs, server, [
'index-pattern',
'visualization',
'dashboard',
])
);
});
test('formats successful response', async () => {

View file

@ -51,7 +51,11 @@ interface ImportRequest extends Hapi.Request {
};
}
export const createResolveImportErrorsRoute = (prereqs: Prerequisites, server: Hapi.Server) => ({
export const createResolveImportErrorsRoute = (
prereqs: Prerequisites,
server: Hapi.Server,
supportedTypes: string[]
) => ({
path: '/api/saved_objects/_resolve_import_errors',
method: 'POST',
config: {
@ -95,6 +99,7 @@ export const createResolveImportErrorsRoute = (prereqs: Prerequisites, server: H
}
return await resolveImportErrors({
supportedTypes,
savedObjectsClient,
readStream: request.payload.file,
retries: request.payload.retries,

View file

@ -26,6 +26,7 @@ import {
ScopedSavedObjectsClientProvider,
} from './service';
import { getRootPropertiesObjects } from '../mappings';
import { SavedObjectsManagement } from './management';
import {
createBulkCreateRoute,
@ -41,10 +42,29 @@ import {
createLogLegacyImportRoute,
} from './routes';
function getImportableAndExportableTypes({ kbnServer, visibleTypes }) {
const { savedObjectsManagement = {} } = kbnServer.uiExports;
return visibleTypes.filter(
type =>
savedObjectsManagement[type] &&
savedObjectsManagement[type].isImportableAndExportable === true
);
}
export function savedObjectsMixin(kbnServer, server) {
const migrator = new KibanaMigrator({ kbnServer });
const mappings = migrator.getActiveMappings();
const allTypes = Object.keys(getRootPropertiesObjects(mappings));
const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas);
const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type));
const importableAndExportableTypes = getImportableAndExportableTypes({ kbnServer, visibleTypes });
server.decorate('server', 'kibanaMigrator', migrator);
server.decorate(
'server',
'getSavedObjectsManagement',
() => new SavedObjectsManagement(kbnServer.uiExports.savedObjectsManagement)
);
const warn = message => server.log(['warning', 'saved-objects'], message);
// we use kibana.index which is technically defined in the kibana plugin, so if
@ -69,16 +89,12 @@ export function savedObjectsMixin(kbnServer, server) {
server.route(createFindRoute(prereqs));
server.route(createGetRoute(prereqs));
server.route(createUpdateRoute(prereqs));
server.route(createExportRoute(prereqs, server));
server.route(createImportRoute(prereqs, server));
server.route(createResolveImportErrorsRoute(prereqs, server));
server.route(createExportRoute(prereqs, server, importableAndExportableTypes));
server.route(createImportRoute(prereqs, server, importableAndExportableTypes));
server.route(createResolveImportErrorsRoute(prereqs, server, importableAndExportableTypes));
server.route(createLogLegacyImportRoute());
const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas);
const serializer = new SavedObjectsSerializer(schema);
const mappings = migrator.getActiveMappings();
const allTypes = Object.keys(getRootPropertiesObjects(mappings));
const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type));
const createRepository = (callCluster, extraTypes = []) => {
if (typeof callCluster !== 'function') {

View file

@ -104,7 +104,7 @@ describe('Saved Objects Mixin', () => {
'kibanaMigrator',
expect.any(Object)
);
expect(mockServer.decorate).toHaveBeenCalledTimes(1);
expect(mockServer.decorate).toHaveBeenCalledTimes(2);
expect(mockServer.route).not.toHaveBeenCalled();
});
});

View file

@ -26,6 +26,7 @@ export {
mappings,
migrations,
savedObjectSchemas,
savedObjectsManagement,
validations,
} from './saved_object';

View file

@ -56,6 +56,8 @@ export const migrations = wrap(
export const savedObjectSchemas = wrap(uniqueKeys(), mergeAtType);
export const savedObjectsManagement = wrap(uniqueKeys(), mergeAtType);
// Combines the `validations` property of each plugin,
// ensuring that properties are unique across plugins.
// See saved_objects/validation for more details.

View file

@ -0,0 +1,305 @@
/*
* 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 expect from '@kbn/expect';
export default function ({ getService }) {
const es = getService('es');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('find', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200 with individual responses', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=visualization&fields=title')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 1,
saved_objects: [
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
version: 'WzIsMV0=',
attributes: {
'title': 'Count of requests'
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
references: [
{
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
},
],
updated_at: '2017-09-21T18:51:23.794Z',
meta: {
editUrl: '/management/kibana/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab',
icon: 'visualizeApp',
inAppUrl: {
path: '/app/kibana#/visualize/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab',
uiCapabilitiesPath: 'visualize.show',
},
title: 'Count of requests',
},
},
],
});
})
));
describe('unknown type', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=wigwags')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: []
});
})
));
});
describe('page beyond total', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=visualization&page=100&perPage=100')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 100,
per_page: 100,
total: 1,
saved_objects: []
});
})
));
});
describe('unknown search field', () => {
it('should return 400 when using searchFields', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=url&searchFields=a')
.expect(400)
.then(resp => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: '"searchFields" is not allowed',
validation: {
source: 'query',
keys: ['searchFields'],
},
});
})
));
});
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return 200 with empty response', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=visualization')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: [],
});
})
));
describe('unknown type', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=wigwags')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 1,
per_page: 20,
total: 0,
saved_objects: [],
});
})
));
});
describe('missing type', () => {
it('should return 400', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find')
.expect(400)
.then(resp => {
expect(resp.body).to.eql({
error: 'Bad Request',
message: 'child "type" fails because ["type" is required]',
statusCode: 400,
validation: {
keys: ['type'],
source: 'query'
},
});
})
));
});
describe('page beyond total', () => {
it('should return 200 with empty response', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=visualization&page=100&perPage=100')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
page: 100,
per_page: 100,
total: 0,
saved_objects: [],
});
})
));
});
describe('unknown search field', () => {
it('should return 400 when using searchFields', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=url&searchFields=a')
.expect(400)
.then(resp => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: '"searchFields" is not allowed',
validation: {
source: 'query',
keys: ['searchFields'],
},
});
})
));
});
});
describe('meta attributes injected properly', () => {
before(() => esArchiver.load('management/saved_objects'));
after(() => esArchiver.unload('management/saved_objects'));
it('should inject meta attributes for searches', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=search')
.expect(200)
.then(resp => {
expect(resp.body.saved_objects).to.have.length(1);
expect(resp.body.saved_objects[0].meta).to.eql({
icon: 'search',
title: 'OneRecord',
editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/discover/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
},
});
})
));
it('should inject meta attributes for dashboards', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=dashboard')
.expect(200)
.then(resp => {
expect(resp.body.saved_objects).to.have.length(1);
expect(resp.body.saved_objects[0].meta).to.eql({
icon: 'dashboardApp',
title: 'Dashboard',
editUrl: '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/dashboard/b70c7ae0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'dashboard.show',
},
});
})
));
it('should inject meta attributes for visualizations', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=visualization')
.expect(200)
.then(resp => {
expect(resp.body.saved_objects).to.have.length(2);
expect(resp.body.saved_objects[0].meta).to.eql({
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
});
expect(resp.body.saved_objects[1].meta).to.eql({
icon: 'visualizeApp',
title: 'Visualization',
editUrl: '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
});
})
));
it('should inject meta attributes for index patterns', async () => (
await supertest
.get('/api/kibana/management/saved_objects/_find?type=index-pattern')
.expect(200)
.then(resp => {
expect(resp.body.saved_objects).to.have.length(1);
expect(resp.body.saved_objects[0].meta).to.eql({
icon: 'indexPatternApp',
title: 'saved_objects*',
editUrl: '/management/kibana/index_patterns/8963ca30-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/management/kibana/index_patterns/8963ca30-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
});
})
));
});
});
}

View file

@ -19,6 +19,7 @@
export default function ({ loadTestFile }) {
describe('saved_objects', () => {
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./relationships'));
});
}

View file

@ -29,9 +29,17 @@ export default function ({ getService }) {
id: Joi.string()
.uuid()
.required(),
title: Joi.string()
.required()
.min(1),
type: Joi.string().required(),
relationship: Joi.string().valid('parent', 'child').required(),
meta: Joi.object().keys({
title: Joi.string().required(),
icon: Joi.string().required(),
editUrl: Joi.string().required(),
inAppUrl: Joi.object().keys({
path: Joi.string().required(),
uiCapabilitiesPath: Joi.string().required(),
}).required(),
}).required(),
})
);
@ -39,10 +47,6 @@ export default function ({ getService }) {
before(() => esArchiver.load('management/saved_objects'));
after(() => esArchiver.unload('management/saved_objects'));
const SEARCH_RESPONSE_SCHEMA = Joi.object().keys({
visualization: GENERIC_RESPONSE_SCHEMA,
'index-pattern': GENERIC_RESPONSE_SCHEMA,
});
const baseApiUrl = `/api/kibana/management/saved_objects/relationships`;
const coerceToArray = itemOrItems => [].concat(itemOrItems);
const getSavedObjectTypesQuery = types => coerceToArray(types).map(type => `savedObjectTypes=${type}`).join('&');
@ -54,7 +58,7 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/search/960372e0-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
const validationResult = Joi.validate(resp.body, SEARCH_RESPONSE_SCHEMA);
const validationResult = Joi.validate(resp.body, GENERIC_RESPONSE_SCHEMA);
expect(validationResult.error).to.be(null);
});
});
@ -64,37 +68,74 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/search/960372e0-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
visualization: [
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
title: 'VisualizationFromSavedSearch',
},
],
'index-pattern': [
{
id: '8963ca30-3224-11e8-a572-ffca06da1357',
expect(resp.body).to.eql([
{
id: '8963ca30-3224-11e8-a572-ffca06da1357',
type: 'index-pattern',
relationship: 'child',
meta: {
title: 'saved_objects*',
icon: 'indexPatternApp',
editUrl: '/management/kibana/index_patterns/8963ca30-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/management/kibana/index_patterns/8963ca30-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
],
});
},
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
type: 'visualization',
relationship: 'parent',
meta: {
title: 'VisualizationFromSavedSearch',
icon: 'visualizeApp',
editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
},
]);
});
});
it('should filter based on savedObjectTypes', async () => {
await supertest
.get(`${baseApiUrl}/search/960372e0-3224-11e8-a572-ffca06da1357?${getSavedObjectTypesQuery('visualization')}`)
.expect(res => console.log(res.text))
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
visualization: [
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
title: 'VisualizationFromSavedSearch',
expect(resp.body).to.eql([
{
id: '8963ca30-3224-11e8-a572-ffca06da1357',
type: 'index-pattern',
meta: {
icon: 'indexPatternApp',
title: 'saved_objects*',
editUrl: '/management/kibana/index_patterns/8963ca30-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/management/kibana/index_patterns/8963ca30-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'management.kibana.index_patterns',
},
},
]
});
relationship: 'child'
},
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
type: 'visualization',
meta: {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
relationship: 'parent',
},
]);
});
});
@ -105,16 +146,12 @@ export default function ({ getService }) {
});
describe('dashboards', async () => {
const DASHBOARD_RESPONSE_SCHEMA = Joi.object().keys({
visualization: GENERIC_RESPONSE_SCHEMA,
});
it('should validate dashboard response schema', async () => {
await supertest
.get(`${baseApiUrl}/dashboard/b70c7ae0-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
const validationResult = Joi.validate(resp.body, DASHBOARD_RESPONSE_SCHEMA);
const validationResult = Joi.validate(resp.body, GENERIC_RESPONSE_SCHEMA);
expect(validationResult.error).to.be(null);
});
});
@ -124,18 +161,36 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/dashboard/b70c7ae0-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
visualization: [
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
expect(resp.body).to.eql([
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
type: 'visualization',
relationship: 'child',
meta: {
icon: 'visualizeApp',
title: 'Visualization',
editUrl: '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
},
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
type: 'visualization',
relationship: 'child',
meta: {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
],
});
},
]);
});
});
@ -144,7 +199,36 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/dashboard/b70c7ae0-3224-11e8-a572-ffca06da1357?${getSavedObjectTypesQuery('search')}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({});
expect(resp.body).to.eql([
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
type: 'visualization',
meta: {
icon: 'visualizeApp',
title: 'Visualization',
editUrl: '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
relationship: 'child',
},
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
type: 'visualization',
meta: {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
editUrl: '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
relationship: 'child',
},
]);
});
});
@ -157,17 +241,12 @@ export default function ({ getService }) {
});
describe('visualizations', async () => {
const VISUALIZATIONS_RESPONSE_SCHEMA = Joi.object().keys({
dashboard: GENERIC_RESPONSE_SCHEMA,
search: GENERIC_RESPONSE_SCHEMA,
});
it('should validate visualization response schema', async () => {
await supertest
.get(`${baseApiUrl}/visualization/a42c0580-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
const validationResult = Joi.validate(resp.body, VISUALIZATIONS_RESPONSE_SCHEMA);
const validationResult = Joi.validate(resp.body, GENERIC_RESPONSE_SCHEMA);
expect(validationResult.error).to.be(null);
});
});
@ -177,20 +256,36 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/visualization/a42c0580-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
search: [
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
title: 'OneRecord'
expect(resp.body).to.eql([
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
type: 'search',
relationship: 'child',
meta: {
icon: 'search',
title: 'OneRecord',
editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/discover/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
},
},
],
dashboard: [
{
id: 'b70c7ae0-3224-11e8-a572-ffca06da1357',
},
{
id: 'b70c7ae0-3224-11e8-a572-ffca06da1357',
type: 'dashboard',
relationship: 'parent',
meta: {
icon: 'dashboardApp',
title: 'Dashboard',
editUrl: '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/dashboard/b70c7ae0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'dashboard.show',
},
},
],
});
},
]);
});
});
@ -199,14 +294,22 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/visualization/a42c0580-3224-11e8-a572-ffca06da1357?${getSavedObjectTypesQuery('search')}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
search: [
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
title: 'OneRecord'
expect(resp.body).to.eql([
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
type: 'search',
meta: {
icon: 'search',
title: 'OneRecord',
editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/discover/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
},
},
]
});
relationship: 'child',
},
]);
});
});
@ -218,17 +321,12 @@ export default function ({ getService }) {
});
describe('index patterns', async () => {
const INDEX_PATTERN_RESPONSE_SCHEMA = Joi.object().keys({
search: GENERIC_RESPONSE_SCHEMA,
visualization: GENERIC_RESPONSE_SCHEMA,
});
it('should validate visualization response schema', async () => {
await supertest
.get(`${baseApiUrl}/index-pattern/8963ca30-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
const validationResult = Joi.validate(resp.body, INDEX_PATTERN_RESPONSE_SCHEMA);
const validationResult = Joi.validate(resp.body, GENERIC_RESPONSE_SCHEMA);
expect(validationResult.error).to.be(null);
});
});
@ -238,20 +336,36 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/index-pattern/8963ca30-3224-11e8-a572-ffca06da1357?${defaultQuery}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
search: [
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
expect(resp.body).to.eql([
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
type: 'search',
relationship: 'parent',
meta: {
icon: 'search',
title: 'OneRecord',
editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/discover/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
},
},
],
visualization: [
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
},
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
type: 'visualization',
relationship: 'parent',
meta: {
icon: 'visualizeApp',
title: 'Visualization',
editUrl: '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/visualize/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
},
},
],
});
},
]);
});
});
@ -260,14 +374,22 @@ export default function ({ getService }) {
.get(`${baseApiUrl}/index-pattern/8963ca30-3224-11e8-a572-ffca06da1357?${getSavedObjectTypesQuery('search')}`)
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
search: [
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
expect(resp.body).to.eql([
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
type: 'search',
meta: {
icon: 'search',
title: 'OneRecord',
editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/kibana#/discover/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
},
},
]
});
relationship: 'parent',
},
]);
});
});

View file

@ -49,47 +49,6 @@ export default function ({ getService }) {
});
});
it('should validate types', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['foo'],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
// eslint-disable-next-line max-len
message: 'child "type" fails because ["type" at position 0 fails because ["0" must be one of [index-pattern, search, visualization, dashboard]]]',
validation: { source: 'payload', keys: [ 'type.0' ] },
});
});
});
it('should validate types in objects', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
objects: [
{
type: 'foo',
id: '1',
},
],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
// eslint-disable-next-line max-len
message: 'child "objects" fails because ["objects" at position 0 fails because [child "type" fails because ["type" must be one of [index-pattern, search, visualization, dashboard]]]]',
validation: { source: 'payload', keys: [ 'objects.0.type' ] },
});
});
});
it('should support including dependencies when exporting selected objects', async () => {
await supertest
.post('/api/saved_objects/_export')
@ -167,6 +126,27 @@ export default function ({ getService }) {
});
});
});
it(`should return 400 when exporting unsupported type`, async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['wigwags'],
})
.expect(400)
.then(resp => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: 'child "type" fails because ["type" at position 0 fails because ' +
'["0" must be one of [config, index-pattern, visualization, search, dashboard, url]]]',
validation: {
source: 'payload',
keys: ['type.0'],
}
});
});
});
});
describe('10,000 objects', () => {

View file

@ -112,6 +112,30 @@ export default function ({ getService }) {
});
});
it('should return 200 when trying to import unsupported types', async () => {
const fileBuffer = Buffer.from('{"id":"1","type":"wigwags","attributes":{"title":"my title"},"references":[]}', 'utf8');
await supertest
.post('/api/saved_objects/_import')
.attach('file', fileBuffer, 'export.ndjson')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
success: false,
successCount: 0,
errors: [
{
id: '1',
type: 'wigwags',
title: 'my title',
error: {
type: 'unsupported_type',
},
},
],
});
});
});
it('should return 400 when trying to import more than 10,000 objects', async () => {
const fileChunks = [];
for (let i = 0; i < 10001; i++) {

View file

@ -88,6 +88,31 @@ export default function ({ getService }) {
});
});
it('should return 200 when retrying unsupported types', async () => {
const fileBuffer = Buffer.from('{"id":"1","type":"wigwags","attributes":{"title":"my title"},"references":[]}', 'utf8');
await supertest
.post('/api/saved_objects/_resolve_import_errors')
.field('retries', JSON.stringify([{ type: 'wigwags', id: '1' }]))
.attach('file', fileBuffer, 'export.ndjson')
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
success: false,
successCount: 0,
errors: [
{
id: '1',
type: 'wigwags',
title: 'my title',
error: {
type: 'unsupported_type',
},
},
],
});
});
});
it('should return 400 when resolving conflicts with a file containing more than 10,000 objects', async () => {
const fileChunks = [];
for (let i = 0; i < 10001; i++) {

View file

@ -638,7 +638,7 @@ export function SettingsPageProvider({ getService, getPageObjects }) {
const title = await titleCell.getVisibleText();
const viewInAppButtons = await row.findAllByCssSelector('[aria-label="In app"]');
const viewInAppButtons = await row.findAllByCssSelector('td:nth-child(3) a');
const canViewInApp = Boolean(viewInAppButtons.length);
summary.push({
title,

View file

@ -7,7 +7,7 @@
import { resolve } from 'path';
import init from './init';
import { mappings } from './server/mappings';
import { CANVAS_APP } from './common/lib';
import { CANVAS_APP, CANVAS_TYPE, CUSTOM_ELEMENT_TYPE } from './common/lib';
import { migrations } from './migrations';
export function canvas(kibana) {
@ -33,6 +33,30 @@ export function canvas(kibana) {
home: ['plugins/canvas/register_feature'],
mappings,
migrations,
savedObjectsManagement: {
[CANVAS_TYPE]: {
icon: 'canvasApp',
defaultSearchField: 'name',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.name;
},
getInAppUrl(obj) {
return {
path: `/app/canvas#/workpad/${encodeURIComponent(obj.id)}`,
uiCapabilitiesPath: 'canvas.show',
};
},
},
[CUSTOM_ELEMENT_TYPE]: {
icon: 'canvasApp',
defaultSearchField: 'name',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.displayName;
},
},
},
},
config: Joi => {

View file

@ -48,14 +48,14 @@ export default async function(server /*options*/) {
all: ['canvas-workpad', 'canvas-element'],
read: ['index-pattern'],
},
ui: ['save'],
ui: ['save', 'show'],
},
read: {
savedObject: {
all: [],
read: ['index-pattern', 'canvas-workpad', 'canvas-element'],
},
ui: [],
ui: ['show'],
},
},
});

View file

@ -57,6 +57,22 @@ export function maps(kibana) {
isNamespaceAgnostic: true
}
},
savedObjectsManagement: {
'map': {
icon: APP_ICON,
defaultSearchField: 'title',
isImportableAndExportable: true,
getTitle(obj) {
return obj.attributes.title;
},
getInAppUrl(obj) {
return {
path: createMapPath(obj.id),
uiCapabilitiesPath: 'maps.show',
};
},
},
},
mappings,
migrations,
},
@ -94,14 +110,14 @@ export function maps(kibana) {
all: ['map'],
read: ['index-pattern']
},
ui: ['save'],
ui: ['save', 'show'],
},
read: {
savedObject: {
all: [],
read: ['map', 'index-pattern']
},
ui: [],
ui: ['show'],
},
}
});

View file

@ -1558,24 +1558,10 @@
"kbn.management.objects.objectsTable.header.refreshButtonLabel": "刷新",
"kbn.management.objects.objectsTable.header.savedObjectsTitle": "已保存对象",
"kbn.management.objects.objectsTable.howToDeleteSavedObjectsDescription": "从这里您可以删除已保存对象,如已保存搜索。还可以编辑已保存对象的原始数据。通常,对象只能通过其关联的应用程序进行修改;或许您应该遵循这一原则,而非使用此屏幕进行修改。",
"kbn.management.objects.objectsTable.relationships.columnActions.inAppDescription": "在 Kibana 内查看此已保存对象",
"kbn.management.objects.objectsTable.relationships.columnActions.inAppName": "应用内",
"kbn.management.objects.objectsTable.relationships.columnActionsName": "操作",
"kbn.management.objects.objectsTable.relationships.columnTitleName": "标题",
"kbn.management.objects.objectsTable.relationships.dashboard.calloutText": "以下是此仪表板上使用的某些可视化。删除此仪表板不会有任何问题,可视化仍会正常工作。",
"kbn.management.objects.objectsTable.relationships.dashboard.calloutTitle": "仪表板",
"kbn.management.objects.objectsTable.relationships.indexPattern.searches.calloutText": "以下是使用此索引模式的一些已保存搜索。如果您删除此索引模式,这些已保存搜索将无法再正常工作。",
"kbn.management.objects.objectsTable.relationships.indexPattern.visualizations.calloutText": "以下是使用此索引模式的一些可视化。如果您删除此索引模式,这些可视化将无法再正常工作。",
"kbn.management.objects.objectsTable.relationships.itemNotFoundText": "未找到任何{type}。",
"kbn.management.objects.objectsTable.relationships.renderErrorMessage": "错误",
"kbn.management.objects.objectsTable.relationships.search.calloutText": "以下是与此已保存搜索绑定的索引模式。",
"kbn.management.objects.objectsTable.relationships.search.calloutTitle": "已保存搜索",
"kbn.management.objects.objectsTable.relationships.search.visualizations.calloutText": "以下是使用此已保存搜索的一些可视化。如果您删除此已保存搜索,这些可视化将无法再正常工作。",
"kbn.management.objects.objectsTable.relationships.visualization.calloutText": "以下是包含此可视化的一些仪表板。如果您删除此可视化,这些仪表板将不再显示它们。",
"kbn.management.objects.objectsTable.relationships.warningTitle": "警告",
"kbn.management.objects.objectsTable.searchBar.unableToParseQueryErrorMessage": "无法解析查询",
"kbn.management.objects.objectsTable.table.columnActions.viewInAppActionDescription": "在 Kibana 内查看此已保存对象",
"kbn.management.objects.objectsTable.table.columnActions.viewInAppActionName": "应用内",
"kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionDescription": "查看此已保存对象与其他已保存对象的关系",
"kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionName": "关系",
"kbn.management.objects.objectsTable.table.columnActionsName": "操作",
@ -1591,7 +1577,6 @@
"kbn.management.objects.savedObjectsDescription": "导入、导出和管理您的已保存搜索、可视化和仪表板。",
"kbn.management.objects.savedObjectsSectionLabel": "已保存对象",
"kbn.management.objects.savedObjectsTitle": "已保存对象",
"kbn.management.objects.unknownSavedObjectTypeNotificationMessage": "未知已保存对象类型:{type}",
"kbn.management.objects.view.cancelButtonAriaLabel": "取消",
"kbn.management.objects.view.cancelButtonLabel": "取消",
"kbn.management.objects.view.deleteItemButtonLabel": "删除“{title}”",

View file

@ -678,6 +678,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/index-pattern/read`,
`ui:${version}:savedObjectsManagement/config/read`,
`ui:${version}:maps/save`,
`ui:${version}:maps/show`,
'allHack:',
],
read: [
@ -699,6 +700,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/map/read`,
`ui:${version}:savedObjectsManagement/index-pattern/read`,
`ui:${version}:savedObjectsManagement/config/read`,
`ui:${version}:maps/show`,
],
},
canvas: {
@ -748,6 +750,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/index-pattern/read`,
`ui:${version}:savedObjectsManagement/config/read`,
`ui:${version}:canvas/save`,
`ui:${version}:canvas/show`,
'allHack:',
],
read: [
@ -773,6 +776,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/canvas-workpad/read`,
`ui:${version}:savedObjectsManagement/canvas-element/read`,
`ui:${version}:savedObjectsManagement/config/read`,
`ui:${version}:canvas/show`,
],
},
infrastructure: {
@ -1138,6 +1142,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/map/delete`,
`ui:${version}:savedObjectsManagement/map/edit`,
`ui:${version}:maps/save`,
`ui:${version}:maps/show`,
`app:${version}:canvas`,
`ui:${version}:catalogue/canvas`,
`ui:${version}:navLinks/canvas`,
@ -1158,6 +1163,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/canvas-element/edit`,
`ui:${version}:savedObjectsManagement/canvas-element/read`,
`ui:${version}:canvas/save`,
`ui:${version}:canvas/show`,
`api:${version}:infra`,
`app:${version}:infra`,
`ui:${version}:catalogue/infraops`,
@ -1274,6 +1280,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`app:${version}:maps`,
`ui:${version}:catalogue/maps`,
`ui:${version}:navLinks/maps`,
`ui:${version}:maps/show`,
`app:${version}:canvas`,
`ui:${version}:catalogue/canvas`,
`ui:${version}:navLinks/canvas`,
@ -1281,6 +1288,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`saved_object:${version}:canvas-element/get`,
`saved_object:${version}:canvas-element/find`,
`ui:${version}:savedObjectsManagement/canvas-element/read`,
`ui:${version}:canvas/show`,
`api:${version}:infra`,
`app:${version}:infra`,
`ui:${version}:catalogue/infraops`,
@ -1468,6 +1476,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/map/delete`,
`ui:${version}:savedObjectsManagement/map/edit`,
`ui:${version}:maps/save`,
`ui:${version}:maps/show`,
`app:${version}:canvas`,
`ui:${version}:catalogue/canvas`,
`ui:${version}:navLinks/canvas`,
@ -1488,6 +1497,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`ui:${version}:savedObjectsManagement/canvas-element/edit`,
`ui:${version}:savedObjectsManagement/canvas-element/read`,
`ui:${version}:canvas/save`,
`ui:${version}:canvas/show`,
`api:${version}:infra`,
`app:${version}:infra`,
`ui:${version}:catalogue/infraops`,
@ -1604,6 +1614,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`app:${version}:maps`,
`ui:${version}:catalogue/maps`,
`ui:${version}:navLinks/maps`,
`ui:${version}:maps/show`,
`app:${version}:canvas`,
`ui:${version}:catalogue/canvas`,
`ui:${version}:navLinks/canvas`,
@ -1611,6 +1622,7 @@ export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
`saved_object:${version}:canvas-element/get`,
`saved_object:${version}:canvas-element/find`,
`ui:${version}:savedObjectsManagement/canvas-element/read`,
`ui:${version}:canvas/show`,
`api:${version}:infra`,
`app:${version}:infra`,
`ui:${version}:catalogue/infraops`,

View file

@ -12,10 +12,13 @@ export default function({ getPageObjects, getService }: KibanaFunctionalTestDefa
const security = getService('security');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common', 'settings', 'security']);
let version: string = '';
describe('feature controls saved objects management', () => {
before(async () => {
await esArchiver.load('saved_objects_management/feature_controls/security');
const versionService = getService('kibanaServer').version;
version = await versionService.get();
});
after(async () => {
@ -65,12 +68,26 @@ export default function({ getPageObjects, getService }: KibanaFunctionalTestDefa
it('shows all saved objects', async () => {
const objects = await PageObjects.settings.getSavedObjectsInTable();
expect(objects).to.eql(['A Dashboard', 'logstash-*', 'A Pie']);
expect(objects).to.eql([
'Advanced Settings [6.0.0]',
`Advanced Settings [${version}]`,
'A Dashboard',
'logstash-*',
'A Pie',
]);
});
it('can view all saved objects in applications', async () => {
const bools = await PageObjects.settings.getSavedObjectsTableSummary();
expect(bools).to.eql([
{
title: 'Advanced Settings [6.0.0]',
canViewInApp: false,
},
{
title: `Advanced Settings [${version}]`,
canViewInApp: false,
},
{
title: 'A Dashboard',
canViewInApp: true,
@ -171,14 +188,27 @@ export default function({ getPageObjects, getService }: KibanaFunctionalTestDefa
await PageObjects.settings.clickKibanaSavedObjects();
});
it('shows a visualization and an index pattern', async () => {
it('shows two configs, a visualization and an index pattern', async () => {
const objects = await PageObjects.settings.getSavedObjectsInTable();
expect(objects).to.eql(['logstash-*', 'A Pie']);
expect(objects).to.eql([
'Advanced Settings [6.0.0]',
`Advanced Settings [${version}]`,
'logstash-*',
'A Pie',
]);
});
it('can view only the visualization in application', async () => {
it('can view only two configs and the visualization in application', async () => {
const bools = await PageObjects.settings.getSavedObjectsTableSummary();
expect(bools).to.eql([
{
title: 'Advanced Settings [6.0.0]',
canViewInApp: false,
},
{
title: `Advanced Settings [${version}]`,
canViewInApp: false,
},
{
title: 'logstash-*',
canViewInApp: false,

View file

@ -11,6 +11,11 @@ export default function (kibana) {
require: ['kibana', 'elasticsearch', 'xpack_main'],
name: 'namespace_agnostic_type_plugin',
uiExports: {
savedObjectsManagement: {
globaltype: {
isImportableAndExportable: true,
},
},
savedObjectSchemas: {
globaltype: {
isNamespaceAgnostic: true

View file

@ -63,10 +63,7 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
type: 'wigwags',
title: 'Wigwags title',
error: {
message: `Unsupported saved object type: 'wigwags': Bad Request`,
statusCode: 400,
error: 'Bad Request',
type: 'unknown',
type: 'unsupported_type',
},
},
],
@ -81,22 +78,6 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
});
};
const expectRbacForbiddenWithUnknownType = (resp: { [key: string]: any }) => {
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to bulk_create dashboard,globaltype,wigwags`,
});
};
const expectRbacForbiddenForUnknownType = (resp: { [key: string]: any }) => {
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to bulk_create wigwags`,
});
};
const makeImportTest = (describeFn: DescribeFn) => (
description: string,
definition: ImportTestDefinition
@ -156,7 +137,5 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
createExpectResults,
expectRbacForbidden,
expectUnknownType,
expectRbacForbiddenWithUnknownType,
expectRbacForbiddenForUnknownType,
};
}

View file

@ -67,10 +67,7 @@ export function resolveImportErrorsTestSuiteFactory(
type: 'wigwags',
title: 'Wigwags title',
error: {
message: `Unsupported saved object type: 'wigwags': Bad Request`,
statusCode: 400,
error: 'Bad Request',
type: 'unknown',
type: 'unsupported_type',
},
},
],
@ -85,22 +82,6 @@ export function resolveImportErrorsTestSuiteFactory(
});
};
const expectRbacForbiddenWithUnknownType = (resp: { [key: string]: any }) => {
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to bulk_create dashboard,wigwags`,
});
};
const expectRbacForbiddenForUnknownType = (resp: { [key: string]: any }) => {
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to bulk_create wigwags`,
});
};
const makeResolveImportErrorsTest = (describeFn: DescribeFn) => (
description: string,
definition: ResolveImportErrorsTestDefinition
@ -184,7 +165,5 @@ export function resolveImportErrorsTestSuiteFactory(
createExpectResults,
expectRbacForbidden,
expectUnknownType,
expectRbacForbiddenWithUnknownType,
expectRbacForbiddenForUnknownType,
};
}

View file

@ -20,8 +20,6 @@ export default function({ getService }: TestInvoker) {
createExpectResults,
expectRbacForbidden,
expectUnknownType,
expectRbacForbiddenWithUnknownType,
expectRbacForbiddenForUnknownType,
} = importTestSuiteFactory(es, esArchiver, supertest);
describe('_import', () => {
@ -67,7 +65,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -97,7 +95,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -111,8 +109,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(scenario.spaceId),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -127,7 +125,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -141,8 +139,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(scenario.spaceId),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -157,7 +155,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -171,8 +169,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(scenario.spaceId),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -187,7 +185,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -202,7 +200,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});

View file

@ -20,8 +20,6 @@ export default function({ getService }: TestInvoker) {
createExpectResults,
expectRbacForbidden,
expectUnknownType,
expectRbacForbiddenWithUnknownType,
expectRbacForbiddenForUnknownType,
} = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest);
describe('_resolve_import_errors', () => {
@ -67,7 +65,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -97,7 +95,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -111,8 +109,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(scenario.spaceId),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -129,7 +127,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
}
@ -144,8 +142,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(scenario.spaceId),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -160,7 +158,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -176,8 +174,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(scenario.spaceId),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
}
@ -195,7 +193,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
}
@ -213,7 +211,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
}

View file

@ -19,8 +19,6 @@ export default function({ getService }: TestInvoker) {
createExpectResults,
expectRbacForbidden,
expectUnknownType,
expectRbacForbiddenWithUnknownType,
expectRbacForbiddenForUnknownType,
} = importTestSuiteFactory(es, esArchiver, supertest);
describe('_import', () => {
@ -33,7 +31,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -61,7 +59,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -74,8 +72,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -89,7 +87,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -102,8 +100,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -117,7 +115,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -131,7 +129,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -145,7 +143,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -159,7 +157,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -173,7 +171,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});

View file

@ -19,8 +19,6 @@ export default function({ getService }: TestInvoker) {
createExpectResults,
expectRbacForbidden,
expectUnknownType,
expectRbacForbiddenWithUnknownType,
expectRbacForbiddenForUnknownType,
} = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest);
describe('_resolve_import_errors', () => {
@ -33,7 +31,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -61,7 +59,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -74,8 +72,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -89,7 +87,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -102,8 +100,8 @@ export default function({ getService }: TestInvoker) {
response: createExpectResults(),
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenForUnknownType,
statusCode: 200,
response: expectUnknownType,
},
},
});
@ -117,7 +115,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -131,7 +129,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -145,7 +143,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -159,7 +157,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});
@ -173,7 +171,7 @@ export default function({ getService }: TestInvoker) {
},
unknownType: {
statusCode: 403,
response: expectRbacForbiddenWithUnknownType,
response: expectRbacForbidden,
},
},
});