diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index 7cdc3f92382b..dd174ecf0e10 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -22,4 +22,5 @@ export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { export interface WorkspaceObject extends WorkspaceAttributeWithPermission { readonly?: boolean; + uiSettings?: Record; } diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 1781b7e32771..cc47fa81c461 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -13,6 +13,7 @@ export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; 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', diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index b4601d5d71dc..364d2ed21d59 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -17,6 +17,7 @@ import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_ID_CONSUMER_WRAPPER_ID, + WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID, } from '../common/constants'; import { IWorkspaceClientImpl, @@ -43,6 +44,7 @@ import { updateDashboardAdminStateForRequest, } from './utils'; 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; @@ -51,6 +53,7 @@ export class WorkspacePlugin implements Plugin; private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; + private workspaceUiSettingsClientWrapper?: WorkspaceUiSettingsClientWrapper; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -128,6 +131,7 @@ export class WorkspacePlugin implements Plugin { + 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 000000000000..8c8dd01b499b --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_ui_settings_client_wrapper.ts @@ -0,0 +1,122 @@ +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, + get: getUiSettingsWithWorkspace, + update: updateUiSettingsWithWorkspace, + }; + }; +}