diff --git a/src/plugins/saved_objects/README.md b/src/plugins/saved_objects/README.md index 3b6dc4f7a79c..2f7d98dbb36b 100644 --- a/src/plugins/saved_objects/README.md +++ b/src/plugins/saved_objects/README.md @@ -2,77 +2,78 @@ The saved object plugin provides all the core services and functionalities of saved objects. It is utilized by many core plugins such as [`visualization`](../visualizations/), [`dashboard`](../dashboard/) and [`visBuilder`](../vis_builder/), as well as external plugins. Saved object is the primary way to store app and plugin data in a standardized form in OpenSearch Dashboards. They allow plugin developers to manage creating, saving, editing and retrieving data for the application. They can also make reference to other saved objects and have useful features out of the box, such as migrations and strict typings. The saved objects can be managed by the Saved Object Management UI. -## Save relationships to index pattern +### Relationships -Saved objects that have relationships to index patterns are saved using the [`kibanaSavedObjectMeta`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L59) attribute and the [`references`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L60) array structure. Functions from the data plugin are used by the saved object plugin to manage this index pattern relationship. +Saved objects can persist parent/child relationships to other saved objects via `references`. These relationships can be viewed on the UI in the [saved objects management plugin](src/core/server/saved_objects_management/README.md). Relationships can be useful to combine existing saved objects to produce new ones, such as using an index pattern as the source for a visualization, or a dashboard consisting of many visualizations. -A standard saved object and its index pattern relationship: +Some saved object fields have pre-defined logic. For example, if a saved object type has a `searchSource` field indicating an index pattern relationship, a reference will automatically be created using the [`kibanaSavedObjectMeta`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L59) attribute and the [`references`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/serialize_saved_object.ts#L60) array structure. Functions from the data plugin are used by the saved object plugin to manage this index pattern relationship. + +An example of a visualization saved object and its index pattern relationship: ```ts "kibanaSavedObjectMeta" : { "searchSourceJSON" : """{"filter":[],"query":{"query":"","language":"kuery"},"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}""" - } - }, - "type" : "visualization", - "references" : [ - { - "name" : "kibanaSavedObjectMeta.searchSourceJSON.index", - "type" : "index-pattern", - "id" : "90943e30-9a47-11e8-b64d-95841ca0b247" - } - ], +} +"type" : "visualization", +"references" : [ + { + "name" : "kibanaSavedObjectMeta.searchSourceJSON.index", + "type" : "index-pattern", + "id" : "90943e30-9a47-11e8-b64d-95841ca0b247" + } +], ``` ### Saving a saved object -When saving a saved object and its relationship to the index pattern: +When saving a saved object and its relationship to the index pattern: 1. A saved object will be built using [`buildSavedObject`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts#L46) function. Services such as hydrating index pattern, initializing and serializing the saved object are set, and configs such as saved object id, migration version are defined. -2. The saved object will then be serialized by three steps: - - a. By using [`extractReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/extract_references.ts#L35) function from the data plugin, the index pattern information will be extracted using the index pattern id within the `kibanaSavedObjectMeta`, and the id will be replaced by a reference name, such as `indexRefName`. A corresponding index pattern object will then be created to include more detailed information of the index pattern: name (`kibanaSavedObjectMeta.searchSourceJSON.index`), type, and id. - - ```ts - let searchSourceFields = { ...state }; - const references = []; - - if (searchSourceFields.index) { - const indexId = searchSourceFields.index.id || searchSourceFields.index; - const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; - references.push({ - name: refName, - type: 'index-pattern', - id: indexId - }); - searchSourceFields = { ...searchSourceFields, - indexRefName: refName, - index: undefined - }; - } - ``` +2. The saved object will then be serialized by three steps: - b. The `indexRefName` along with other information will be stringified and saved into `kibanaSavedObjectMeta.searchSourceJSON`. - - c. Saved object client will create the reference array attribute, and the index pattern object will be pushed into the reference array. + a. By using [`extractReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/extract_references.ts#L35) function from the data plugin, the index pattern information will be extracted using the index pattern id within the `kibanaSavedObjectMeta`, and the id will be replaced by a reference name, such as `indexRefName`. A corresponding index pattern object will then be created to include more detailed information of the index pattern: name (`kibanaSavedObjectMeta.searchSourceJSON.index`), type, and id. + ```ts + let searchSourceFields = { ...state }; + const references = []; + + if (searchSourceFields.index) { + const indexId = searchSourceFields.index.id || searchSourceFields.index; + const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; + references.push({ + name: refName, + type: 'index-pattern', + id: indexId, + }); + searchSourceFields = { ...searchSourceFields, indexRefName: refName, index: undefined }; + } + ``` + + b. The `indexRefName` along with other information will be stringified and saved into `kibanaSavedObjectMeta.searchSourceJSON`. + + c. Saved object client will create the reference array attribute, and the index pattern object will be pushed into the reference array. ### Loading an existing or creating a new saved object -1. When loading an existing object or creating a new saved object, [`initializeSavedObject`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts#L38) function will be called. +1. When loading an existing object or creating a new saved object, [`initializeSavedObject`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/initialize_saved_object.ts#L38) function will be called. 2. The saved object will be deserialized in the [`applyOpenSearchResp`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/saved_objects/public/saved_object/helpers/apply_opensearch_resp.ts#L50) function. - a. Using [`injectReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/inject_references.ts#L34) function from the data plugin, the index pattern reference name within the `kibanaSavedObject` will be substituted by the index pattern id and the corresponding index pattern reference object will be deleted if filters are applied. + a. Using [`injectReferences`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/data/common/search/search_source/inject_references.ts#L34) function from the data plugin, the index pattern reference name within the `kibanaSavedObject` will be substituted by the index pattern id and the corresponding index pattern reference object will be deleted if filters are applied. + + ```ts + searchSourceReturnFields.index = reference.id; + delete searchSourceReturnFields.indexRefName; + ``` - ```ts - searchSourceReturnFields.index = reference.id; - delete searchSourceReturnFields.indexRefName; - ``` +### Creating a new saved object type -### Others - -If a saved object type wishes to have additional custom functionalities when extracting/injecting references, or after OpenSearch's response, it can define functions in the class constructor when extending the `SavedObjectClass`. For example, visualization plugin's [`SavedVis`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts#L91) class has additional `extractReferences`, `injectReferences` and `afterOpenSearchResp` functions defined in [`_saved_vis.ts`](../visualizations/public/saved_visualizations/_saved_vis.ts). +Steps need to be done on both the public/client-side & the server-side for creating a new saved object type. + +Client-side: + +1. Define a class that extends `SavedObjectClass`. This is where custom functionalities, such as extracting/injecting references, or overriding `afterOpenSearchResp` can be set in the constructor. For example, visualization plugin's [`SavedVis`](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/4a06f5a6fe404a65b11775d292afaff4b8677c33/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts#L91) class has additional `extractReferences`, `injectReferences` and `afterOpenSearchResp` functions defined in [`_saved_vis.ts`](../visualizations/public/saved_visualizations/_saved_vis.ts), and set in the `SavedVis` constructor. ```ts class SavedVis extends SavedObjectClass { @@ -85,14 +86,71 @@ class SavedVis extends SavedObjectClass { afterOpenSearchResp: async (savedObject: SavedObject) => { const savedVis = (savedObject as any) as ISavedVis; ... ... - + return (savedVis as any) as SavedObject; }, ``` +2. Optionally create a loader class that extends `SavedObjectLoader`. This can be useful for performing default CRUD operations on this particular saved object type, as well as overriding default utility functions like `find`. For example, the `visualization` saved object overrides `mapHitSource` (used in `find` & `findAll`) to do additional checking on the returned source object, such as if the returned type is valid: + +```ts +class SavedObjectLoaderVisualize extends SavedObjectLoader { + mapHitSource = (source: Record, id: string) => { + const visTypes = visualizationTypes; + ... ... + let typeName = source.typeName; + if (source.visState) { + try { + typeName = JSON.parse(String(source.visState)).type; + } catch (e) { + /* missing typename handled below */ + } + } + + if (!typeName || !visTypes.get(typeName)) { + source.error = 'Unknown visualization type'; + return source; + } + ... ... + return source; + }; +``` + +The loader can then be instantiated once and referenced when needed. For example, the `visualizations` plugin creates and sets it in its `services` in the plugin's start lifecycle: + +```ts +public start( + core: CoreStart, + { data, expressions, uiActions, embeddable, dashboard }: VisualizationsStartDeps +): VisualizationsStart { + ... ... + const savedVisualizationsLoader = createSavedVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + visualizationTypes: types, + }); + setSavedVisualizationsLoader(savedVisualizationsLoader); + ... ... +} +``` + +Server-side: + +1. Define the new type that is of type `SavedObjectsType`, which is where various settings can be configured, including the index mappings when the object is stored in the system index. To see an example type definition, you can refer to the [visualization saved object type](src/plugins/visualizations/server/saved_objects/visualization.ts). +2. Register the new type in the respective plugin's setup lifecycle function. For example, the `visualizations` plugin registers the `visualization` saved object type like below: + +```ts +core.savedObjects.registerType(visualizationSavedObjectType); +``` + +To make the new type manageable in the `saved_objects_management` plugin, refer to the [plugin README](src/plugins/saved_objects_management/README.md) + ## Migration -When a saved object is created using a previous version, the migration will trigger if there is a new way of saving the saved object and the migration functions alter the structure of the old saved object to follow the new structure. Migrations can be defined in the specific saved object type in the plugin's server folder. For example, +When a saved object is created using a previous version, the migration will trigger if there is a new way of saving the saved object and the migration functions alter the structure of the old saved object to follow the new structure. Migrations can be defined in the specific saved object type in the plugin's server folder. For example, ```ts export const visualizationSavedObjectType: SavedObjectsType = { @@ -116,4 +174,4 @@ The migraton version will be saved as a `migrationVersion` attribute in the save }, ``` -For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). \ No newline at end of file +For a more detailed explanation on the migration, refer to [`saved objects management`](src/core/server/saved_objects/migrations/README.md). diff --git a/src/plugins/saved_objects_management/opensearch_dashboards.json b/src/plugins/saved_objects_management/opensearch_dashboards.json index 6d02893311e3..1de1260afceb 100644 --- a/src/plugins/saved_objects_management/opensearch_dashboards.json +++ b/src/plugins/saved_objects_management/opensearch_dashboards.json @@ -4,7 +4,14 @@ "server": true, "ui": true, "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "visBuilder"], + "optionalPlugins": [ + "dashboard", + "visualizations", + "discover", + "home", + "visBuilder", + "visAugmenter" + ], "extraPublicDirs": ["public/lib"], "requiredBundles": ["opensearchDashboardsReact", "home"] } diff --git a/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts b/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts index ab524bb5d993..f537bac45522 100644 --- a/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts +++ b/src/plugins/saved_objects_management/public/lib/in_app_url.test.ts @@ -77,6 +77,22 @@ describe('canViewInApp', () => { expect(canViewInApp(uiCapabilities, 'visualizations')).toEqual(false); }); + it('should handle augment-vis', () => { + let uiCapabilities = createCapabilities({ + visAugmenter: { + show: true, + }, + }); + expect(canViewInApp(uiCapabilities, 'augment-vis')).toEqual(true); + + uiCapabilities = createCapabilities({ + visAugmenter: { + show: false, + }, + }); + expect(canViewInApp(uiCapabilities, 'augment-vis')).toEqual(false); + }); + it('should handle index patterns', () => { let uiCapabilities = createCapabilities({ management: { diff --git a/src/plugins/saved_objects_management/public/lib/in_app_url.ts b/src/plugins/saved_objects_management/public/lib/in_app_url.ts index e55eaa858f4a..ea8a373bca8b 100644 --- a/src/plugins/saved_objects_management/public/lib/in_app_url.ts +++ b/src/plugins/saved_objects_management/public/lib/in_app_url.ts @@ -38,6 +38,8 @@ export function canViewInApp(uiCapabilities: Capabilities, type: string): boolea case 'visualization': case 'visualizations': return uiCapabilities.visualize.show as boolean; + case 'augment-vis': + return uiCapabilities.visAugmenter.show as boolean; case 'index-pattern': case 'index-patterns': case 'indexPatterns': diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx index ce1a12fdbaef..8197fd0a2b51 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx @@ -169,7 +169,7 @@ export class SavedObjectEdition extends Component< ); if (confirmed) { await savedObjectsClient.delete(type, id); - notifications.toasts.addSuccess(`Deleted '${object!.attributes.title}' ${type} object`); + notifications.toasts.addSuccess(`Deleted ${this.formatTitle(object)} ${type} object`); this.redirectToListing(); } } @@ -179,10 +179,14 @@ export class SavedObjectEdition extends Component< const { object, type } = this.state; await savedObjectsClient.update(object!.type, object!.id, attributes, { references }); - notifications.toasts.addSuccess(`Updated '${attributes.title}' ${type} object`); + notifications.toasts.addSuccess(`Updated ${this.formatTitle(object)} ${type} object`); this.redirectToListing(); }; + formatTitle = (object: SimpleSavedObject | undefined) => { + return object?.attributes?.title ? `'${object.attributes.title}'` : ''; + }; + redirectToListing() { this.props.history.push('/'); } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap index b4842f289136..e7e57c341930 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -690,6 +690,151 @@ exports[`Relationships from legacy app should render visualizations normally 1`] `; +exports[`Relationships should render augment-vis objects normally 1`] = ` + + + +

+ + + +    + MyAugmentVisObject +

+
+
+ +
+ +

+ Here are the saved objects related to MyAugmentVisObject. Deleting this augment-vis affects its parent objects, but not its children. +

+
+ + +
+
+
+`; + exports[`Relationships should render dashboards normally 1`] = ` { expect(component).toMatchSnapshot(); }); + it('should render augment-vis objects normally', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => [ + { + type: 'visualization', + id: '1', + relationship: 'child', + meta: { + title: 'MyViz', + icon: 'visualizeApp', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + inAppUrl: { + path: '/edit/1', + uiCapabilitiesPath: 'visualize.show', + }, + }, + }, + ]), + savedObject: { + id: '1', + type: 'augment-vis', + attributes: {}, + references: [], + meta: { + title: 'MyAugmentVisObject', + icon: 'savedObject', + editUrl: '/management/opensearch-dashboards/objects/savedAugmentVis/1', + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Make sure we are showing loading + expect(component.find('EuiLoadingSpinner').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: RelationshipsProps = { goInspectObject: () => {}, diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index ec7d64ed700c..b2bcb614c50a 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -38,6 +38,7 @@ import { DashboardStart } from '../../dashboard/public'; import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; import { VisualizationsStart } from '../../visualizations/public'; +import { VisAugmenterStart } from '../../vis_augmenter/public'; import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, @@ -75,6 +76,7 @@ export interface StartDependencies { data: DataPublicPluginStart; dashboard?: DashboardStart; visualizations?: VisualizationsStart; + visAugmenter?: VisAugmenterStart; discover?: DiscoverStart; visBuilder?: VisBuilderStart; } diff --git a/src/plugins/saved_objects_management/public/register_services.ts b/src/plugins/saved_objects_management/public/register_services.ts index 514ab66a4595..1b11eb578547 100644 --- a/src/plugins/saved_objects_management/public/register_services.ts +++ b/src/plugins/saved_objects_management/public/register_services.ts @@ -36,7 +36,10 @@ export const registerServices = async ( registry: ISavedObjectsManagementServiceRegistry, getStartServices: StartServicesAccessor ) => { - const [, { dashboard, visualizations, discover, visBuilder }] = await getStartServices(); + const [ + , + { dashboard, visualizations, visAugmenter, discover, visBuilder }, + ] = await getStartServices(); if (dashboard) { registry.register({ @@ -54,6 +57,14 @@ export const registerServices = async ( }); } + if (visAugmenter) { + registry.register({ + id: 'savedAugmentVis', + title: 'augmentVis', + service: visAugmenter.savedAugmentVisLoader, + }); + } + if (discover) { registry.register({ id: 'savedSearches', diff --git a/src/plugins/vis_augmenter/public/index.ts b/src/plugins/vis_augmenter/public/index.ts index e931e2ac2e03..cf736bf6d3e6 100644 --- a/src/plugins/vis_augmenter/public/index.ts +++ b/src/plugins/vis_augmenter/public/index.ts @@ -10,3 +10,12 @@ export function plugin(initializerContext: PluginInitializerContext) { return new VisAugmenterPlugin(initializerContext); } export { VisAugmenterSetup, VisAugmenterStart }; + +export { + createSavedAugmentVisLoader, + createAugmentVisSavedObject, + SavedAugmentVisLoader, + SavedObjectOpenSearchDashboardsServicesWithAugmentVis, +} from './saved_augment_vis'; + +export { ISavedAugmentVis, VisLayerExpressionFn, AugmentVisSavedObject } from './types'; diff --git a/src/plugins/vis_augmenter/public/plugin.ts b/src/plugins/vis_augmenter/public/plugin.ts index d53116bdd12d..1c064a1cee10 100644 --- a/src/plugins/vis_augmenter/public/plugin.ts +++ b/src/plugins/vis_augmenter/public/plugin.ts @@ -7,12 +7,15 @@ import { ExpressionsSetup } from '../../expressions/public'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; import { visLayers } from './expressions'; +import { setSavedAugmentVisLoader } from './services'; +import { createSavedAugmentVisLoader, SavedAugmentVisLoader } from './saved_augment_vis'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface VisAugmenterSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface VisAugmenterStart {} +export interface VisAugmenterStart { + savedAugmentVisLoader: SavedAugmentVisLoader; +} export interface VisAugmenterSetupDeps { data: DataPublicPluginSetup; @@ -37,7 +40,15 @@ export class VisAugmenterPlugin } public start(core: CoreStart, { data }: VisAugmenterStartDeps): VisAugmenterStart { - return {}; + const savedAugmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + setSavedAugmentVisLoader(savedAugmentVisLoader); + return { savedAugmentVisLoader }; } public stop() {} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/_saved_augment_vis.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/_saved_augment_vis.ts new file mode 100644 index 000000000000..ffaa64e92304 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/_saved_augment_vis.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @name SavedAugmentVis + * + * @extends SavedObject. + */ +import { get } from 'lodash'; +import { + createSavedObjectClass, + SavedObject, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { IIndexPattern } from '../../../data/public'; +import { extractReferences, injectReferences } from './saved_augment_vis_references'; + +const name = 'augment-vis'; + +export function createSavedAugmentVisClass(services: SavedObjectOpenSearchDashboardsServices) { + const SavedObjectClass = createSavedObjectClass(services); + + class SavedAugmentVis extends SavedObjectClass { + public static type: string = name; + public static mapping: Record = { + description: 'text', + pluginResourceId: 'text', + visId: 'keyword', + visLayerExpressionFn: 'text', + version: 'integer', + }; + + constructor(opts: Record | string = {}) { + if (typeof opts !== 'object') { + opts = { id: opts }; + } + super({ + type: SavedAugmentVis.type, + mapping: SavedAugmentVis.mapping, + extractReferences, + injectReferences, + id: (opts.id as string) || '', + indexPattern: opts.indexPattern as IIndexPattern, + defaults: { + description: get(opts, 'description', ''), + pluginResourceId: get(opts, 'pluginResourceId', ''), + visId: get(opts, 'visId', ''), + visLayerExpressionFn: get(opts, 'visLayerExpressionFn', {}), + version: 1, + }, + }); + this.showInRecentlyAccessed = false; + } + } + + return SavedAugmentVis as new (opts: Record | string) => SavedObject; +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/index.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/index.ts new file mode 100644 index 000000000000..5ac3a159132e --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './saved_augment_vis'; +export * from './utils'; diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.test.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.test.ts new file mode 100644 index 000000000000..bc44a0ed6dc2 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisLayerExpressionFn } from '../types'; +import { VisLayerTypes } from '../../common'; +import { + createSavedAugmentVisLoader, + SavedObjectOpenSearchDashboardsServicesWithAugmentVis, +} from './saved_augment_vis'; +import { generateAugmentVisSavedObject, getMockAugmentVisSavedObjectClient } from './utils'; + +describe('SavedObjectLoaderAugmentVis', () => { + const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, + } as VisLayerExpressionFn; + const validObj1 = generateAugmentVisSavedObject('valid-obj-id-1', fn); + const validObj2 = generateAugmentVisSavedObject('valid-obj-id-2', fn); + const invalidFnTypeObj = generateAugmentVisSavedObject('invalid-fn-obj-id-1', { + ...fn, + // @ts-ignore + type: 'invalid-type', + }); + // @ts-ignore + const missingFnObj = generateAugmentVisSavedObject('missing-fn-obj-id-1', {}); + + it('find returns single saved obj', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([validObj1]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('valid-obj-id-1'); + expect(resp.hits[0].error).toEqual(undefined); + }); + + it('find returns multiple saved objs', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([validObj1, validObj2]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(2); + expect(resp.hits[0].id).toEqual('valid-obj-id-1'); + expect(resp.hits[1].id).toEqual('valid-obj-id-2'); + expect(resp.hits[0].error).toEqual(undefined); + expect(resp.hits[1].error).toEqual(undefined); + }); + + it('find returns empty response', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(0); + }); + + it('find does not return objs with errors', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([invalidFnTypeObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.find(); + expect(resp.hits.length).toEqual(0); + }); + + it('findAll returns obj with invalid VisLayer fn', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([invalidFnTypeObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('invalid-fn-obj-id-1'); + expect(resp.hits[0].error).toEqual('Unknown VisLayer expression function type'); + }); + + it('findAll returns obj with missing VisLayer fn', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([missingFnObj]), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('missing-fn-obj-id-1'); + expect(resp.hits[0].error).toEqual( + 'visLayerExpressionFn is missing in augment-vis saved object' + ); + }); + + it('findAll returns obj with missing reference', async () => { + const loader = createSavedAugmentVisLoader({ + savedObjectsClient: getMockAugmentVisSavedObjectClient([validObj1], false), + } as SavedObjectOpenSearchDashboardsServicesWithAugmentVis); + const resp = await loader.findAll(); + expect(resp.hits.length).toEqual(1); + expect(resp.hits[0].id).toEqual('valid-obj-id-1'); + expect(resp.hits[0].error).toEqual('visReference is missing in augment-vis saved object'); + }); +}); diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.ts new file mode 100644 index 000000000000..82e6e24a7e3e --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis.ts @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get, isEmpty } from 'lodash'; +import { + SavedObjectLoader, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { createSavedAugmentVisClass } from './_saved_augment_vis'; +import { VisLayerTypes } from '../../common'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SavedObjectOpenSearchDashboardsServicesWithAugmentVis + extends SavedObjectOpenSearchDashboardsServices {} +export type SavedAugmentVisLoader = ReturnType; +export function createSavedAugmentVisLoader( + services: SavedObjectOpenSearchDashboardsServicesWithAugmentVis +) { + const { savedObjectsClient } = services; + + class SavedObjectLoaderAugmentVis extends SavedObjectLoader { + mapHitSource = (source: Record, id: string) => { + source.id = id; + source.visId = get(source, 'visReference.id', ''); + + if (isEmpty(source.visReference)) { + source.error = 'visReference is missing in augment-vis saved object'; + return source; + } + if (isEmpty(source.visLayerExpressionFn)) { + source.error = 'visLayerExpressionFn is missing in augment-vis saved object'; + return source; + } + if (!(get(source, 'visLayerExpressionFn.type', '') in VisLayerTypes)) { + source.error = 'Unknown VisLayer expression function type'; + return source; + } + return source; + }; + + /** + * Updates hit.attributes to contain an id related to the referenced visualization + * (visId) and returns the updated attributes object. + * @param hit + * @returns {hit.attributes} The modified hit.attributes object, with an id and url field. + */ + mapSavedObjectApiHits(hit: { + references: any[]; + attributes: Record; + id: string; + }) { + // For now we are assuming only one vis reference per saved object. + // If we change to multiple, we will need to dynamically handle that + const visReference = hit.references[0]; + return this.mapHitSource({ ...hit.attributes, visReference }, hit.id); + } + } + const SavedAugmentVis = createSavedAugmentVisClass(services); + return new SavedObjectLoaderAugmentVis(SavedAugmentVis, savedObjectsClient) as SavedObjectLoader; +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.test.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.test.ts new file mode 100644 index 000000000000..4a19b84dc40e --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { extractReferences, injectReferences } from './saved_augment_vis_references'; +import { AugmentVisSavedObject } from '../types'; +import { VIS_REFERENCE_NAME } from './saved_augment_vis_references'; + +describe('extractReferences()', () => { + test('extracts nothing if visId is null', () => { + const doc = { + id: '1', + attributes: { + foo: true, + }, + references: [], + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "foo": true, + }, + "references": Array [], + } + `); + }); + + test('extracts references from visId', () => { + const doc = { + id: '1', + attributes: { + foo: true, + visId: 'test-id', + }, + references: [], + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "foo": true, + "visName": "visualization_0", + }, + "references": Array [ + Object { + "id": "test-id", + "name": "visualization_0", + "type": "visualization", + }, + ], + } + `); + }); +}); + +describe('injectReferences()', () => { + test('injects nothing when visName is null', () => { + const context = ({ + id: '1', + pluginResourceId: 'test-resource-id', + visLayerExpressionFn: 'test-fn', + } as unknown) as AugmentVisSavedObject; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` + Object { + "id": "1", + "pluginResourceId": "test-resource-id", + "visLayerExpressionFn": "test-fn", + } + `); + }); + + test('injects references into context', () => { + const context = ({ + id: '1', + pluginResourceId: 'test-resource-id', + visLayerExpressionFn: 'test-fn', + visName: VIS_REFERENCE_NAME, + } as unknown) as AugmentVisSavedObject; + const references = [ + { + name: VIS_REFERENCE_NAME, + type: 'visualization', + id: 'test-id', + }, + ]; + injectReferences(context, references); + expect(context).toMatchInlineSnapshot(` + Object { + "id": "1", + "pluginResourceId": "test-resource-id", + "visId": "test-id", + "visLayerExpressionFn": "test-fn", + } + `); + }); + + test(`fails when it can't find the saved object reference in the array`, () => { + const context = ({ + id: '1', + pluginResourceId: 'test-resource-id', + visLayerExpressionFn: 'test-fn', + visName: VIS_REFERENCE_NAME, + } as unknown) as AugmentVisSavedObject; + expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( + `"Could not find visualization reference \\"${VIS_REFERENCE_NAME}\\""` + ); + }); +}); diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.ts new file mode 100644 index 000000000000..5b2cc3f3d0e5 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/saved_augment_vis_references.ts @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/public'; +import { AugmentVisSavedObject } from '../types'; + +/** + * Note that references aren't stored in the object's client-side interface (AugmentVisSavedObject). + * Rather, just the ID/type is. The concept of references is a server-side definition used to define + * relationships between saved objects. They are visible in the saved objs management page or + * when making direct saved obj API calls. + * + * So, we need helper fns to construct & deconstruct references when creating and reading the + * indexed/stored saved objects, respectively. + */ + +/** + * Using a constant value for the visualization name to easily extact/inject + * the reference. Setting as "_0" which could be expanded and incremented upon + * in the future if we decide to persist multiple visualizations per + * AugmentVisSavedObject. + */ +export const VIS_REFERENCE_NAME = 'visualization_0'; + +/** + * Used during creation. Converting from AugmentVisSavedObject to the actual indexed saved object + * with references. + */ +export function extractReferences({ + attributes, + references = [], +}: { + attributes: SavedObjectAttributes; + references: SavedObjectReference[]; +}) { + const updatedAttributes = { ...attributes }; + const updatedReferences = [...references]; + + // Extract saved object + if (updatedAttributes.visId) { + updatedReferences.push({ + name: VIS_REFERENCE_NAME, + type: 'visualization', + id: String(updatedAttributes.visId), + }); + delete updatedAttributes.visId; + + updatedAttributes.visName = VIS_REFERENCE_NAME; + } + return { + references: updatedReferences, + attributes: updatedAttributes, + }; +} + +/** + * Used during reading. Converting from the indexed saved object with references + * to a AugmentVisSavedObject + */ +export function injectReferences( + savedObject: AugmentVisSavedObject, + references: SavedObjectReference[] +) { + if (savedObject.visName) { + const visReference = references.find((reference) => reference.name === savedObject.visName); + if (!visReference) { + throw new Error(`Could not find visualization reference "${savedObject.visName}"`); + } + savedObject.visId = visReference.id; + delete savedObject.visName; + } +} diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/utils/helpers.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/helpers.ts new file mode 100644 index 000000000000..c3a54a377317 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/helpers.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getSavedAugmentVisLoader } from '../../services'; +import { ISavedAugmentVis } from '../../types'; + +/** + * Create an augment vis saved object given an object that + * implements the ISavedAugmentVis interface + */ +export const createAugmentVisSavedObject = async (AugmentVis: ISavedAugmentVis): Promise => { + const loader = getSavedAugmentVisLoader(); + return await loader.get((AugmentVis as any) as Record); +}; diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/utils/index.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/index.ts new file mode 100644 index 000000000000..aa4d6dbf3e35 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './helpers'; +export * from './test_helpers'; diff --git a/src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts new file mode 100644 index 000000000000..c237fa7551c3 --- /dev/null +++ b/src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep } from 'lodash'; +import { VisLayerExpressionFn, ISavedAugmentVis } from '../../types'; +import { VIS_REFERENCE_NAME } from '../saved_augment_vis_references'; + +const pluginResourceId = 'test-plugin-resource-id'; +const visId = 'test-vis-id'; +const version = 1; + +export const generateAugmentVisSavedObject = (idArg: string, exprFnArg: VisLayerExpressionFn) => { + return { + id: idArg, + pluginResourceId, + visLayerExpressionFn: exprFnArg, + VIS_REFERENCE_NAME, + visId, + version, + } as ISavedAugmentVis; +}; + +export const getMockAugmentVisSavedObjectClient = ( + augmentVisSavedObjs: ISavedAugmentVis[], + keepReferences: boolean = true +): any => { + const savedObjs = (augmentVisSavedObjs = cloneDeep(augmentVisSavedObjs)); + + const client = { + find: jest.fn(() => + Promise.resolve({ + total: savedObjs.length, + savedObjects: savedObjs.map((savedObj) => { + const objVisId = savedObj.visId; + const objId = savedObj.id; + delete savedObj.visId; + delete savedObj.id; + return { + id: objId, + attributes: savedObj as Record, + references: keepReferences + ? [ + { + name: savedObj.visName, + type: 'visualization', + id: objVisId, + }, + ] + : [], + }; + }), + }) + ), + } as any; + return client; +}; diff --git a/src/plugins/vis_augmenter/public/services.ts b/src/plugins/vis_augmenter/public/services.ts new file mode 100644 index 000000000000..00fa45374980 --- /dev/null +++ b/src/plugins/vis_augmenter/public/services.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../opensearch_dashboards_utils/common'; +import { SavedObjectLoader } from '../../saved_objects/public'; + +export const [getSavedAugmentVisLoader, setSavedAugmentVisLoader] = createGetterSetter< + SavedObjectLoader +>('savedAugmentVisLoader'); diff --git a/src/plugins/vis_augmenter/public/types.ts b/src/plugins/vis_augmenter/public/types.ts new file mode 100644 index 000000000000..5ddd191cace5 --- /dev/null +++ b/src/plugins/vis_augmenter/public/types.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from '../../saved_objects/public'; +import { VisLayerTypes } from '../common'; + +export interface ISavedAugmentVis { + id?: string; + description?: string; + pluginResourceId: string; + visName?: string; + visId?: string; + visLayerExpressionFn: VisLayerExpressionFn; + version?: number; +} + +export interface VisLayerExpressionFn { + type: keyof typeof VisLayerTypes; + name: string; + // plugin expression fns can freely set custom arguments + args: { [key: string]: any }; +} + +export interface AugmentVisSavedObject extends SavedObject, ISavedAugmentVis {} diff --git a/src/plugins/vis_augmenter/server/capabilities_provider.ts b/src/plugins/vis_augmenter/server/capabilities_provider.ts new file mode 100644 index 000000000000..f9c899d3a0de --- /dev/null +++ b/src/plugins/vis_augmenter/server/capabilities_provider.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const capabilitiesProvider = () => ({ + visAugmenter: { + show: true, + delete: true, + save: true, + saveQuery: true, + }, +}); diff --git a/src/plugins/vis_augmenter/server/plugin.ts b/src/plugins/vis_augmenter/server/plugin.ts index d921126a1f66..f30cf6c974fe 100644 --- a/src/plugins/vis_augmenter/server/plugin.ts +++ b/src/plugins/vis_augmenter/server/plugin.ts @@ -10,6 +10,8 @@ import { Plugin, Logger, } from '../../../core/server'; +import { augmentVisSavedObjectType } from './saved_objects'; +import { capabilitiesProvider } from './capabilities_provider'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface VisAugmenterPluginSetup {} @@ -26,6 +28,8 @@ export class VisAugmenterPlugin public setup(core: CoreSetup) { this.logger.debug('VisAugmenter: Setup'); + core.savedObjects.registerType(augmentVisSavedObjectType); + core.capabilities.registerProvider(capabilitiesProvider); return {}; } diff --git a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts new file mode 100644 index 000000000000..0efe98fc14ce --- /dev/null +++ b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType } from 'opensearch-dashboards/server'; + +export const augmentVisSavedObjectType: SavedObjectsType = { + name: 'augment-vis', + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + getEditUrl(obj) { + return `/management/opensearch-dashboards/objects/savedAugmentVis/${encodeURIComponent( + obj.id + )}`; + }, + }, + mappings: { + properties: { + title: { type: 'text' }, + description: { type: 'text' }, + pluginResourceId: { type: 'text' }, + visName: { type: 'keyword', index: false, doc_values: false }, + visLayerExpressionFn: { + properties: { + type: { type: 'text' }, + name: { type: 'text' }, + // keeping generic to not limit what users may pass as args to their fns + // users may not have this field at all, if no args are needed + args: { type: 'object', dynamic: true }, + }, + }, + version: { type: 'integer' }, + }, + }, +}; diff --git a/src/plugins/vis_augmenter/server/saved_objects/index.ts b/src/plugins/vis_augmenter/server/saved_objects/index.ts new file mode 100644 index 000000000000..b96dbd4b2a58 --- /dev/null +++ b/src/plugins/vis_augmenter/server/saved_objects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { augmentVisSavedObjectType } from './augment_vis';