diff --git a/changelogs/fragments/7188.yml b/changelogs/fragments/7188.yml new file mode 100644 index 000000000000..3582b5d6feef --- /dev/null +++ b/changelogs/fragments/7188.yml @@ -0,0 +1,2 @@ +feat: +- Support workspace level default data source ([#7188](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7188)) \ No newline at end of file diff --git a/src/plugins/data_source_management/common/index.ts b/src/plugins/data_source_management/common/index.ts index 3ba84084699a..980bf8939456 100644 --- a/src/plugins/data_source_management/common/index.ts +++ b/src/plugins/data_source_management/common/index.ts @@ -5,3 +5,4 @@ export const PLUGIN_ID = 'dataSourceManagement'; export const PLUGIN_NAME = 'Data sources'; +export const DEFAULT_DATA_SOURCE_UI_SETTINGS_ID = 'defaultDataSource'; diff --git a/src/plugins/data_source_management/public/components/constants.tsx b/src/plugins/data_source_management/public/components/constants.tsx index bd5fc6558455..14a71eb892be 100644 --- a/src/plugins/data_source_management/public/components/constants.tsx +++ b/src/plugins/data_source_management/public/components/constants.tsx @@ -19,8 +19,6 @@ export const CONNECT_DATASOURCES_MESSAGE = 'Connect your data sources to get sta export const NO_COMPATIBLE_DATASOURCES_MESSAGE = 'No compatible data sources are available.'; export const ADD_COMPATIBLE_DATASOURCES_MESSAGE = 'Add a compatible data source.'; -export const DEFAULT_DATA_SOURCE_UI_SETTINGS_ID = 'defaultDataSource'; - export const OPENSEARCH_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/dashboards/management/data-sources/'; @@ -46,3 +44,5 @@ export const UrlToDatasourceType: { [key: string]: DatasourceType } = { }; export type AuthMethod = 'noauth' | 'basicauth' | 'awssigv4'; + +export { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../../common'; diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 5005b605cd45..8bdec0d7d5bd 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -52,7 +52,6 @@ export interface DataSourceManagementPluginStart { getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; } -// src/plugins/workspace/public/plugin.ts Workspace depends on this ID and hard code to avoid adding dependency on DSM bundle. export const DSM_APP_ID = 'dataSources'; export class DataSourceManagementPlugin diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 1081284b7d9e..eed42fb85636 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -113,7 +113,7 @@ export class WorkspacePlugin implements Plugin { type: 'config', attributes: { defaultIndex: 'default-index-global', + [DEFAULT_DATA_SOURCE_UI_SETTINGS_ID]: 'default-ds-global', }, }); } else if (type === WORKSPACE_TYPE) { @@ -59,7 +61,7 @@ describe('WorkspaceUiSettingsClientWrapper', () => { }; }; - it('should return workspace ui settings if in a workspace', async () => { + it('should return workspace ui settings and should return workspace default data source and not extend global if in a workspace', async () => { // Currently in a workspace jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ requestWorkspaceId: 'workspace-id' }); @@ -72,6 +74,7 @@ describe('WorkspaceUiSettingsClientWrapper', () => { type: 'config', attributes: { defaultIndex: 'default-index-workspace', + [DEFAULT_DATA_SOURCE_UI_SETTINGS_ID]: undefined, }, }); }); @@ -89,6 +92,7 @@ describe('WorkspaceUiSettingsClientWrapper', () => { type: 'config', attributes: { defaultIndex: 'default-index-global', + [DEFAULT_DATA_SOURCE_UI_SETTINGS_ID]: 'default-ds-global', }, }); }); diff --git a/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.ts index 9cc860ec903e..52cccc6a01c0 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.ts @@ -18,6 +18,7 @@ import { } from '../../../../core/server'; import { WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID } from '../../common/constants'; import { Logger } from '../../../../core/server'; +import { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../../../data_source_management/common'; /** * This saved object client wrapper offers methods to get and update UI settings considering @@ -73,9 +74,14 @@ export class WorkspaceUiSettingsClientWrapper { this.logger.error(`Unable to get workspaceObject with id: ${requestWorkspaceId}`); } + const workspaceLevelDefaultDS = + workspaceObject?.attributes?.uiSettings?.[DEFAULT_DATA_SOURCE_UI_SETTINGS_ID]; + configObject.attributes = { ...configObject.attributes, ...(workspaceObject ? workspaceObject.attributes.uiSettings : {}), + // Workspace level default data source value should not extend global UIsettings value. + [DEFAULT_DATA_SOURCE_UI_SETTINGS_ID]: workspaceLevelDefaultDS, }; return configObject as SavedObject; diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 703df213bc84..82d4bc594a7e 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -12,6 +12,7 @@ import { WorkspaceAttribute, SavedObjectsServiceStart, Permissions, + UiSettingsServiceStart, } from '../../../core/server'; export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { @@ -48,6 +49,13 @@ export interface IWorkspaceClientImpl { * @public */ setSavedObjects(savedObjects: SavedObjectsServiceStart): void; + /** + * Set ui settings client that will be used inside the workspace client. + * @param uiSettings {@link UiSettingsServiceStart} + * @returns void + * @public + */ + setUiSettings(uiSettings: UiSettingsServiceStart): void; /** * Create a workspace * @param requestDetail {@link IRequestDetail} diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 95db93e5b97a..ba7532c216eb 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -8,6 +8,7 @@ import { httpServerMock, httpServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../core/server/mocks'; import { generateRandomId, @@ -16,9 +17,11 @@ import { updateDashboardAdminStateForRequest, transferCurrentUserInPermissions, getDataSourcesList, + checkAndSetDefaultDataSource, } from './utils'; import { getWorkspaceState } from '../../../core/server/utils'; import { Observable, of } from 'rxjs'; +import { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../../data_source_management/common'; describe('workspace utils', () => { const mockAuth = httpServiceMock.createAuth(); @@ -205,4 +208,52 @@ describe('workspace utils', () => { const result = await getDataSourcesList(savedObjectsClient, []); expect(result).toEqual([]); }); + + it('should set first data sources as default when not need check', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); + const dataSources = ['id1', 'id2']; + await checkAndSetDefaultDataSource(uiSettingsClient, dataSources, false); + expect(uiSettingsClient.set).toHaveBeenCalledWith( + DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, + dataSources[0] + ); + }); + + it('should not set default data source after checking if not needed', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); + const dataSources = ['id1', 'id2']; + uiSettingsClient.get = jest.fn().mockResolvedValue(dataSources[0]); + await checkAndSetDefaultDataSource(uiSettingsClient, dataSources, true); + expect(uiSettingsClient.set).not.toBeCalled(); + }); + + it('should check then set first data sources as default if needed when checking', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); + const dataSources = ['id1', 'id2']; + uiSettingsClient.get = jest.fn().mockResolvedValue(''); + await checkAndSetDefaultDataSource(uiSettingsClient, dataSources, true); + expect(uiSettingsClient.set).toHaveBeenCalledWith( + DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, + dataSources[0] + ); + }); + + it('should clear default data source if there is no new data source', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); + const dataSources: string[] = []; + uiSettingsClient.get = jest.fn().mockResolvedValue(''); + await checkAndSetDefaultDataSource(uiSettingsClient, dataSources, true); + expect(uiSettingsClient.set).toHaveBeenCalledWith( + DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, + undefined + ); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index f48c78f9a5dd..9f144c7eb1c3 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -15,9 +15,11 @@ import { SharedGlobalConfig, Permissions, SavedObjectsClientContract, + IUiSettingsClient, } from '../../../core/server'; import { AuthInfo } from './types'; import { updateWorkspaceState } from '../../../core/server/utils'; +import { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../../data_source_management/common'; import { CURRENT_USER_PLACEHOLDER } from '../common/constants'; /** @@ -136,3 +138,25 @@ export const getDataSourcesList = (client: SavedObjectsClientContract, workspace } }); }; + +export const checkAndSetDefaultDataSource = async ( + uiSettingsClient: IUiSettingsClient, + dataSources: string[], + needCheck: boolean +) => { + if (dataSources?.length > 0) { + if (!needCheck) { + // Create# Will set first data source as default data source. + await uiSettingsClient.set(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, dataSources[0]); + } else { + // Update will check if default DS still exists. + const defaultDSId = (await uiSettingsClient.get(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID)) ?? ''; + if (!dataSources.includes(defaultDSId)) { + await uiSettingsClient.set(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, dataSources[0]); + } + } + } else { + // If there is no data source left, clear workspace level default data source. + await uiSettingsClient.set(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, undefined); + } +}; diff --git a/src/plugins/workspace/server/workspace_client.test.ts b/src/plugins/workspace/server/workspace_client.test.ts index 7d1d692e2e4d..208def26ad02 100644 --- a/src/plugins/workspace/server/workspace_client.test.ts +++ b/src/plugins/workspace/server/workspace_client.test.ts @@ -5,15 +5,22 @@ import { WorkspaceClient } from './workspace_client'; -import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { + coreMock, + httpServerMock, + uiSettingsServiceMock, + loggingSystemMock, +} from '../../../core/server/mocks'; import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../data_source/common'; -import { SavedObjectsServiceStart } from '../../../core/server'; +import { SavedObjectsServiceStart, SavedObjectsClientContract } from '../../../core/server'; import { IRequestDetail } from './types'; const coreSetup = coreMock.createSetup(); const mockWorkspaceId = 'workspace_id'; const mockWorkspaceName = 'workspace_name'; +const mockCheckAndSetDefaultDataSource = jest.fn(); +const logger = loggingSystemMock.create().get(); jest.mock('./utils', () => ({ generateRandomId: () => mockWorkspaceId, @@ -25,6 +32,7 @@ jest.mock('./utils', () => ({ id: 'id2', }, ]), + checkAndSetDefaultDataSource: (...args) => mockCheckAndSetDefaultDataSource(...args), })); describe('#WorkspaceClient', () => { @@ -35,20 +43,24 @@ describe('#WorkspaceClient', () => { const find = jest.fn(); const addToWorkspaces = jest.fn(); const deleteFromWorkspaces = jest.fn(); + const savedObjectClient = ({ + find, + addToWorkspaces, + deleteFromWorkspaces, + create: jest.fn(), + get: jest.fn().mockResolvedValue({ + attributes: { + name: mockWorkspaceName, + }, + }), + } as unknown) as SavedObjectsClientContract; const savedObjects = ({ ...coreSetup.savedObjects, - getScopedClient: () => ({ - find, - addToWorkspaces, - deleteFromWorkspaces, - get: jest.fn().mockResolvedValue({ - attributes: { - name: mockWorkspaceName, - }, - }), - }), + getScopedClient: () => savedObjectClient, } as unknown) as SavedObjectsServiceStart; + const uiSettings = uiSettingsServiceMock.createStartContract(); + const mockRequestDetail = ({ request: httpServerMock.createOpenSearchDashboardsRequest(), context: coreMock.createRequestHandlerContext(), @@ -56,7 +68,7 @@ describe('#WorkspaceClient', () => { } as unknown) as IRequestDetail; it('create# should not call addToWorkspaces if no data sources passed', async () => { - const client = new WorkspaceClient(coreSetup); + const client = new WorkspaceClient(coreSetup, logger); await client.setup(coreSetup); client?.setSavedObjects(savedObjects); @@ -69,7 +81,7 @@ describe('#WorkspaceClient', () => { }); it('create# should call addToWorkspaces with passed data sources normally', async () => { - const client = new WorkspaceClient(coreSetup); + const client = new WorkspaceClient(coreSetup, logger); await client.setup(coreSetup); client?.setSavedObjects(savedObjects); @@ -84,8 +96,25 @@ describe('#WorkspaceClient', () => { ]); }); + it('create# should call set default data source after creating', async () => { + const client = new WorkspaceClient(coreSetup, logger); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + client?.setUiSettings(uiSettings); + + await client.create(mockRequestDetail, { + name: mockWorkspaceName, + permissions: {}, + dataSources: ['id1'], + }); + + const uiSettingsClient = uiSettings.asScopedToClient(savedObjectClient); + + expect(mockCheckAndSetDefaultDataSource).toHaveBeenCalledWith(uiSettingsClient, ['id1'], false); + }); + it('update# should not call addToWorkspaces if no new data sources added', async () => { - const client = new WorkspaceClient(coreSetup); + const client = new WorkspaceClient(coreSetup, logger); await client.setup(coreSetup); client?.setSavedObjects(savedObjects); @@ -99,7 +128,7 @@ describe('#WorkspaceClient', () => { }); it('update# should call deleteFromWorkspaces if there is data source to be removed', async () => { - const client = new WorkspaceClient(coreSetup); + const client = new WorkspaceClient(coreSetup, logger); await client.setup(coreSetup); client?.setSavedObjects(savedObjects); @@ -117,7 +146,7 @@ describe('#WorkspaceClient', () => { ]); }); it('update# should calculate data sources to be added and to be removed', async () => { - const client = new WorkspaceClient(coreSetup); + const client = new WorkspaceClient(coreSetup, logger); await client.setup(coreSetup); client?.setSavedObjects(savedObjects); @@ -134,4 +163,21 @@ describe('#WorkspaceClient', () => { mockWorkspaceId, ]); }); + + it('update# should call set default data source with check after updating', async () => { + const client = new WorkspaceClient(coreSetup, logger); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + client?.setUiSettings(uiSettings); + + await client.update(mockRequestDetail, mockWorkspaceId, { + name: mockWorkspaceName, + permissions: {}, + dataSources: ['id1'], + }); + + const uiSettingsClient = uiSettings.asScopedToClient(savedObjectClient); + + expect(mockCheckAndSetDefaultDataSource).toHaveBeenCalledWith(uiSettingsClient, ['id1'], true); + }); }); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index ad9d81cb952c..159136e1304f 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -10,8 +10,11 @@ import { CoreSetup, WorkspaceAttribute, SavedObjectsServiceStart, + UiSettingsServiceStart, + WORKSPACE_TYPE, + Logger, } from '../../../core/server'; -import { WORKSPACE_TYPE } from '../../../core/server'; +import { updateWorkspaceState, getWorkspaceState } from '../../../core/server/utils'; import { IWorkspaceClientImpl, WorkspaceFindOptions, @@ -20,7 +23,7 @@ import { WorkspaceAttributeWithPermission, } from './types'; import { workspace } from './saved_objects'; -import { generateRandomId, getDataSourcesList } from './utils'; +import { generateRandomId, getDataSourcesList, checkAndSetDefaultDataSource } from './utils'; import { WORKSPACE_ID_CONSUMER_WRAPPER_ID, WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, @@ -35,10 +38,13 @@ const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name. export class WorkspaceClient implements IWorkspaceClientImpl { private setupDep: CoreSetup; + private logger: Logger; private savedObjects?: SavedObjectsServiceStart; + private uiSettings?: UiSettingsServiceStart; - constructor(core: CoreSetup) { + constructor(core: CoreSetup, logger: Logger) { this.setupDep = core; + this.logger = logger; } private getScopedClientWithoutPermission( @@ -122,6 +128,25 @@ export class WorkspaceClient implements IWorkspaceClientImpl { permissions, } ); + if (dataSources && this.uiSettings && client) { + const rawState = getWorkspaceState(requestDetail.request); + // This is for setting in workspace environment, otherwise uiSettings can't set workspace level value. + updateWorkspaceState(requestDetail.request, { + requestWorkspaceId: id, + }); + // Set first data source as default after creating workspace + const uiSettingsClient = this.uiSettings.asScopedToClient(client); + try { + await checkAndSetDefaultDataSource(uiSettingsClient, dataSources, false); + } catch (e) { + this.logger.error('Set default data source error'); + } finally { + // Reset workspace state + updateWorkspaceState(requestDetail.request, { + requestWorkspaceId: rawState.requestWorkspaceId, + }); + } + } return { success: true, @@ -248,6 +273,12 @@ export class WorkspaceClient implements IWorkspaceClientImpl { version: workspaceInDB.version, } ); + + if (newDataSources && this.uiSettings && client) { + const uiSettingsClient = this.uiSettings.asScopedToClient(client); + checkAndSetDefaultDataSource(uiSettingsClient, newDataSources, true); + } + return { success: true, result: true, @@ -292,6 +323,9 @@ export class WorkspaceClient implements IWorkspaceClientImpl { public setSavedObjects(savedObjects: SavedObjectsServiceStart) { this.savedObjects = savedObjects; } + public setUiSettings(uiSettings: UiSettingsServiceStart) { + this.uiSettings = uiSettings; + } public async destroy(): Promise> { return { success: true,