diff --git a/src/plugins/saved_objects_management/opensearch_dashboards.json b/src/plugins/saved_objects_management/opensearch_dashboards.json index 1de1260afceb..f76b69999ecb 100644 --- a/src/plugins/saved_objects_management/opensearch_dashboards.json +++ b/src/plugins/saved_objects_management/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["management", "data"], + "requiredPlugins": ["management", "data", "uiActions"], "optionalPlugins": [ "dashboard", "visualizations", diff --git a/src/plugins/saved_objects_management/public/index.ts b/src/plugins/saved_objects_management/public/index.ts index 2377afe175c4..317b3079efa0 100644 --- a/src/plugins/saved_objects_management/public/index.ts +++ b/src/plugins/saved_objects_management/public/index.ts @@ -48,6 +48,8 @@ export { } from './services'; export { ProcessedImportResponse, processImportResponse, FailedImport } from './lib'; export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; +export { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; +export { SavedObjectDeleteContext } from './ui_actions_bootstrap'; export function plugin(initializerContext: PluginInitializerContext) { return new SavedObjectsManagementPlugin(); diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 2c42df5c7824..a1c7b5343eb1 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -59,7 +59,7 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data }, pluginStart] = await core.getStartServices(); + const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -88,6 +88,7 @@ export const mountManagementSection = async ({ }> void; history: ScopedHistory; @@ -79,6 +82,7 @@ const SavedObjectsEditionPage = ({ savedObjectsClient={coreStart.savedObjects.client} overlays={coreStart.overlays} notifications={coreStart.notifications} + uiActions={uiActionsStart} capabilities={capabilities} notFoundType={query.notFound as string} history={history} diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index e55760846a5f..c8e762f73dcc 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -32,6 +32,7 @@ import { coreMock } from '../../../core/public/mocks'; import { homePluginMock } from '../../home/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; import { dataPluginMock } from '../../data/public/mocks'; +import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; import { SavedObjectsManagementPlugin } from './plugin'; describe('SavedObjectsManagementPlugin', () => { @@ -48,8 +49,13 @@ describe('SavedObjectsManagementPlugin', () => { }); const homeSetup = homePluginMock.createSetupContract(); const managementSetup = managementPluginMock.createSetupContract(); + const uiActionsSetup = uiActionsPluginMock.createSetupContract(); - await plugin.setup(coreSetup, { home: homeSetup, management: managementSetup }); + await plugin.setup(coreSetup, { + home: homeSetup, + management: managementSetup, + uiActions: uiActionsSetup, + }); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index b2bcb614c50a..43356eb8f9e5 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -33,6 +33,7 @@ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { VisBuilderStart } from '../../vis_builder/public'; import { ManagementSetup } from '../../management/public'; +import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; import { DataPublicPluginStart } from '../../data/public'; import { DashboardStart } from '../../dashboard/public'; import { DiscoverStart } from '../../discover/public'; @@ -53,6 +54,7 @@ import { ISavedObjectsManagementServiceRegistry, } from './services'; import { registerServices } from './register_services'; +import { bootstrap } from './ui_actions_bootstrap'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -70,6 +72,7 @@ export interface SavedObjectsManagementPluginStart { export interface SetupDependencies { management: ManagementSetup; home?: HomePublicPluginSetup; + uiActions: UiActionsSetup; } export interface StartDependencies { @@ -79,6 +82,7 @@ export interface StartDependencies { visAugmenter?: VisAugmenterStart; discover?: DiscoverStart; visBuilder?: VisBuilderStart; + uiActions: UiActionsStart; } export class SavedObjectsManagementPlugin @@ -96,7 +100,7 @@ export class SavedObjectsManagementPlugin public setup( core: CoreSetup, - { home, management }: SetupDependencies + { home, management, uiActions }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -136,6 +140,9 @@ export class SavedObjectsManagementPlugin }, }); + // sets up the context mappings and registers any triggers/actions for the plugin + bootstrap(uiActions); + // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); @@ -147,7 +154,7 @@ export class SavedObjectsManagementPlugin }; } - public start(core: CoreStart, { data }: StartDependencies) { + public start(core: CoreStart, { data, uiActions }: StartDependencies) { const actionStart = this.actionService.start(); const columnStart = this.columnService.start(); const namespaceStart = this.namespaceService.start(); diff --git a/src/plugins/saved_objects_management/public/triggers/index.ts b/src/plugins/saved_objects_management/public/triggers/index.ts new file mode 100644 index 000000000000..001864544cd3 --- /dev/null +++ b/src/plugins/saved_objects_management/public/triggers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + SAVED_OBJECT_DELETE_TRIGGER, + savedObjectDeleteTrigger, +} from './saved_object_delete_trigger'; diff --git a/src/plugins/saved_objects_management/public/triggers/saved_object_delete_trigger.ts b/src/plugins/saved_objects_management/public/triggers/saved_object_delete_trigger.ts new file mode 100644 index 000000000000..9c3ced212d5d --- /dev/null +++ b/src/plugins/saved_objects_management/public/triggers/saved_object_delete_trigger.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Trigger } from '../../../ui_actions/public'; + +export const SAVED_OBJECT_DELETE_TRIGGER = 'SAVED_OBJECT_DELETE_TRIGGER'; +export const savedObjectDeleteTrigger: Trigger<'SAVED_OBJECT_DELETE_TRIGGER'> = { + id: SAVED_OBJECT_DELETE_TRIGGER, + title: i18n.translate('savedObjectsManagement.triggers.savedObjectDeleteTitle', { + defaultMessage: 'Saved object delete', + }), + description: i18n.translate('savedObjectsManagement.triggers.savedObjectDeleteDescription', { + defaultMessage: 'Perform additional actions after deleting a saved object', + }), +}; diff --git a/src/plugins/saved_objects_management/public/ui_actions_bootstrap.ts b/src/plugins/saved_objects_management/public/ui_actions_bootstrap.ts new file mode 100644 index 000000000000..e005ba660dee --- /dev/null +++ b/src/plugins/saved_objects_management/public/ui_actions_bootstrap.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { UiActionsSetup } from '../../ui_actions/public'; +import { SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteTrigger } from './triggers'; + +export interface SavedObjectDeleteContext { + type: string; + savedObjectId: string; +} + +declare module '../../ui_actions/public' { + export interface TriggerContextMapping { + [SAVED_OBJECT_DELETE_TRIGGER]: SavedObjectDeleteContext; + } +} + +export const bootstrap = (uiActions: UiActionsSetup) => { + uiActions.registerTrigger(savedObjectDeleteTrigger); +}; diff --git a/src/plugins/vis_augmenter/opensearch_dashboards.json b/src/plugins/vis_augmenter/opensearch_dashboards.json index 007cae78290b..9026bdd24859 100644 --- a/src/plugins/vis_augmenter/opensearch_dashboards.json +++ b/src/plugins/vis_augmenter/opensearch_dashboards.json @@ -12,5 +12,5 @@ "uiActions", "embeddable" ], - "requiredBundles": ["opensearchDashboardsReact"] + "requiredBundles": ["opensearchDashboardsReact", "savedObjectsManagement"] } diff --git a/src/plugins/vis_augmenter/public/actions/index.ts b/src/plugins/vis_augmenter/public/actions/index.ts new file mode 100644 index 000000000000..893032f86813 --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + PLUGIN_RESOURCE_DELETE_ACTION, + PluginResourceDeleteAction, +} from './plugin_resource_delete_action'; +export { SAVED_OBJECT_DELETE_ACTION, SavedObjectDeleteAction } from './saved_object_delete_action'; diff --git a/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.test.ts b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.test.ts new file mode 100644 index 000000000000..0060b7837f1f --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createPointInTimeEventsVisLayer } from '../mocks'; +import { generateAugmentVisSavedObject } from '../saved_augment_vis'; +import { PluginResourceDeleteAction } from './plugin_resource_delete_action'; + +const sampleSavedObj = generateAugmentVisSavedObject( + 'test-id', + { + type: 'PointInTimeEvents', + name: 'test-fn-name', + args: {}, + }, + 'test-vis-id', + 'test-origin-plugin', + { + type: 'test-resource-type', + id: 'test-resource-id', + } +); + +const sampleVisLayer = createPointInTimeEventsVisLayer(); + +describe('SavedObjectDeleteAction', () => { + it('is incompatible with invalid saved obj list', async () => { + const action = new PluginResourceDeleteAction(); + const visLayers = [sampleVisLayer]; + // @ts-ignore + expect(await action.isCompatible({ savedObjs: null, visLayers })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ savedObjs: undefined, visLayers })).toBe(false); + expect(await action.isCompatible({ savedObjs: [], visLayers })).toBe(false); + }); + + it('is incompatible with invalid vislayer list', async () => { + const action = new PluginResourceDeleteAction(); + const savedObjs = [sampleSavedObj]; + // @ts-ignore + expect(await action.isCompatible({ savedObjs, visLayers: null })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ savedObjs, visLayers: undefined })).toBe(false); + expect(await action.isCompatible({ savedObjs, visLayers: [] })).toBe(false); + }); + + it('execute throws error if incompatible saved objs list', async () => { + const action = new PluginResourceDeleteAction(); + async function check(savedObjs: any, visLayers: any) { + await action.execute({ savedObjs, visLayers }); + } + await expect(check(null, [sampleVisLayer])).rejects.toThrow(Error); + }); + + it('execute throws error if incompatible vis layer list', async () => { + const action = new PluginResourceDeleteAction(); + async function check(savedObjs: any, visLayers: any) { + await action.execute({ savedObjs, visLayers }); + } + await expect(check([sampleSavedObj], null)).rejects.toThrow(Error); + }); + + it('execute is successful if valid saved obj and vis layer lists', async () => { + const action = new PluginResourceDeleteAction(); + async function check(savedObjs: any, visLayers: any) { + await action.execute({ savedObjs, visLayers }); + } + await expect(check([sampleSavedObj], [sampleVisLayer])).resolves; + }); + + it('Returns display name', async () => { + const action = new PluginResourceDeleteAction(); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns icon type', async () => { + const action = new PluginResourceDeleteAction(); + expect(action.getIconType()).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts new file mode 100644 index 000000000000..6e3939820d28 --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/plugin_resource_delete_action.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isEmpty } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { Action, IncompatibleActionError } from '../../../ui_actions/public'; +import { getSavedAugmentVisLoader } from '../services'; +import { PluginResourceDeleteContext } from '../ui_actions_bootstrap'; +import { cleanupStaleObjects } from '../utils'; + +export const PLUGIN_RESOURCE_DELETE_ACTION = 'PLUGIN_RESOURCE_DELETE_ACTION'; + +export class PluginResourceDeleteAction implements Action { + public readonly type = PLUGIN_RESOURCE_DELETE_ACTION; + public readonly id = PLUGIN_RESOURCE_DELETE_ACTION; + public order = 1; + + public getIconType(): EuiIconType { + return `trash`; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.deleteSavedObject.name', { + defaultMessage: + 'Clean up all augment-vis saved objects associated to the deleted visualization', + }); + } + + public async isCompatible({ savedObjs, visLayers }: PluginResourceDeleteContext) { + return !isEmpty(savedObjs) && !isEmpty(visLayers); + } + + /** + * If we have just collected all of the saved objects and generated vis layers, + * sweep through them all and if any of the resources are deleted, delete those + * corresponding saved objects + */ + public async execute({ savedObjs, visLayers }: PluginResourceDeleteContext) { + if (!(await this.isCompatible({ savedObjs, visLayers }))) { + throw new IncompatibleActionError(); + } + cleanupStaleObjects(savedObjs, visLayers, getSavedAugmentVisLoader()); + } +} diff --git a/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.test.ts b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.test.ts new file mode 100644 index 000000000000..cec742a218cc --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectDeleteAction } from './saved_object_delete_action'; +import services from '../services'; + +jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { + return { + getSavedAugmentVisLoader: () => { + return { + delete: () => {}, + findAll: () => { + return { + hits: [], + }; + }, + }; + }, + }; +}); + +describe('SavedObjectDeleteAction', () => { + it('is incompatible with invalid types', async () => { + const action = new SavedObjectDeleteAction(); + const savedObjectId = '1234'; + // @ts-ignore + expect(await action.isCompatible({ type: null, savedObjectId })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ type: undefined, savedObjectId })).toBe(false); + expect(await action.isCompatible({ type: '', savedObjectId })).toBe(false); + expect(await action.isCompatible({ type: 'not-visualization-type', savedObjectId })).toBe( + false + ); + expect(await action.isCompatible({ type: 'savedSearch', savedObjectId })).toBe(false); + }); + + it('is incompatible with invalid saved obj ids', async () => { + const action = new SavedObjectDeleteAction(); + const type = 'visualization'; + // @ts-ignore + expect(await action.isCompatible({ type, savedObjectId: null })).toBe(false); + // @ts-ignore + expect(await action.isCompatible({ type, savedObjectId: undefined })).toBe(false); + expect(await action.isCompatible({ type, savedObjectId: '' })).toBe(false); + }); + + it('execute throws error if incompatible type', async () => { + const action = new SavedObjectDeleteAction(); + async function check(type: any, id: any) { + await action.execute({ type, savedObjectId: id }); + } + await expect(check(null, '1234')).rejects.toThrow(Error); + }); + + it('execute throws error if incompatible saved obj id', async () => { + const action = new SavedObjectDeleteAction(); + async function check(type: any, id: any) { + await action.execute({ type, savedObjectId: id }); + } + await expect(check('visualization', null)).rejects.toThrow(Error); + }); + + it('execute is successful if valid type and saved obj id', async () => { + const getLoaderSpy = jest.spyOn(services, 'getSavedAugmentVisLoader'); + const action = new SavedObjectDeleteAction(); + async function check(type: any, id: any) { + await action.execute({ type, savedObjectId: id }); + } + await expect(check('visualization', 'test-id')).resolves; + expect(getLoaderSpy).toHaveBeenCalledTimes(1); + }); + + it('Returns display name', async () => { + const action = new SavedObjectDeleteAction(); + expect(action.getDisplayName()).toBeDefined(); + }); + + it('Returns icon type', async () => { + const action = new SavedObjectDeleteAction(); + expect(action.getIconType()).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.ts b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.ts new file mode 100644 index 000000000000..2db6d1210448 --- /dev/null +++ b/src/plugins/vis_augmenter/public/actions/saved_object_delete_action.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isEmpty } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { Action, IncompatibleActionError } from '../../../ui_actions/public'; +import { getAllAugmentVisSavedObjs } from '../utils'; +import { getSavedAugmentVisLoader } from '../services'; +import { SavedObjectDeleteContext } from '../ui_actions_bootstrap'; + +export const SAVED_OBJECT_DELETE_ACTION = 'SAVED_OBJECT_DELETE_ACTION'; + +export class SavedObjectDeleteAction implements Action { + public readonly type = SAVED_OBJECT_DELETE_ACTION; + public readonly id = SAVED_OBJECT_DELETE_ACTION; + public order = 1; + + public getIconType(): EuiIconType { + return `trash`; + } + + public getDisplayName() { + return i18n.translate('dashboard.actions.deleteSavedObject.name', { + defaultMessage: 'Clean up augment-vis saved objects associated to a deleted vis', + }); + } + + public async isCompatible({ type, savedObjectId }: SavedObjectDeleteContext) { + return type === 'visualization' && (savedObjectId ? true : false); + } + + /** + * If deletion of a vis saved object has been triggered, we want to clean up + * any augment-vis saved objects that have a reference to this vis since it + * is now stale. + */ + public async execute({ type, savedObjectId }: SavedObjectDeleteContext) { + if (!(await this.isCompatible({ type, savedObjectId }))) { + throw new IncompatibleActionError(); + } + + const loader = getSavedAugmentVisLoader(); + const allAugmentVisObjs = await getAllAugmentVisSavedObjs(loader); + const augmentVisIdsToDelete = allAugmentVisObjs + .filter((augmentVisObj) => augmentVisObj.visId === savedObjectId) + .map((augmentVisObj) => augmentVisObj.id as string); + + if (!isEmpty(augmentVisIdsToDelete)) loader.delete(augmentVisIdsToDelete); + } +} diff --git a/src/plugins/vis_augmenter/public/index.ts b/src/plugins/vis_augmenter/public/index.ts index 9a4a5faeb803..3fc82ecc0267 100644 --- a/src/plugins/vis_augmenter/public/index.ts +++ b/src/plugins/vis_augmenter/public/index.ts @@ -34,3 +34,5 @@ export * from './constants'; export * from './vega'; export * from './saved_augment_vis'; export * from './test_constants'; +export * from './triggers'; +export * from './actions'; diff --git a/src/plugins/vis_augmenter/public/mocks.ts b/src/plugins/vis_augmenter/public/mocks.ts index 34d6d81a1ef6..d3ff8422287d 100644 --- a/src/plugins/vis_augmenter/public/mocks.ts +++ b/src/plugins/vis_augmenter/public/mocks.ts @@ -90,6 +90,7 @@ const PLUGIN_RESOURCE = { } as PluginResource; const EVENT_COUNT = 3; const ERROR_MESSAGE = 'test-error-message'; +const EVENT_TYPE = 'test-event-type'; export const createPluginResource = ( type: string = PLUGIN_RESOURCE.type, @@ -171,6 +172,7 @@ export const createPointInTimeEventsVisLayer = ( type: VisLayerTypes.PointInTimeEvents, pluginResource, events, + pluginEventType: EVENT_TYPE, error: error ? { type: VisLayerErrorTypes.FETCH_FAILURE, diff --git a/src/plugins/vis_augmenter/public/plugin.ts b/src/plugins/vis_augmenter/public/plugin.ts index d7b09d57d790..9760bfd75b2d 100644 --- a/src/plugins/vis_augmenter/public/plugin.ts +++ b/src/plugins/vis_augmenter/public/plugin.ts @@ -8,7 +8,6 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../.. import { visLayers } from './expressions'; import { setSavedAugmentVisLoader, setUISettings } from './services'; import { createSavedAugmentVisLoader, SavedAugmentVisLoader } from './saved_augment_vis'; -import { registerTriggersAndActions } from './ui_actions_bootstrap'; import { UiActionsStart } from '../../ui_actions/public'; import { setUiActions, @@ -21,6 +20,7 @@ import { EmbeddableStart } from '../../embeddable/public'; import { DataPublicPluginStart } from '../../data/public'; import { VisualizationsStart } from '../../visualizations/public'; import { VIEW_EVENTS_FLYOUT_STATE, setFlyoutState } from './view_events_flyout'; +import { bootstrapUiActions } from './ui_actions_bootstrap'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface VisAugmenterSetup {} @@ -51,6 +51,7 @@ export class VisAugmenterPlugin ): VisAugmenterSetup { expressions.registerType(visLayers); setUISettings(core.uiSettings); + return {}; } @@ -65,8 +66,6 @@ export class VisAugmenterPlugin setCore(core); setFlyoutState(VIEW_EVENTS_FLYOUT_STATE.CLOSED); - registerTriggersAndActions(core); - const savedAugmentVisLoader = createSavedAugmentVisLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns: data.indexPatterns, @@ -75,6 +74,10 @@ export class VisAugmenterPlugin overlays: core.overlays, }); setSavedAugmentVisLoader(savedAugmentVisLoader); + + // sets up the context mappings and registers any triggers/actions for the plugin + bootstrapUiActions(uiActions); + return { savedAugmentVisLoader }; } 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 index 88178a7a08cd..d40382b3a104 100644 --- 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 @@ -24,9 +24,23 @@ export function createSavedAugmentVisClass(services: SavedObjectOpenSearchDashbo class SavedAugmentVis extends SavedObjectClass { public static type: string = name; - public static mapping: AugmentVisSavedObjectAttributes; + public static mapping: Record = { + title: 'text', + description: 'text', + originPlugin: 'text', + pluginResource: 'object', + visLayerExpressionFn: 'object', + visId: 'keyword,', + version: 'integer', + }; - constructor(opts: AugmentVisSavedObjectAttributes) { + constructor(opts: Record | string = {}) { + // The default delete() fn from the saved object loader will only + // pass a string ID. To handle that case here, we embed it within + // an opts object. + if (typeof opts !== 'object') { + opts = { id: opts }; + } super({ type: SavedAugmentVis.type, mapping: SavedAugmentVis.mapping, diff --git a/src/plugins/vis_augmenter/public/services.ts b/src/plugins/vis_augmenter/public/services.ts index 48a3233714e6..1d7f3e2111db 100644 --- a/src/plugins/vis_augmenter/public/services.ts +++ b/src/plugins/vis_augmenter/public/services.ts @@ -17,6 +17,7 @@ export const [getSavedAugmentVisLoader, setSavedAugmentVisLoader] = createGetter >('savedAugmentVisLoader'); export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + export const [getUiActions, setUiActions] = createGetterSetter('UIActions'); export const [getEmbeddable, setEmbeddable] = createGetterSetter('embeddable'); @@ -30,3 +31,15 @@ export const [getVisualizations, setVisualizations] = createGetterSetter('Core'); + +// This is primarily used for mocking this module and each of its fns in tests. +// eslint-disable-next-line import/no-default-export +export default { + getSavedAugmentVisLoader, + getUISettings, + getUiActions, + getEmbeddable, + getQueryService, + getVisualizations, + getCore, +}; diff --git a/src/plugins/vis_augmenter/public/triggers/index.ts b/src/plugins/vis_augmenter/public/triggers/index.ts new file mode 100644 index 000000000000..5b1833e38d62 --- /dev/null +++ b/src/plugins/vis_augmenter/public/triggers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + PLUGIN_RESOURCE_DELETE_TRIGGER, + pluginResourceDeleteTrigger, +} from './plugin_resource_delete_trigger'; diff --git a/src/plugins/vis_augmenter/public/triggers/plugin_resource_delete_trigger.ts b/src/plugins/vis_augmenter/public/triggers/plugin_resource_delete_trigger.ts new file mode 100644 index 000000000000..249bb61132eb --- /dev/null +++ b/src/plugins/vis_augmenter/public/triggers/plugin_resource_delete_trigger.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Trigger } from '../../../ui_actions/public'; + +export const PLUGIN_RESOURCE_DELETE_TRIGGER = 'PLUGIN_RESOURCE_DELETE_TRIGGER'; +export const pluginResourceDeleteTrigger: Trigger<'PLUGIN_RESOURCE_DELETE_TRIGGER'> = { + id: PLUGIN_RESOURCE_DELETE_TRIGGER, + title: i18n.translate('visAugmenter.triggers.pluginResourceDeleteTitle', { + defaultMessage: 'Plugin resource delete', + }), + description: i18n.translate('visAugmenter.triggers.pluginResourceDeleteDescription', { + defaultMessage: 'Delete augment-vis saved objs associated to the deleted plugin resource', + }), +}; diff --git a/src/plugins/vis_augmenter/public/types.ts b/src/plugins/vis_augmenter/public/types.ts index 27fd9d7c241f..35c00ea932a7 100644 --- a/src/plugins/vis_augmenter/public/types.ts +++ b/src/plugins/vis_augmenter/public/types.ts @@ -10,6 +10,7 @@ export enum VisLayerTypes { export enum VisLayerErrorTypes { PERMISSIONS_FAILURE = 'PERMISSIONS_FAILURE', FETCH_FAILURE = 'FETCH_FAILURE', + RESOURCE_DELETED = 'RESOURCE_DELETED', } export enum VisFlyoutContext { diff --git a/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts b/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts index aae3f237a28f..27bd6284e71b 100644 --- a/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts +++ b/src/plugins/vis_augmenter/public/ui_actions_bootstrap.ts @@ -3,45 +3,76 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CoreStart } from 'opensearch-dashboards/public'; import { OpenEventsFlyoutAction, ViewEventsOptionAction, OPEN_EVENTS_FLYOUT_ACTION, VIEW_EVENTS_OPTION_ACTION, } from './view_events_flyout'; -import { externalActionTrigger, EXTERNAL_ACTION_TRIGGER } from '../../ui_actions/public'; import { CONTEXT_MENU_TRIGGER, EmbeddableContext } from '../../embeddable/public'; -import { getUiActions } from './services'; +import { SAVED_OBJECT_DELETE_TRIGGER } from '../../saved_objects_management/public'; +import { + externalActionTrigger, + EXTERNAL_ACTION_TRIGGER, + UiActionsSetup, +} from '../../ui_actions/public'; +import { ISavedAugmentVis } from './saved_augment_vis'; +import { VisLayer } from './types'; +import { + PLUGIN_RESOURCE_DELETE_ACTION, + PluginResourceDeleteAction, + SAVED_OBJECT_DELETE_ACTION, + SavedObjectDeleteAction, +} from './actions'; +import { PLUGIN_RESOURCE_DELETE_TRIGGER, pluginResourceDeleteTrigger } from './triggers'; export interface AugmentVisContext { savedObjectId: string; } +export interface SavedObjectDeleteContext { + type: string; + savedObjectId: string; +} + +export interface PluginResourceDeleteContext { + savedObjs: ISavedAugmentVis[]; + visLayers: VisLayer[]; +} + // Overriding the mappings defined in UIActions plugin so that // the new trigger and action definitions resolve. // This is a common pattern among internal Dashboards plugins. declare module '../../ui_actions/public' { export interface TriggerContextMapping { [EXTERNAL_ACTION_TRIGGER]: AugmentVisContext; + [PLUGIN_RESOURCE_DELETE_TRIGGER]: PluginResourceDeleteContext; } export interface ActionContextMapping { [OPEN_EVENTS_FLYOUT_ACTION]: AugmentVisContext; [VIEW_EVENTS_OPTION_ACTION]: EmbeddableContext; + [SAVED_OBJECT_DELETE_ACTION]: SavedObjectDeleteContext; + [PLUGIN_RESOURCE_DELETE_ACTION]: PluginResourceDeleteContext; } } -export const registerTriggersAndActions = (core: CoreStart) => { - const openEventsFlyoutAction = new OpenEventsFlyoutAction(core); - const viewEventsOptionAction = new ViewEventsOptionAction(core); +export const bootstrapUiActions = (uiActions: UiActionsSetup) => { + const openEventsFlyoutAction = new OpenEventsFlyoutAction(); + const viewEventsOptionAction = new ViewEventsOptionAction(); + const savedObjectDeleteAction = new SavedObjectDeleteAction(); + const pluginResourceDeleteAction = new PluginResourceDeleteAction(); + + uiActions.registerAction(openEventsFlyoutAction); + uiActions.registerAction(viewEventsOptionAction); + uiActions.registerAction(savedObjectDeleteAction); + uiActions.registerAction(pluginResourceDeleteAction); - getUiActions().registerAction(openEventsFlyoutAction); - getUiActions().registerAction(viewEventsOptionAction); - getUiActions().registerTrigger(externalActionTrigger); + uiActions.registerTrigger(externalActionTrigger); + uiActions.registerTrigger(pluginResourceDeleteTrigger); - // Opening View Events flyout from the chart - getUiActions().addTriggerAction(EXTERNAL_ACTION_TRIGGER, openEventsFlyoutAction); - // Opening View Events flyout from the context menu - getUiActions().addTriggerAction(CONTEXT_MENU_TRIGGER, viewEventsOptionAction); + uiActions.addTriggerAction(EXTERNAL_ACTION_TRIGGER, openEventsFlyoutAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, viewEventsOptionAction); + uiActions.addTriggerAction(SAVED_OBJECT_DELETE_TRIGGER, savedObjectDeleteAction); + uiActions.addTriggerAction(PLUGIN_RESOURCE_DELETE_TRIGGER, pluginResourceDeleteAction); }; diff --git a/src/plugins/vis_augmenter/public/utils/utils.test.ts b/src/plugins/vis_augmenter/public/utils/utils.test.ts index 707c36f87073..250e17e8d595 100644 --- a/src/plugins/vis_augmenter/public/utils/utils.test.ts +++ b/src/plugins/vis_augmenter/public/utils/utils.test.ts @@ -16,10 +16,14 @@ import { ISavedAugmentVis, VisLayerTypes, VisLayerExpressionFn, + cleanupStaleObjects, + VisLayer, + PluginResource, + VisLayerErrorTypes, + SavedObjectLoaderAugmentVis, } from '../'; import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; -import { AggConfigs, AggTypesRegistryStart, IndexPattern } from '../../../data/common'; -import { mockAggTypesRegistry } from '../../../data/common/search/aggs/test_helpers'; +import { AggConfigs } from '../../../data/common'; import { uiSettingsServiceMock } from '../../../../core/public/mocks'; import { setUISettings } from '../services'; import { @@ -28,6 +32,7 @@ import { VALID_AGGS, VALID_CONFIG_STATES, VALID_VIS, + createPointInTimeEventsVisLayer, createVisLayer, } from '../mocks'; @@ -429,4 +434,129 @@ describe('utils', () => { expect(err?.stack).toStrictEqual(`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"`); }); }); + + describe('cleanupStaleObjects', () => { + const fn = { + type: VisLayerTypes.PointInTimeEvents, + name: 'test-fn', + args: { + testArg: 'test-value', + }, + } as VisLayerExpressionFn; + const originPlugin = 'test-plugin'; + const resourceId1 = 'resource-1'; + const resourceId2 = 'resource-2'; + const resourceType1 = 'resource-type-1'; + const augmentVisObj1 = generateAugmentVisSavedObject('id-1', fn, 'vis-id-1', originPlugin, { + type: resourceType1, + id: resourceId1, + }); + const augmentVisObj2 = generateAugmentVisSavedObject('id-2', fn, 'vis-id-1', originPlugin, { + type: resourceType1, + id: resourceId2, + }); + const resource1 = { + type: 'test-resource-type-1', + id: resourceId1, + name: 'resource-1', + urlPath: 'test-path', + } as PluginResource; + const resource2 = { + type: 'test-resource-type-1', + id: resourceId2, + name: 'resource-2', + urlPath: 'test-path', + } as PluginResource; + const validVisLayer1 = createPointInTimeEventsVisLayer(originPlugin, resource1, 1, false); + const staleVisLayer1 = { + ...createPointInTimeEventsVisLayer(originPlugin, resource1, 0, true), + error: { + type: VisLayerErrorTypes.RESOURCE_DELETED, + message: 'resource is deleted', + }, + }; + const staleVisLayer2 = { + ...createPointInTimeEventsVisLayer(originPlugin, resource2, 0, true), + error: { + type: VisLayerErrorTypes.RESOURCE_DELETED, + message: 'resource is deleted', + }, + }; + + it('no augment-vis objs, no vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [] as ISavedAugmentVis[]; + const visLayers = [] as VisLayer[]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(0); + }); + it('no stale vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1]; + const visLayers = [validVisLayer1]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(0); + }); + it('1 stale vislayer', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1]; + const visLayers = [staleVisLayer1]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(1); + }); + it('multiple stale vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1, augmentVisObj2]; + const visLayers = [staleVisLayer1, staleVisLayer2]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(2); + }); + it('stale and valid vislayers', async () => { + const mockDeleteFn = jest.fn(); + const augmentVisObjs = [augmentVisObj1, augmentVisObj2]; + const visLayers = [validVisLayer1, staleVisLayer2]; + const augmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: { + ...getMockAugmentVisSavedObjectClient(augmentVisObjs), + delete: mockDeleteFn, + }, + } as any) as SavedObjectLoaderAugmentVis; + + cleanupStaleObjects(augmentVisObjs, visLayers, augmentVisLoader); + + expect(mockDeleteFn).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/plugins/vis_augmenter/public/utils/utils.ts b/src/plugins/vis_augmenter/public/utils/utils.ts index 9d683ff962c1..907867630cbe 100644 --- a/src/plugins/vis_augmenter/public/utils/utils.ts +++ b/src/plugins/vis_augmenter/public/utils/utils.ts @@ -11,12 +11,14 @@ import { buildExpression, ExpressionAstFunctionBuilder, } from '../../../../plugins/expressions/public'; +import { SavedObjectLoader } from '../../../../plugins/saved_objects/public'; import { ISavedAugmentVis, SavedAugmentVisLoader, VisLayerFunctionDefinition, VisLayer, isVisLayerWithError, + VisLayerErrorTypes, } from '../'; import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; import { getUISettings } from '../services'; @@ -48,9 +50,8 @@ export const isEligibleForVisLayers = (vis: Vis): boolean => { }; /** - * Using a SavedAugmentVisLoader, fetch all saved objects that are of 'augment-vis' type - * and filter out to return the ones associated to the particular vis via - * matching vis ID. + * Using a SavedAugmentVisLoader, fetch all saved objects that are of 'augment-vis' type. + * Filter by vis ID. */ export const getAugmentVisSavedObjs = async ( visId: string | undefined, @@ -66,14 +67,27 @@ export const getAugmentVisSavedObjs = async ( ); } try { - const resp = await loader?.findAll(); - const allSavedObjects = (get(resp, 'hits', []) as any[]) as ISavedAugmentVis[]; + const allSavedObjects = await getAllAugmentVisSavedObjs(loader); return allSavedObjects.filter((hit: ISavedAugmentVis) => hit.visId === visId); } catch (e) { return [] as ISavedAugmentVis[]; } }; +/** + * Using a SavedAugmentVisLoader, fetch all saved objects that are of 'augment-vis' type. + */ +export const getAllAugmentVisSavedObjs = async ( + loader: SavedAugmentVisLoader | undefined +): Promise => { + try { + const resp = await loader?.findAll(); + return (get(resp, 'hits', []) as any[]) as ISavedAugmentVis[]; + } catch (e) { + return [] as ISavedAugmentVis[]; + } +}; + /** * Given an array of augment-vis saved objects that contain expression function details, * construct a pipeline that will execute each of these expression functions. @@ -130,3 +144,36 @@ export const getAnyErrors = (visLayers: VisLayer[], visTitle: string): Error | u return undefined; } }; + +/** + * Cleans up any stale saved objects caused by plugin resources being deleted. Kicks + * off an async call to delete the stale objs. + * + * @param augmentVisSavedObs the original augment-vis saved objs for this particular vis + * @param visLayers the produced VisLayers containing details if the resource has been deleted + * @param visualizationsLoader the visualizations saved object loader to handle deletion + */ + +export const cleanupStaleObjects = ( + augmentVisSavedObjs: ISavedAugmentVis[], + visLayers: VisLayer[], + loader: SavedAugmentVisLoader | undefined +): void => { + const staleVisLayers = visLayers + .filter((visLayer) => isVisLayerWithError(visLayer)) + .filter( + (visLayerWithError) => visLayerWithError.error?.type === VisLayerErrorTypes.RESOURCE_DELETED + ); + if (!isEmpty(staleVisLayers)) { + const objIdsToDelete = [] as string[]; + staleVisLayers.forEach((staleVisLayer) => { + // Match the VisLayer to its origin saved obj to extract the to-be-deleted saved obj ID + const deletedPluginResourceId = staleVisLayer.pluginResource.id; + const savedObjId = augmentVisSavedObjs.find( + (savedObj) => savedObj.pluginResource.id === deletedPluginResourceId + )?.id; + if (savedObjId !== undefined) objIdsToDelete.push(savedObjId); + }); + loader?.delete(objIdsToDelete); + } +}; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx index cd7d90aedf11..c9f6d75e1190 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout.tsx @@ -3,19 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; -import { CoreStart } from 'src/core/public'; import { toMountPoint } from '../../../../opensearch_dashboards_react/public'; import { ViewEventsFlyout } from '../components'; import { VIEW_EVENTS_FLYOUT_STATE, setFlyoutState } from '../flyout_state'; +import { getCore } from '../../services'; interface Props { - core: CoreStart; savedObjectId: string; } export async function openViewEventsFlyout(props: Props) { setFlyoutState(VIEW_EVENTS_FLYOUT_STATE.OPEN); - const flyoutSession = props.core.overlays.openFlyout( + const flyoutSession = getCore().overlays.openFlyout( toMountPoint( { diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts index 381cbcb2e453..e6cb654ab422 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.test.ts @@ -3,10 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'opensearch-dashboards/public'; import { OpenEventsFlyoutAction } from './open_events_flyout_action'; import flyoutStateModule from '../flyout_state'; +import servicesModule from '../../services'; // Mocking the flyout state service. Defaulting to CLOSED. May override // getFlyoutState() in below individual tests to test out different scenarios. @@ -21,37 +20,46 @@ jest.mock('src/plugins/vis_augmenter/public/view_events_flyout/flyout_state', () }; }); -let coreStart: CoreStart; -beforeEach(async () => { - coreStart = coreMock.createStart(); +// Mocking core service as needed when making calls to the core's overlays service +jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { + return { + getCore: () => { + return { + overlays: { + openFlyout: () => {}, + }, + }; + }, + }; }); + afterEach(async () => { jest.clearAllMocks(); }); describe('OpenEventsFlyoutAction', () => { it('is incompatible with null saved obj id', async () => { - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); const savedObjectId = null; // @ts-ignore expect(await action.isCompatible({ savedObjectId })).toBe(false); }); it('is incompatible with undefined saved obj id', async () => { - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); const savedObjectId = undefined; // @ts-ignore expect(await action.isCompatible({ savedObjectId })).toBe(false); }); it('is incompatible with empty saved obj id', async () => { - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); const savedObjectId = ''; expect(await action.isCompatible({ savedObjectId })).toBe(false); }); it('execute throws error if incompatible saved obj id', async () => { - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); async function check(id: any) { await action.execute({ savedObjectId: id }); } @@ -64,10 +72,13 @@ describe('OpenEventsFlyoutAction', () => { const getFlyoutStateSpy = jest .spyOn(flyoutStateModule, 'getFlyoutState') .mockImplementation(() => 'CLOSED'); + // openFlyout exists within core.overlays service. We spy on the initial getCore() fn call indicating + // that openFlyout is getting called. + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); const savedObjectId = 'test-id'; - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); await action.execute({ savedObjectId }); - expect(coreStart.overlays.openFlyout).toHaveBeenCalledTimes(1); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(1); expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); }); @@ -75,20 +86,21 @@ describe('OpenEventsFlyoutAction', () => { const getFlyoutStateSpy = jest .spyOn(flyoutStateModule, 'getFlyoutState') .mockImplementation(() => 'OPEN'); + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); const savedObjectId = 'test-id'; - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); await action.execute({ savedObjectId }); - expect(coreStart.overlays.openFlyout).toHaveBeenCalledTimes(0); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(0); expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); }); it('Returns display name', async () => { - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); expect(action.getDisplayName()).toBeDefined(); }); it('Returns undefined icon type', async () => { - const action = new OpenEventsFlyoutAction(coreStart); + const action = new OpenEventsFlyoutAction(); expect(action.getIconType()).toBeUndefined(); }); }); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts index c141a361048a..cb47e5d6a85c 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/open_events_flyout_action.ts @@ -25,7 +25,7 @@ export class OpenEventsFlyoutAction implements Action { public readonly id = OPEN_EVENTS_FLYOUT_ACTION; public order = 1; - constructor(private core: CoreStart) {} + constructor() {} public getIconType() { return undefined; @@ -53,7 +53,6 @@ export class OpenEventsFlyoutAction implements Action { // re-opening it. if (getFlyoutState() === VIEW_EVENTS_FLYOUT_STATE.CLOSED) { openViewEventsFlyout({ - core: this.core, savedObjectId, }); } diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts index 64b75547d019..451087e48a15 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.test.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'opensearch-dashboards/public'; import { ViewEventsOptionAction } from './view_events_option_action'; import { createMockErrorEmbeddable, createMockVisEmbeddable } from '../../mocks'; import flyoutStateModule from '../flyout_state'; +import servicesModule from '../../services'; // Mocking the flyout state service. Defaulting to CLOSED. May override // getFlyoutState() in below individual tests to test out different scenarios. @@ -24,6 +23,7 @@ jest.mock('src/plugins/vis_augmenter/public/view_events_flyout/flyout_state', () // Mocking the UISettings service. This is needed when making eligibility checks for the actions, // which does UISettings checks to ensure the feature is enabled. +// Also mocking core service as needed when making calls to the core's overlays service jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { return { getUISettings: () => { @@ -40,40 +40,42 @@ jest.mock('src/plugins/vis_augmenter/public/services.ts', () => { }, }; }, + getCore: () => { + return { + overlays: { + openFlyout: () => {}, + }, + }; + }, }; }); -let coreStart: CoreStart; - -beforeEach(async () => { - coreStart = coreMock.createStart(); -}); afterEach(async () => { jest.clearAllMocks(); }); describe('ViewEventsOptionAction', () => { it('is incompatible with ErrorEmbeddables', async () => { - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); const errorEmbeddable = createMockErrorEmbeddable(); expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false); }); it('is incompatible with VisualizeEmbeddable with invalid vis', async () => { const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title', false); - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); expect(await action.isCompatible({ embeddable: visEmbeddable })).toBe(false); }); it('is compatible with VisualizeEmbeddable with valid vis', async () => { const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); expect(await action.isCompatible({ embeddable: visEmbeddable })).toBe(true); }); it('execute throws error if incompatible embeddable', async () => { const errorEmbeddable = createMockErrorEmbeddable(); - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); async function check() { await action.execute({ embeddable: errorEmbeddable }); } @@ -84,10 +86,13 @@ describe('ViewEventsOptionAction', () => { const getFlyoutStateSpy = jest .spyOn(flyoutStateModule, 'getFlyoutState') .mockImplementation(() => 'CLOSED'); + // openFlyout exists within core.overlays service. We spy on the initial getCore() fn call indicating + // that openFlyout is getting called. + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); await action.execute({ embeddable: visEmbeddable }); - expect(coreStart.overlays.openFlyout).toHaveBeenCalledTimes(1); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(1); expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); }); @@ -95,20 +100,21 @@ describe('ViewEventsOptionAction', () => { const getFlyoutStateSpy = jest .spyOn(flyoutStateModule, 'getFlyoutState') .mockImplementation(() => 'OPEN'); + const openFlyoutStateSpy = jest.spyOn(servicesModule, 'getCore'); const visEmbeddable = createMockVisEmbeddable('test-saved-obj-id', 'test-title'); - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); await action.execute({ embeddable: visEmbeddable }); - expect(coreStart.overlays.openFlyout).toHaveBeenCalledTimes(0); + expect(openFlyoutStateSpy).toHaveBeenCalledTimes(0); expect(getFlyoutStateSpy).toHaveBeenCalledTimes(1); }); it('Returns display name', async () => { - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); expect(action.getDisplayName()).toBeDefined(); }); it('Returns an icon type', async () => { - const action = new ViewEventsOptionAction(coreStart); + const action = new ViewEventsOptionAction(); expect(action.getIconType()).toBeDefined(); }); }); diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx index 1ad2a9c57fe2..975f6ac0cc4c 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx @@ -21,7 +21,7 @@ export class ViewEventsOptionAction implements Action { public readonly id = VIEW_EVENTS_OPTION_ACTION; public order = 1; - constructor(private core: CoreStart) {} + constructor() {} public getIconType(): EuiIconType { return 'apmTrace'; @@ -52,7 +52,6 @@ export class ViewEventsOptionAction implements Action { // re-opening it. if (getFlyoutState() === VIEW_EVENTS_FLYOUT_STATE.CLOSED) { openViewEventsFlyout({ - core: this.core, savedObjectId, }); } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index be6c832d3385..505c7b045cb4 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -72,16 +72,12 @@ import { getAugmentVisSavedObjs, buildPipelineFromAugmentVisSavedObjs, getAnyErrors, - VisLayerErrorTypes, AugmentVisContext, -} from '../../../vis_augmenter/public'; -import { VisSavedObject } from '../types'; -import { - PointInTimeEventsVisLayer, VisLayer, - VisLayerTypes, VisAugmenterEmbeddableConfig, + PLUGIN_RESOURCE_DELETE_TRIGGER, } from '../../../vis_augmenter/public'; +import { VisSavedObject } from '../types'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -519,11 +515,6 @@ export class VisualizeEmbeddable * Collects any VisLayers from plugin expressions functions * by fetching all AugmentVisSavedObjects that match the vis * saved object ID. - * - * TODO: final eligibility will be defined as part of a separate effort. - * Right now we have a placeholder function isEligibleForVisLayers() which - * is used below. For more details, see - * https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3268 */ fetchVisLayers = async (): Promise => { try { @@ -558,6 +549,15 @@ export class VisualizeEmbeddable expressionParams as Record )) as ExprVisLayers; const visLayers = exprVisLayers.layers; + + // There may be some stale saved objs if any plugin resources have been deleted since last time + // data was fetched from them via the expression functions. Execute this trigger so any listening + // action can perform cleanup. + getUiActions().getTrigger(PLUGIN_RESOURCE_DELETE_TRIGGER).exec({ + savedObjs: augmentVisSavedObjs, + visLayers, + }); + const err = getAnyErrors(visLayers, this.vis.title); // This is only true when one or more VisLayers has an error if (err !== undefined) { diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 957c9d8c80cd..6c8cf4ec51d2 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -76,3 +76,4 @@ export { export { ExprVisAPIEvents } from './expressions/vis'; export { VisualizationListItem } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; +export { createSavedVisLoader } from './saved_visualizations'; diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 07b40066018c..9b094c9ef754 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -182,6 +182,14 @@ export class VisualizationsPlugin { data, expressions, uiActions, embeddable, dashboard }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); + const savedAugmentVisLoader = createSavedAugmentVisLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + setSavedAugmentVisLoader(savedAugmentVisLoader); setI18n(core.i18n); setTypes(types); setEmbeddable(embeddable); @@ -205,6 +213,7 @@ export class VisualizationsPlugin chrome: core.chrome, overlays: core.overlays, visualizationTypes: types, + savedAugmentVisLoader, }); setSavedVisualizationsLoader(savedVisualizationsLoader); const savedSearchLoader = createSavedSearchesLoader({ @@ -214,14 +223,6 @@ export class VisualizationsPlugin chrome: core.chrome, overlays: core.overlays, }); - const savedAugmentVisLoader = createSavedAugmentVisLoader({ - savedObjectsClient: core.savedObjects.client, - indexPatterns: data.indexPatterns, - search: data.search, - chrome: core.chrome, - overlays: core.overlays, - }); - setSavedAugmentVisLoader(savedAugmentVisLoader); setSavedSearchLoader(savedSearchLoader); setNotifications(core.notifications); return { diff --git a/src/plugins/visualize/opensearch_dashboards.json b/src/plugins/visualize/opensearch_dashboards.json index c898f7da3779..47573b58b9d2 100644 --- a/src/plugins/visualize/opensearch_dashboards.json +++ b/src/plugins/visualize/opensearch_dashboards.json @@ -19,6 +19,7 @@ "opensearchDashboardsReact", "home", "discover", - "visDefaultEditor" + "visDefaultEditor", + "savedObjectsManagement" ] } diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 46db44bb066d..ec768e19885a 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -29,7 +29,7 @@ */ import './visualize_listing.scss'; - +import { get } from 'lodash'; import React, { useCallback, useRef, useMemo, useEffect } from 'react'; import { i18n } from '@osd/i18n'; import { useUnmount, useMount } from 'react-use'; @@ -43,6 +43,8 @@ import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public import { VisualizeServices } from '../types'; import { VisualizeConstants } from '../visualize_constants'; import { getTableColumns, getNoItemsMessage } from '../utils'; +import { getUiActions } from '../../services'; +import { SAVED_OBJECT_DELETE_TRIGGER } from '../../../../saved_objects_management/public'; export const VisualizeListing = () => { const { @@ -134,15 +136,25 @@ export const VisualizeListing = () => { const deleteItems = useCallback( async (selectedItems: object[]) => { + const uiActions = getUiActions(); await Promise.all( - selectedItems.map((item: any) => savedObjects.client.delete(item.savedObjectType, item.id)) - ).catch((error) => { - toastNotifications.addError(error, { - title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); - }); + selectedItems.map((item: any) => + savedObjects.client + .delete(item.savedObjectType, item.id) + .then(() => { + uiActions + .getTrigger(SAVED_OBJECT_DELETE_TRIGGER) + .exec({ type: item.savedObjectType, savedObjectId: item.id }); + }) + .catch((error) => { + toastNotifications.addError(error, { + title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), + }); + }) + ) + ); }, [savedObjects.client, toastNotifications] ); diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 297db26c48de..c146efef1fab 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -60,13 +60,14 @@ import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; -import { UiActionsSetup, VISUALIZE_FIELD_TRIGGER } from '../../ui_actions/public'; +import { UiActionsSetup, UiActionsStart, VISUALIZE_FIELD_TRIGGER } from '../../ui_actions/public'; import { setUISettings, setApplication, setIndexPatterns, setQueryService, setShareService, + setUiActions, } from './services'; import { visualizeFieldAction } from './actions/visualize_field_action'; import { createVisualizeUrlGenerator } from './url_generator'; @@ -80,6 +81,7 @@ export interface VisualizePluginStartDependencies { urlForwarding: UrlForwardingStart; savedObjects: SavedObjectsStart; dashboard: DashboardStart; + uiActions: UiActionsStart; } export interface VisualizePluginSetupDependencies { @@ -248,6 +250,7 @@ export class VisualizePlugin if (plugins.share) { setShareService(plugins.share); } + setUiActions(plugins.uiActions); } stop() { diff --git a/src/plugins/visualize/public/services.ts b/src/plugins/visualize/public/services.ts index c0f359e8a002..ac367522ab7e 100644 --- a/src/plugins/visualize/public/services.ts +++ b/src/plugins/visualize/public/services.ts @@ -32,6 +32,7 @@ import { ApplicationStart, IUiSettingsClient } from '../../../core/public'; import { createGetterSetter } from '../../../plugins/opensearch_dashboards_utils/public'; import { IndexPatternsContract, DataPublicPluginStart } from '../../../plugins/data/public'; import { SharePluginStart } from '../../share/public'; +import { UiActionsStart } from '../../ui_actions/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -46,3 +47,5 @@ export const [getIndexPatterns, setIndexPatterns] = createGetterSetter('Query'); + +export const [getUiActions, setUiActions] = createGetterSetter('UIActions');