diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b07ea1dde7b..830f213036d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Create data source menu component able to be mount to nav bar ([#6082](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6082)) - [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry ([#6049](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6049)) - [Multiple Datasource] Add Vega support to MDS by specifying a data source name in the Vega spec ([#5975](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5975)) +- [Workspace] Consume workspace id in saved object client ([#6014](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6014)) ### 🐛 Bug Fixes diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index cc3405f246c5..b2f6d4afbb7f 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -329,6 +329,26 @@ describe('SavedObjectsClient', () => { `); }); + test('makes HTTP call with workspaces', () => { + savedObjectsClient.create('index-pattern', attributes, { + workspaces: ['foo'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + test('rejects when HTTP call fails', async () => { http.fetch.mockRejectedValueOnce(new Error('Request failed')); await expect( @@ -386,6 +406,29 @@ describe('SavedObjectsClient', () => { ] `); }); + + test('makes HTTP call with workspaces', () => { + savedObjectsClient.bulkCreate([doc], { + workspaces: ['foo'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": undefined, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); }); describe('#bulk_update', () => { @@ -510,5 +553,294 @@ describe('SavedObjectsClient', () => { ] `); }); + + test('makes HTTP call correctly with workspaces', () => { + const options = { + invalid: true, + workspaces: ['foo'], + }; + + // @ts-expect-error + savedObjectsClient.find(options); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_find", + Object { + "body": undefined, + "method": "GET", + "query": Object { + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + }); +}); + +describe('SavedObjectsClientWithWorkspaceSet', () => { + const updatedAt = new Date().toISOString(); + const doc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + attributes: { title: 'Example title' }, + version: 'foo', + updated_at: updatedAt, + }; + + const http = httpServiceMock.createStartContract(); + let savedObjectsClient: SavedObjectsClient; + + beforeEach(() => { + savedObjectsClient = new SavedObjectsClient(http); + savedObjectsClient.setCurrentWorkspace('foo'); + http.fetch.mockClear(); + }); + + describe('#create', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + + beforeEach(() => { + http.fetch.mockResolvedValue({ id: 'serverId', type: 'server-type', attributes }); + }); + + test('makes HTTP call with ID', () => { + savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern/myId", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + + test('makes HTTP call without ID', () => { + savedObjectsClient.create('index-pattern', attributes); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + + test('makes HTTP call with workspaces', () => { + savedObjectsClient.create('index-pattern', attributes, { + workspaces: ['foo'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/index-pattern", + Object { + "body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"workspaces\\":[\\"foo\\"]}", + "method": "POST", + "query": Object { + "overwrite": undefined, + }, + }, + ], + ] + `); + }); + }); + + describe('#bulk_create', () => { + beforeEach(() => { + http.fetch.mockResolvedValue({ saved_objects: [doc] }); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.bulkCreate([doc]); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": false, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + + test('makes HTTP call with overwrite query paramater', async () => { + await savedObjectsClient.bulkCreate([doc], { overwrite: true }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": true, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + + test('makes HTTP call with workspaces', () => { + savedObjectsClient.bulkCreate([doc], { + workspaces: ['bar'], + }); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_create", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", + "method": "POST", + "query": Object { + "overwrite": undefined, + "workspaces": Array [ + "bar", + ], + }, + }, + ], + ] + `); + }); + }); + + describe('#bulk_update', () => { + const bulkUpdateDoc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + attributes: { title: 'Example title' }, + version: 'foo', + }; + beforeEach(() => { + http.fetch.mockResolvedValue({ saved_objects: [bulkUpdateDoc] }); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.bulkUpdate([bulkUpdateDoc]); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_update", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\"}]", + "method": "PUT", + "query": undefined, + }, + ], + ] + `); + }); + }); + + describe('#find', () => { + const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' }; + + beforeEach(() => { + http.fetch.mockResolvedValue({ saved_objects: [object], page: 0, per_page: 1, total: 1 }); + }); + + test('makes HTTP call correctly mapping options into snake case query parameters', () => { + const options = { + defaultSearchOperator: 'OR' as const, + fields: ['title'], + hasReference: { id: '1', type: 'reference' }, + page: 10, + perPage: 100, + search: 'what is the meaning of life?|life', + searchFields: ['title^5', 'body'], + sortField: 'sort_field', + type: 'index-pattern', + }; + + savedObjectsClient.find(options); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_find", + Object { + "body": undefined, + "method": "GET", + "query": Object { + "default_search_operator": "OR", + "fields": Array [ + "title", + ], + "has_reference": "{\\"id\\":\\"1\\",\\"type\\":\\"reference\\"}", + "page": 10, + "per_page": 100, + "search": "what is the meaning of life?|life", + "search_fields": Array [ + "title^5", + "body", + ], + "sort_field": "sort_field", + "type": "index-pattern", + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); + + test('makes HTTP call correctly with workspaces', () => { + const options = { + invalid: true, + workspaces: ['bar'], + }; + + // @ts-expect-error + savedObjectsClient.find(options); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_find", + Object { + "body": undefined, + "method": "GET", + "query": Object { + "workspaces": Array [ + "bar", + ], + }, + }, + ], + ] + `); + }); }); }); diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index d6b6b6b6d89c..44e8be470c32 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -61,6 +61,7 @@ export interface SavedObjectsCreateOptions { /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; references?: SavedObjectReference[]; + workspaces?: string[]; } /** @@ -78,6 +79,7 @@ export interface SavedObjectsBulkCreateObject extends SavedObjectsC export interface SavedObjectsBulkCreateOptions { /** If a document with the given `id` already exists, overwrite it's contents (default=false). */ overwrite?: boolean; + workspaces?: string[]; } /** @public */ @@ -183,6 +185,35 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { export class SavedObjectsClient { private http: HttpSetup; private batchQueue: BatchQueueEntry[]; + /** + * The currentWorkspaceId may be undefined when workspace plugin is not enabled. + */ + private currentWorkspaceId: string | undefined; + + /** + * Check if workspaces field present in given options, if so, overwrite the current workspace id. + * @param options + * @returns + */ + private formatWorkspacesParams(options: { + workspaces?: SavedObjectsCreateOptions['workspaces']; + }): { workspaces: string[] } | {} { + const currentWorkspaceId = this.currentWorkspaceId; + let finalWorkspaces; + if (options.hasOwnProperty('workspaces')) { + finalWorkspaces = options.workspaces; + } else if (typeof currentWorkspaceId === 'string') { + finalWorkspaces = [currentWorkspaceId]; + } + + if (finalWorkspaces) { + return { + workspaces: finalWorkspaces, + }; + } + + return {}; + } /** * Throttled processing of get requests into bulk requests at 100ms interval @@ -227,6 +258,10 @@ export class SavedObjectsClient { this.batchQueue = []; } + public setCurrentWorkspace(workspaceId: string) { + this.currentWorkspaceId = workspaceId; + } + /** * Persists an object * @@ -256,6 +291,7 @@ export class SavedObjectsClient { attributes, migrationVersion: options.migrationVersion, references: options.references, + ...this.formatWorkspacesParams(options), }), }); @@ -275,11 +311,14 @@ export class SavedObjectsClient { options: SavedObjectsBulkCreateOptions = { overwrite: false } ) => { const path = this.getPath(['_bulk_create']); - const query = { overwrite: options.overwrite }; + const query: HttpFetchOptions['query'] = { overwrite: options.overwrite }; const request: ReturnType = this.savedObjectsFetch(path, { method: 'POST', - query, + query: { + ...query, + ...this.formatWorkspacesParams(options), + }, body: JSON.stringify(objects), }); return request.then((resp) => { @@ -348,7 +387,10 @@ export class SavedObjectsClient { workspaces: 'workspaces', }; - const renamedQuery = renameKeys(renameMap, options); + const renamedQuery = renameKeys(renameMap, { + ...options, + ...this.formatWorkspacesParams(options), + }); const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]) as Partial< Record >; diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 47bd146058f7..00ca44072958 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -41,6 +41,7 @@ const createStartContractMock = () => { find: jest.fn(), get: jest.fn(), update: jest.fn(), + setCurrentWorkspace: jest.fn(), }, }; return mock; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index cf7e1d8246a7..da477604c029 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -812,6 +812,73 @@ describe('getSortedObjectsForExport()', () => { `); }); + test('exports selected objects when passed workspaces', async () => { + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '2', + type: 'search', + attributes: {}, + references: [ + { + id: '1', + name: 'name', + type: 'index-pattern', + }, + ], + }, + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + ], + }); + await exportSavedObjectsToStream({ + exportSizeLimit: 10000, + savedObjectsClient, + objects: [ + { + type: 'index-pattern', + id: '1', + }, + { + type: 'search', + id: '2', + }, + ], + workspaces: ['foo'], + }); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + id: 1, + type: index-pattern, + }, + Object { + id: 2, + type: search, + }, + ], + Object { + namespace: undefined, + }, + ], + ], + "results": Array [ + Object { + type: return, + value: Promise {}, + }, + ], + } + `); + }); + test('export selected objects throws error when exceeding exportSizeLimit', async () => { const exportOpts = { exportSizeLimit: 1, diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index ef22155f046b..35ca022df276 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -242,7 +242,8 @@ describe('#importSavedObjectsFromStream', () => { test('checks conflicts', async () => { const createNewCopies = (Symbol() as unknown) as boolean; const retries = [createRetry()]; - const options = setupOptions(retries, createNewCopies); + const workspaces = ['foo']; + const options = { ...setupOptions(retries, createNewCopies), workspaces }; const collectedObjects = [createObject()]; getMockFn(collectSavedObjects).mockResolvedValue({ errors: [], @@ -257,6 +258,7 @@ describe('#importSavedObjectsFromStream', () => { namespace, retries, createNewCopies, + workspaces, }; expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); }); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 49b7c67b5ab6..33f62b98eeb1 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -131,6 +131,7 @@ export async function resolveSavedObjectsImportErrors({ retries, createNewCopies, dataSourceId, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index c9ffe147e5f8..dfd9d3d3e378 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -932,6 +932,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2064,6 +2065,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3257,6 +3259,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4450,6 +4453,7 @@ exports[`dashboard listing renders table rows 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -5643,6 +5647,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 1954051c9474..54b40858b4f1 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -810,6 +810,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -1767,6 +1768,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2724,6 +2726,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3681,6 +3684,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4638,6 +4642,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -5595,6 +5600,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts new file mode 100644 index 000000000000..e1a45ee115ab --- /dev/null +++ b/src/plugins/workspace/public/plugin.test.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/public/mocks'; +import { WorkspacePlugin } from './plugin'; + +describe('Workspace plugin', () => { + it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { + const workspacePlugin = new WorkspacePlugin(); + const coreStart = coreMock.createStart(); + workspacePlugin.start(coreStart); + coreStart.workspaces.currentWorkspaceId$.next('foo'); + expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); + }); +}); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 18e84e3a6f35..3840066fcee3 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,16 +3,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Plugin } from '../../../core/public'; +import type { Subscription } from 'rxjs'; +import { Plugin, CoreStart } from '../../../core/public'; export class WorkspacePlugin implements Plugin<{}, {}, {}> { + private coreStart?: CoreStart; + private currentWorkspaceSubscription?: Subscription; + private _changeSavedObjectCurrentWorkspace() { + if (this.coreStart) { + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } + }); + } + } public async setup() { return {}; } - public start() { + public start(core: CoreStart) { + this.coreStart = core; + + this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); return {}; } - public stop() {} + public stop() { + this.currentWorkspaceSubscription?.unsubscribe(); + } }