diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e45d4f28edcc3..b3d6c3e0763c2 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -51,7 +51,11 @@ import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plug import { ContextSetup } from './context'; import { IUiSettingsClient, UiSettingsServiceSetup, UiSettingsServiceStart } from './ui_settings'; import { SavedObjectsClientContract } from './saved_objects/types'; -import { SavedObjectsServiceSetup, SavedObjectsServiceStart } from './saved_objects'; +import { + ISavedObjectTypeRegistry, + SavedObjectsServiceSetup, + SavedObjectsServiceStart, +} from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { UuidServiceSetup } from './uuid'; @@ -227,6 +231,7 @@ export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry, SavedObjectsType, + SavedObjectsTypeManagementDefinition, SavedObjectMigrationMap, SavedObjectMigrationFn, } from './saved_objects'; @@ -291,6 +296,7 @@ export interface RequestHandlerContext { rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; }; elasticsearch: { dataClient: IScopedClusterClient; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 96b28ab5827e1..ceb74c1eef0bf 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -26,6 +26,7 @@ import { httpServiceMock } from './http/http_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; +import { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { InternalCoreSetup, InternalCoreStart } from './internal_types'; @@ -173,6 +174,7 @@ function createCoreRequestHandlerContextMock() { }, savedObjects: { client: savedObjectsClientMock.create(), + typeRegistry: savedObjectsTypeRegistryMock.create(), }, elasticsearch: { adminClient: elasticsearchServiceMock.createScopedClusterClient(), diff --git a/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap b/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap index 89ff2b542c60f..5431d2ca47892 100644 --- a/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap +++ b/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap @@ -6,6 +6,7 @@ Array [ "convertToAliasScript": undefined, "hidden": false, "indexPattern": undefined, + "management": undefined, "mappings": Object { "properties": Object { "fieldA": Object { @@ -21,6 +22,7 @@ Array [ "convertToAliasScript": undefined, "hidden": false, "indexPattern": undefined, + "management": undefined, "mappings": Object { "properties": Object { "fieldB": Object { @@ -36,6 +38,7 @@ Array [ "convertToAliasScript": undefined, "hidden": false, "indexPattern": undefined, + "management": undefined, "mappings": Object { "properties": Object { "fieldC": Object { @@ -56,6 +59,7 @@ Array [ "convertToAliasScript": undefined, "hidden": true, "indexPattern": "myIndex", + "management": undefined, "mappings": Object { "properties": Object { "fieldA": Object { @@ -74,6 +78,7 @@ Array [ "convertToAliasScript": "some alias script", "hidden": false, "indexPattern": undefined, + "management": undefined, "mappings": Object { "properties": Object { "anotherFieldB": Object { @@ -92,6 +97,7 @@ Array [ "convertToAliasScript": undefined, "hidden": false, "indexPattern": undefined, + "management": undefined, "mappings": Object { "properties": Object { "fieldC": Object { @@ -114,6 +120,7 @@ Array [ "convertToAliasScript": undefined, "hidden": true, "indexPattern": "fooBar", + "management": undefined, "mappings": Object { "properties": Object { "fieldA": Object { @@ -129,6 +136,7 @@ Array [ "convertToAliasScript": undefined, "hidden": false, "indexPattern": undefined, + "management": undefined, "mappings": Object { "properties": Object { "fieldC": Object { diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 9bfe658028258..2e9077d20fdde 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -21,8 +21,6 @@ export * from './service'; export { SavedObjectsSchema } from './schema'; -export { SavedObjectsManagement } from './management'; - export * from './import'; export { @@ -70,7 +68,7 @@ export { SavedObjectMigrationContext, } from './migrations'; -export { SavedObjectsType } from './types'; +export { SavedObjectsType, SavedObjectsTypeManagementDefinition } from './types'; export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; diff --git a/src/core/server/saved_objects/management/management.test.ts b/src/core/server/saved_objects/management/management.test.ts deleted file mode 100644 index e936326d957f9..0000000000000 --- a/src/core/server/saved_objects/management/management.test.ts +++ /dev/null @@ -1,174 +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 { 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' }); - }); -}); diff --git a/src/core/server/saved_objects/management/management.ts b/src/core/server/saved_objects/management/management.ts deleted file mode 100644 index b7dce2c087c5f..0000000000000 --- a/src/core/server/saved_objects/management/management.ts +++ /dev/null @@ -1,91 +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 { SavedObject } from '../types'; - -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); - } - } - } -} diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index ab287332d8a65..015f7c729cdd0 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -31,13 +31,13 @@ import { getSortedObjectsForExport } from '../export'; export const registerExportRoute = ( router: IRouter, config: SavedObjectConfig, - supportedTypes: string[] + getSupportedTypes: () => string[] ) => { const { maxImportExportSize } = config; const typeSchema = schema.string({ validate: (type: string) => { - if (!supportedTypes.includes(type)) { + if (!getSupportedTypes().includes(type)) { return `${type} is not exportable`; } }, diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index e3f249dca05f7..57947447f3c03 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -34,7 +34,7 @@ interface FileStream extends Readable { export const registerImportRoute = ( router: IRouter, config: SavedObjectConfig, - supportedTypes: string[] + getSupportedTypes: () => string[] ) => { const { maxImportExportSize, maxImportPayloadBytes } = config; @@ -66,7 +66,7 @@ export const registerImportRoute = ( } const result = await importSavedObjects({ - supportedTypes, + supportedTypes: getSupportedTypes(), savedObjectsClient: context.core.savedObjects.client, readStream: createSavedObjectsStreamFromNdJson(file), objectLimit: maxImportExportSize, diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 0afa24b18760b..ddc1204fce611 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -39,13 +39,13 @@ export function registerRoutes({ http, logger, config, - importableExportableTypes, + getImportableAndExportableTypes, migratorPromise, }: { http: InternalHttpServiceSetup; logger: Logger; config: SavedObjectConfig; - importableExportableTypes: string[]; + getImportableAndExportableTypes: () => string[]; migratorPromise: Promise; }) { const router = http.createRouter('/api/saved_objects/'); @@ -59,9 +59,9 @@ export function registerRoutes({ registerBulkCreateRoute(router); registerBulkUpdateRoute(router); registerLogLegacyImportRoute(router, logger); - registerExportRoute(router, config, importableExportableTypes); - registerImportRoute(router, config, importableExportableTypes); - registerResolveImportErrorsRoute(router, config, importableExportableTypes); + registerExportRoute(router, config, getImportableAndExportableTypes); + registerImportRoute(router, config, getImportableAndExportableTypes); + registerResolveImportErrorsRoute(router, config, getImportableAndExportableTypes); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index b52a8957176cc..a480d52c022f5 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -45,7 +45,7 @@ describe('POST /api/saved_objects/_export', () => { ({ server, httpSetup } = await setupServer()); const router = httpSetup.createRouter('/api/saved_objects/'); - registerExportRoute(router, config, allowedTypes); + registerExportRoute(router, config, () => allowedTypes); await server.start(); }); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 954e6d9e4831a..0a0af94701ce9 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -52,7 +52,7 @@ describe('POST /internal/saved_objects/_import', () => { savedObjectsClient.find.mockResolvedValue(emptyResponse); const router = httpSetup.createRouter('/internal/saved_objects/'); - registerImportRoute(router, config, allowedTypes); + registerImportRoute(router, config, () => allowedTypes); await server.start(); }); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index c2974395217f8..b1146273d301d 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -43,7 +43,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => { savedObjectsClient = handlerContext.savedObjects.client; const router = httpSetup.createRouter('/api/saved_objects/'); - registerResolveImportErrorsRoute(router, config, allowedTypes); + registerResolveImportErrorsRoute(router, config, () => allowedTypes); await server.start(); }); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index efa7add7951b0..3e738ed43d9ce 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -34,7 +34,7 @@ interface FileStream extends Readable { export const registerResolveImportErrorsRoute = ( router: IRouter, config: SavedObjectConfig, - supportedTypes: string[] + getSupportedTypes: () => string[] ) => { const { maxImportExportSize, maxImportPayloadBytes } = config; @@ -76,7 +76,7 @@ export const registerResolveImportErrorsRoute = ( return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } const result = await resolveImportErrors({ - supportedTypes, + supportedTypes: getSupportedTypes(), savedObjectsClient: context.core.savedObjects.client, readStream: createSavedObjectsStreamFromNdJson(file), retries: req.body.retries, diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 62e25ad5fb458..257c37785a597 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -38,7 +38,7 @@ import { SavedObjectConfig, } from './saved_objects_config'; import { KibanaRequest, InternalHttpServiceSetup } from '../http'; -import { SavedObjectsClientContract, SavedObjectsType, SavedObjectsLegacyUiExports } from './types'; +import { SavedObjectsClientContract, SavedObjectsType } from './types'; import { ISavedObjectsRepository, SavedObjectsRepository } from './service/lib/repository'; import { SavedObjectsClientFactoryProvider, @@ -296,9 +296,12 @@ export class SavedObjectsService legacyTypes.forEach(type => this.typeRegistry.registerType(type)); this.validations = setupDeps.legacyPlugins.uiExports.savedObjectValidations || {}; - const importableExportableTypes = getImportableAndExportableTypes( - setupDeps.legacyPlugins.uiExports - ); + const getImportableAndExportableTypes = () => { + return this.typeRegistry + .getAllTypes() + .map(type => type.name) + .filter(type => this.typeRegistry.isImportableAndExportable(type)); + }; const savedObjectsConfig = await this.coreContext.configService .atPath('savedObjects') @@ -315,7 +318,7 @@ export class SavedObjectsService logger: this.logger, config: this.config, migratorPromise: this.migrator$.pipe(first()).toPromise(), - importableExportableTypes, + getImportableAndExportableTypes, }); return { @@ -449,7 +452,9 @@ export class SavedObjectsService }; } - public async stop() {} + public async stop() { + this.migrator$.complete(); + } private createMigrator( kibanaConfig: KibanaConfigType, @@ -473,16 +478,3 @@ export class SavedObjectsService }); } } - -function getImportableAndExportableTypes({ - savedObjectMappings = [], - savedObjectsManagement = {}, -}: SavedObjectsLegacyUiExports) { - const visibleTypes = savedObjectMappings.reduce( - (types, mapping) => [...types, ...Object.keys(mapping.properties)], - [] as string[] - ); - return visibleTypes.filter( - type => savedObjectsManagement[type]?.isImportableAndExportable === true ?? false - ); -} diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 435e352335ecf..f1930b3bf00a0 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -28,11 +28,13 @@ const createRegistryMock = (): jest.Mocked type === 'global'); + mock.isImportableAndExportable.mockReturnValue(true); return mock; }; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index 4268ab7718f8d..d481d4f9b0430 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -212,4 +212,28 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.getIndex('unknownType')).toBeUndefined(); }); }); + + describe('#isImportableAndExportable', () => { + it('returns correct value for the type', () => { + registry.registerType( + createType({ name: 'typeA', management: { importableAndExportable: true } }) + ); + registry.registerType( + createType({ name: 'typeB', management: { importableAndExportable: false } }) + ); + + expect(registry.isImportableAndExportable('typeA')).toBe(true); + expect(registry.isImportableAndExportable('typeB')).toBe(false); + }); + it('returns false when the type is not registered', () => { + registry.registerType(createType({ name: 'typeA', management: {} })); + registry.registerType(createType({ name: 'typeB', management: {} })); + + expect(registry.isImportableAndExportable('typeA')).toBe(false); + }); + it('returns false when management is not defined for the type', () => { + registry.registerType(createType({ name: 'typeA' })); + expect(registry.isImportableAndExportable('unknownType')).toBe(false); + }); + }); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index b73c80ad9dff7..19d81431b149e 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -27,7 +27,12 @@ import { SavedObjectsType } from './types'; */ export type ISavedObjectTypeRegistry = Pick< SavedObjectTypeRegistry, - 'getType' | 'getAllTypes' | 'getIndex' | 'isNamespaceAgnostic' | 'isHidden' + | 'getType' + | 'getAllTypes' + | 'getIndex' + | 'isNamespaceAgnostic' + | 'isHidden' + | 'isImportableAndExportable' >; /** @@ -86,4 +91,11 @@ export class SavedObjectTypeRegistry { public getIndex(type: string) { return this.types.get(type)?.indexPattern; } + + /** + * TODO: doc + */ + public isImportableAndExportable(type: string) { + return this.types.get(type)?.management?.importableAndExportable ?? false; + } } diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 9f625b4732e26..f44824238aa21 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -36,6 +36,7 @@ export interface SavedObjectsLegacyService { getScopedSavedObjectsClient: SavedObjectsClientProvider['getClient']; SavedObjectsClient: typeof SavedObjectsClient; types: string[]; + importAndExportableTypes: string[]; schema: SavedObjectsSchema; getSavedObjectsRepository(...rest: any[]): any; importExport: { diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 495d896ad12cd..e63be51da983b 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -21,7 +21,6 @@ import { SavedObjectsClient } from './service/saved_objects_client'; import { SavedObjectsTypeMappingDefinition, SavedObjectsTypeMappingDefinitions } from './mappings'; import { SavedObjectMigrationMap } from './migrations'; import { PropertyValidators } from './validation'; -import { SavedObjectsManagementDefinition } from './management'; export { SavedObjectsImportResponse, @@ -247,6 +246,24 @@ export interface SavedObjectsType { * An optional map of {@link SavedObjectMigrationFn | migrations} to be used to migrate the type. */ migrations?: SavedObjectMigrationMap; + /** + * TODO doc + */ + management?: SavedObjectsTypeManagementDefinition; +} + +/** + * TODO doc + * + * @public + */ +export interface SavedObjectsTypeManagementDefinition { + importableAndExportable?: boolean; + defaultSearchField?: string; + icon?: string; + getTitle?: (savedObject: SavedObject) => string; + getEditUrl?: (savedObject: SavedObject) => string; + getInAppUrl?: (savedObject: SavedObject) => { path: string; uiCapabilitiesPath: string }; } /** @@ -258,7 +275,7 @@ export interface SavedObjectsLegacyUiExports { savedObjectMigrations: SavedObjectsLegacyMigrationDefinitions; savedObjectSchemas: SavedObjectsLegacySchemaDefinitions; savedObjectValidations: PropertyValidators; - savedObjectsManagement: SavedObjectsManagementDefinition; + savedObjectsManagement: SavedObjectsLegacyManagementDefinition; } /** @@ -270,6 +287,27 @@ export interface SavedObjectsLegacyMapping { properties: SavedObjectsTypeMappingDefinitions; } +/** + * @internal + * @deprecated + */ +export interface SavedObjectsLegacyManagementDefinition { + [key: string]: SavedObjectsLegacyManagementTypeDefinition; +} + +/** + * @internal + * @deprecated + */ +export interface SavedObjectsLegacyManagementTypeDefinition { + isImportableAndExportable?: boolean; + defaultSearchField?: string; + icon?: string; + getTitle?: (savedObject: SavedObject) => string; + getEditUrl?: (savedObject: SavedObject) => string; + getInAppUrl?: (savedObject: SavedObject) => { path: string; uiCapabilitiesPath: string }; +} + /** * @internal * @deprecated diff --git a/src/core/server/saved_objects/utils.test.ts b/src/core/server/saved_objects/utils.test.ts index 0a56535ac8509..0719fe7138e8a 100644 --- a/src/core/server/saved_objects/utils.test.ts +++ b/src/core/server/saved_objects/utils.test.ts @@ -235,6 +235,75 @@ describe('convertLegacyTypes', () => { expect(legacyMigration).toHaveBeenCalledWith(doc, context.log); }); + it('imports type management information', () => { + const uiExports: SavedObjectsLegacyUiExports = { + savedObjectMappings: [ + { + pluginId: 'pluginA', + properties: { + typeA: { + properties: { + fieldA: { type: 'text' }, + }, + }, + }, + }, + { + pluginId: 'pluginB', + properties: { + typeB: { + properties: { + fieldB: { type: 'text' }, + }, + }, + typeC: { + properties: { + fieldC: { type: 'text' }, + }, + }, + }, + }, + ], + savedObjectsManagement: { + typeA: { + isImportableAndExportable: true, + icon: 'iconA', + defaultSearchField: 'searchFieldA', + getTitle: savedObject => savedObject.id, + }, + typeB: { + isImportableAndExportable: false, + icon: 'iconB', + getEditUrl: savedObject => `/some-url/${savedObject.id}`, + getInAppUrl: savedObject => ({ path: 'path', uiCapabilitiesPath: 'ui-path' }), + }, + }, + savedObjectMigrations: {}, + savedObjectSchemas: {}, + savedObjectValidations: {}, + }; + + const converted = convertLegacyTypes(uiExports, legacyConfig); + expect(converted.length).toEqual(3); + const [typeA, typeB, typeC] = converted; + + expect(typeA.management).toEqual({ + importableAndExportable: true, + icon: 'iconA', + defaultSearchField: 'searchFieldA', + getTitle: uiExports.savedObjectsManagement.typeA.getTitle, + }); + + expect(typeB.management).toEqual({ + importableAndExportable: false, + icon: 'iconB', + getEditUrl: uiExports.savedObjectsManagement.typeB.getEditUrl, + getInAppUrl: uiExports.savedObjectsManagement.typeB.getInAppUrl, + }); + + expect(typeC.management).toBeUndefined(); + }); + it('merges everything when all are present', () => { const uiExports: SavedObjectsLegacyUiExports = { savedObjectMappings: [ diff --git a/src/core/server/saved_objects/utils.ts b/src/core/server/saved_objects/utils.ts index bb2c42c6a362c..ea90efd8b9fbd 100644 --- a/src/core/server/saved_objects/utils.ts +++ b/src/core/server/saved_objects/utils.ts @@ -23,6 +23,8 @@ import { SavedObjectsType, SavedObjectsLegacyUiExports, SavedObjectLegacyMigrationMap, + SavedObjectsLegacyManagementTypeDefinition, + SavedObjectsTypeManagementDefinition, } from './types'; import { SavedObjectsSchemaDefinition } from './schema'; @@ -35,15 +37,17 @@ export const convertLegacyTypes = ( savedObjectMappings = [], savedObjectMigrations = {}, savedObjectSchemas = {}, + savedObjectsManagement = {}, }: SavedObjectsLegacyUiExports, legacyConfig: LegacyConfig ): SavedObjectsType[] => { - return savedObjectMappings.reduce((types, { pluginId, properties }) => { + return savedObjectMappings.reduce((types, { properties }) => { return [ ...types, ...Object.entries(properties).map(([type, mappings]) => { const schema = savedObjectSchemas[type]; const migrations = savedObjectMigrations[type]; + const management = savedObjectsManagement[type]; return { name: type, hidden: schema?.hidden ?? false, @@ -55,6 +59,7 @@ export const convertLegacyTypes = ( : schema?.indexPattern, convertToAliasScript: schema?.convertToAliasScript, migrations: convertLegacyMigrations(migrations ?? {}), + management: management ? convertLegacyTypeManagement(management) : undefined, }; }), ]; @@ -90,3 +95,16 @@ const convertLegacyMigrations = ( }; }, {} as SavedObjectMigrationMap); }; + +const convertLegacyTypeManagement = ( + legacyTypeManagement: SavedObjectsLegacyManagementTypeDefinition +): SavedObjectsTypeManagementDefinition => { + return { + importableAndExportable: legacyTypeManagement.isImportableAndExportable, + defaultSearchField: legacyTypeManagement.defaultSearchField, + icon: legacyTypeManagement.icon, + getTitle: legacyTypeManagement.getTitle, + getEditUrl: legacyTypeManagement.getEditUrl, + getInAppUrl: legacyTypeManagement.getInAppUrl, + }; +}; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index db2493b38d6e0..368310d1472b0 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -234,6 +234,7 @@ export class Server { }, savedObjects: { client: savedObjectsClient, + typeRegistry: this.coreStart!.savedObjects.getTypeRegistry(), }, elasticsearch: { adminClient: coreSetup.elasticsearch.adminClient.asScoped(req), diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 221133a17d59a..173e62b9f9469 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -24,7 +24,6 @@ import { promisify } from 'util'; import { migrations } from './migrations'; import { importApi } from './server/routes/api/import'; import { exportApi } from './server/routes/api/export'; -import { managementApi } from './server/routes/api/management'; import mappings from './mappings.json'; import { getUiSettingDefaults } from './ui_setting_defaults'; import { registerCspCollector } from './server/lib/csp_usage_collector'; @@ -322,7 +321,6 @@ export default function(kibana) { // routes importApi(server); exportApi(server); - managementApi(server); registerCspCollector(usageCollection, server); server.injectUiAppVars('kibana', () => injectVars(server)); }, diff --git a/src/legacy/core_plugins/kibana/inject_vars.js b/src/legacy/core_plugins/kibana/inject_vars.js index 01623341e4d38..c26c1966f8e66 100644 --- a/src/legacy/core_plugins/kibana/inject_vars.js +++ b/src/legacy/core_plugins/kibana/inject_vars.js @@ -21,11 +21,7 @@ export function injectVars(server) { const serverConfig = server.config(); // 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) - ); + const { importAndExportableTypes } = server.savedObjects; return { importAndExportableTypes, diff --git a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts index 604575a6e6220..4c24f8435e4c2 100644 --- a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts +++ b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts @@ -17,64 +17,45 @@ * under the License. */ -import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { npStart } from 'ui/new_platform'; -import { SavedObjectLoader } from '../../../../../plugins/saved_objects/public'; +import { npSetup, npStart } from 'ui/new_platform'; import { createSavedDashboardLoader } from '../dashboard'; import { TypesService, createSavedVisLoader } from '../../../visualizations/public'; import { createSavedSearchesLoader } from '../../../../../plugins/discover/public'; -/** - * This registry is used for the editing mode of Saved Searches, Visualizations, - * Dashboard and Time Lion saved objects. - */ -interface SavedObjectRegistryEntry { - id: string; - service: SavedObjectLoader; - title: string; +const registry = npSetup.plugins.savedObjectsManagement?.serviceRegistry; + +// TODO: should no longer be needed after migration +export const savedObjectManagementRegistry = registry!; + +if (registry) { + const services = { + savedObjectsClient: npStart.core.savedObjects.client, + indexPatterns: npStart.plugins.data.indexPatterns, + chrome: npStart.core.chrome, + overlays: npStart.core.overlays, + }; + + savedObjectManagementRegistry.register({ + id: 'savedVisualizations', + service: createSavedVisLoader({ + ...services, + ...{ visualizationTypes: new TypesService().start() }, + }), + title: 'visualizations', + }); + + savedObjectManagementRegistry.register({ + id: 'savedDashboards', + service: createSavedDashboardLoader(services), + title: i18n.translate('kbn.dashboard.savedDashboardsTitle', { + defaultMessage: 'dashboards', + }), + }); + + savedObjectManagementRegistry.register({ + id: 'savedSearches', + service: createSavedSearchesLoader(services), + title: 'searches', + }); } - -const registry: SavedObjectRegistryEntry[] = []; - -export const savedObjectManagementRegistry = { - register: (service: SavedObjectRegistryEntry) => { - registry.push(service); - }, - all: () => { - return registry; - }, - get: (id: string) => { - return _.find(registry, { id }); - }, -}; - -const services = { - savedObjectsClient: npStart.core.savedObjects.client, - indexPatterns: npStart.plugins.data.indexPatterns, - chrome: npStart.core.chrome, - overlays: npStart.core.overlays, -}; - -savedObjectManagementRegistry.register({ - id: 'savedVisualizations', - service: createSavedVisLoader({ - ...services, - ...{ visualizationTypes: new TypesService().start() }, - }), - title: 'visualizations', -}); - -savedObjectManagementRegistry.register({ - id: 'savedDashboards', - service: createSavedDashboardLoader(services), - title: i18n.translate('kbn.dashboard.savedDashboardsTitle', { - defaultMessage: 'dashboards', - }), -}); - -savedObjectManagementRegistry.register({ - id: 'savedSearches', - service: createSavedSearchesLoader(services), - title: 'searches', -}); diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/find.js b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/find.js deleted file mode 100644 index 920b5c43678d1..0000000000000 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/find.js +++ /dev/null @@ -1,107 +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. - */ - -/** - * 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; - }), - }; - }, - }); -} diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js deleted file mode 100644 index eb6a7fc7b5195..0000000000000 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js +++ /dev/null @@ -1,59 +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 Joi from 'joi'; -import { findRelationships } from '../../../../lib/management/saved_objects/relationships'; - -export function registerRelationships(server) { - server.route({ - path: '/api/kibana/management/saved_objects/relationships/{type}/{id}', - method: ['GET'], - config: { - validate: { - params: Joi.object().keys({ - type: Joi.string(), - id: Joi.string(), - }), - query: Joi.object().keys({ - size: Joi.number().default(10000), - savedObjectTypes: Joi.array() - .single() - .items(Joi.string()) - .required(), - }), - }, - }, - - handler: async req => { - const type = req.params.type; - const id = req.params.id; - 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, - }); - }, - }); -} diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js deleted file mode 100644 index b3edd42149d45..0000000000000 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js +++ /dev/null @@ -1,120 +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 Joi from 'joi'; - -async function findAll(savedObjectsClient, findOptions, page = 1, allObjects = []) { - const objects = await savedObjectsClient.find({ - ...findOptions, - page, - }); - - allObjects.push(...objects.saved_objects); - if (allObjects.length < objects.total) { - return findAll(savedObjectsClient, findOptions, page + 1, allObjects); - } - - return allObjects; -} - -export function registerScrollForExportRoute(server) { - server.route({ - path: '/api/kibana/management/saved_objects/scroll/export', - method: ['POST'], - config: { - validate: { - payload: Joi.object() - .keys({ - typesToInclude: Joi.array() - .items(Joi.string()) - .required(), - }) - .required(), - }, - }, - - handler: async req => { - const savedObjectsClient = req.getSavedObjectsClient(); - const objects = await findAll(savedObjectsClient, { - perPage: 1000, - type: req.payload.typesToInclude, - }); - - return objects.map(hit => { - return { - _id: hit.id, - _source: hit.attributes, - _meta: { - savedObjectVersion: 2, - }, - _migrationVersion: hit.migrationVersion, - _references: hit.references || [], - }; - }); - }, - }); -} - -export function registerScrollForCountRoute(server) { - server.route({ - path: '/api/kibana/management/saved_objects/scroll/counts', - method: ['POST'], - config: { - validate: { - payload: Joi.object() - .keys({ - typesToInclude: Joi.array() - .items(Joi.string()) - .required(), - searchString: Joi.string(), - }) - .required(), - }, - }, - - handler: async req => { - const savedObjectsClient = req.getSavedObjectsClient(); - const findOptions = { - type: req.payload.typesToInclude, - perPage: 1000, - }; - - if (req.payload.searchString) { - findOptions.search = `${req.payload.searchString}*`; - findOptions.searchFields = ['title']; - } - - const objects = await findAll(savedObjectsClient, findOptions); - const counts = objects.reduce((accum, result) => { - const type = result.type; - accum[type] = accum[type] || 0; - accum[type]++; - return accum; - }, {}); - - for (const type of req.payload.typesToInclude) { - if (!counts[type]) { - counts[type] = 0; - } - } - - return counts; - }, - }); -} diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.test.js b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.test.js deleted file mode 100644 index 0d14da39d73b3..0000000000000 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.test.js +++ /dev/null @@ -1,95 +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 Hapi from 'hapi'; -import { registerScrollForExportRoute } from './scroll'; - -const createMockServer = () => { - const mockServer = new Hapi.Server({ - debug: false, - port: 8080, - routes: { - validate: { - failAction: (r, h, err) => { - throw err; - }, - }, - }, - }); - return mockServer; -}; - -describe(`POST /api/kibana/management/saved_objects/scroll/export`, () => { - test('requires "typesToInclude"', async () => { - const mockServer = createMockServer(); - registerScrollForExportRoute(mockServer); - - const headers = {}; - const payload = {}; - - const request = { - method: 'POST', - url: `/api/kibana/management/saved_objects/scroll/export`, - headers, - payload, - }; - - const { result, statusCode } = await mockServer.inject(request); - expect(statusCode).toEqual(400); - expect(result).toMatchObject({ - message: `child "typesToInclude" fails because ["typesToInclude" is required]`, - }); - }); - - test(`uses "typesToInclude" when searching for objects to export`, async () => { - const mockServer = createMockServer(); - const mockClient = { - find: jest.fn(() => { - return { - saved_objects: [], - }; - }), - }; - - mockServer.decorate('request', 'getSavedObjectsClient', () => mockClient); - - registerScrollForExportRoute(mockServer); - - const headers = {}; - const payload = { - typesToInclude: ['foo', 'bar'], - }; - - const request = { - method: 'POST', - url: `/api/kibana/management/saved_objects/scroll/export`, - headers, - payload, - }; - - const { result, statusCode } = await mockServer.inject(request); - expect(statusCode).toEqual(200); - expect(result).toEqual([]); - - expect(mockClient.find).toHaveBeenCalledWith({ - page: 1, - perPage: 1000, - type: ['foo', 'bar'], - }); - }); -}); diff --git a/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts b/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts index 228ef96f8c9f3..d668739436726 100644 --- a/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts +++ b/src/legacy/plugin_discovery/plugin_spec/plugin_spec_options.d.ts @@ -19,13 +19,13 @@ import { Server } from '../../server/kbn_server'; import { Capabilities } from '../../../core/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObjectsManagementDefinition } from '../../../core/server/saved_objects/management'; +import { SavedObjectsLegacyManagementDefinition } from '../../../core/server/saved_objects/types'; export type InitPluginFunction = (server: Server) => void; export interface UiExports { injectDefaultVars?: (server: Server) => { [key: string]: any }; styleSheetPaths?: string; - savedObjectsManagement?: SavedObjectsManagementDefinition; + savedObjectsManagement?: SavedObjectsLegacyManagementDefinition; mappings?: unknown; visTypes?: string[]; interpreter?: string[]; diff --git a/src/legacy/plugin_discovery/types.ts b/src/legacy/plugin_discovery/types.ts index 9425003eae874..4d8090a138ffb 100644 --- a/src/legacy/plugin_discovery/types.ts +++ b/src/legacy/plugin_discovery/types.ts @@ -23,7 +23,7 @@ import { Capabilities } from '../../core/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectsSchemaDefinition } from '../../core/server/saved_objects/schema'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObjectsManagementDefinition } from '../../core/server/saved_objects/management'; +import { SavedObjectsLegacyManagementDefinition } from '../../core/server/saved_objects/types'; import { AppCategory } from '../../core/types'; /** @@ -73,7 +73,7 @@ export interface LegacyPluginOptions { mappings: any; migrations: any; savedObjectSchemas: SavedObjectsSchemaDefinition; - savedObjectsManagement: SavedObjectsManagementDefinition; + savedObjectsManagement: SavedObjectsLegacyManagementDefinition; visTypes: string[]; embeddableActions?: string[]; embeddableFactories?: string[]; diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 8da1b3b05fa76..50cbb75d7aeaf 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -38,9 +38,6 @@ import { LegacyServiceDiscoverPlugins, } from '../../core/server'; -// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObjectsManagement } from '../../core/server/saved_objects/management'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { LegacyConfig, ILegacyService, ILegacyInternals } from '../../core/server/legacy'; import { ApmOssPlugin } from '../core_plugins/apm_oss'; @@ -77,7 +74,6 @@ declare module 'hapi' { addScopedTutorialContextFactory: ( scopedTutorialContextFactory: (...args: any[]) => any ) => void; - savedObjectsManagement(): SavedObjectsManagement; getInjectedUiAppVars: (pluginName: string) => { [key: string]: any }; getUiNavLinks(): Array<{ _id: string }>; addMemoizedFactoryToRequest: ( diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index f5140fc8d0ac2..2b4a09886af79 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -29,7 +29,6 @@ import { } from '../../../core/server/saved_objects'; import { getRootPropertiesObjects } from '../../../core/server/saved_objects/mappings'; import { convertTypesToLegacySchema } from '../../../core/server/saved_objects/utils'; -import { SavedObjectsManagement } from '../../../core/server/saved_objects/management'; export function savedObjectsMixin(kbnServer, server) { const migrator = kbnServer.newPlatform.__internals.kibanaMigrator; @@ -40,11 +39,6 @@ export function savedObjectsMixin(kbnServer, server) { const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type)); 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 @@ -84,8 +78,14 @@ export function savedObjectsMixin(kbnServer, server) { const provider = kbnServer.newPlatform.__internals.savedObjectsClientProvider; + const importAndExportableTypes = typeRegistry + .getAllTypes() + .map(type => type.name) + .filter(type => typeRegistry.isImportableAndExportable(type)); + const service = { types: visibleTypes, + importAndExportableTypes, SavedObjectsClient, SavedObjectsRepository, getSavedObjectsRepository: createRepository, diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index b8636d510b979..2099aa354020e 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -183,7 +183,7 @@ describe('Saved Objects Mixin', () => { 'kibanaMigrator', expect.any(Object) ); - expect(mockServer.decorate).toHaveBeenCalledTimes(2); + expect(mockServer.decorate).toHaveBeenCalledTimes(1); expect(mockServer.route).not.toHaveBeenCalled(); }); }); diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index 00d76bc341322..540c94329608f 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -52,6 +52,7 @@ import { NavigationPublicPluginStart, } from '../../../../plugins/navigation/public'; import { VisTypeVegaSetup } from '../../../../plugins/vis_type_vega/public'; +import { SavedObjectsManagementPluginSetup } from '../../../../plugins/so_management/public'; export interface PluginsSetup { bfetch: BfetchPublicSetup; @@ -71,6 +72,7 @@ export interface PluginsSetup { management: ManagementSetup; visTypeVega: VisTypeVegaSetup; telemetry?: TelemetryPluginSetup; + savedObjectsManagement?: SavedObjectsManagementPluginSetup; } export interface PluginsStart { diff --git a/src/plugins/so_management/kibana.json b/src/plugins/so_management/kibana.json new file mode 100644 index 0000000000000..e4b5436969a01 --- /dev/null +++ b/src/plugins/so_management/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "savedObjectsManagement", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["management", "data"] +} diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/index.js b/src/plugins/so_management/public/index.ts similarity index 66% rename from src/legacy/core_plugins/kibana/server/routes/api/management/index.js rename to src/plugins/so_management/public/index.ts index b98ce360f57d3..13dc11e48e571 100644 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/index.js +++ b/src/plugins/so_management/public/index.ts @@ -17,13 +17,12 @@ * under the License. */ -import { registerFind } from './saved_objects/find'; -import { registerRelationships } from './saved_objects/relationships'; -import { registerScrollForExportRoute, registerScrollForCountRoute } from './saved_objects/scroll'; +import { PluginInitializerContext } from 'kibana/public'; +import { SavedObjectsManagementPlugin } from './plugin'; -export function managementApi(server) { - registerRelationships(server); - registerFind(server); - registerScrollForExportRoute(server); - registerScrollForCountRoute(server); +export { SavedObjectsManagementPluginSetup } from './types'; +export { ISavedObjectsManagementRegistry } from './management_registry'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SavedObjectsManagementPlugin(); } diff --git a/src/plugins/so_management/public/management/components/edition/field.tsx b/src/plugins/so_management/public/management/components/edition/field.tsx new file mode 100644 index 0000000000000..8d0bc2e9fdaf6 --- /dev/null +++ b/src/plugins/so_management/public/management/components/edition/field.tsx @@ -0,0 +1,168 @@ +/* + * 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 React, { PureComponent } from 'react'; +import { + EuiFieldNumber, + EuiFieldText, + EuiFormLabel, + EuiSwitch, + // @ts-ignore + EuiCodeEditor, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { FieldState, FieldType } from '../../types'; + +interface FieldProps { + type: FieldType; + name: string; + value: any; + disabled: boolean; + state?: FieldState; + onChange: (name: string, state: FieldState) => void; +} + +export class Field extends PureComponent { + render() { + const { name } = this.props; + + return ( +
+ + {name} + + {this.renderField()} +
+ ); + } + + onCodeEditorChange(targetValue: any) { + const { name, onChange } = this.props; + let invalid = false; + try { + JSON.parse(targetValue); + } catch (e) { + invalid = true; + } + onChange(name, { + value: targetValue, + invalid, + }); + } + + onFieldChange(targetValue: any) { + const { name, type, onChange } = this.props; + + let newParsedValue = targetValue; + let invalid = false; + if (type === 'number') { + try { + newParsedValue = Number(newParsedValue); + } catch (e) { + invalid = true; + } + } + onChange(name, { + value: newParsedValue, + invalid, + }); + } + + renderField() { + const { type, name, state, disabled } = this.props; + const currentValue = state?.value ?? this.props.value; + + switch (type) { + case 'number': + return ( + this.onFieldChange(e.target.value)} + disabled={disabled} + data-test-subj={`savedObjects-editField-${name}`} + /> + ); + case 'boolean': + return ( + + ) : ( + + ) + } + checked={!!currentValue} + onChange={e => this.onFieldChange(e.target.checked)} + disabled={disabled} + data-test-subj={`savedObjects-editField-${name}`} + /> + ); + case 'json': + case 'array': + return ( +
+ this.onCodeEditorChange(value)} + width="100%" + height="auto" + minLines={6} + maxLines={30} + isReadOnly={disabled} + setOptions={{ + showLineNumbers: true, + tabSize: 2, + softTabs: true, + }} + editorProps={{ + $blockScrolling: Infinity, + }} + showGutter={true} + fullWidth + /> +
+ ); + default: + return ( + this.onFieldChange(e.target.value)} + disabled={disabled} + data-test-subj={`savedObjects-editField-${name}`} + /> + ); + } + } + + private get fieldId() { + const { name } = this.props; + return `savedObjects-editField-${name}`; + } +} diff --git a/src/plugins/so_management/public/management/components/edition/form.tsx b/src/plugins/so_management/public/management/components/edition/form.tsx new file mode 100644 index 0000000000000..c7914254eb6d6 --- /dev/null +++ b/src/plugins/so_management/public/management/components/edition/form.tsx @@ -0,0 +1,267 @@ +/* + * 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 React, { Component } from 'react'; +import { + forOwn, + indexBy, + cloneDeep, + isNumber, + isBoolean, + isPlainObject, + isString, + set, +} from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SimpleSavedObject, SavedObjectsClientContract } from '../../../../../../core/public'; +import { castEsToKbnFieldTypeName } from '../../../../../data/public'; +import { SavedObjectLoader } from '../../../../../saved_objects/public'; +import { Field } from './field'; +import { ObjectField, FieldState, SubmittedFormData } from '../../types'; + +interface FormProps { + object: SimpleSavedObject; + service: SavedObjectLoader; + savedObjectsClient: SavedObjectsClientContract; + editionEnabled: boolean; + onSave: (form: SubmittedFormData) => void; +} + +interface FormState { + fields: ObjectField[]; + fieldStates: Record; +} + +export class Form extends Component { + constructor(props: FormProps) { + super(props); + this.state = { + fields: [], + fieldStates: {}, + }; + } + + componentDidMount() { + const { object, service } = this.props; + + const fields = Object.entries(object.attributes as Record).reduce( + (objFields, [key, value]) => { + return [...objFields, ...recursiveCreateFields(key, value)]; + }, + [] as ObjectField[] + ); + if ((service as any).Class) { + addFieldsFromClass((service as any).Class, fields); + } + + this.setState({ + fields, + }); + } + + render() { + const { editionEnabled, service } = this.props; + const { fields, fieldStates } = this.state; + const isValid = this.isFormValid(); + return ( + <> +
+ {fields.map(field => ( + + ))} +
+
+ {editionEnabled && ( + + )} + + +
+ + ); + } + + handleFieldChange = (name: string, newState: FieldState) => { + this.setState({ + fieldStates: { + ...this.state.fieldStates, + [name]: newState, + }, + }); + }; + + isFormValid() { + const { fieldStates } = this.state; + return !Object.values(fieldStates).some(state => state.invalid === true); + } + + onCancel = () => { + window.history.back(); + }; + + onSubmit = async () => { + const { object, onSave } = this.props; + const { fields, fieldStates } = this.state; + + if (!this.isFormValid()) { + return; + } + + const source = cloneDeep(object.attributes as any); + fields.forEach(field => { + let value = fieldStates[field.name]?.value ?? field.value; + + if (field.type === 'array' && typeof value === 'string') { + value = JSON.parse(value); + } + + set(source, field.name, value); + }); + + const { references, ...attributes } = source; + + onSave({ attributes, references }); + }; +} + +/** + * Creates a field definition and pushes it to the memo stack. This function + * is designed to be used in conjunction with _.reduce(). If the + * values is plain object it will recurse through all the keys till it hits + * a string, number or an array. + * + * @param {string} key The key of the field + * @param {mixed} value The value of the field + * @param {array} parents The parent keys to the field + * @returns {array} + */ +const recursiveCreateFields = (key: string, value: any, parents: string[] = []): ObjectField[] => { + const path = [...parents, key]; + + const field: ObjectField = { type: 'text', name: path.join('.'), value }; + let fields: ObjectField[] = [field]; + + if (isString(field.value)) { + try { + field.value = JSON.stringify(JSON.parse(field.value), undefined, 2); + field.type = 'json'; + } catch (err) { + field.type = 'text'; + } + } else if (isNumber(field.value)) { + field.type = 'number'; + } else if (Array.isArray(field.value)) { + field.type = 'array'; + field.value = JSON.stringify(field.value, undefined, 2); + } else if (isBoolean(field.value)) { + field.type = 'boolean'; + } else if (isPlainObject(field.value)) { + forOwn(field.value, (childValue, childKey) => { + fields = [...recursiveCreateFields(childKey as string, childValue, path)]; + }); + } + + return fields; +}; + +const addFieldsFromClass = function( + Class: { mapping: Record; searchSource: any }, + fields: ObjectField[] +) { + const fieldMap = indexBy(fields, 'name'); + + _.forOwn(Class.mapping, (esType, name) => { + if (!name || fieldMap[name]) { + return; + } + + const getFieldTypeFromEsType = () => { + switch (castEsToKbnFieldTypeName(esType)) { + case 'string': + return 'text'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + default: + return 'json'; + } + }; + + fields.push({ + name, + type: getFieldTypeFromEsType(), + value: undefined, + }); + }); + + if (Class.searchSource && !fieldMap['kibanaSavedObjectMeta.searchSourceJSON']) { + fields.push({ + name: 'kibanaSavedObjectMeta.searchSourceJSON', + type: 'json', + value: '{}', + }); + } + + if (!fieldMap.references) { + fields.push({ + name: 'references', + type: 'array', + value: '[]', + }); + } +}; diff --git a/src/plugins/so_management/public/management/components/edition/header.tsx b/src/plugins/so_management/public/management/components/edition/header.tsx new file mode 100644 index 0000000000000..77fe04381c1eb --- /dev/null +++ b/src/plugins/so_management/public/management/components/edition/header.tsx @@ -0,0 +1,104 @@ +/* + * 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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +interface HeaderProps { + canEdit: boolean; + canDelete: boolean; + canViewInApp: boolean; + type: string; + viewUrl: string; + onDeleteClick: () => void; +} + +export const Header = ({ + canEdit, + canDelete, + canViewInApp, + type, + viewUrl, + onDeleteClick, +}: HeaderProps) => { + return ( +
+
+

+ {canEdit ? ( + + ) : ( + + )} +

+
+ +
+ {canViewInApp && ( + + + + + + + + + )} + + {canDelete && ( + + )} +
+
+ ); +}; diff --git a/src/plugins/so_management/public/management/components/edition/index.ts b/src/plugins/so_management/public/management/components/edition/index.ts new file mode 100644 index 0000000000000..a3a05c05cb4a9 --- /dev/null +++ b/src/plugins/so_management/public/management/components/edition/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { Header } from './header'; +export { NotFoundErrors } from './not_found_errors'; +export { Intro } from './intro'; +export { Form } from './form'; diff --git a/src/plugins/so_management/public/management/components/edition/intro.tsx b/src/plugins/so_management/public/management/components/edition/intro.tsx new file mode 100644 index 0000000000000..f481d9a678333 --- /dev/null +++ b/src/plugins/so_management/public/management/components/edition/intro.tsx @@ -0,0 +1,48 @@ +/* + * 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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const Intro = () => { + return ( +
+
+
+ + + + +
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/src/plugins/so_management/public/management/components/edition/not_found_errors.tsx b/src/plugins/so_management/public/management/components/edition/not_found_errors.tsx new file mode 100644 index 0000000000000..f79e5f5afd71f --- /dev/null +++ b/src/plugins/so_management/public/management/components/edition/not_found_errors.tsx @@ -0,0 +1,79 @@ +/* + * 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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface NotFoundErrors { + type: string; +} + +export const NotFoundErrors = ({ type }: NotFoundErrors) => { + return ( +
+
+
+ + + + +
+ +
+ {type === 'search' && ( +
+ +
+ )} + + {type === 'index-pattern' && ( +
+ +
+ )} + + {type === 'index-pattern-field' && ( +
+ +
+ )} + +
+ +
+
+
+
+ ); +}; diff --git a/src/plugins/so_management/public/management/components/flyout/__snapshots__/flyout.test.js.snap b/src/plugins/so_management/public/management/components/flyout/__snapshots__/flyout.test.js.snap new file mode 100644 index 0000000000000..34ce8394232ed --- /dev/null +++ b/src/plugins/so_management/public/management/components/flyout/__snapshots__/flyout.test.js.snap @@ -0,0 +1,679 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Flyout conflicts should allow conflict resolution 1`] = ` + + + +

+ +

+
+
+ + + + + } + > +

+ + + , + } + } + /> +

+
+
+ +
+ + + + + + + + + + + + + + +
+`; + +exports[`Flyout conflicts should allow conflict resolution 2`] = ` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "getConflictResolutions": [Function], + "state": Object { + "conflictedIndexPatterns": undefined, + "conflictedSavedObjectsLinkedToSavedSearches": undefined, + "conflictedSearchDocs": undefined, + "conflictingRecord": undefined, + "error": undefined, + "failedImports": Array [ + Object { + "error": Object { + "references": Array [ + Object { + "id": "MyIndexPattern*", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "obj": Object { + "id": "1", + "title": "My Visualization", + "type": "visualization", + }, + }, + ], + "file": Object { + "name": "foo.ndjson", + "path": "/home/foo.ndjson", + }, + "importCount": 0, + "indexPatterns": Array [ + Object { + "id": "1", + }, + Object { + "id": "2", + }, + ], + "isLegacyFile": false, + "isOverwriteAllChecked": true, + "loadingMessage": undefined, + "status": "loading", + "unmatchedReferences": Array [ + Object { + "existingIndexPatternId": "MyIndexPattern*", + "list": Array [ + Object { + "id": "1", + "title": "My Visualization", + "type": "visualization", + }, + ], + "newIndexPatternId": "2", + }, + ], + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "failedImports": Array [], + "importCount": 1, + "status": "success", + }, + }, + ], +} +`; + +exports[`Flyout conflicts should handle errors 1`] = ` + + } +> +

+ +

+

+ +`; + +exports[`Flyout errors should display unsupported type errors properly 1`] = ` + + } +> +

+ +

+

+ wigwags [id=1] unsupported type +

+
+`; + +exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` + + + +

+ +

+
+
+ + + + + } + > +

+ +

+
+
+ + + + } + > +

+ + + , + } + } + /> +

+
+
+ +
+ + + + + + + + + + + + + + +
+`; + +exports[`Flyout legacy conflicts should handle errors 1`] = ` +Array [ + + } + > +

+ +

+
, + + } + > +

+ + + , + } + } + /> +

+
, + + } + > +

+ foobar +

+
, +] +`; + +exports[`Flyout should render import step 1`] = ` + + + +

+ +

+
+
+ + + + } + labelType="label" + > + + } + onChange={[Function]} + /> + + + + } + name="overwriteAll" + onChange={[Function]} + /> + + + + + + + + + + + + + + + + + +
+`; diff --git a/src/plugins/so_management/public/management/components/flyout/flyout.test.js b/src/plugins/so_management/public/management/components/flyout/flyout.test.js new file mode 100644 index 0000000000000..ff16ec73bf068 --- /dev/null +++ b/src/plugins/so_management/public/management/components/flyout/flyout.test.js @@ -0,0 +1,570 @@ +/* + * 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 React from 'react'; +// import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +// import { mockManagementPlugin } from '../../../../../../../../../../management/public/np_ready/mocks'; +// import { Flyout } from '../flyout'; + +/* +jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() })); + +jest.mock('../../../../../lib/import_file', () => ({ + importFile: jest.fn(), +})); + +jest.mock('../../../../../lib/resolve_import_errors', () => ({ + resolveImportErrors: jest.fn(), +})); + +jest.mock('ui/chrome', () => ({ + addBasePath: () => {}, + getInjected: () => ['index-pattern', 'visualization', 'dashboard', 'search'], +})); + +jest.mock('../../../../../lib/import_legacy_file', () => ({ + importLegacyFile: jest.fn(), +})); + +jest.mock('../../../../../lib/resolve_saved_objects', () => ({ + resolveSavedObjects: jest.fn(), + resolveSavedSearches: jest.fn(), + resolveIndexPatternConflicts: jest.fn(), + saveObjects: jest.fn(), +})); + +jest.mock('../../../../../../../../../../management/public/legacy', () => ({ + setup: mockManagementPlugin.createSetupContract(), + start: mockManagementPlugin.createStartContract(), +})); + +jest.mock('ui/notify', () => ({})); + */ +/* +const defaultProps = { + close: jest.fn(), + done: jest.fn(), + services: [], + newIndexPatternUrl: '', + getConflictResolutions: jest.fn(), + confirmModalPromise: jest.fn(), + indexPatterns: { + getFields: jest.fn().mockImplementation(() => [{ id: '1' }, { id: '2' }]), + }, +}; + +const mockFile = { + name: 'foo.ndjson', + path: '/home/foo.ndjson', +}; +const legacyMockFile = { + name: 'foo.json', + path: '/home/foo.json', +}; + +describe('Flyout', () => { + it('should render import step', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + it('should toggle the overwrite all control', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('isOverwriteAllChecked')).toBe(true); + component.find('EuiSwitch').simulate('change'); + expect(component.state('isOverwriteAllChecked')).toBe(false); + }); + + it('should allow picking a file', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('file')).toBe(undefined); + component.find('EuiFilePicker').simulate('change', [mockFile]); + expect(component.state('file')).toBe(mockFile); + }); + + it('should allow removing a file', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await Promise.resolve(); + // Ensure the state changes are reflected + component.update(); + + expect(component.state('file')).toBe(undefined); + component.find('EuiFilePicker').simulate('change', [mockFile]); + expect(component.state('file')).toBe(mockFile); + component.find('EuiFilePicker').simulate('change', []); + expect(component.state('file')).toBe(undefined); + }); + + it('should handle invalid files', async () => { + const { importLegacyFile } = require('../../../../../lib/import_legacy_file'); + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + importLegacyFile.mockImplementation(() => { + throw new Error('foobar'); + }); + + await component.instance().legacyImport(); + expect(component.state('error')).toBe('The file could not be processed.'); + + importLegacyFile.mockImplementation(() => ({ + invalid: true, + })); + + await component.instance().legacyImport(); + expect(component.state('error')).toBe( + 'Saved objects file format is invalid and cannot be imported.' + ); + }); + + describe('conflicts', () => { + const { importFile } = require('../../../../../lib/import_file'); + const { resolveImportErrors } = require('../../../../../lib/resolve_import_errors'); + + beforeEach(() => { + importFile.mockImplementation(() => ({ + success: false, + successCount: 0, + errors: [ + { + id: '1', + type: 'visualization', + title: 'My Visualization', + error: { + type: 'missing_references', + references: [ + { + id: 'MyIndexPattern*', + type: 'index-pattern', + }, + ], + }, + }, + ], + })); + resolveImportErrors.mockImplementation(() => ({ + status: 'success', + importCount: 1, + failedImports: [], + })); + }); + + it('should figure out unmatchedReferences', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: mockFile, isLegacyFile: false }); + await component.instance().import(); + + expect(importFile).toHaveBeenCalledWith(mockFile, true); + expect(component.state()).toMatchObject({ + conflictedIndexPatterns: undefined, + conflictedSavedObjectsLinkedToSavedSearches: undefined, + conflictedSearchDocs: undefined, + importCount: 0, + status: 'idle', + error: undefined, + unmatchedReferences: [ + { + existingIndexPatternId: 'MyIndexPattern*', + newIndexPatternId: undefined, + list: [ + { + id: '1', + type: 'visualization', + title: 'My Visualization', + }, + ], + }, + ], + }); + }); + + it('should allow conflict resolution', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: mockFile, isLegacyFile: false }); + await component.instance().import(); + + // Ensure it looks right + component.update(); + expect(component).toMatchSnapshot(); + + // Ensure we can change the resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + expect(component.state('unmatchedReferences')[0].newIndexPatternId).toBe('2'); + + // Let's resolve now + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + expect(resolveImportErrors).toMatchSnapshot(); + }); + + it('should handle errors', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + resolveImportErrors.mockImplementation(() => ({ + status: 'success', + importCount: 0, + failedImports: [ + { + obj: { + type: 'visualization', + id: '1', + }, + error: { + type: 'unknown', + }, + }, + ], + })); + + component.setState({ file: mockFile, isLegacyFile: false }); + + // Go through the import flow + await component.instance().import(); + component.update(); + // Set a resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + + expect(component.state('failedImports')).toEqual([ + { + error: { + type: 'unknown', + }, + obj: { + id: '1', + type: 'visualization', + }, + }, + ]); + expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); + }); + }); + + 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 = shallowWithI18nProvider(); + + // 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 { + resolveSavedObjects, + resolveSavedSearches, + resolveIndexPatternConflicts, + saveObjects, + } = require('../../../../../lib/resolve_saved_objects'); + + const mockData = [ + { + _id: '1', + _type: 'search', + }, + { + _id: '2', + _type: 'index-pattern', + }, + { + _id: '3', + _type: 'invalid', + }, + ]; + + const mockConflictedIndexPatterns = [ + { + doc: { + _type: 'index-pattern', + _id: '1', + _source: { + title: 'MyIndexPattern*', + }, + }, + obj: { + searchSource: { + getOwnField: field => { + if (field === 'index') { + return 'MyIndexPattern*'; + } + if (field === 'filter') { + return [{ meta: { index: 'filterIndex' } }]; + } + }, + }, + _serialize: () => { + return { references: [{ id: 'MyIndexPattern*' }, { id: 'filterIndex' }] }; + }, + }, + }, + ]; + + const mockConflictedSavedObjectsLinkedToSavedSearches = [2]; + const mockConflictedSearchDocs = [3]; + + beforeEach(() => { + importLegacyFile.mockImplementation(() => mockData); + resolveSavedObjects.mockImplementation(() => ({ + conflictedIndexPatterns: mockConflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs: mockConflictedSearchDocs, + importedObjectCount: 2, + confirmModalPromise: () => {}, + })); + }); + + it('should figure out unmatchedReferences', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: legacyMockFile, isLegacyFile: true }); + await component.instance().legacyImport(); + + expect(importLegacyFile).toHaveBeenCalledWith(legacyMockFile); + // Remove the last element from data since it should be filtered out + expect(resolveSavedObjects).toHaveBeenCalledWith( + mockData.slice(0, 2).map(doc => ({ ...doc, _migrationVersion: {} })), + true, + defaultProps.services, + defaultProps.indexPatterns, + defaultProps.confirmModalPromise + ); + + expect(component.state()).toMatchObject({ + conflictedIndexPatterns: mockConflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs: mockConflictedSearchDocs, + importCount: 2, + status: 'idle', + error: undefined, + unmatchedReferences: [ + { + existingIndexPatternId: 'MyIndexPattern*', + newIndexPatternId: undefined, + list: [ + { + id: 'MyIndexPattern*', + title: 'MyIndexPattern*', + type: 'index-pattern', + }, + ], + }, + { + existingIndexPatternId: 'filterIndex', + list: [ + { + id: 'filterIndex', + title: 'MyIndexPattern*', + type: 'index-pattern', + }, + ], + newIndexPatternId: undefined, + }, + ], + }); + }); + + it('should allow conflict resolution', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + component.setState({ file: legacyMockFile, isLegacyFile: true }); + await component.instance().legacyImport(); + + // Ensure it looks right + component.update(); + expect(component).toMatchSnapshot(); + + // Ensure we can change the resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + expect(component.state('unmatchedReferences')[0].newIndexPatternId).toBe('2'); + + // Let's resolve now + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + expect(resolveIndexPatternConflicts).toHaveBeenCalledWith( + component.instance().resolutions, + mockConflictedIndexPatterns, + true + ); + expect(saveObjects).toHaveBeenCalledWith( + mockConflictedSavedObjectsLinkedToSavedSearches, + true + ); + expect(resolveSavedSearches).toHaveBeenCalledWith( + mockConflictedSearchDocs, + defaultProps.services, + defaultProps.indexPatterns, + true + ); + }); + + it('should handle errors', async () => { + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + resolveIndexPatternConflicts.mockImplementation(() => { + throw new Error('foobar'); + }); + + component.setState({ file: legacyMockFile, isLegacyFile: true }); + + // Go through the import flow + await component.instance().legacyImport(); + component.update(); + // Set a resolution + component.instance().onIndexChanged('MyIndexPattern*', { target: { value: '2' } }); + await component + .find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]') + .simulate('click'); + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + + expect(component.state('error')).toEqual('foobar'); + expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot(); + }); + }); +}); + + + */ diff --git a/src/plugins/so_management/public/management/components/flyout/flyout.tsx b/src/plugins/so_management/public/management/components/flyout/flyout.tsx new file mode 100644 index 0000000000000..1a57062385f8e --- /dev/null +++ b/src/plugins/so_management/public/management/components/flyout/flyout.tsx @@ -0,0 +1,978 @@ +/* + * 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 React, { Component, Fragment } from 'react'; +import { take, get as getField } from 'lodash'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiButtonEmpty, + EuiButton, + EuiText, + EuiTitle, + EuiForm, + EuiFormRow, + EuiSwitch, + // @ts-ignore + EuiFilePicker, + EuiInMemoryTable, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingKibana, + EuiCallOut, + EuiSpacer, + EuiLink, + EuiConfirmModal, + EuiOverlayMask, + EUI_MODAL_CONFIRM_BUTTON, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { OverlayStart, HttpStart } from 'src/core/public'; +import { IndexPatternsContract, IIndexPattern } from '../../../../../data/public'; +import { + importFile, + importLegacyFile, + resolveImportErrors, + logLegacyImport, + getDefaultTitle, + processImportResponse, + ProcessedImportResponse, +} from '../../lib'; +import { + resolveSavedObjects, + resolveSavedSearches, + resolveIndexPatternConflicts, + saveObjects, +} from '../../lib/resolve_saved_objects'; +import { ISavedObjectsManagementRegistry } from '../../../management_registry'; + +interface FlyoutProps { + serviceRegistry: ISavedObjectsManagementRegistry; + allowedTypes: string[]; + close: () => void; + done: () => void; + newIndexPatternUrl: string; + indexPatterns: IndexPatternsContract; + overlays: OverlayStart; + http: HttpStart; +} + +interface FlyoutState { + conflictedIndexPatterns?: any[]; // TODO + conflictedSavedObjectsLinkedToSavedSearches?: any[]; // TODO + conflictedSearchDocs?: any[]; // TODO + unmatchedReferences?: ProcessedImportResponse['unmatchedReferences']; + failedImports?: ProcessedImportResponse['failedImports']; + conflictingRecord?: ConflictingRecord; + error?: string; + file?: File; + importCount: number; + indexPatterns?: IIndexPattern[]; + isOverwriteAllChecked: boolean; + loadingMessage?: string; + isLegacyFile: boolean; + status: string; // TODO: improve type +} + +/// +interface ConflictingRecord { + id: string; + type: string; + title: string; + done: (success: boolean) => void; +} +/// + +export class Flyout extends Component { + constructor(props: FlyoutProps) { + super(props); + + this.state = { + conflictedIndexPatterns: undefined, + conflictedSavedObjectsLinkedToSavedSearches: undefined, + conflictedSearchDocs: undefined, + unmatchedReferences: undefined, + conflictingRecord: undefined, + error: undefined, + file: undefined, + importCount: 0, + indexPatterns: undefined, + isOverwriteAllChecked: true, + loadingMessage: undefined, + isLegacyFile: false, + status: 'idle', + }; + } + + componentDidMount() { + this.fetchIndexPatterns(); + } + + fetchIndexPatterns = async () => { + const indexPatterns = await this.props.indexPatterns.getFields(['id', 'title']); + this.setState({ indexPatterns } as any); + }; + + changeOverwriteAll = () => { + this.setState(state => ({ + isOverwriteAllChecked: !state.isOverwriteAllChecked, + })); + }; + + setImportFile = ([file]: [File?]) => { + if (!file) { + this.setState({ file: undefined, isLegacyFile: false }); + return; + } + this.setState({ + file, + isLegacyFile: /\.json$/i.test(file.name) || file.type === 'application/json', + }); + }; + + /** + * Import + * + * Does the initial import of a file, resolveImportErrors then handles errors and retries + */ + import = async () => { + const { http } = this.props; + const { file, isOverwriteAllChecked } = this.state; + this.setState({ status: 'loading', error: undefined }); + + // Import the file + try { + const response = await importFile(http, file!, isOverwriteAllChecked); + this.setState(processImportResponse(response) as any, () => { + // Resolve import errors right away if there's no index patterns to match + // This will ask about overwriting each object, etc + if (this.state.unmatchedReferences?.length === 0) { + this.resolveImportErrors(); + } + }); + } catch (e) { + this.setState({ + status: 'error', + error: i18n.translate('kbn.management.objects.objectsTable.flyout.importFileErrorMessage', { + defaultMessage: 'The file could not be processed.', + }), + }); + return; + } + }; + + /** + * Get Conflict Resolutions + * + * Function iterates through the objects, displays a modal for each asking the user if they wish to overwrite it or not. + * + * @param {array} objects List of objects to request the user if they wish to overwrite it + * @return {Promise} An object with the key being "type:id" and value the resolution chosen by the user + */ + getConflictResolutions = async (objects: any[]) => { + const resolutions: Record = {}; + for (const { type, id, title } of objects) { + const overwrite = await new Promise(resolve => { + this.setState({ + conflictingRecord: { + id, + type, + title, + done: resolve, + }, + }); + }); + resolutions[`${type}:${id}`] = overwrite; + this.setState({ conflictingRecord: undefined }); + } + return resolutions; + }; + + /** + * Resolve Import Errors + * + * Function goes through the failedImports and tries to resolve the issues. + */ + resolveImportErrors = async () => { + this.setState({ + error: undefined, + status: 'loading', + loadingMessage: undefined, + }); + + try { + const updatedState = await resolveImportErrors({ + http: this.props.http, + state: this.state, + getConflictResolutions: this.getConflictResolutions, + }); + this.setState(updatedState); + } catch (e) { + this.setState({ + status: 'error', + error: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.resolveImportErrorsFileErrorMessage', + { defaultMessage: 'The file could not be processed.' } + ), + }); + } + }; + + legacyImport = async () => { + const { serviceRegistry, indexPatterns, overlays, http, allowedTypes } = this.props; + const { file, isOverwriteAllChecked } = this.state; + + this.setState({ status: 'loading', error: undefined }); + + // Log warning on server, don't wait for response + logLegacyImport(http); + + let contents; + try { + contents = await importLegacyFile(file!); + } catch (e) { + this.setState({ + status: 'error', + error: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.importLegacyFileErrorMessage', + { defaultMessage: 'The file could not be processed.' } + ), + }); + return; + } + + if (!Array.isArray(contents)) { + this.setState({ + status: 'error', + error: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage', + { defaultMessage: 'Saved objects file format is invalid and cannot be imported.' } + ), + }); + return; + } + + contents = contents + .filter(content => allowedTypes.includes(content._type)) + .map(doc => ({ + ...doc, + // The server assumes that documents with no migrationVersion are up to date. + // That assumption enables Kibana and other API consumers to not have to build + // up migrationVersion prior to creating new objects. But it means that imports + // need to set migrationVersion to something other than undefined, so that imported + // docs are not seen as automatically up-to-date. + _migrationVersion: doc._migrationVersion || {}, + })); + + const { + conflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + importedObjectCount, + failedImports, + } = await resolveSavedObjects( + contents, + isOverwriteAllChecked, + serviceRegistry.all().map(e => e.service), + indexPatterns, + overlays + ); + + const byId: Record = {}; + conflictedIndexPatterns + .map(({ doc, obj }) => { + return { doc, obj: obj._serialize() }; + }) + .forEach(({ doc, obj }) => + obj.references.forEach((ref: Record) => { + byId[ref.id] = byId[ref.id] != null ? byId[ref.id].concat({ doc, obj }) : [{ doc, obj }]; + }) + ); + const unmatchedReferences = Object.entries(byId).reduce( + (accum, [existingIndexPatternId, list]) => { + accum.push({ + existingIndexPatternId, + newIndexPatternId: undefined, + list: list.map(({ doc }) => ({ + id: existingIndexPatternId, + type: doc._type, + title: doc._source.title, + })), + }); + return accum; + }, + [] as any[] + ); + + this.setState({ + conflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + failedImports, + unmatchedReferences, + importCount: importedObjectCount, + status: unmatchedReferences.length === 0 ? 'success' : 'idle', + }); + }; + + public get hasUnmatchedReferences() { + return this.state.unmatchedReferences && this.state.unmatchedReferences.length > 0; + } + + public get resolutions() { + return this.state.unmatchedReferences!.reduce( + (accum, { existingIndexPatternId, newIndexPatternId }) => { + if (newIndexPatternId) { + accum.push({ + oldId: existingIndexPatternId, + newId: newIndexPatternId, + }); + } + return accum; + }, + [] as Array<{ oldId: string; newId: string }> + ); + } + + confirmLegacyImport = async () => { + const { + conflictedIndexPatterns, + isOverwriteAllChecked, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + failedImports, + } = this.state; + + const { serviceRegistry, indexPatterns } = this.props; + + this.setState({ + error: undefined, + status: 'loading', + loadingMessage: undefined, + }); + + let importCount = this.state.importCount; + + if (this.hasUnmatchedReferences) { + try { + const resolutions = this.resolutions; + + // Do not Promise.all these calls as the order matters + this.setState({ + loadingMessage: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage', + { defaultMessage: 'Resolving conflicts…' } + ), + }); + if (resolutions.length) { + importCount += await resolveIndexPatternConflicts( + resolutions, + conflictedIndexPatterns!, + isOverwriteAllChecked + ); + } + this.setState({ + loadingMessage: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage', + { defaultMessage: 'Saving conflicts…' } + ), + }); + importCount += await saveObjects( + conflictedSavedObjectsLinkedToSavedSearches!, + isOverwriteAllChecked + ); + this.setState({ + loadingMessage: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage', + { defaultMessage: 'Ensure saved searches are linked properly…' } + ), + }); + importCount += await resolveSavedSearches( + conflictedSearchDocs!, + serviceRegistry.all().map(e => e.service), + indexPatterns, + isOverwriteAllChecked + ); + this.setState({ + loadingMessage: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage', + { defaultMessage: 'Retrying failed objects…' } + ), + }); + importCount += await saveObjects( + failedImports!.map(({ obj }) => obj) as any[], + isOverwriteAllChecked + ); + } catch (e) { + this.setState({ + error: e.message, + status: 'error', + loadingMessage: undefined, + }); + return; + } + } + + this.setState({ status: 'success', importCount }); + }; + + onIndexChanged = (id: string, e: any) => { + const value = e.target.value; + this.setState(state => { + const conflictIndex = state.unmatchedReferences?.findIndex( + conflict => conflict.existingIndexPatternId === id + ); + if (conflictIndex === undefined || conflictIndex === -1) { + return state; + } + + return { + unmatchedReferences: [ + ...state.unmatchedReferences!.slice(0, conflictIndex), + { + ...state.unmatchedReferences![conflictIndex], + newIndexPatternId: value, + }, + ...state.unmatchedReferences!.slice(conflictIndex + 1), + ], + } as any; + }); + }; + + renderUnmatchedReferences() { + const { unmatchedReferences } = this.state; + + if (!unmatchedReferences) { + return null; + } + + const columns = [ + { + field: 'existingIndexPatternId', + name: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdName', + { defaultMessage: 'ID' } + ), + description: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnIdDescription', + { defaultMessage: 'ID of the index pattern' } + ), + sortable: true, + }, + { + field: 'list', + name: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountName', + { defaultMessage: 'Count' } + ), + description: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnCountDescription', + { defaultMessage: 'How many affected objects' } + ), + render: (list: any[]) => { + return {list.length}; + }, + }, + { + field: 'list', + name: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName', + { defaultMessage: 'Sample of affected objects' } + ), + description: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription', + { defaultMessage: 'Sample of affected objects' } + ), + render: (list: any[]) => { + return ( +
    + {take(list, 3).map((obj, key) => ( +
  • {obj.title}
  • + ))} +
+ ); + }, + }, + { + field: 'existingIndexPatternId', + name: i18n.translate( + 'kbn.management.objects.objectsTable.flyout.renderConflicts.columnNewIndexPatternName', + { defaultMessage: 'New index pattern' } + ), + render: (id: string) => { + const options = this.state.indexPatterns!.map(indexPattern => ({ + text: indexPattern.title, + value: indexPattern.id, + ['data-test-subj']: `indexPatternOption-${indexPattern.title}`, + })); + + options.unshift({ + text: '-- Skip Import --', + value: '', + } as any); + + return ( + this.onIndexChanged(id, e)} + options={options} + /> + ); + }, + }, + ]; + + const pagination = { + pageSizeOptions: [5, 10, 25], + }; + + return ( + + ); + } + + renderError() { + const { error, status } = this.state; + + if (status !== 'error') { + return null; + } + + return ( + + + } + color="danger" + > +

{error}

+
+ +
+ ); + } + + renderBody() { + const { + status, + loadingMessage, + isOverwriteAllChecked, + importCount, + failedImports = [], + isLegacyFile, + } = this.state; + + if (status === 'loading') { + return ( + + + + + +

{loadingMessage}

+
+
+
+ ); + } + + // Kept backwards compatible logic + if ( + failedImports.length && + (!this.hasUnmatchedReferences || (isLegacyFile === false && status === 'success')) + ) { + return ( + + } + color="warning" + iconType="help" + > +

+ +

+

+ {failedImports + .map(({ error, obj }) => { + if (error.type === 'missing_references') { + return error.references.map(reference => { + return i18n.translate( + 'kbn.management.objects.objectsTable.flyout.importFailedMissingReference', + { + defaultMessage: '{type} [id={id}] could not locate {refType} [id={refId}]', + values: { + id: obj.id, + type: obj.type, + refId: reference.id, + refType: reference.type, + }, + } + ); + }); + } else if (error.type === 'unsupported_type') { + return i18n.translate( + 'kbn.management.objects.objectsTable.flyout.importFailedUnsupportedType', + { + defaultMessage: '{type} [id={id}] unsupported type', + values: { + id: obj.id, + type: obj.type, + }, + } + ); + } + return getField(error, 'body.message', (error as any).message ?? ''); + }) + .join(' ')} +

+
+ ); + } + + if (status === 'success') { + if (importCount === 0) { + return ( + + } + color="primary" + /> + ); + } + + return ( + + } + color="success" + iconType="check" + > +

+ +

+
+ ); + } + + if (this.hasUnmatchedReferences) { + return this.renderUnmatchedReferences(); + } + + return ( + + + } + > + + } + onChange={this.setImportFile} + /> + + + + } + data-test-subj="importSavedObjectsOverwriteToggle" + checked={isOverwriteAllChecked} + onChange={this.changeOverwriteAll} + /> + + + ); + } + + renderFooter() { + const { status } = this.state; + const { done, close } = this.props; + + let confirmButton; + + if (status === 'success') { + confirmButton = ( + + + + ); + } else if (this.hasUnmatchedReferences) { + confirmButton = ( + + + + ); + } else { + confirmButton = ( + + + + ); + } + + return ( + + + + + + + {confirmButton} + + ); + } + + renderSubheader() { + if (this.state.status === 'loading' || this.state.status === 'success') { + return null; + } + + let legacyFileWarning; + if (this.state.isLegacyFile) { + legacyFileWarning = ( + + } + color="warning" + iconType="help" + > +

+ +

+
+ ); + } + + let indexPatternConflictsWarning; + if (this.hasUnmatchedReferences) { + indexPatternConflictsWarning = ( + + } + color="warning" + iconType="help" + > +

+ + + + ), + }} + /> +

+
+ ); + } + + if (!legacyFileWarning && !indexPatternConflictsWarning) { + return null; + } + + return ( + + {legacyFileWarning && ( + + + {legacyFileWarning} + + )} + {indexPatternConflictsWarning && ( + + + {indexPatternConflictsWarning} + + )} + + ); + } + + overwriteConfirmed() { + this.state.conflictingRecord!.done(true); + } + + overwriteSkipped() { + this.state.conflictingRecord!.done(false); + } + + render() { + const { close } = this.props; + + let confirmOverwriteModal; + if (this.state.conflictingRecord) { + confirmOverwriteModal = ( + + +

+ +

+
+
+ ); + } + + return ( + + + +

+ +

+
+
+ + + {this.renderSubheader()} + {this.renderError()} + {this.renderBody()} + + + {this.renderFooter()} + {confirmOverwriteModal} +
+ ); + } +} diff --git a/src/core/server/saved_objects/management/index.ts b/src/plugins/so_management/public/management/components/flyout/index.ts similarity index 89% rename from src/core/server/saved_objects/management/index.ts rename to src/plugins/so_management/public/management/components/flyout/index.ts index c32639e74d079..cdeebdbf7b63a 100644 --- a/src/core/server/saved_objects/management/index.ts +++ b/src/plugins/so_management/public/management/components/flyout/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { SavedObjectsManagement, SavedObjectsManagementDefinition } from './management'; +export { Flyout } from './flyout'; diff --git a/src/plugins/so_management/public/management/components/header/__snapshots__/header.test.js.snap b/src/plugins/so_management/public/management/components/header/__snapshots__/header.test.js.snap new file mode 100644 index 0000000000000..51bd51a5e2e58 --- /dev/null +++ b/src/plugins/so_management/public/management/components/header/__snapshots__/header.test.js.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header should render normally 1`] = ` + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + +

+ + + +

+
+ +
+`; diff --git a/src/plugins/so_management/public/management/components/header/header.test.tsx b/src/plugins/so_management/public/management/components/header/header.test.tsx new file mode 100644 index 0000000000000..41b98e65e784b --- /dev/null +++ b/src/plugins/so_management/public/management/components/header/header.test.tsx @@ -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 React from 'react'; +import { shallow } from 'enzyme'; +import { Header } from './header'; + +describe('Header', () => { + it('should render normally', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + filteredCount: 2, + }; + + const component = shallow(
); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/so_management/public/management/components/header/header.tsx b/src/plugins/so_management/public/management/components/header/header.tsx new file mode 100644 index 0000000000000..7df5b931722c9 --- /dev/null +++ b/src/plugins/so_management/public/management/components/header/header.tsx @@ -0,0 +1,114 @@ +/* + * 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 React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextColor, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const Header = ({ + onExportAll, + onImport, + onRefresh, + filteredCount, +}: { + onExportAll: () => void; + onImport: () => void; + onRefresh: () => void; + filteredCount: number; +}) => ( + + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + +

+ + + +

+
+ +
+); diff --git a/src/plugins/so_management/public/management/components/header/index.tsx b/src/plugins/so_management/public/management/components/header/index.tsx new file mode 100644 index 0000000000000..ac1e7bac06c87 --- /dev/null +++ b/src/plugins/so_management/public/management/components/header/index.tsx @@ -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 { Header } from './header'; diff --git a/src/plugins/so_management/public/management/components/index.ts b/src/plugins/so_management/public/management/components/index.ts new file mode 100644 index 0000000000000..2501740750575 --- /dev/null +++ b/src/plugins/so_management/public/management/components/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { Header } from './header'; +export { Table } from './table'; +export { Relationships } from './relationships'; +export { Flyout } from './flyout'; diff --git a/src/plugins/so_management/public/management/components/relationships/__snapshots__/relationships.test.js.snap b/src/plugins/so_management/public/management/components/relationships/__snapshots__/relationships.test.js.snap new file mode 100644 index 0000000000000..c1241d5d7c1e5 --- /dev/null +++ b/src/plugins/so_management/public/management/components/relationships/__snapshots__/relationships.test.js.snap @@ -0,0 +1,704 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Relationships should render dashboards normally 1`] = ` + + + +

+ + + +    + MyDashboard +

+
+
+ +
+ +

+ Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + +exports[`Relationships should render errors 1`] = ` + + + +

+ + + +    + MyDashboard +

+
+
+ + + } + > + foo + + +
+`; + +exports[`Relationships should render index patterns normally 1`] = ` + + + +

+ + + +    + MyIndexPattern* +

+
+
+ +
+ +

+ Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + +exports[`Relationships should render searches normally 1`] = ` + + + +

+ + + +    + MySearch +

+
+
+ +
+ +

+ Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + +exports[`Relationships should render visualizations normally 1`] = ` + + + +

+ + + +    + MyViz +

+
+
+ +
+ +

+ Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. +

+
+ + +
+
+
+`; diff --git a/src/plugins/so_management/public/management/components/relationships/index.tsx b/src/plugins/so_management/public/management/components/relationships/index.tsx new file mode 100644 index 0000000000000..522b1ce83a6b6 --- /dev/null +++ b/src/plugins/so_management/public/management/components/relationships/index.tsx @@ -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 { Relationships } from './relationships'; diff --git a/src/plugins/so_management/public/management/components/relationships/relationships.test.js b/src/plugins/so_management/public/management/components/relationships/relationships.test.js new file mode 100644 index 0000000000000..479726e8785d8 --- /dev/null +++ b/src/plugins/so_management/public/management/components/relationships/relationships.test.js @@ -0,0 +1,324 @@ +/* + * 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 React from 'react'; +import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; + +jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() })); + +jest.mock('ui/chrome', () => ({ + addBasePath: () => '', +})); + +jest.mock('../../../../../lib/fetch_export_by_type_and_search', () => ({ + fetchExportByTypeAndSearch: jest.fn(), +})); + +jest.mock('../../../../../lib/fetch_export_objects', () => ({ + fetchExportObjects: jest.fn(), +})); + +import { Relationships } from '../relationships'; + +describe('Relationships', () => { + it('should render index patterns normally', async () => { + const props = { + 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(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render searches normally', async () => { + const props = { + 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(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render visualizations normally', async () => { + const props = { + 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', + }, + }, + { + 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(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render dashboards normally', async () => { + const props = { + 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', + }, + }, + { + 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(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingKibana').length).toBe(1); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); + + it('should render errors', async () => { + const props = { + goInspectObject: () => {}, + getRelationships: jest.fn().mockImplementation(() => { + throw new Error('foo'); + }), + 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(), + }; + + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/so_management/public/management/components/relationships/relationships.tsx b/src/plugins/so_management/public/management/components/relationships/relationships.tsx new file mode 100644 index 0000000000000..e36d69a582831 --- /dev/null +++ b/src/plugins/so_management/public/management/components/relationships/relationships.tsx @@ -0,0 +1,336 @@ +/* + * 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 React, { Component } from 'react'; +import { + EuiTitle, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiLink, + EuiIcon, + EuiCallOut, + EuiLoadingKibana, + EuiInMemoryTable, + EuiToolTip, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import { FilterConfig } from '@elastic/eui/src/components/basic_table/in_memory_table'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { IBasePath } from 'src/core/public'; +import { getDefaultTitle, getSavedObjectLabel } from '../../lib'; +import { SavedObjectWithMetadata } from '../../../types'; +import { SavedObjectRelation } from '../../types'; + +interface RelationshipsProps { + basePath: IBasePath; + getRelationships: (type: string, id: string) => Promise; + savedObject: SavedObjectWithMetadata; + close: () => void; + goInspectObject: (obj: SavedObjectWithMetadata) => void; + canGoInApp: (obj: SavedObjectWithMetadata) => boolean; +} + +interface RelationshipsState { + relationships: SavedObjectRelation[]; + isLoading: boolean; + error?: string; +} + +export class Relationships extends Component { + constructor(props: RelationshipsProps) { + super(props); + + this.state = { + relationships: [], + isLoading: false, + error: undefined, + }; + } + + UNSAFE_componentWillMount() { + this.getRelationshipData(); + } + + UNSAFE_componentWillReceiveProps(nextProps: RelationshipsProps) { + if (nextProps.savedObject.id !== this.props.savedObject.id) { + this.getRelationshipData(); + } + } + + async getRelationshipData() { + const { savedObject, getRelationships } = this.props; + + this.setState({ isLoading: true }); + + try { + const relationships = await getRelationships(savedObject.type, savedObject.id); + this.setState({ relationships, isLoading: false, error: undefined }); + } catch (err) { + this.setState({ error: err.message, isLoading: false }); + } + } + + renderError() { + const { error } = this.state; + + if (!error) { + return null; + } + + return ( + + } + color="danger" + > + {error} + + ); + } + + renderRelationships() { + const { goInspectObject, savedObject, basePath } = this.props; + const { relationships, isLoading, error } = this.state; + + if (error) { + return this.renderError(); + } + + if (isLoading) { + return ; + } + + const columns = [ + { + field: 'type', + name: i18n.translate('kbn.management.objects.objectsTable.relationships.columnTypeName', { + defaultMessage: 'Type', + }), + width: '50px', + align: 'center', + description: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.columnTypeDescription', + { defaultMessage: 'Type of the saved object' } + ), + sortable: false, + render: (type: string, object: SavedObjectWithMetadata) => { + return ( + + + + ); + }, + }, + { + field: 'relationship', + name: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.columnRelationshipName', + { defaultMessage: 'Direct relationship' } + ), + dataType: 'string', + sortable: false, + width: '125px', + render: (relationship: string) => { + if (relationship === 'parent') { + return ( + + + + ); + } + if (relationship === 'child') { + return ( + + + + ); + } + }, + }, + { + field: 'meta.title', + name: i18n.translate('kbn.management.objects.objectsTable.relationships.columnTitleName', { + defaultMessage: 'Title', + }), + description: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.columnTitleDescription', + { defaultMessage: 'Title of the saved object' } + ), + dataType: 'string', + sortable: false, + render: (title: string, object: SavedObjectWithMetadata) => { + const canGoInApp = this.props.canGoInApp(object); + if (!canGoInApp) { + return {title || getDefaultTitle(object)}; + } + const { path = '' } = object.meta.inAppUrl || {}; + return ( + {title || getDefaultTitle(object)} + ); + }, + }, + { + name: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.columnActionsName', + { defaultMessage: 'Actions' } + ), + actions: [ + { + name: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.columnActions.inspectActionName', + { defaultMessage: 'Inspect' } + ), + description: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.columnActions.inspectActionDescription', + { defaultMessage: 'Inspect this saved object' } + ), + type: 'icon', + icon: 'inspect', + onClick: (object: SavedObjectWithMetadata) => goInspectObject(object), + available: (object: SavedObjectWithMetadata) => !!object.meta.editUrl, + }, + ], + }, + ]; + + const filterTypesMap = new Map( + relationships.map(relationship => [ + relationship.type, + { + value: relationship.type, + name: relationship.type, + view: relationship.type, + }, + ]) + ); + + const search = { + filters: [ + { + type: 'field_value_selection', + field: 'relationship', + name: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.name', + { defaultMessage: 'Direct relationship' } + ), + multiSelect: 'or', + options: [ + { + value: 'parent', + name: 'parent', + view: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.parentAsValue.view', + { defaultMessage: 'Parent' } + ), + }, + { + value: 'child', + name: 'child', + view: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.search.filters.relationship.childAsValue.view', + { defaultMessage: 'Child' } + ), + }, + ], + }, + { + type: 'field_value_selection', + field: 'type', + name: i18n.translate( + 'kbn.management.objects.objectsTable.relationships.search.filters.type.name', + { defaultMessage: 'Type' } + ), + multiSelect: 'or', + options: [...filterTypesMap.values()], + }, + ] as FilterConfig[], + }; + + return ( +
+ +

+ {i18n.translate( + '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.', + values: { + type: savedObject.type, + title: savedObject.meta.title || getDefaultTitle(savedObject), + }, + } + )} +

+
+ + +
+ ); + } + + render() { + const { close, savedObject } = this.props; + + return ( + + + +

+ + + +    + {savedObject.meta.title || getDefaultTitle(savedObject)} +

+
+
+ + {this.renderRelationships()} +
+ ); + } +} diff --git a/src/plugins/so_management/public/management/components/table/__snapshots__/table.test.js.snap b/src/plugins/so_management/public/management/components/table/__snapshots__/table.test.js.snap new file mode 100644 index 0000000000000..805131042f385 --- /dev/null +++ b/src/plugins/so_management/public/management/components/table/__snapshots__/table.test.js.snap @@ -0,0 +1,418 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table prevents saved objects from being deleted 1`] = ` + + + + , + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + + } + labelType="label" + > + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + , + ] + } + /> + +
+ +
+
+`; + +exports[`Table should render normally 1`] = ` + + + + , + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + + } + labelType="label" + > + + } + name="includeReferencesDeep" + onChange={[Function]} + /> + + + + + + + , + ] + } + /> + +
+ +
+
+`; diff --git a/src/plugins/so_management/public/management/components/table/index.ts b/src/plugins/so_management/public/management/components/table/index.ts new file mode 100644 index 0000000000000..e1195c6edfe31 --- /dev/null +++ b/src/plugins/so_management/public/management/components/table/index.ts @@ -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 { Table } from './table'; diff --git a/src/plugins/so_management/public/management/components/table/table.test.js b/src/plugins/so_management/public/management/components/table/table.test.js new file mode 100644 index 0000000000000..5ebea27dfa6f2 --- /dev/null +++ b/src/plugins/so_management/public/management/components/table/table.test.js @@ -0,0 +1,128 @@ +/* + * 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 React from 'react'; +import { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { keyCodes } from '@elastic/eui/lib/services'; +// import { mockManagementPlugin } from '../../../../../management/public/np_ready/mocks'; + +// TODO: fix tests + +/* +jest.mock('../../../../../../../../../../management/public/legacy', () => ({ + setup: mockManagementPlugin.createSetupContract(), + start: mockManagementPlugin.createStartContract(), +})); +*/ + +import { Table } from '../table'; + +const defaultProps = { + 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: () => {}, + goInspectObject: () => {}, + canGoInApp: () => {}, + pageIndex: 1, + pageSize: 2, + 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: () => {}, + onTableChange: () => {}, + isSearching: false, + onShowRelationships: () => {}, + canDelete: true, +}; + +describe('Table', () => { + it('should render normally', () => { + const component = shallowWithI18nProvider(); + + expect(component).toMatchSnapshot(); + }); + + it('should handle query parse error', () => { + const onQueryChangeMock = jest.fn(); + const customizedProps = { + ...defaultProps, + onQueryChange: onQueryChangeMock, + }; + + const component = mountWithI18nProvider(
); + const searchBar = findTestSubject(component, 'savedObjectSearchBar'); + + // Send invalid query + searchBar.simulate('keyup', { keyCode: keyCodes.ENTER, target: { value: '?' } }); + expect(onQueryChangeMock).toHaveBeenCalledTimes(0); + expect(component.state().isSearchTextValid).toBe(false); + + onQueryChangeMock.mockReset(); + + // Send valid query to ensure component can recover from invalid query + searchBar.simulate('keyup', { keyCode: keyCodes.ENTER, target: { value: 'I am valid' } }); + expect(onQueryChangeMock).toHaveBeenCalledTimes(1); + expect(component.state().isSearchTextValid).toBe(true); + }); + + it(`prevents saved objects from being deleted`, () => { + const selectedSavedObjects = [ + { type: 'visualization' }, + { type: 'search' }, + { type: 'index-pattern' }, + ]; + const customizedProps = { ...defaultProps, selectedSavedObjects, canDelete: false }; + const component = shallowWithI18nProvider(
); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/so_management/public/management/components/table/table.tsx b/src/plugins/so_management/public/management/components/table/table.tsx new file mode 100644 index 0000000000000..3e0c5283defff --- /dev/null +++ b/src/plugins/so_management/public/management/components/table/table.tsx @@ -0,0 +1,388 @@ +/* + * 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 { IBasePath } from 'src/core/public'; +// import { setup as managementSetup } from '../../../../../../../../../management/public/legacy'; +import React, { PureComponent, Fragment } from 'react'; + +import { + // @ts-ignore + EuiSearchBar, + EuiBasicTable, + EuiButton, + EuiIcon, + EuiLink, + EuiSpacer, + EuiToolTip, + EuiFormErrorText, + EuiPopover, + EuiSwitch, + EuiFormRow, + EuiText, + EuiTableFieldDataColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getDefaultTitle, getSavedObjectLabel } from '../../lib'; +import { SavedObjectWithMetadata } from '../../../types'; + +interface TableProps { + basePath: IBasePath; + selectedSavedObjects: SavedObjectWithMetadata[]; + selectionConfig: { + onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; + }; + filterOptions: any[]; // TODO + canDelete: boolean; + onDelete: () => void; + onExport: (includeReferencesDeep: boolean) => void; + goInspectObject: (obj: SavedObjectWithMetadata) => void; + pageIndex: number; + pageSize: number; + items: SavedObjectWithMetadata[]; + itemId: string | (() => string); + totalItemCount: number; + onQueryChange: (query: any) => void; // TODO + onTableChange: (table: any) => void; // TODO + isSearching: boolean; + onShowRelationships: (object: SavedObjectWithMetadata) => void; + canGoInApp: (obj: SavedObjectWithMetadata) => boolean; +} + +interface TableState { + isSearchTextValid: boolean; + parseErrorMessage: any; // TODO + isExportPopoverOpen: boolean; + isIncludeReferencesDeepChecked: boolean; + activeAction: any; // TODO +} + +export class Table extends PureComponent { + state = { + isSearchTextValid: true, + parseErrorMessage: null, + isExportPopoverOpen: false, + isIncludeReferencesDeepChecked: true, + activeAction: null, + }; + + constructor(props: TableProps) { + super(props); + // this.extraActions = managementSetup.savedObjects.registry.get(); // TODO: extraActions + } + + onChange = ({ query, error }: any) => { + if (error) { + this.setState({ + isSearchTextValid: false, + parseErrorMessage: error.message, + }); + return; + } + + this.setState({ + isSearchTextValid: true, + parseErrorMessage: null, + }); + this.props.onQueryChange({ query }); + }; + + closeExportPopover = () => { + this.setState({ isExportPopoverOpen: false }); + }; + + toggleExportPopoverVisibility = () => { + this.setState(state => ({ + isExportPopoverOpen: !state.isExportPopoverOpen, + })); + }; + + toggleIsIncludeReferencesDeepChecked = () => { + this.setState(state => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + onExportClick = () => { + const { onExport } = this.props; + const { isIncludeReferencesDeepChecked } = this.state; + onExport(isIncludeReferencesDeepChecked); + this.setState({ isExportPopoverOpen: false }); + }; + + render() { + const { + pageIndex, + pageSize, + itemId, + items, + totalItemCount, + isSearching, + filterOptions, + selectionConfig: selection, + onDelete, + selectedSavedObjects, + onTableChange, + goInspectObject, + onShowRelationships, + basePath, + } = this.props; + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [5, 10, 20, 50], + }; + + const filters = [ + { + type: 'field_value_selection', + field: 'type', + name: i18n.translate('kbn.management.objects.objectsTable.table.typeFilterName', { + defaultMessage: 'Type', + }), + multiSelect: 'or', + options: filterOptions, + }, + // Add this back in once we have tag support + // { + // type: 'field_value_selection', + // field: 'tag', + // name: 'Tags', + // multiSelect: 'or', + // options: [], + // }, + ]; + + const columns = [ + { + field: 'type', + name: i18n.translate('kbn.management.objects.objectsTable.table.columnTypeName', { + defaultMessage: 'Type', + }), + width: '50px', + align: 'center', + description: i18n.translate( + 'kbn.management.objects.objectsTable.table.columnTypeDescription', + { defaultMessage: 'Type of the saved object' } + ), + sortable: false, + render: (type: string, object: SavedObjectWithMetadata) => { + return ( + + + + ); + }, + } as EuiTableFieldDataColumnType>, + { + field: 'meta.title', + name: i18n.translate('kbn.management.objects.objectsTable.table.columnTitleName', { + defaultMessage: 'Title', + }), + description: i18n.translate( + 'kbn.management.objects.objectsTable.table.columnTitleDescription', + { defaultMessage: 'Title of the saved object' } + ), + dataType: 'string', + sortable: false, + render: (title: string, object: SavedObjectWithMetadata) => { + const { path = '' } = object.meta.inAppUrl || {}; + const canGoInApp = this.props.canGoInApp(object); + if (!canGoInApp) { + return {title || getDefaultTitle(object)}; + } + return ( + {title || getDefaultTitle(object)} + ); + }, + } as EuiTableFieldDataColumnType>, + { + name: i18n.translate('kbn.management.objects.objectsTable.table.columnActionsName', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate( + 'kbn.management.objects.objectsTable.table.columnActions.inspectActionName', + { defaultMessage: 'Inspect' } + ), + description: i18n.translate( + 'kbn.management.objects.objectsTable.table.columnActions.inspectActionDescription', + { defaultMessage: 'Inspect this saved object' } + ), + type: 'icon', + icon: 'inspect', + onClick: object => goInspectObject(object), + available: object => !!object.meta.editUrl, + }, + { + name: i18n.translate( + 'kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionName', + { defaultMessage: 'Relationships' } + ), + description: i18n.translate( + 'kbn.management.objects.objectsTable.table.columnActions.viewRelationshipsActionDescription', + { + defaultMessage: + 'View the relationships this saved object has to other saved objects', + } + ), + type: 'icon', + icon: 'kqlSelector', + onClick: object => onShowRelationships(object), + } /* TODO: extraActions + ...this.extraActions.map(action => { + return { + ...action.euiAction, + onClick: (object: SavedObjectWithMetadata) => { + this.setState({ + activeAction: action, + }); + + action.registerOnFinishCallback(() => { + this.setState({ + activeAction: null, + }); + }); + + action.euiAction.onClick(object); + }, + }; + }), + */, + ], + } as EuiTableActionsColumnType, + ]; + + let queryParseError; + if (!this.state.isSearchTextValid) { + const parseErrorMsg = i18n.translate( + 'kbn.management.objects.objectsTable.searchBar.unableToParseQueryErrorMessage', + { defaultMessage: 'Unable to parse query' } + ); + queryParseError = ( + {`${parseErrorMsg}. ${this.state.parseErrorMessage}`} + ); + } + + const button = ( + + + + ); + + const activeActionContents = null; // this.state.activeAction?.render() : null; // TODO restore + + return ( + + {activeActionContents} + + + , + + + } + > + + } + checked={this.state.isIncludeReferencesDeepChecked} + onChange={this.toggleIsIncludeReferencesDeepChecked} + /> + + + + + + + , + ]} + /> + {queryParseError} + +
+ +
+
+ ); + } +} diff --git a/src/plugins/so_management/public/management/index.tsx b/src/plugins/so_management/public/management/index.tsx new file mode 100644 index 0000000000000..d44e5b20e7b63 --- /dev/null +++ b/src/plugins/so_management/public/management/index.tsx @@ -0,0 +1,180 @@ +/* + * 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 React, { useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter, Switch, Route, useParams, useLocation } from 'react-router-dom'; +import { parse } from 'query-string'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreSetup, CoreStart, ChromeBreadcrumb } from 'src/core/public'; +import { ManagementSetup } from '../../../management/public'; +import { DataPublicPluginStart } from '../../../data/public'; +import { StartDependencies } from '../plugin'; +import { ISavedObjectsManagementRegistry } from '../management_registry'; +import { SavedObjectsTable } from './saved_objects_table'; +import { SavedObjectEdition } from './saved_objects_view'; +import { getAllowedTypes } from './lib'; + +interface RegisterOptions { + core: CoreSetup; + sections: ManagementSetup['sections']; + serviceRegistry: ISavedObjectsManagementRegistry; +} + +const title = i18n.translate('kbn.management.objects.savedObjectsSectionLabel', { + defaultMessage: 'Saved Objects XXX', +}); + +export const registerManagementSection = ({ core, sections, serviceRegistry }: RegisterOptions) => { + const kibanaSection = sections.getSection('kibana'); + if (!kibanaSection) { + throw new Error('`kibana` management section not found.'); + } + kibanaSection.registerApp({ + id: 'toto', // 'objects', + title, + order: 10, + mount: async ({ element, basePath, setBreadcrumbs }) => { + const [coreStart, { data }] = await core.getStartServices(); + const allowedTypes = await getAllowedTypes(coreStart.http); + + ReactDOM.render( + + + + + + + + + + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; + }, + }); +}; + +const SavedObjectsEditionPage = ({ + coreStart, + serviceRegistry, + setBreadcrumbs, +}: { + coreStart: CoreStart; + serviceRegistry: ISavedObjectsManagementRegistry; + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; +}) => { + const { service: serviceName, id } = useParams<{ service: string; id: string }>(); + const capabilities = coreStart.application.capabilities; + + const { search } = useLocation(); + const query = parse(search); + + useEffect(() => { + setBreadcrumbs([]); // TODO: proper breadcrumb + }, [setBreadcrumbs]); + + return ( + + ); +}; + +const SavedObjectsTablePage = ({ + coreStart, + dataStart, + allowedTypes, + serviceRegistry, + setBreadcrumbs, +}: { + coreStart: CoreStart; + dataStart: DataPublicPluginStart; + allowedTypes: string[]; + serviceRegistry: ISavedObjectsManagementRegistry; + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; +}) => { + const capabilities = coreStart.application.capabilities; + const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); + + useEffect(() => { + setBreadcrumbs([ + { + text: i18n.translate('kbn.management.savedObjects.indexBreadcrumb', { + defaultMessage: 'Saved objects', + }), + href: '#/management/kibana/objects', + }, + ]); + }, [setBreadcrumbs]); + + return ( + { + const { editUrl } = savedObject.meta; + if (editUrl) { + // TODO: it seems only editable objects are done from without the management page. + // previously, kbnUrl.change(object.meta.editUrl); was used. + // using direct access to location.hash seems the only option for now. + + // TODO: remove redirect hack + window.location.hash = editUrl.replace('/objects', '/toto'); + } + }} + canGoInApp={savedObject => { + const { inAppUrl } = savedObject.meta; + return inAppUrl ? get(capabilities, inAppUrl.uiCapabilitiesPath) : false; + }} + /> + ); +}; diff --git a/src/plugins/so_management/public/management/lib/case_conversion.test.ts b/src/plugins/so_management/public/management/lib/case_conversion.test.ts new file mode 100644 index 0000000000000..bb749de8dcb71 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/case_conversion.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { keysToCamelCaseShallow } from './case_conversion'; + +describe('keysToCamelCaseShallow', () => { + test("should convert all of an object's keys to camel case", () => { + const data = { + camelCase: 'camelCase', + 'kebab-case': 'kebabCase', + snake_case: 'snakeCase', + }; + + const result = keysToCamelCaseShallow(data); + + expect(result.camelCase).toBe('camelCase'); + expect(result.kebabCase).toBe('kebabCase'); + expect(result.snakeCase).toBe('snakeCase'); + }); +}); diff --git a/src/plugins/so_management/public/management/lib/case_conversion.ts b/src/plugins/so_management/public/management/lib/case_conversion.ts new file mode 100644 index 0000000000000..718530eb3b602 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/case_conversion.ts @@ -0,0 +1,24 @@ +/* + * 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 { mapKeys, camelCase } from 'lodash'; + +export function keysToCamelCaseShallow(object: Record) { + return mapKeys(object, (value, key) => camelCase(key)); +} diff --git a/src/plugins/so_management/public/management/lib/extract_export_details.ts b/src/plugins/so_management/public/management/lib/extract_export_details.ts new file mode 100644 index 0000000000000..fdd72aece06bc --- /dev/null +++ b/src/plugins/so_management/public/management/lib/extract_export_details.ts @@ -0,0 +1,51 @@ +/* + * 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 async function extractExportDetails( + blob: Blob +): Promise { + const reader = new FileReader(); + const content = await new Promise((resolve, reject) => { + reader.addEventListener('loadend', e => { + resolve((e as any).target.result); + }); + reader.addEventListener('error', e => { + reject(e); + }); + reader.readAsText(blob, 'utf-8'); + }); + const lines = content.split('\n').filter(l => l.length > 0); + const maybeDetails = JSON.parse(lines[lines.length - 1]); + if (isExportDetails(maybeDetails)) { + return maybeDetails; + } +} + +export interface SavedObjectsExportResultDetails { + exportedCount: number; + missingRefCount: number; + missingReferences: Array<{ + id: string; + type: string; + }>; +} + +function isExportDetails(object: any): object is SavedObjectsExportResultDetails { + return 'exportedCount' in object && 'missingRefCount' in object && 'missingReferences' in object; +} diff --git a/src/plugins/so_management/public/management/lib/fetch_export_by_type_and_search.ts b/src/plugins/so_management/public/management/lib/fetch_export_by_type_and_search.ts new file mode 100644 index 0000000000000..e0f005fab2a3b --- /dev/null +++ b/src/plugins/so_management/public/management/lib/fetch_export_by_type_and_search.ts @@ -0,0 +1,35 @@ +/* + * 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 { HttpStart } from 'src/core/public'; + +export async function fetchExportByTypeAndSearch( + http: HttpStart, + types: string[], + search: string | undefined, + includeReferencesDeep: boolean = false +): Promise { + return http.post('/api/saved_objects/_export', { + body: JSON.stringify({ + type: types, + search, + includeReferencesDeep, + }), + }); +} diff --git a/src/plugins/so_management/public/management/lib/fetch_export_objects.ts b/src/plugins/so_management/public/management/lib/fetch_export_objects.ts new file mode 100644 index 0000000000000..745d3758371a3 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/fetch_export_objects.ts @@ -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. + */ + +import { HttpStart } from 'src/core/public'; + +export async function fetchExportObjects( + http: HttpStart, + objects: any[], + includeReferencesDeep: boolean = false +): Promise { + return http.post('/api/saved_objects/_export', { + body: JSON.stringify({ + objects, + includeReferencesDeep, + }), + }); +} diff --git a/src/plugins/so_management/public/management/lib/find_objects.ts b/src/plugins/so_management/public/management/lib/find_objects.ts new file mode 100644 index 0000000000000..d23bd4494c54b --- /dev/null +++ b/src/plugins/so_management/public/management/lib/find_objects.ts @@ -0,0 +1,43 @@ +/* + * 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 { HttpStart, SavedObjectsFindOptions } from 'src/core/public'; +import { keysToCamelCaseShallow } from './case_conversion'; +import { SavedObjectWithMetadata } from '../../types'; + +interface SavedObjectsFindResponse { + total: number; + page: number; + perPage: number; + savedObjects: SavedObjectWithMetadata[]; // TODO: this is camelCased, so not exactly the same type... +} + +export async function findObjects( + http: HttpStart, + findOptions: SavedObjectsFindOptions +): Promise { + const response = await http.get>( + '/api/kibana/management/saved_objects/_find', + { + query: findOptions as Record, + } + ); + + return keysToCamelCaseShallow(response) as SavedObjectsFindResponse; +} diff --git a/src/plugins/so_management/public/management/lib/get_allowed_types.ts b/src/plugins/so_management/public/management/lib/get_allowed_types.ts new file mode 100644 index 0000000000000..7d952ebf2ca14 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/get_allowed_types.ts @@ -0,0 +1,31 @@ +/* + * 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 { HttpStart } from 'src/core/public'; + +interface GetAllowedTypesResponse { + types: string[]; +} + +export async function getAllowedTypes(http: HttpStart) { + const response = await http.get( + '/api/kibana/management/saved_objects/_allowed_types' + ); + return response.types; +} diff --git a/src/plugins/so_management/public/management/lib/get_default_title.ts b/src/plugins/so_management/public/management/lib/get_default_title.ts new file mode 100644 index 0000000000000..0abfeee72915c --- /dev/null +++ b/src/plugins/so_management/public/management/lib/get_default_title.ts @@ -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: { id: string; type: string }) { + return `${object.type} [id=${object.id}]`; +} diff --git a/src/plugins/so_management/public/management/lib/get_relationships.ts b/src/plugins/so_management/public/management/lib/get_relationships.ts new file mode 100644 index 0000000000000..cf69534f87a75 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/get_relationships.ts @@ -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 { HttpStart } from 'src/core/public'; +import { SavedObjectRelation } from '../types'; + +export async function getRelationships( + type: string, + id: string, + savedObjectTypes: string[], + http: HttpStart +): Promise { + const url = `/api/kibana/management/saved_objects/relationships/${encodeURIComponent( + type + )}/${encodeURIComponent(id)}`; + return http.get(url, { + query: { + savedObjectTypes: savedObjectTypes as any, // TODO: is sending array allowed ? + }, + }); +} diff --git a/src/plugins/so_management/public/management/lib/get_saved_object_counts.ts b/src/plugins/so_management/public/management/lib/get_saved_object_counts.ts new file mode 100644 index 0000000000000..bfc528d15ce5d --- /dev/null +++ b/src/plugins/so_management/public/management/lib/get_saved_object_counts.ts @@ -0,0 +1,31 @@ +/* + * 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 { HttpStart } from 'src/core/public'; + +export async function getSavedObjectCounts( + http: HttpStart, + typesToInclude: string[], + searchString: string +): Promise> { + return await http.post>( + `/api/kibana/management/saved_objects/scroll/counts`, + { body: JSON.stringify({ typesToInclude, searchString }) } + ); +} diff --git a/src/plugins/so_management/public/management/lib/get_saved_object_label.ts b/src/plugins/so_management/public/management/lib/get_saved_object_label.ts new file mode 100644 index 0000000000000..9b34d8d0af321 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/get_saved_object_label.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function getSavedObjectLabel(type: string) { + switch (type) { + case 'index-pattern': + case 'index-patterns': + case 'indexPatterns': + return 'index patterns'; + default: + return type; + } +} diff --git a/src/plugins/so_management/public/management/lib/import_file.ts b/src/plugins/so_management/public/management/lib/import_file.ts new file mode 100644 index 0000000000000..1293000c77a42 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/import_file.ts @@ -0,0 +1,42 @@ +/* + * 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 { HttpStart, SavedObjectsImportError } from 'src/core/public'; + +interface ImportResponse { + success: boolean; + successCount: number; + errors?: SavedObjectsImportError[]; +} + +export async function importFile(http: HttpStart, file: File, overwriteAll: boolean = false) { + const formData = new FormData(); + formData.append('file', file); + return await http.post('/api/saved_objects/_import', { + body: formData, + headers: { + // TODO: this was for kfetch. is this also needed here? + // Important to be undefined, it forces proper headers to be set for FormData + 'Content-Type': undefined, + }, + query: { + overwrite: overwriteAll, + }, + }); +} diff --git a/src/plugins/so_management/public/management/lib/import_legacy_file.ts b/src/plugins/so_management/public/management/lib/import_legacy_file.ts new file mode 100644 index 0000000000000..dfc81f1185f0c --- /dev/null +++ b/src/plugins/so_management/public/management/lib/import_legacy_file.ts @@ -0,0 +1,36 @@ +/* + * 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 async function importLegacyFile( + file: File, + fileReader: typeof FileReader = window.FileReader +) { + return new Promise((resolve, reject) => { + const fr = new fileReader(); + fr.onload = event => { + const result = event.target!.result as string; + try { + resolve(JSON.parse(result)); + } catch (e) { + reject(e); + } + }; + fr.readAsText(file); + }); +} diff --git a/src/plugins/so_management/public/management/lib/in_app_url.ts b/src/plugins/so_management/public/management/lib/in_app_url.ts new file mode 100644 index 0000000000000..33c945c2dbf4c --- /dev/null +++ b/src/plugins/so_management/public/management/lib/in_app_url.ts @@ -0,0 +1,40 @@ +/* + * 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 { Capabilities } from 'src/core/public'; + +export function canViewInApp(uiCapabilities: Capabilities, type: string) { + switch (type) { + case 'search': + case 'searches': + return uiCapabilities.discover.show as boolean; + case 'visualization': + case 'visualizations': + return uiCapabilities.visualize.show as boolean; + case 'index-pattern': + case 'index-patterns': + case 'indexPatterns': + return uiCapabilities.management.kibana.index_patterns as boolean; + case 'dashboard': + case 'dashboards': + return uiCapabilities.dashboard.show as boolean; + default: + return uiCapabilities[type].show as boolean; + } +} diff --git a/src/plugins/so_management/public/management/lib/index.ts b/src/plugins/so_management/public/management/lib/index.ts new file mode 100644 index 0000000000000..809af13d931f0 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/index.ts @@ -0,0 +1,35 @@ +/* + * 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 { fetchExportByTypeAndSearch } from './fetch_export_by_type_and_search'; +export { fetchExportObjects } from './fetch_export_objects'; +export { findObjects } from './find_objects'; +export { getRelationships } from './get_relationships'; +export { getSavedObjectCounts } from './get_saved_object_counts'; +export { getSavedObjectLabel } from './get_saved_object_label'; +export { getDefaultTitle } from './get_default_title'; +export { parseQuery } from './parse_query'; +export { extractExportDetails, SavedObjectsExportResultDetails } from './extract_export_details'; +export { getAllowedTypes } from './get_allowed_types'; +export { importFile } from './import_file'; +export { importLegacyFile } from './import_legacy_file'; +export { logLegacyImport } from './log_legacy_import'; +export { processImportResponse, ProcessedImportResponse } from './process_import_response'; +export { resolveImportErrors } from './resolve_import_errors'; +export { canViewInApp } from './in_app_url'; diff --git a/src/plugins/so_management/public/management/lib/log_legacy_import.ts b/src/plugins/so_management/public/management/lib/log_legacy_import.ts new file mode 100644 index 0000000000000..9ec3c85b91c22 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/log_legacy_import.ts @@ -0,0 +1,24 @@ +/* + * 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 { HttpStart } from 'src/core/public'; + +export async function logLegacyImport(http: HttpStart) { + return http.post('/api/saved_objects/_log_legacy_import'); +} diff --git a/src/plugins/so_management/public/management/lib/parse_query.ts b/src/plugins/so_management/public/management/lib/parse_query.ts new file mode 100644 index 0000000000000..9b33deedafd95 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/parse_query.ts @@ -0,0 +1,40 @@ +/* + * 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 parseQuery(query: any) { + let queryText; + let visibleTypes; + + if (query) { + if (query.ast.getTermClauses().length) { + queryText = query.ast + .getTermClauses() + .map((clause: any) => clause.value) + .join(' '); + } + if (query.ast.getFieldClauses('type')) { + visibleTypes = query.ast.getFieldClauses('type')[0].value; + } + } + + return { + queryText, + visibleTypes, + }; +} diff --git a/src/plugins/so_management/public/management/lib/process_import_response.ts b/src/plugins/so_management/public/management/lib/process_import_response.ts new file mode 100644 index 0000000000000..2444d18133af4 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/process_import_response.ts @@ -0,0 +1,89 @@ +/* + * 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 { + SavedObjectsImportResponse, + SavedObjectsImportConflictError, + SavedObjectsImportUnsupportedTypeError, + SavedObjectsImportMissingReferencesError, + SavedObjectsImportUnknownError, + SavedObjectsImportError, +} from 'src/core/public'; + +export interface ProcessedImportResponse { + failedImports: Array<{ + obj: Pick; + error: + | SavedObjectsImportConflictError + | SavedObjectsImportUnsupportedTypeError + | SavedObjectsImportMissingReferencesError + | SavedObjectsImportUnknownError; + }>; + unmatchedReferences: Array<{ + existingIndexPatternId: string; + list: Array>; + newIndexPatternId: string | undefined; + }>; + status: 'success' | 'idle'; + importCount: number; + conflictedSavedObjectsLinkedToSavedSearches: undefined; + conflictedSearchDocs: undefined; +} + +export function processImportResponse( + response: SavedObjectsImportResponse +): ProcessedImportResponse { + // Go through the failures and split between unmatchedReferences and failedImports + const failedImports = []; + const unmatchedReferences = new Map(); + for (const { error, ...obj } of response.errors || []) { + failedImports.push({ obj, error }); + if (error.type !== 'missing_references') { + continue; + } + // Currently only supports resolving references on index patterns + const indexPatternRefs = error.references.filter(ref => ref.type === 'index-pattern'); + for (const missingReference of indexPatternRefs) { + const conflict = unmatchedReferences.get( + `${missingReference.type}:${missingReference.id}` + ) || { + existingIndexPatternId: missingReference.id, + list: [], + newIndexPatternId: undefined, + }; + conflict.list.push(obj); + unmatchedReferences.set(`${missingReference.type}:${missingReference.id}`, conflict); + } + } + + return { + failedImports, + unmatchedReferences: Array.from(unmatchedReferences.values()), + // Import won't be successful in the scenario unmatched references exist, import API returned errors of type unknown or import API + // returned errors of type missing_references. + status: + unmatchedReferences.size === 0 && + !failedImports.some(issue => issue.error.type === 'conflict') + ? 'success' + : 'idle', + importCount: response.successCount, + conflictedSavedObjectsLinkedToSavedSearches: undefined, + conflictedSearchDocs: undefined, + }; +} diff --git a/src/plugins/so_management/public/management/lib/resolve_import_errors.ts b/src/plugins/so_management/public/management/lib/resolve_import_errors.ts new file mode 100644 index 0000000000000..88d44f5524337 --- /dev/null +++ b/src/plugins/so_management/public/management/lib/resolve_import_errors.ts @@ -0,0 +1,184 @@ +/* + * 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 { HttpStart } from 'src/core/public'; +import { ProcessedImportResponse } from './process_import_response'; + +type FailedImport = ProcessedImportResponse['failedImports'][0]; + +async function callResolveImportErrorsApi(http: HttpStart, file: File, retries: any) { + const formData = new FormData(); + formData.append('file', file); + formData.append('retries', JSON.stringify(retries)); + return http.post('/api/saved_objects/_resolve_import_errors', { + headers: { + // Important to be undefined, it forces proper headers to be set for FormData + 'Content-Type': undefined, + }, + body: formData, + }); +} + +function mapImportFailureToRetryObject({ + failure, + overwriteDecisionCache, + replaceReferencesCache, + state, +}: { + failure: FailedImport; + overwriteDecisionCache: Map; + replaceReferencesCache: Map; + state: any; +}) { + const { isOverwriteAllChecked, unmatchedReferences } = state; + const isOverwriteGranted = + isOverwriteAllChecked || + overwriteDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`) === true; + + // Conflicts wihtout overwrite granted are skipped + if (!isOverwriteGranted && failure.error.type === 'conflict') { + return; + } + + // Replace references if user chose a new reference + if (failure.error.type === 'missing_references') { + const objReplaceReferences = + replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`) || []; + const indexPatternRefs = failure.error.references.filter(obj => obj.type === 'index-pattern'); + for (const reference of indexPatternRefs) { + for (const unmatchedReference of unmatchedReferences) { + const hasNewValue = !!unmatchedReference.newIndexPatternId; + const matchesIndexPatternId = unmatchedReference.existingIndexPatternId === reference.id; + if (!hasNewValue || !matchesIndexPatternId) { + continue; + } + objReplaceReferences.push({ + type: 'index-pattern', + from: unmatchedReference.existingIndexPatternId, + to: unmatchedReference.newIndexPatternId, + }); + } + } + replaceReferencesCache.set(`${failure.obj.type}:${failure.obj.id}`, objReplaceReferences); + // Skip if nothing to replace, the UI option selected would be --Skip Import-- + if (objReplaceReferences.length === 0) { + return; + } + } + + return { + id: failure.obj.id, + type: failure.obj.type, + overwrite: + isOverwriteAllChecked || + overwriteDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`) === true, + replaceReferences: replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`) || [], + }; +} + +export async function resolveImportErrors({ + http, + getConflictResolutions, + state, +}: { + http: HttpStart; + getConflictResolutions: (objects: any[]) => Promise>; + state: { importCount: number; failedImports?: FailedImport[] } & Record; +}) { + const overwriteDecisionCache = new Map(); + const replaceReferencesCache = new Map(); + let { importCount: successImportCount, failedImports: importFailures = [] } = state; + const { file, isOverwriteAllChecked } = state; + + const doesntHaveOverwriteDecision = ({ obj }: FailedImport) => { + return !overwriteDecisionCache.has(`${obj.type}:${obj.id}`); + }; + const getOverwriteDecision = ({ obj }: FailedImport) => { + return overwriteDecisionCache.get(`${obj.type}:${obj.id}`); + }; + const callMapImportFailure = (failure: FailedImport) => { + return mapImportFailureToRetryObject({ + failure, + overwriteDecisionCache, + replaceReferencesCache, + state, + }); + }; + const isNotSkipped = (failure: FailedImport) => { + return ( + (failure.error.type !== 'conflict' && failure.error.type !== 'missing_references') || + getOverwriteDecision(failure) + ); + }; + + // Loop until all issues are resolved + while ( + importFailures.some((failure: FailedImport) => + ['conflict', 'missing_references'].includes(failure.error.type) + ) + ) { + // Ask for overwrites + if (!isOverwriteAllChecked) { + const result = await getConflictResolutions( + importFailures + .filter(({ error }) => error.type === 'conflict') + .filter(doesntHaveOverwriteDecision) + .map(({ obj }) => obj) + ); + for (const key of Object.keys(result)) { + overwriteDecisionCache.set(key, result[key]); + } + } + + // Build retries array + const retries = importFailures.map(callMapImportFailure).filter(obj => !!obj); + for (const { error, obj } of importFailures) { + if (error.type !== 'missing_references') { + continue; + } + if (!retries.some(retryObj => retryObj?.type === obj.type && retryObj?.id === obj.id)) { + continue; + } + for (const { type, id } of error.blocking || []) { + retries.push({ type, id } as any); + } + } + + // Scenario where everything is skipped and nothing to retry + if (retries.length === 0) { + // Cancelled overwrites aren't failures anymore + importFailures = importFailures.filter(isNotSkipped); + break; + } + + // Call API + const response = await callResolveImportErrorsApi(http, file, retries); + successImportCount += response.successCount; + importFailures = []; + for (const { error, ...obj } of response.errors || []) { + importFailures.push({ error, obj }); + } + } + + return { + status: 'success', + importCount: successImportCount, + failedImports: importFailures, + }; +} diff --git a/src/plugins/so_management/public/management/lib/resolve_saved_objects.ts b/src/plugins/so_management/public/management/lib/resolve_saved_objects.ts new file mode 100644 index 0000000000000..21aaca94f9d8e --- /dev/null +++ b/src/plugins/so_management/public/management/lib/resolve_saved_objects.ts @@ -0,0 +1,329 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { OverlayStart } from 'src/core/public'; +import { SavedObject, SavedObjectLoader } from '../../../../saved_objects/public'; +import { IndexPatternsContract, IIndexPattern } from '../../../../data/public'; + +async function getSavedObject(doc: any, services: SavedObjectLoader[]) { + const service = services.find(s => s.type === doc._type); + if (!service) { + return; + } + + const obj = await service.get(doc._id); + obj.id = doc._id; + obj.migrationVersion = doc._migrationVersion; + return obj; +} + +function addJsonFieldToIndexPattern( + target: Record, + sourceString: string, + fieldName: string, + indexName: string +) { + if (sourceString) { + try { + target[fieldName] = JSON.parse(sourceString); + } catch (error) { + throw new Error( + i18n.translate('kbn.management.objects.parsingFieldErrorMessage', { + defaultMessage: + 'Error encountered parsing {fieldName} for index pattern {indexName}: {errorMessage}', + values: { + fieldName, + indexName, + errorMessage: error.message, + }, + }) + ); + } + } +} +async function importIndexPattern( + doc: any, + indexPatterns: IndexPatternsContract, + overwriteAll: boolean, + overlays: OverlayStart +) { + // TODO: consolidate this is the code in create_index_pattern_wizard.js + const emptyPattern = await indexPatterns.make(); + const { + title, + timeFieldName, + fields, + fieldFormatMap, + sourceFilters, + type, + typeMeta, + } = doc._source; + const importedIndexPattern = { + id: doc._id, + title, + timeFieldName, + } as IIndexPattern; + if (type) { + importedIndexPattern.type = type; + } + addJsonFieldToIndexPattern(importedIndexPattern, fields, 'fields', title); + addJsonFieldToIndexPattern(importedIndexPattern, fieldFormatMap, 'fieldFormatMap', title); + addJsonFieldToIndexPattern(importedIndexPattern, sourceFilters, 'sourceFilters', title); + addJsonFieldToIndexPattern(importedIndexPattern, typeMeta, 'typeMeta', title); + Object.assign(emptyPattern, importedIndexPattern); + + let newId = await emptyPattern.create(overwriteAll); + if (!newId) { + // We can override and we want to prompt for confirmation + const isConfirmed = await overlays.openConfirm( + i18n.translate('kbn.management.indexPattern.confirmOverwriteLabel', { + values: { title }, + defaultMessage: "Are you sure you want to overwrite '{title}'?", + }), + { + title: i18n.translate('kbn.management.indexPattern.confirmOverwriteTitle', { + defaultMessage: 'Overwrite {type}?', + values: { type }, + }), + confirmButtonText: i18n.translate('kbn.management.indexPattern.confirmOverwriteButton', { + defaultMessage: 'Overwrite', + }), + } + ); + + if (isConfirmed) { + newId = (await emptyPattern.create(true)) as string; + } else { + return; + } + } + indexPatterns.clearCache(newId); + return newId; +} + +async function importDocument(obj: SavedObject, doc: any, overwriteAll: boolean) { + await obj.applyESResp({ + references: doc._references || [], + ...doc, + }); + return await obj.save({ confirmOverwrite: !overwriteAll }); +} + +function groupByType(docs: any) { + const defaultDocTypes = { + searches: [], + indexPatterns: [], + other: [], + }; + + return docs.reduce((types: Record, doc: any) => { + switch (doc._type) { + case 'search': + types.searches.push(doc); + break; + case 'index-pattern': + types.indexPatterns.push(doc); + break; + default: + types.other.push(doc); + } + return types; + }, defaultDocTypes as Record); +} + +async function awaitEachItemInParallel(list: T[], op: (item: T) => R) { + return await Promise.all(list.map(item => op(item))); +} + +export async function resolveIndexPatternConflicts( + resolutions: Array<{ oldId: string; newId: string }>, + conflictedIndexPatterns: any[], + overwriteAll: boolean +) { + let importCount = 0; + + await awaitEachItemInParallel(conflictedIndexPatterns, async ({ obj }) => { + // Resolve search index reference: + let oldIndexId = obj.searchSource.getOwnField('index'); + // Depending on the object, this can either be the raw id or the actual index pattern object + if (typeof oldIndexId !== 'string') { + oldIndexId = oldIndexId.id; + } + let resolution = resolutions.find(({ oldId }) => oldId === oldIndexId); + if (resolution) { + const newIndexId = resolution.newId; + await obj.hydrateIndexPattern(newIndexId); + } + + // Resolve filter index reference: + const filter = (obj.searchSource.getOwnField('filter') || []).map((filter2: any) => { + if (!(filter2.meta && filter2.meta.index)) { + return filter2; + } + + resolution = resolutions.find(({ oldId }) => oldId === filter2.meta.index); + return resolution + ? { ...filter2, ...{ meta: { ...filter2.meta, index: resolution.newId } } } + : filter2; + }); + + if (filter.length > 0) { + obj.searchSource.setField('filter', filter); + } + + if (!resolution) { + // The user decided to skip this conflict so do nothing + return; + } + if (await saveObject(obj, overwriteAll)) { + importCount++; + } + }); + return importCount; +} + +export async function saveObjects(objs: SavedObject[], overwriteAll: boolean) { + let importCount = 0; + await awaitEachItemInParallel(objs, async obj => { + if (await saveObject(obj, overwriteAll)) { + importCount++; + } + }); + return importCount; +} + +export async function saveObject(obj: SavedObject, overwriteAll: boolean) { + return await obj.save({ confirmOverwrite: !overwriteAll }); +} + +export async function resolveSavedSearches( + savedSearches: any[], + services: SavedObjectLoader[], + indexPatterns: IndexPatternsContract, + overwriteAll: boolean +) { + let importCount = 0; + await awaitEachItemInParallel(savedSearches, async searchDoc => { + const obj = await getSavedObject(searchDoc, services); + if (!obj) { + // Just ignore? + return; + } + if (await importDocument(obj, searchDoc, overwriteAll)) { + importCount++; + } + }); + return importCount; +} + +export async function resolveSavedObjects( + savedObjects: any[], + overwriteAll: boolean, + services: SavedObjectLoader[], + indexPatterns: IndexPatternsContract, + overlays: OverlayStart +) { + const docTypes = groupByType(savedObjects); + + // Keep track of how many we actually import because the user + // can cancel an override + let importedObjectCount = 0; + const failedImports: any[] = []; + // Start with the index patterns since everything is dependent on them + await awaitEachItemInParallel(docTypes.indexPatterns, async indexPatternDoc => { + try { + const importedIndexPatternId = await importIndexPattern( + indexPatternDoc, + indexPatterns, + overwriteAll, + overlays + ); + if (importedIndexPatternId) { + importedObjectCount++; + } + } catch (error) { + failedImports.push({ indexPatternDoc, error }); + } + }); + + // We want to do the same for saved searches, but we want to keep them separate because they need + // to be applied _first_ because other saved objects can be dependent on those saved searches existing + const conflictedSearchDocs: any[] = []; + // Keep a record of the index patterns assigned to our imported saved objects that do not + // exist. We will provide a way for the user to manually select a new index pattern for those + // saved objects. + const conflictedIndexPatterns: any[] = []; + // Keep a record of any objects which fail to import for unknown reasons. + + // It's possible to have saved objects that link to saved searches which then link to index patterns + // and those could error out, but the error comes as an index pattern not found error. We can't resolve + // those the same as way as normal index pattern not found errors, but when those are fixed, it's very + // likely that these saved objects will work once resaved so keep them around to resave them. + const conflictedSavedObjectsLinkedToSavedSearches: any[] = []; + + await awaitEachItemInParallel(docTypes.searches, async searchDoc => { + const obj = await getSavedObject(searchDoc, services); + + try { + if (await importDocument(obj, searchDoc, overwriteAll)) { + importedObjectCount++; + } + } catch (error) { + if (error.constructor.name === 'SavedObjectNotFound') { + if (error.savedObjectType === 'index-pattern') { + conflictedIndexPatterns.push({ obj, doc: searchDoc }); + } else { + conflictedSearchDocs.push(searchDoc); + } + } else { + failedImports.push({ obj, error }); + } + } + }); + + await awaitEachItemInParallel(docTypes.other, async otherDoc => { + const obj = await getSavedObject(otherDoc, services); + + try { + if (await importDocument(obj, otherDoc, overwriteAll)) { + importedObjectCount++; + } + } catch (error) { + const isIndexPatternNotFound = + error.constructor.name === 'SavedObjectNotFound' && + error.savedObjectType === 'index-pattern'; + if (isIndexPatternNotFound && obj.savedSearchId) { + conflictedSavedObjectsLinkedToSavedSearches.push(obj); + } else if (isIndexPatternNotFound) { + conflictedIndexPatterns.push({ obj, doc: otherDoc }); + } else { + failedImports.push({ obj, error }); + } + } + }); + + return { + conflictedIndexPatterns, + conflictedSavedObjectsLinkedToSavedSearches, + conflictedSearchDocs, + importedObjectCount, + failedImports, + }; +} diff --git a/src/plugins/so_management/public/management/saved_objects_table.tsx b/src/plugins/so_management/public/management/saved_objects_table.tsx new file mode 100644 index 0000000000000..c407c4d2f1ce0 --- /dev/null +++ b/src/plugins/so_management/public/management/saved_objects_table.tsx @@ -0,0 +1,756 @@ +/* + * 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 React, { Component } from 'react'; +import { debounce } from 'lodash'; +// @ts-ignore +import { saveAs } from '@elastic/filesaver'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EUI_MODAL_CONFIRM_BUTTON, + EuiButton, + EuiButtonEmpty, + EuiCheckboxGroup, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiInMemoryTable, + EuiLoadingKibana, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, + EuiSwitch, + EuiToolTip, + EuiPageContent, + // @ts-ignore + Query, +} from '@elastic/eui'; +import { + SavedObjectsClientContract, + SavedObjectsFindOptions, + HttpStart, + OverlayStart, + NotificationsStart, + Capabilities, +} from 'src/core/public'; +import { IndexPatternsContract } from '../../../data/public'; +import { SavedObjectWithMetadata } from '../types'; +import { ISavedObjectsManagementRegistry } from '../management_registry'; +import { + parseQuery, + getSavedObjectCounts, + getRelationships, + getSavedObjectLabel, + fetchExportObjects, + fetchExportByTypeAndSearch, + findObjects, + extractExportDetails, + SavedObjectsExportResultDetails, +} from './lib'; +import { Table, Header, Relationships, Flyout } from './components'; + +interface SavedObjectsTableProps { + allowedTypes: string[]; + serviceRegistry: ISavedObjectsManagementRegistry; + savedObjectsClient: SavedObjectsClientContract; + indexPatterns: IndexPatternsContract; + http: HttpStart; + overlays: OverlayStart; + notifications: NotificationsStart; + capabilities: Capabilities; + perPageConfig: number; + // newIndexPatternUrl - kbnUrl.eval('#/management/kibana/index_pattern') + goInspectObject: (obj: SavedObjectWithMetadata) => void; + canGoInApp: (obj: SavedObjectWithMetadata) => boolean; +} + +interface SavedObjectsTableState { + totalCount: number; + page: number; + perPage: number; + savedObjects: SavedObjectWithMetadata[]; + savedObjectCounts: Record; + activeQuery: QueryType; + selectedSavedObjects: SavedObjectWithMetadata[]; + isShowingImportFlyout: boolean; + isSearching: boolean; + filteredItemCount: number; + isShowingRelationships: boolean; + relationshipObject?: SavedObjectWithMetadata; + isShowingDeleteConfirmModal: boolean; + isShowingExportAllOptionsModal: boolean; + isDeleting: boolean; + exportAllOptions: ExportAllOption[]; + exportAllSelectedOptions: Record; + isIncludeReferencesDeepChecked: boolean; +} + +interface ExportAllOption { + id: string; + label: string; +} + +interface QueryType { + text: string; + ast: any; + syntax: any; +} + +export class SavedObjectsTable extends Component { + private _isMounted = false; + + constructor(props: SavedObjectsTableProps) { + super(props); + + this.state = { + totalCount: 0, + page: 0, + perPage: props.perPageConfig || 50, + savedObjects: [], + savedObjectCounts: props.allowedTypes.reduce((typeToCountMap, type) => { + typeToCountMap[type] = 0; + return typeToCountMap; + }, {} as Record), + activeQuery: Query.parse(''), + selectedSavedObjects: [], + isShowingImportFlyout: false, + isSearching: false, + filteredItemCount: 0, + isShowingRelationships: false, + relationshipObject: undefined, + isShowingDeleteConfirmModal: false, + isShowingExportAllOptionsModal: false, + isDeleting: false, + exportAllOptions: [], + exportAllSelectedOptions: {}, + isIncludeReferencesDeepChecked: true, + }; + } + + componentDidMount() { + this._isMounted = true; + this.fetchSavedObjects(); + this.fetchCounts(); + } + + componentWillUnmount() { + this._isMounted = false; + this.debouncedFetch.cancel(); + } + + fetchCounts = async () => { + const { allowedTypes } = this.props; + const { queryText, visibleTypes } = parseQuery(this.state.activeQuery); + + const filteredTypes = allowedTypes.filter(type => !visibleTypes || visibleTypes.includes(type)); + + // These are the saved objects visible in the table. + const filteredSavedObjectCounts = await getSavedObjectCounts( + this.props.http, + filteredTypes, + queryText + ); + + const exportAllOptions: ExportAllOption[] = []; + const exportAllSelectedOptions: Record = {}; + + Object.keys(filteredSavedObjectCounts).forEach(id => { + // Add this type as a bulk-export option. + exportAllOptions.push({ + id, + label: `${id} (${filteredSavedObjectCounts[id] || 0})`, + }); + + // Select it by default. + exportAllSelectedOptions[id] = true; + }); + + // Fetch all the saved objects that exist so we can accurately populate the counts within + // the table filter dropdown. + const savedObjectCounts = await getSavedObjectCounts(this.props.http, allowedTypes, queryText); + + this.setState(state => ({ + ...state, + savedObjectCounts, + exportAllOptions, + exportAllSelectedOptions, + })); + }; + + fetchSavedObjects = () => { + this.setState( + { + isSearching: true, + }, + this.debouncedFetch + ); + }; + + debouncedFetch = debounce(async () => { + const { activeQuery: query, page, perPage } = this.state; + const { notifications, http, allowedTypes } = this.props; + 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: SavedObjectsFindOptions = { + search: queryText ? `${queryText}*` : undefined, + perPage, + page: page + 1, + fields: ['id'], + type: allowedTypes.filter(type => !visibleTypes || visibleTypes.includes(type)), + }; + if (findOptions.type.length > 1) { + findOptions.sortField = 'type'; + } + + try { + const resp = await findObjects(http, findOptions); + if (!this._isMounted) { + return; + } + + this.setState(({ activeQuery }) => { + // ignore results for old requests + if (activeQuery.text !== query.text) { + return null; + } + + return { + savedObjects: resp.savedObjects, + filteredItemCount: resp.total, + isSearching: false, + }; + }); + } catch (error) { + if (this._isMounted) { + this.setState({ + isSearching: false, + }); + } + notifications.toasts.addDanger({ + title: i18n.translate( + 'kbn.management.objects.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } + ), + text: `${error}`, + }); + } + }, 300); + + refreshData = async () => { + await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); + }; + + onSelectionChanged = (selection: SavedObjectWithMetadata[]) => { + this.setState({ selectedSavedObjects: selection }); + }; + + onQueryChange = ({ query }: { query: QueryType }) => { + // TODO: Use isSameQuery to compare new query with state.activeQuery to avoid re-fetching the + // same data we already have. + this.setState( + { + activeQuery: query, + page: 0, // Reset this on each query change + selectedSavedObjects: [], + }, + () => { + this.fetchSavedObjects(); + this.fetchCounts(); + } + ); + }; + + onTableChange = async (table: any) => { + // TODO type + const { index: page, size: perPage } = table.page || {}; + + this.setState( + { + page, + perPage, + selectedSavedObjects: [], + }, + this.fetchSavedObjects + ); + }; + + onShowRelationships = (object: SavedObjectWithMetadata) => { + this.setState({ + isShowingRelationships: true, + relationshipObject: object, + }); + }; + + onHideRelationships = () => { + this.setState({ + isShowingRelationships: false, + relationshipObject: undefined, + }); + }; + + onExport = async (includeReferencesDeep: boolean) => { + const { selectedSavedObjects } = this.state; + const { notifications, http } = this.props; + const objectsToExport = selectedSavedObjects.map(obj => ({ id: obj.id, type: obj.type })); + + let blob; + try { + blob = await fetchExportObjects(http, objectsToExport, includeReferencesDeep); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('kbn.management.objects.objectsTable.export.dangerNotification', { + defaultMessage: 'Unable to generate export', + }), + }); + throw e; + } + + saveAs(blob, 'export.ndjson'); + + const exportDetails = await extractExportDetails(blob); + this.showExportSuccessMessage(exportDetails); + }; + + onExportAll = async () => { + const { exportAllSelectedOptions, isIncludeReferencesDeepChecked, activeQuery } = this.state; + const { notifications, http } = this.props; + const { queryText } = parseQuery(activeQuery); + const exportTypes = Object.entries(exportAllSelectedOptions).reduce((accum, [id, selected]) => { + if (selected) { + accum.push(id); + } + return accum; + }, [] as string[]); + + let blob; + try { + blob = await fetchExportByTypeAndSearch( + http, + exportTypes, + queryText ? `${queryText}*` : undefined, + isIncludeReferencesDeepChecked + ); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('kbn.management.objects.objectsTable.export.dangerNotification', { + defaultMessage: 'Unable to generate export', + }), + }); + throw e; + } + + saveAs(blob, 'export.ndjson'); + + const exportDetails = await extractExportDetails(blob); + this.showExportSuccessMessage(exportDetails); + this.setState({ isShowingExportAllOptionsModal: false }); + }; + + showExportSuccessMessage = (exportDetails: SavedObjectsExportResultDetails | undefined) => { + const { notifications } = this.props; + if (exportDetails && exportDetails.missingReferences.length > 0) { + notifications.toasts.addWarning({ + title: i18n.translate( + 'kbn.management.objects.objectsTable.export.successWithMissingRefsNotification', + { + defaultMessage: + 'Your file is downloading in the background. ' + + 'Some related objects could not be found. ' + + 'Please see the last line in the exported file for a list of missing objects.', + } + ), + }); + } else { + notifications.toasts.addSuccess({ + title: i18n.translate('kbn.management.objects.objectsTable.export.successNotification', { + defaultMessage: 'Your file is downloading in the background', + }), + }); + } + }; + + finishImport = () => { + this.hideImportFlyout(); + this.fetchSavedObjects(); + this.fetchCounts(); + }; + + showImportFlyout = () => { + this.setState({ isShowingImportFlyout: true }); + }; + + hideImportFlyout = () => { + this.setState({ isShowingImportFlyout: false }); + }; + + onDelete = () => { + this.setState({ isShowingDeleteConfirmModal: true }); + }; + + delete = async () => { + const { savedObjectsClient } = this.props; + const { selectedSavedObjects, isDeleting } = this.state; + + if (isDeleting) { + return; + } + + this.setState({ isDeleting: true }); + + const indexPatterns = selectedSavedObjects.filter(object => object.type === 'index-pattern'); + if (indexPatterns.length) { + await this.props.indexPatterns.clearCache(); + } + + const objects = await savedObjectsClient.bulkGet(selectedSavedObjects); + const deletes = objects.savedObjects.map(object => + savedObjectsClient.delete(object.type, object.id) + ); + await Promise.all(deletes); + + // Unset this + this.setState({ + selectedSavedObjects: [], + }); + + // Fetching all data + await this.fetchSavedObjects(); + await this.fetchCounts(); + + // Allow the user to interact with the table once the saved objects have been re-fetched. + this.setState({ + isShowingDeleteConfirmModal: false, + isDeleting: false, + }); + }; + + getRelationships = async (type: string, id: string) => { + const { allowedTypes, http } = this.props; + return await getRelationships(type, id, allowedTypes, http); + }; + + renderFlyout() { + if (!this.state.isShowingImportFlyout) { + return null; + } + + return ( + + ); + } + + renderRelationships() { + if (!this.state.isShowingRelationships) { + return null; + } + + return ( + + ); + } + + renderDeleteConfirmModal() { + const { isShowingDeleteConfirmModal, isDeleting, selectedSavedObjects } = this.state; + + if (!isShowingDeleteConfirmModal) { + return null; + } + + let modal; + + if (isDeleting) { + // Block the user from interacting with the table while its contents are being deleted. + modal = ; + } else { + const onCancel = () => { + this.setState({ isShowingDeleteConfirmModal: false }); + }; + + const onConfirm = () => { + this.delete(); + }; + + modal = ( + + } + onCancel={onCancel} + onConfirm={onConfirm} + buttonColor="danger" + cancelButtonText={ + + } + confirmButtonText={ + isDeleting ? ( + + ) : ( + + ) + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + > +

+ +

+ ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', + { defaultMessage: 'Id' } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ ); + } + + return {modal}; + } + + changeIncludeReferencesDeep = () => { + this.setState(state => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + closeExportAllModal = () => { + this.setState({ isShowingExportAllOptionsModal: false }); + }; + + renderExportAllOptionsModal() { + const { + isShowingExportAllOptionsModal, + filteredItemCount, + exportAllOptions, + exportAllSelectedOptions, + isIncludeReferencesDeepChecked, + } = this.state; + + if (!isShowingExportAllOptionsModal) { + return null; + } + + return ( + + + + + + + + + + } + labelType="legend" + > + { + const newExportAllSelectedOptions = { + ...exportAllSelectedOptions, + ...{ + [optionId]: !exportAllSelectedOptions[optionId], + }, + }; + + this.setState({ + exportAllSelectedOptions: newExportAllSelectedOptions, + }); + }} + /> + + + + } + checked={isIncludeReferencesDeepChecked} + onChange={this.changeIncludeReferencesDeep} + /> + + + + + + + + + + + + + + + + + + + + + + ); + } + + render() { + const { + selectedSavedObjects, + page, + perPage, + savedObjects, + filteredItemCount, + isSearching, + savedObjectCounts, + } = this.state; + const { http, allowedTypes } = this.props; + + const selectionConfig = { + onSelectionChange: this.onSelectionChanged, + }; + + const filterOptions = allowedTypes.map(type => ({ + value: type, + name: type, + view: `${type} (${savedObjectCounts[type] || 0})`, + })); + + return ( + + {this.renderFlyout()} + {this.renderRelationships()} + {this.renderDeleteConfirmModal()} + {this.renderExportAllOptionsModal()} +
this.setState({ isShowingExportAllOptionsModal: true })} + onImport={this.showImportFlyout} + onRefresh={this.refreshData} + filteredCount={filteredItemCount} + /> + +
+ + ); + } +} diff --git a/src/plugins/so_management/public/management/saved_objects_view.tsx b/src/plugins/so_management/public/management/saved_objects_view.tsx new file mode 100644 index 0000000000000..ecfd984980884 --- /dev/null +++ b/src/plugins/so_management/public/management/saved_objects_view.tsx @@ -0,0 +1,157 @@ +/* + * 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 React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + Capabilities, + SavedObjectsClientContract, + OverlayStart, + NotificationsStart, + SimpleSavedObject, +} from '../../../../core/public'; +import { ISavedObjectsManagementRegistry } from '../management_registry'; +import { Header, NotFoundErrors, Intro, Form } from './components/edition'; +import { canViewInApp } from './lib'; +import { SubmittedFormData } from './types'; + +interface SavedObjectEditionProps { + id: string; + serviceName: string; + serviceRegistry: ISavedObjectsManagementRegistry; + capabilities: Capabilities; + overlays: OverlayStart; + notifications: NotificationsStart; + notFoundType?: string; + savedObjectsClient: SavedObjectsClientContract; +} + +interface SavedObjectEditionState { + type: string; + object?: SimpleSavedObject; +} + +export class SavedObjectEdition extends Component< + SavedObjectEditionProps, + SavedObjectEditionState +> { + constructor(props: SavedObjectEditionProps) { + super(props); + + const { serviceRegistry, serviceName } = props; + const type = serviceRegistry.get(serviceName)!.service.type; + + this.state = { + object: undefined, + type, + }; + } + + componentDidMount() { + const { id, savedObjectsClient } = this.props; + const { type } = this.state; + savedObjectsClient.get(type, id).then(object => { + this.setState({ + object, + }); + }); + } + + render() { + const { + capabilities, + notFoundType, + serviceRegistry, + id, + serviceName, + savedObjectsClient, + } = this.props; + const { type } = this.state; + const { object } = this.state; + const { edit: canEdit, delete: canDelete } = capabilities.savedObjectsManagement as Record< + string, + boolean + >; + const canView = canViewInApp(capabilities, type); + const service = serviceRegistry.get(serviceName)!.service; + + return ( +
+
this.delete()} + viewUrl={service.urlFor(id)} + /> + {notFoundType && } + {canEdit && } + {object && ( +
+ )} +
+ ); + } + + async delete() { + const { id, savedObjectsClient, overlays, notifications } = this.props; + const { type, object } = this.state; + + const confirmed = await overlays.openConfirm( + i18n.translate('kbn.management.objects.confirmModalOptions.modalDescription', { + defaultMessage: "You can't recover deleted objects", + }), + { + confirmButtonText: i18n.translate( + 'kbn.management.objects.confirmModalOptions.deleteButtonLabel', + { + defaultMessage: 'Delete', + } + ), + title: i18n.translate('kbn.management.objects.confirmModalOptions.modalTitle', { + defaultMessage: 'Delete saved Kibana object?', + }), + } + ); + if (confirmed) { + await savedObjectsClient.delete(type, id); + notifications.toasts.addSuccess(`Deleted '${object!.attributes.title}' ${type} object`); + window.location.hash = '/management/kibana/objects'; + } + } + + saveChanges = async ({ attributes, references }: SubmittedFormData) => { + const { savedObjectsClient, notifications } = this.props; + const { object, type } = this.state; + + await savedObjectsClient.update(object!.type, object!.id, attributes, { references }); + notifications.toasts.addSuccess(`Updated '${attributes.title}' ${type} object`); + window.location.hash = '/management/kibana/objects'; + }; +} diff --git a/src/plugins/so_management/public/management/types.ts b/src/plugins/so_management/public/management/types.ts new file mode 100644 index 0000000000000..b913e600572c5 --- /dev/null +++ b/src/plugins/so_management/public/management/types.ts @@ -0,0 +1,47 @@ +/* + * 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 { SavedObjectReference } from 'src/core/public'; +import { SavedObjectWithMetadata } from '../types'; + +// TODO: same as in `src/plugins/so_management/server/lib/find_relationships.ts`, create common folder +export interface SavedObjectRelation { + id: string; + type: string; + relationship: 'child' | 'parent'; + meta: SavedObjectWithMetadata['meta']; +} + +export interface ObjectField { + type: FieldType; + name: string; + value: any; +} + +export type FieldType = 'text' | 'number' | 'boolean' | 'array' | 'json'; + +export interface FieldState { + value?: any; + invalid?: boolean; +} + +export interface SubmittedFormData { + attributes: any; + references: SavedObjectReference[]; +} diff --git a/src/plugins/so_management/public/management_registry.ts b/src/plugins/so_management/public/management_registry.ts new file mode 100644 index 0000000000000..be511f1a69807 --- /dev/null +++ b/src/plugins/so_management/public/management_registry.ts @@ -0,0 +1,47 @@ +/* + * 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 { SavedObjectLoader } from '../../saved_objects/public'; + +export interface SavedObjectsManagementRegistryEntry { + id: string; + service: SavedObjectLoader; + title: string; +} + +export type ISavedObjectsManagementRegistry = PublicMethodsOf; + +export class SavedObjectsManagementRegistry { + private readonly registry = new Map(); + + public register(service: SavedObjectsManagementRegistryEntry) { + if (this.registry.has(service.id)) { + throw new Error(''); + } + this.registry.set(service.id, service); + } + + public all(): SavedObjectsManagementRegistryEntry[] { + return [...this.registry.values()]; + } + + public get(id: string): SavedObjectsManagementRegistryEntry | undefined { + return this.registry.get(id); + } +} diff --git a/src/plugins/so_management/public/plugin.ts b/src/plugins/so_management/public/plugin.ts new file mode 100644 index 0000000000000..369fa5d5398f7 --- /dev/null +++ b/src/plugins/so_management/public/plugin.ts @@ -0,0 +1,57 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { ManagementSetup } from '../../management/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { registerManagementSection } from './management'; +import { SavedObjectsManagementPluginSetup } from './types'; +import { SavedObjectsManagementRegistry } from './management_registry'; + +export interface SetupDependencies { + management: ManagementSetup; +} + +export interface StartDependencies { + data: DataPublicPluginStart; +} + +export class SavedObjectsManagementPlugin + implements Plugin { + private readonly serviceRegistry = new SavedObjectsManagementRegistry(); + + public setup( + core: CoreSetup, + { management }: SetupDependencies + ): SavedObjectsManagementPluginSetup { + registerManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + sections: management.sections, + }); + + return { + serviceRegistry: this.serviceRegistry, + }; + } + + public start(core: CoreStart) { + return {}; + } +} diff --git a/src/plugins/so_management/public/types.ts b/src/plugins/so_management/public/types.ts new file mode 100644 index 0000000000000..034cd300ce3d1 --- /dev/null +++ b/src/plugins/so_management/public/types.ts @@ -0,0 +1,34 @@ +/* + * 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 'src/core/public'; +import { ISavedObjectsManagementRegistry } from './management_registry'; + +export interface SavedObjectsManagementPluginSetup { + serviceRegistry: ISavedObjectsManagementRegistry; +} + +export type SavedObjectWithMetadata = SavedObject & { + meta: { + icon?: string; + title?: string; + editUrl?: string; + inAppUrl?: { path: string; uiCapabilitiesPath: string }; + }; +}; diff --git a/src/plugins/so_management/server/index.ts b/src/plugins/so_management/server/index.ts new file mode 100644 index 0000000000000..ef4f97b1bc1ac --- /dev/null +++ b/src/plugins/so_management/server/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { PluginInitializerContext } from 'src/core/server'; +import { SavedObjectsManagementPlugin } from './plugin'; + +export const plugin = (context: PluginInitializerContext) => + new SavedObjectsManagementPlugin(context); diff --git a/src/plugins/so_management/server/lib/find_all.ts b/src/plugins/so_management/server/lib/find_all.ts new file mode 100644 index 0000000000000..e37588e952685 --- /dev/null +++ b/src/plugins/so_management/server/lib/find_all.ts @@ -0,0 +1,39 @@ +/* + * 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 { SavedObjectsClientContract, SavedObject, SavedObjectsFindOptions } from 'src/core/server'; + +export const findAll = async ( + client: SavedObjectsClientContract, + findOptions: SavedObjectsFindOptions, + page = 1, + allObjects: SavedObject[] = [] +): Promise => { + const objects = await client.find({ + ...findOptions, + page, + }); + + allObjects.push(...objects.saved_objects); + if (allObjects.length < objects.total) { + return findAll(client, findOptions, page + 1, allObjects); + } + + return allObjects; +}; diff --git a/src/plugins/so_management/server/lib/find_relationships.ts b/src/plugins/so_management/server/lib/find_relationships.ts new file mode 100644 index 0000000000000..e9e5f54b734d9 --- /dev/null +++ b/src/plugins/so_management/server/lib/find_relationships.ts @@ -0,0 +1,94 @@ +/* + * 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 { SavedObjectsClientContract } from 'src/core/server'; +import { injectMetaAttributes, SavedObjectWithMetadata } from './inject_meta_attributes'; +import { ISavedObjectsManagement } from '../services'; + +interface SavedObjectRelation { + id: string; + type: string; + relationship: 'child' | 'parent'; + meta: SavedObjectWithMetadata['meta']; +} + +export async function findRelationships({ + type, + id, + size, + client, + savedObjectTypes, + savedObjectsManagement, +}: { + type: string; + id: string; + size: number; + client: SavedObjectsClientContract; + savedObjectTypes: string[]; + savedObjectsManagement: ISavedObjectsManagement; +}): Promise { + const { references = [] } = await client.get(type, id); + + // Use a map to avoid duplicates, it does happen but have a different "name" in the reference + const referencedToBulkGetOpts = new Map( + references.map(ref => [`${ref.type}:${ref.id}`, { id: ref.id, type: ref.type }]) + ); + + const [referencedObjects, referencedResponse] = await Promise.all([ + referencedToBulkGetOpts.size > 0 + ? client.bulkGet([...referencedToBulkGetOpts.values()]) + : Promise.resolve({ saved_objects: [] }), + client.find({ + hasReference: { type, id }, + perPage: size, + type: savedObjectTypes, + }), + ]); + + return referencedObjects.saved_objects + .map(obj => injectMetaAttributes(obj, savedObjectsManagement)) + .map(extractCommonProperties) + .map( + obj => + ({ + ...obj, + relationship: 'child', + } as SavedObjectRelation) + ) + .concat( + referencedResponse.saved_objects + .map(obj => injectMetaAttributes(obj, savedObjectsManagement)) + .map(extractCommonProperties) + .map( + obj => + ({ + ...obj, + relationship: 'parent', + } as SavedObjectRelation) + ) + ); +} + +function extractCommonProperties(savedObject: SavedObjectWithMetadata) { + return { + id: savedObject.id, + type: savedObject.type, + meta: savedObject.meta, + }; +} diff --git a/src/plugins/so_management/server/lib/index.ts b/src/plugins/so_management/server/lib/index.ts new file mode 100644 index 0000000000000..dea6813d690ec --- /dev/null +++ b/src/plugins/so_management/server/lib/index.ts @@ -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 { injectMetaAttributes } from './inject_meta_attributes'; +export { findAll } from './find_all'; +export { findRelationships } from './find_relationships'; diff --git a/src/plugins/so_management/server/lib/inject_meta_attributes.ts b/src/plugins/so_management/server/lib/inject_meta_attributes.ts new file mode 100644 index 0000000000000..1387e7ed7046d --- /dev/null +++ b/src/plugins/so_management/server/lib/inject_meta_attributes.ts @@ -0,0 +1,48 @@ +/* + * 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 'src/core/server'; +import { ISavedObjectsManagement } from '../services'; + +export type SavedObjectWithMetadata = SavedObject & { + meta: { + icon?: string; + title?: string; + editUrl?: string; + inAppUrl?: { path: string; uiCapabilitiesPath: string }; + }; +}; + +export function injectMetaAttributes( + savedObject: SavedObject | SavedObjectWithMetadata, + savedObjectsManagement: ISavedObjectsManagement +): SavedObjectWithMetadata { + const result = { + ...savedObject, + meta: (savedObject as SavedObjectWithMetadata).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; +} diff --git a/src/plugins/so_management/server/plugin.ts b/src/plugins/so_management/server/plugin.ts new file mode 100644 index 0000000000000..d5dc0dad2cbea --- /dev/null +++ b/src/plugins/so_management/server/plugin.ts @@ -0,0 +1,51 @@ +/* + * 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 { Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'src/core/server'; +import { SavedObjectsManagement } from './services'; +import { registerRoutes } from './routes'; + +export class SavedObjectsManagementPlugin implements Plugin<{}, {}> { + private readonly logger: Logger; + private managementService$ = new Subject(); + + constructor(private readonly context: PluginInitializerContext) { + this.logger = this.context.logger.get(); + } + + public async setup({ http }: CoreSetup) { + this.logger.debug('Setting up SavedObjectsManagement plugin'); + + registerRoutes({ + http, + managementServicePromise: this.managementService$.pipe(first()).toPromise(), + }); + + return {}; + } + + public async start(core: CoreStart) { + this.logger.debug('Starting up SavedObjectsManagement plugin'); + const managementService = new SavedObjectsManagement(core.savedObjects.getTypeRegistry()); + this.managementService$.next(managementService); + return {}; + } +} diff --git a/src/plugins/so_management/server/routes/find.ts b/src/plugins/so_management/server/routes/find.ts new file mode 100644 index 0000000000000..781cad0d7fbd6 --- /dev/null +++ b/src/plugins/so_management/server/routes/find.ts @@ -0,0 +1,97 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { injectMetaAttributes } from '../lib'; +import { SavedObjectsManagement } from '../services'; + +export const registerFindRoute = ( + router: IRouter, + managementServicePromise: Promise +) => { + router.get( + { + path: '/api/kibana/management/saved_objects/_find', // TODO: change + validate: { + query: schema.object({ + perPage: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + type: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + search: schema.maybe(schema.string()), + defaultSearchOperator: schema.oneOf([schema.literal('OR'), schema.literal('AND')], { + defaultValue: 'OR', + }), + sortField: schema.maybe(schema.string()), + hasReference: schema.maybe( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + fields: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { + defaultValue: [], + }), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const managementService = await managementServicePromise; + const { client } = context.core.savedObjects; + const searchTypes = Array.isArray(req.query.type) ? req.query.type : [req.query.type]; + const includedFields = Array.isArray(req.query.fields) + ? req.query.fields + : [req.query.fields]; + const importAndExportableTypes = searchTypes.filter(type => + managementService.isImportAndExportable(type) + ); + + const searchFields = new Set(); + importAndExportableTypes.forEach(type => { + const searchField = managementService.getDefaultSearchField(type); + if (searchField) { + searchFields.add(searchField); + } + }); + + const findResponse = await client.find({ + ...req.query, + fields: undefined, + searchFields: [...searchFields], + }); + + const enhancedSavedObjects = findResponse.saved_objects + .map(so => injectMetaAttributes(so, managementService)) + .map(obj => { + const result = { ...obj, attributes: {} as Record }; + for (const field of includedFields || []) { + result.attributes[field] = obj.attributes[field]; + } + return result; + }); + + return res.ok({ + body: { + ...findResponse, + saved_objects: enhancedSavedObjects, + }, + }); + }) + ); +}; diff --git a/src/plugins/so_management/server/routes/get_allowed_types.ts b/src/plugins/so_management/server/routes/get_allowed_types.ts new file mode 100644 index 0000000000000..992c29c98c593 --- /dev/null +++ b/src/plugins/so_management/server/routes/get_allowed_types.ts @@ -0,0 +1,43 @@ +/* + * 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 { IRouter } from 'src/core/server'; +import { SavedObjectsManagement } from '../services'; + +export const registerGetAllowedTypesRoute = ( + router: IRouter, + managementServicePromise: Promise +) => { + router.get( + { + path: '/api/kibana/management/saved_objects/_allowed_types', // TODO: change base + validate: false, + }, + async (context, req, res) => { + const managementService = await managementServicePromise; + const allowedTypes = managementService.getImportableAndExportableTypes(); + + return res.ok({ + body: { + types: allowedTypes, + }, + }); + } + ); +}; diff --git a/src/plugins/so_management/server/routes/index.ts b/src/plugins/so_management/server/routes/index.ts new file mode 100644 index 0000000000000..5c2da2acfaaf9 --- /dev/null +++ b/src/plugins/so_management/server/routes/index.ts @@ -0,0 +1,40 @@ +/* + * 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 { HttpServiceSetup } from 'src/core/server'; +import { SavedObjectsManagement } from '../services'; +import { registerFindRoute } from './find'; +import { registerScrollForCountRoute } from './scroll_count'; +import { registerScrollForExportRoute } from './scroll_export'; +import { registerRelationshipsRoute } from './relationships'; +import { registerGetAllowedTypesRoute } from './get_allowed_types'; + +interface RegisterRouteOptions { + http: HttpServiceSetup; + managementServicePromise: Promise; +} + +export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) { + const router = http.createRouter(); + registerFindRoute(router, managementServicePromise); + registerScrollForCountRoute(router); + registerScrollForExportRoute(router); + registerRelationshipsRoute(router, managementServicePromise); + registerGetAllowedTypesRoute(router, managementServicePromise); +} diff --git a/src/plugins/so_management/server/routes/relationships.ts b/src/plugins/so_management/server/routes/relationships.ts new file mode 100644 index 0000000000000..53e82fe1a7999 --- /dev/null +++ b/src/plugins/so_management/server/routes/relationships.ts @@ -0,0 +1,66 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { findRelationships } from '../lib'; +import { SavedObjectsManagement } from '../services'; + +export const registerRelationshipsRoute = ( + router: IRouter, + managementServicePromise: Promise +) => { + router.get( + { + path: '/api/kibana/management/saved_objects/relationships/{type}/{id}', // TODO: change + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + query: schema.object({ + size: schema.number({ defaultValue: 10000 }), + savedObjectTypes: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const managementService = await managementServicePromise; + const { client } = context.core.savedObjects; + const { type, id } = req.params; + const { size } = req.query; + const savedObjectTypes = Array.isArray(req.query.savedObjectTypes) + ? req.query.savedObjectTypes + : [req.query.savedObjectTypes]; + + const relations = await findRelationships({ + type, + id, + client, + size, + savedObjectTypes, + savedObjectsManagement: managementService, + }); + + return res.ok({ + body: relations, + }); + }) + ); +}; diff --git a/src/plugins/so_management/server/routes/scroll_count.ts b/src/plugins/so_management/server/routes/scroll_count.ts new file mode 100644 index 0000000000000..7a0fe7ae78d0c --- /dev/null +++ b/src/plugins/so_management/server/routes/scroll_count.ts @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; +import { findAll } from '../lib'; + +export const registerScrollForCountRoute = (router: IRouter) => { + router.post( + { + path: '/api/kibana/management/saved_objects/scroll/counts', // TODO: change + validate: { + body: schema.object({ + typesToInclude: schema.arrayOf(schema.string()), + searchString: schema.maybe(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { client } = context.core.savedObjects; + + const findOptions: SavedObjectsFindOptions = { + type: req.body.typesToInclude, + perPage: 1000, + }; + if (req.body.searchString) { + findOptions.search = `${req.body.searchString}*`; + findOptions.searchFields = ['title']; + } + + const objects = await findAll(client, findOptions); + + const counts = objects.reduce((accum, result) => { + const type = result.type; + accum[type] = accum[type] || 0; + accum[type]++; + return accum; + }, {} as Record); + + for (const type of req.body.typesToInclude) { + if (!counts[type]) { + counts[type] = 0; + } + } + + return res.ok({ + body: counts, + }); + }) + ); +}; diff --git a/src/plugins/so_management/server/routes/scroll_export.ts b/src/plugins/so_management/server/routes/scroll_export.ts new file mode 100644 index 0000000000000..d1c4b933f400e --- /dev/null +++ b/src/plugins/so_management/server/routes/scroll_export.ts @@ -0,0 +1,56 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { findAll } from '../lib'; + +export const registerScrollForExportRoute = (router: IRouter) => { + router.post( + { + path: '/api/kibana/management/saved_objects/scroll/export', // TODO: change + validate: { + body: schema.object({ + typesToInclude: schema.arrayOf(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { client } = context.core.savedObjects; + const objects = await findAll(client, { + perPage: 1000, + type: req.body.typesToInclude, + }); + + return res.ok({ + body: objects.map(hit => { + return { + _id: hit.id, + _source: hit.attributes, + _meta: { + savedObjectVersion: 2, + }, + _migrationVersion: hit.migrationVersion, + _references: hit.references || [], + }; + }), + }); + }) + ); +}; diff --git a/src/plugins/so_management/server/services/index.ts b/src/plugins/so_management/server/services/index.ts new file mode 100644 index 0000000000000..fddd53c73634e --- /dev/null +++ b/src/plugins/so_management/server/services/index.ts @@ -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, ISavedObjectsManagement } from './management'; diff --git a/src/core/server/saved_objects/management/management.mock.ts b/src/plugins/so_management/server/services/management.mock.ts similarity index 85% rename from src/core/server/saved_objects/management/management.mock.ts rename to src/plugins/so_management/server/services/management.mock.ts index 2099cc0f77bcc..080f48de11cc5 100644 --- a/src/core/server/saved_objects/management/management.mock.ts +++ b/src/plugins/so_management/server/services/management.mock.ts @@ -22,13 +22,18 @@ import { SavedObjectsManagement } from './management'; type Management = PublicMethodsOf; const createManagementMock = () => { const mocked: jest.Mocked = { - isImportAndExportable: jest.fn().mockReturnValue(true), + getImportableAndExportableTypes: jest.fn(), + isImportAndExportable: jest.fn(), getDefaultSearchField: jest.fn(), getIcon: jest.fn(), getTitle: jest.fn(), getEditUrl: jest.fn(), getInAppUrl: jest.fn(), }; + + mocked.getImportableAndExportableTypes.mockReturnValue([]); + mocked.isImportAndExportable.mockReturnValue(true); + return mocked; }; diff --git a/src/plugins/so_management/server/services/management.test.ts b/src/plugins/so_management/server/services/management.test.ts new file mode 100644 index 0000000000000..6b95048749fae --- /dev/null +++ b/src/plugins/so_management/server/services/management.test.ts @@ -0,0 +1,201 @@ +/* + * 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'; +import { SavedObjectsType, SavedObjectTypeRegistry } from '../../../../core/server'; + +describe('SavedObjectsManagement', () => { + let registry: SavedObjectTypeRegistry; + let management: SavedObjectsManagement; + + const registerType = (type: Partial) => + registry.registerType({ + name: 'unknown', + hidden: false, + namespaceAgnostic: false, + mappings: { properties: {} }, + migrations: {}, + ...type, + }); + + beforeEach(() => { + registry = new SavedObjectTypeRegistry(); + management = new SavedObjectsManagement(registry); + }); + + describe('isImportAndExportable()', () => { + it('returns false for unknown types', () => { + const result = management.isImportAndExportable('bar'); + expect(result).toBe(false); + }); + + it('returns true for explicitly importable and exportable type', () => { + registerType({ + name: 'foo', + management: { + importableAndExportable: true, + }, + }); + + const result = management.isImportAndExportable('foo'); + expect(result).toBe(true); + }); + + it('returns false for explicitly importable and exportable type', () => { + registerType({ + name: 'foo', + management: { + importableAndExportable: false, + }, + }); + + const result = management.isImportAndExportable('foo'); + expect(result).toBe(false); + }); + }); + + describe('getDefaultSearchField()', () => { + it('returns empty for unknown types', () => { + const result = management.getDefaultSearchField('bar'); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ + name: 'foo', + management: { + defaultSearchField: 'value', + }, + }); + + const result = management.getDefaultSearchField('foo'); + expect(result).toEqual('value'); + }); + }); + + describe('getIcon()', () => { + it('returns empty for unknown types', () => { + const result = management.getIcon('bar'); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ + name: 'foo', + management: { + icon: 'value', + }, + }); + const result = management.getIcon('foo'); + expect(result).toEqual('value'); + }); + }); + + describe('getTitle()', () => { + it('returns empty for unknown type', () => { + const result = management.getTitle({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ + name: 'foo', + management: { + 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 result = management.getEditUrl({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ + name: 'foo', + management: { + 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 result = management.getInAppUrl({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ + name: 'foo', + management: { + 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' }); + }); + }); +}); diff --git a/src/plugins/so_management/server/services/management.ts b/src/plugins/so_management/server/services/management.ts new file mode 100644 index 0000000000000..e473d677f81be --- /dev/null +++ b/src/plugins/so_management/server/services/management.ts @@ -0,0 +1,61 @@ +/* + * 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 { ISavedObjectTypeRegistry, SavedObject } from 'src/core/server'; + +export type ISavedObjectsManagement = PublicMethodsOf; + +export class SavedObjectsManagement { + constructor(private readonly registry: ISavedObjectTypeRegistry) {} + + // TODO: add tests + public getImportableAndExportableTypes() { + return this.registry + .getAllTypes() + .map(type => type.name) + .filter(type => this.isImportAndExportable(type)); + } + + public isImportAndExportable(type: string) { + return this.registry.isImportableAndExportable(type); + } + + public getDefaultSearchField(type: string) { + return this.registry.getType(type)?.management?.defaultSearchField; + } + + public getIcon(type: string) { + return this.registry.getType(type)?.management?.icon; + } + + public getTitle(savedObject: SavedObject) { + const getTitle = this.registry.getType(savedObject.type)?.management?.getTitle; + return getTitle ? getTitle(savedObject) : undefined; + } + + public getEditUrl(savedObject: SavedObject) { + const getEditUrl = this.registry.getType(savedObject.type)?.management?.getEditUrl; + return getEditUrl ? getEditUrl(savedObject) : undefined; + } + + public getInAppUrl(savedObject: SavedObject) { + const getInAppUrl = this.registry.getType(savedObject.type)?.management?.getInAppUrl; + return getInAppUrl ? getInAppUrl(savedObject) : undefined; + } +}