diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index 92888b84525..d0a0d47b221 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -13,6 +13,7 @@ export interface WorkspaceAttribute { color?: string; icon?: string; reserved?: boolean; + uiSettings?: Record; } export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 0eecc20063a..be071dd0c10 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -16,6 +16,7 @@ export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; +export const WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID = 'workspace_ui_settings'; export enum WorkspacePermissionMode { Read = 'read', @@ -28,10 +29,11 @@ export const WORKSPACE_ID_CONSUMER_WRAPPER_ID = 'workspace_id_consumer'; /** * The priority for these wrappers matters: - * 1. WORKSPACE_ID_CONSUMER should be placed before the other two wrappers(smaller than the other two wrappers) as it cost little - * and will append the essential workspaces field into the options, which will be honored by permission control wrapper and conflict wrapper. + * 1. WORKSPACE_ID_CONSUMER wrapper should be the first wrapper to execute, as it will add the `workspaces` field + * to `options` based on the request, which will be honored by permission control wrapper and conflict wrapper. * 2. The order of permission wrapper and conflict wrapper does not matter as no dependency between these two wrappers. */ -export const PRIORITY_FOR_WORKSPACE_ID_CONSUMER_WRAPPER = -2; -export const PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER = 0; +export const PRIORITY_FOR_WORKSPACE_ID_CONSUMER_WRAPPER = -3; +export const PRIORITY_FOR_WORKSPACE_UI_SETTINGS_WRAPPER = -2; export const PRIORITY_FOR_WORKSPACE_CONFLICT_CONTROL_WRAPPER = -1; +export const PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER = 0; diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 0ad72b51b6d..e065066cd67 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -26,7 +26,7 @@ describe('Workspace server plugin', () => { }, } `); - expect(setupMock.savedObjects.addClientWrapper).toBeCalledTimes(3); + expect(setupMock.savedObjects.addClientWrapper).toBeCalledTimes(4); }); it('#proxyWorkspaceTrafficToRealHandler', async () => { diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 0056e9ac784..7956d7928a9 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -20,6 +20,8 @@ import { PRIORITY_FOR_WORKSPACE_CONFLICT_CONTROL_WRAPPER, PRIORITY_FOR_WORKSPACE_ID_CONSUMER_WRAPPER, PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER, + WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID, + PRIORITY_FOR_WORKSPACE_UI_SETTINGS_WRAPPER, } from '../common/constants'; import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; import { WorkspaceClient } from './workspace_client'; @@ -36,6 +38,7 @@ import { SavedObjectsPermissionControlContract, } from './permission_control/client'; import { WorkspaceIdConsumerWrapper } from './saved_objects/workspace_id_consumer_wrapper'; +import { WorkspaceUiSettingsClientWrapper } from './saved_objects/workspace_ui_settings_client_wrapper'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; @@ -44,6 +47,7 @@ export class WorkspacePlugin implements Plugin; private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; + private workspaceUiSettingsClientWrapper?: WorkspaceUiSettingsClientWrapper; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -90,6 +94,14 @@ export class WorkspacePlugin implements Plugin { + let opensearchServer: osdTestServer.TestOpenSearchUtils; + let osd: osdTestServer.TestOpenSearchDashboardsUtils; + let globalUiSettingsClient: IUiSettingsClient; + let testWorkspace: WorkspaceAttribute = { + id: '', + name: '', + }; + + beforeAll(async () => { + const servers = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + savedObjects: { + permission: { + enabled: true, + }, + }, + migrations: { + skip: false, + }, + }, + }, + }); + opensearchServer = await servers.startOpenSearch(); + osd = await servers.startOpenSearchDashboards(); + + const savedObjectsClient = osd.coreStart.savedObjects.getScopedClient( + httpServerMock.createOpenSearchDashboardsRequest() + ); + globalUiSettingsClient = osd.coreStart.uiSettings.asScopedToClient(savedObjectsClient); + + const res = await osdTestServer.request.post(osd.root, '/api/workspaces').send({ + attributes: { name: 'test workspace' }, + }); + testWorkspace = res.body.result; + }, 30000); + + afterAll(async () => { + await opensearchServer.stop(); + await osd.stop(); + }, 30000); + + beforeEach(async () => { + await globalUiSettingsClient.set('defaultIndex', 'global-index'); + }); + + it('should get and update workspace ui settings when currently in a workspace', async () => { + const workspaceScopedSavedObjectsClient = osd.coreStart.savedObjects.getScopedClient( + httpServerMock.createOpenSearchDashboardsRequest({ + opensearchDashboardsRequestState: { requestWorkspaceId: testWorkspace.id }, + }) + ); + const workspaceScopedUiSettingsClient = osd.coreStart.uiSettings.asScopedToClient( + workspaceScopedSavedObjectsClient + ); + + expect(await globalUiSettingsClient.get('defaultIndex')).toBe('global-index'); + + // workspace defaultIndex is not set, it will use the global value + expect(await workspaceScopedUiSettingsClient.get('defaultIndex')).toBe('global-index'); + + // update ui settings in a workspace + await workspaceScopedUiSettingsClient.set('defaultIndex', 'workspace-index'); + + // global ui settings remain unchanged + expect(await globalUiSettingsClient.get('defaultIndex')).toBe('global-index'); + + // workspace ui settings updated to the new value + expect(await workspaceScopedUiSettingsClient.get('defaultIndex')).toBe('workspace-index'); + }); + + it('should get and update global ui settings when currently not in a workspace', async () => { + expect(await globalUiSettingsClient.get('defaultIndex')).toBe('global-index'); + + await globalUiSettingsClient.set('defaultIndex', 'global-index-new'); + expect(await globalUiSettingsClient.get('defaultIndex')).toBe('global-index-new'); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/workspace.ts b/src/plugins/workspace/server/saved_objects/workspace.ts index 7ff018a31dd..18947c312d9 100644 --- a/src/plugins/workspace/server/saved_objects/workspace.ts +++ b/src/plugins/workspace/server/saved_objects/workspace.ts @@ -39,6 +39,10 @@ export const workspace: SavedObjectsType = { reserved: { type: 'boolean', }, + uiSettings: { + dynamic: false, + properties: {}, + }, }, }, }; diff --git a/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.test.ts new file mode 100644 index 00000000000..b77ffd8aa2b --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; +import { WorkspaceUiSettingsClientWrapper } from './workspace_ui_settings_client_wrapper'; +import { WORKSPACE_TYPE } from '../../../../core/server'; + +import * as utils from '../../../../core/server/utils'; + +jest.mock('../../../../core/server/utils'); + +describe('WorkspaceUiSettingsClientWrapper', () => { + const createWrappedClient = () => { + const clientMock = savedObjectsClientMock.create(); + const getClientMock = jest.fn().mockReturnValue(clientMock); + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + + clientMock.get.mockImplementation(async (type, id) => { + if (type === 'config') { + return Promise.resolve({ + id, + references: [], + type: 'config', + attributes: { + defaultIndex: 'default-index-global', + }, + }); + } else if (type === WORKSPACE_TYPE) { + return Promise.resolve({ + id, + references: [], + type: WORKSPACE_TYPE, + attributes: { + uiSettings: { + defaultIndex: 'default-index-workspace', + }, + }, + }); + } + return Promise.reject(); + }); + + const wrapper = new WorkspaceUiSettingsClientWrapper(); + wrapper.setScopedClient(getClientMock); + + return { + wrappedClient: wrapper.wrapperFactory({ + client: clientMock, + request: requestMock, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + }), + clientMock, + }; + }; + + it('should return workspace ui settings if in a workspace', async () => { + // Currently in a workspace + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ requestWorkspaceId: 'workspace-id' }); + + const { wrappedClient } = createWrappedClient(); + + const result = await wrappedClient.get('config', '3.0.0'); + expect(result).toEqual({ + id: '3.0.0', + references: [], + type: 'config', + attributes: { + defaultIndex: 'default-index-workspace', + }, + }); + }); + + it('should return global ui settings if NOT in a workspace', async () => { + // Currently NOT in a workspace + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({}); + + const { wrappedClient } = createWrappedClient(); + + const result = await wrappedClient.get('config', '3.0.0'); + expect(result).toEqual({ + id: '3.0.0', + references: [], + type: 'config', + attributes: { + defaultIndex: 'default-index-global', + }, + }); + }); + + it('should update workspace ui settings', async () => { + // Currently in a workspace + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ requestWorkspaceId: 'workspace-id' }); + + const { wrappedClient, clientMock } = createWrappedClient(); + + clientMock.update.mockResolvedValue({ + id: 'workspace-id', + references: [], + type: WORKSPACE_TYPE, + attributes: { + uiSettings: { + defaultIndex: 'new-index-id', + }, + }, + }); + + await wrappedClient.update('config', '3.0.0', { defaultIndex: 'new-index-id' }); + + expect(clientMock.update).toHaveBeenCalledWith( + WORKSPACE_TYPE, + 'workspace-id', + { + uiSettings: { defaultIndex: 'new-index-id' }, + }, + {} + ); + }); + + it('should update global ui settings', async () => { + // Currently NOT in a workspace + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({}); + + const { wrappedClient, clientMock } = createWrappedClient(); + + await wrappedClient.update('config', '3.0.0', { defaultIndex: 'new-index-id' }); + + expect(clientMock.update).toHaveBeenCalledWith( + 'config', + '3.0.0', + { + defaultIndex: 'new-index-id', + }, + {} + ); + }); +}); 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 new file mode 100644 index 00000000000..b2d8ebb8032 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.ts @@ -0,0 +1,138 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getWorkspaceState } from '../../../../core/server/utils'; +import { + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsClientWrapperFactory, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, + SavedObjectsServiceStart, + WORKSPACE_TYPE, + WorkspaceAttribute, + OpenSearchDashboardsRequest, + SavedObjectsClientContract, +} from '../../../../core/server'; +import { WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID } from '../../common/constants'; + +/** + * This saved object client wrapper offers methods to get and update UI settings considering + * the context of the current workspace. + */ +export class WorkspaceUiSettingsClientWrapper { + private getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + + /** + * WORKSPACE_TYPE is a hidden type, regular saved object client won't return hidden types. + * To access workspace uiSettings which is defined as a property of workspace object, the + * WORKSPACE_TYPE needs to be excluded. + */ + private getWorkspaceTypeEnabledClient(request: OpenSearchDashboardsRequest) { + return this.getScopedClient?.(request, { + includedHiddenTypes: [WORKSPACE_TYPE], + excludedWrappers: [WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID], + }) as SavedObjectsClientContract; + } + + public setScopedClient(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this.getScopedClient = getScopedClient; + } + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const getUiSettingsWithWorkspace = async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const { requestWorkspaceId } = getWorkspaceState(wrapperOptions.request); + + /** + * When getting ui settings within a workspace, it will combine the workspace ui settings with + * the global ui settings and workspace ui settings have higher priority if the same setting + * was defined in both places + */ + if (type === 'config' && requestWorkspaceId) { + const configObject = await wrapperOptions.client.get>( + 'config', + id, + options + ); + + const workspaceObject = await this.getWorkspaceTypeEnabledClient( + wrapperOptions.request + ).get(WORKSPACE_TYPE, requestWorkspaceId); + + configObject.attributes = { + ...configObject.attributes, + ...workspaceObject.attributes.uiSettings, + }; + + return configObject as SavedObject; + } + + return wrapperOptions.client.get(type, id, options); + }; + + const updateUiSettingsWithWorkspace = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + const { requestWorkspaceId } = getWorkspaceState(wrapperOptions.request); + + /** + * When updating ui settings within a workspace, it will update the workspace ui settings, + * the global ui settings will remain unchanged. + */ + if (type === 'config' && requestWorkspaceId) { + const configObject = await wrapperOptions.client.get>( + 'config', + id, + options + ); + const savedObjectsClient = this.getWorkspaceTypeEnabledClient(wrapperOptions.request); + + const workspaceObject = await savedObjectsClient.get( + WORKSPACE_TYPE, + requestWorkspaceId + ); + + const workspaceUpdateResult = await savedObjectsClient.update( + WORKSPACE_TYPE, + requestWorkspaceId, + { + ...workspaceObject.attributes, + uiSettings: { ...workspaceObject.attributes.uiSettings, ...attributes }, + }, + options + ); + + configObject.attributes = workspaceUpdateResult.attributes.uiSettings; + + return configObject as SavedObjectsUpdateResponse; + } + return wrapperOptions.client.update(type, id, attributes, options); + }; + + return { + ...wrapperOptions.client, + checkConflicts: wrapperOptions.client.checkConflicts, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + create: wrapperOptions.client.create, + bulkCreate: wrapperOptions.client.bulkCreate, + delete: wrapperOptions.client.delete, + bulkUpdate: wrapperOptions.client.bulkUpdate, + deleteByWorkspace: wrapperOptions.client.deleteByWorkspace, + get: getUiSettingsWithWorkspace, + update: updateUiSettingsWithWorkspace, + }; + }; +}